feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
@@ -5,6 +5,7 @@ import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, ty
|
||||
import { Renderer, type ResolvedImage } from "./renderer";
|
||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
import { naniText } from "./naninovel";
|
||||
import type {
|
||||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
|
||||
} from "./types";
|
||||
@@ -138,8 +139,8 @@ export class Engine {
|
||||
}
|
||||
this.persistent = this.makePersistent();
|
||||
const scope: Scope = {
|
||||
get: (n) => (n in this.store ? this.store[n] : this.builtins[n]),
|
||||
has: (n) => n in this.store || n in this.builtins,
|
||||
get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]),
|
||||
has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n),
|
||||
set: (n, v) => void (this.store[n] = v),
|
||||
del: (n) => void delete this.store[n],
|
||||
};
|
||||
@@ -189,7 +190,7 @@ export class Engine {
|
||||
}
|
||||
}
|
||||
const entry = logical ? this.manifest.files[logical] : undefined;
|
||||
return entry ? `/blobs/${entry.hash}` : null;
|
||||
return entry ? `/api/versions/${this.manifest.versionId}/files/${entry.hash}` : null;
|
||||
}
|
||||
|
||||
private resolveImage(name: string[]): ResolvedImage | null {
|
||||
@@ -262,6 +263,7 @@ export class Engine {
|
||||
return this.py.eval(e);
|
||||
} catch (err) {
|
||||
if (err instanceof JumpSignal) throw err;
|
||||
if (this.game.engine === "naninovel") throw err;
|
||||
console.warn("[pgvn] ошибка выражения", err);
|
||||
return null;
|
||||
}
|
||||
@@ -614,6 +616,48 @@ export class Engine {
|
||||
|
||||
private async exec(node: Node) {
|
||||
switch (node.op) {
|
||||
case "nani_exit":
|
||||
this.stack = [];
|
||||
this.cur = { label: "", path: [0] };
|
||||
return;
|
||||
case "nani_add_choice":
|
||||
(this.store.__nani_choices ??= []).push({ text: node.what, tid: node.tid,
|
||||
target: node.target, handler: node.handler });
|
||||
this.advance();
|
||||
return;
|
||||
case "nani_clear_choice":
|
||||
this.store.__nani_choices = (this.store.__nani_choices ?? []).filter(
|
||||
(choice: any) => node.handler !== "*" && choice.handler !== node.handler);
|
||||
this.advance();
|
||||
return;
|
||||
case "nani_menu": {
|
||||
const choices: { text: string; tid: string; target: string }[] = this.store.__nani_choices ?? [];
|
||||
if (!choices.length) throw new Error("Naninovel: Stop без доступных вариантов выбора");
|
||||
this.interactionStart();
|
||||
this.setSkip(false);
|
||||
const target = await this.wait<string>((done) => {
|
||||
this.menuEl.textContent = "";
|
||||
for (const choice of choices) {
|
||||
const button = el("button", "pg-choice");
|
||||
renderText(button, parseText(this.interp(this.tr.line(choice.tid, choice.text))), Infinity, this.baseSize);
|
||||
button.onclick = (event) => { event.stopPropagation(); done(choice.target); };
|
||||
this.menuEl.append(button);
|
||||
}
|
||||
this.menuEl.classList.remove("pg-hidden");
|
||||
});
|
||||
this.menuEl.classList.add("pg-hidden");
|
||||
this.store.__nani_choices = [];
|
||||
this.jump(target);
|
||||
return;
|
||||
}
|
||||
case "nani_hide_characters":
|
||||
this.renderer.commit();
|
||||
for (const image of this.renderer.snapshot()) {
|
||||
if (image.tag.startsWith("nani-char-")) this.renderer.hide(image.tag);
|
||||
}
|
||||
if (node.duration) await this.renderer.transition({ t: "dissolve", d: node.duration });
|
||||
this.advance();
|
||||
return;
|
||||
case "say":
|
||||
await this.say(node);
|
||||
this.advance();
|
||||
@@ -693,6 +737,7 @@ export class Engine {
|
||||
this.py.exec(node.code);
|
||||
} catch (err) {
|
||||
if (err instanceof JumpSignal) throw err;
|
||||
if (this.game.engine === "naninovel") throw err;
|
||||
console.warn("[pgvn] ошибка python", err, node.code);
|
||||
}
|
||||
this.advance();
|
||||
@@ -771,6 +816,8 @@ export class Engine {
|
||||
}
|
||||
|
||||
private interp(s: string): string {
|
||||
if (this.game.engine === "naninovel") return naniText(s, this.game.naniExpressions ?? {},
|
||||
(name) => Object.hasOwn(this.store, name) ? this.store[name] : undefined);
|
||||
return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v));
|
||||
}
|
||||
|
||||
@@ -1066,10 +1113,22 @@ export class Engine {
|
||||
}
|
||||
|
||||
private snapshotState(): SaveData {
|
||||
const anchor = (f: Frame): Frame => {
|
||||
const label = this.labels.get(f.label)?.label;
|
||||
let body = label?.body;
|
||||
for (let i = 0; body && i < f.path.length - 1; i += 2) {
|
||||
const n = body[f.path[i]];
|
||||
if (!n) { body = undefined; break; }
|
||||
body = n.op === "if" ? n.branches[f.path[i + 1]]?.body
|
||||
: n.op === "menu" ? n.items[f.path[i + 1]]?.body : n.body;
|
||||
}
|
||||
return { ...f, path: [...f.path], nodeId: body?.[f.path[f.path.length - 1]]?.id };
|
||||
};
|
||||
return {
|
||||
v: 1,
|
||||
cur: { label: this.cur.label, path: [...this.cur.path] },
|
||||
stack: this.stack.map((f) => ({ label: f.label, path: [...f.path], saved: encodeValue(f.saved) })),
|
||||
revision: this.manifest.revision,
|
||||
cur: anchor(this.cur),
|
||||
stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })),
|
||||
store: encodeValue(this.store),
|
||||
scene: this.renderer.snapshot(),
|
||||
music: this.audio.current("music"),
|
||||
@@ -1101,6 +1160,18 @@ export class Engine {
|
||||
this.notify("Сохранение не подходит к этой версии игры");
|
||||
return;
|
||||
}
|
||||
if (data.revision && data.revision !== this.manifest.revision) {
|
||||
// IDs repair positions, but cannot prove compatibility of variables/game logic.
|
||||
const frames = [data.cur, ...data.stack];
|
||||
const mapped = frames.map((f) => f.nodeId ? this.findNode(f.nodeId) : null);
|
||||
if (mapped.some((f) => !f)) {
|
||||
this.notify("После изменения игры позиция сохранения не найдена. Используйте прежнюю ревизию или начните заново");
|
||||
return;
|
||||
}
|
||||
if (!(await this.confirmAsk("Игра изменилась. Позиции найдены по ID, но состояние переменных может быть несовместимо. Попробовать загрузить?", "Загрузить"))) return;
|
||||
frames.forEach((f, i) => Object.assign(f, mapped[i]));
|
||||
}
|
||||
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
|
||||
this.restart(() => {
|
||||
this.resetStore();
|
||||
Object.assign(this.store, decodeValue(data.store));
|
||||
|
||||
Reference in new issue
Block a user