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));
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
|
||||
const blob = (path: string) => {
|
||||
const f = manifest.files[path];
|
||||
if (!f) throw new Error(`в версии нет файла ${path}`);
|
||||
return fetch(`/blobs/${f.hash}`).then((r) => {
|
||||
return fetch(`/api/versions/${manifest.versionId}/files/${f.hash}`).then((r) => {
|
||||
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
||||
return r.json() as Promise<Record<string, Entry>>;
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ async function getJSON<T>(url: string): Promise<T> {
|
||||
function blob(manifest: Manifest, path: string) {
|
||||
const f = manifest.files[path];
|
||||
if (!f) throw new Error(`в версии нет файла ${path}`);
|
||||
return `/blobs/${f.hash}`;
|
||||
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
|
||||
}
|
||||
|
||||
function saveApi(versionId: number): SaveApi {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Evaluate the server-parsed Naninovel AST. Game text is never evaluated as JS.
|
||||
export type NaniAst = any[];
|
||||
|
||||
export function naniTruth(value: any): boolean {
|
||||
if (typeof value === "string") return value !== "" && !["false", "0", "null"].includes(value.toLowerCase());
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function number(value: any): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) throw new Error(`Naninovel: не число ${String(value)}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random): any {
|
||||
const evaluate = (node: NaniAst): any => {
|
||||
switch (node[0]) {
|
||||
case "const": return node[1];
|
||||
case "name": return lookup(node[1]) ?? 0;
|
||||
case "unary": {
|
||||
const value = evaluate(node[2]);
|
||||
if (node[1] === "!" || node[1] === "not") return !naniTruth(value);
|
||||
if (node[1] === "-") return -number(value);
|
||||
if (node[1] === "+") return number(value);
|
||||
if (node[1] === "~") return ~number(value);
|
||||
throw new Error(`Naninovel: оператор ${node[1]}`);
|
||||
}
|
||||
case "if": return evaluate(naniTruth(evaluate(node[1])) ? node[2] : node[3]);
|
||||
case "bin": {
|
||||
const a = evaluate(node[2]);
|
||||
if (["and", "&&"].includes(node[1])) return naniTruth(a) && naniTruth(evaluate(node[3]));
|
||||
if (["or", "||"].includes(node[1])) return naniTruth(a) || naniTruth(evaluate(node[3]));
|
||||
const b = evaluate(node[3]);
|
||||
switch (node[1]) {
|
||||
case "+": return typeof a === "string" || typeof b === "string" ? String(a) + String(b) : number(a) + number(b);
|
||||
case "-": return number(a) - number(b);
|
||||
case "*": return number(a) * number(b);
|
||||
case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b);
|
||||
case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b);
|
||||
case "=": case "==": return String(a) === String(b);
|
||||
case "!=": case "<>": return String(a) !== String(b);
|
||||
case "<": return number(a) < number(b);
|
||||
case ">": return number(a) > number(b);
|
||||
case "<=": return number(a) <= number(b);
|
||||
case ">=": return number(a) >= number(b);
|
||||
case "&": return number(a) & number(b);
|
||||
case "|": return number(a) | number(b);
|
||||
case "^": return number(a) ^ number(b);
|
||||
case "<<": return number(a) << number(b);
|
||||
case ">>": return number(a) >> number(b);
|
||||
default: throw new Error(`Naninovel: оператор ${node[1]}`);
|
||||
}
|
||||
}
|
||||
case "call": {
|
||||
const args = node[2].map((value: NaniAst) => number(evaluate(value)));
|
||||
switch (node[1]) {
|
||||
case "Random": case "random": case "Rand": {
|
||||
const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0];
|
||||
return min + Math.floor(random() * (max - min));
|
||||
}
|
||||
case "GetRandValue": return Math.floor(random() * args[0]);
|
||||
case "Min": return Math.min(...args);
|
||||
case "Max": return Math.max(...args);
|
||||
case "Abs": return Math.abs(args[0]);
|
||||
case "Pow": return Math.pow(args[0], args[1]);
|
||||
case "Round": { const power = 10 ** (args[1] ?? 0); return Math.round(args[0] * power) / power; }
|
||||
default: throw new Error(`Naninovel: функция ${node[1]}`);
|
||||
}
|
||||
}
|
||||
default: throw new Error(`Naninovel: узел выражения ${ast[0]}`);
|
||||
}
|
||||
};
|
||||
return evaluate(ast);
|
||||
}
|
||||
|
||||
export function naniText(source: string, expressions: Record<string, NaniAst>, lookup: (name: string) => any): string {
|
||||
// Expressions are substituted after the locale is selected. Brackets in their
|
||||
// results are escaped before passing to the shared rich-text renderer.
|
||||
const escaped = source.replace(/\{([^{}]+)\}/g, (_match, expression: string) => {
|
||||
const ast = expressions[expression];
|
||||
if (!ast) throw new Error(`Naninovel: неизвестное выражение в тексте ${expression}`);
|
||||
return String(evaluateNani(ast, lookup)).replaceAll("{", "{{").replaceAll("[", "[[");
|
||||
});
|
||||
return escaped.replace(/<(\/?)(b|i|u|s)>/gi, "{$1$2}")
|
||||
.replace(/<(color|size)=([^<>]+)>/gi, "{$1=$2}")
|
||||
.replace(/<\/(color|size)>/gi, "{/$1}")
|
||||
.replace(/<br\s*\/?>/gi, "\n");
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
|
||||
|
||||
import type { Expr, PyStmt } from "./types";
|
||||
import { evaluateNani } from "./naninovel";
|
||||
|
||||
export class PyDict {
|
||||
map = new Map<any, any>();
|
||||
@@ -157,9 +158,16 @@ function compare(op: string, a: any, b: any): boolean {
|
||||
}
|
||||
|
||||
function index(len: number, i: number) {
|
||||
if (!Number.isInteger(i)) throw new Error("Индекс последовательности должен быть целым числом");
|
||||
return i < 0 ? len + i : i;
|
||||
}
|
||||
|
||||
function safeProperty(name: any): boolean {
|
||||
if (typeof name !== "string") return typeof name === "number" && Number.isFinite(name);
|
||||
return !name.startsWith("__") &&
|
||||
!["constructor", "prototype", "caller", "callee", "arguments"].includes(name);
|
||||
}
|
||||
|
||||
function methodOf(obj: any, name: string): any {
|
||||
if (Array.isArray(obj)) {
|
||||
const m: Record<string, Builtin> = {
|
||||
@@ -184,7 +192,7 @@ function methodOf(obj: any, name: string): any {
|
||||
if (i >= 0) obj.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return m[name];
|
||||
return Object.hasOwn(m, name) ? m[name] : undefined;
|
||||
}
|
||||
if (obj instanceof PyDict) {
|
||||
const m: Record<string, Builtin> = {
|
||||
@@ -205,7 +213,7 @@ function methodOf(obj: any, name: string): any {
|
||||
clear: () => obj.map.clear(),
|
||||
copy: () => new PyDict(obj.map.entries()),
|
||||
};
|
||||
return m[name];
|
||||
return Object.hasOwn(m, name) ? m[name] : undefined;
|
||||
}
|
||||
if (typeof obj === "string") {
|
||||
const m: Record<string, Builtin> = {
|
||||
@@ -230,7 +238,7 @@ function methodOf(obj: any, name: string): any {
|
||||
isdigit: () => /^\d+$/.test(obj),
|
||||
isalpha: () => /^\p{L}+$/u.test(obj),
|
||||
};
|
||||
return m[name];
|
||||
return Object.hasOwn(m, name) ? m[name] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -256,6 +264,7 @@ export class Python {
|
||||
}
|
||||
|
||||
lookup(name: string): any {
|
||||
if (!safeProperty(name)) return null;
|
||||
for (let i = this.locals.length - 1; i >= 0; i--)
|
||||
if (this.locals[i].has(name)) return this.locals[i].get(name);
|
||||
if (this.scope.has(name)) return this.scope.get(name);
|
||||
@@ -266,12 +275,15 @@ export class Python {
|
||||
eval(e: Expr | null | undefined): any {
|
||||
if (!e) return null;
|
||||
switch (e.k) {
|
||||
case "nani":
|
||||
return evaluateNani(e.ast, (name) => safeProperty(name) && this.scope.has(name) ? this.scope.get(name) : undefined);
|
||||
case "const":
|
||||
return e.v;
|
||||
case "name":
|
||||
return this.lookup(e.id);
|
||||
case "attr": {
|
||||
const o = this.eval(e.o);
|
||||
if (!safeProperty(e.a) || typeof o === "function") return null;
|
||||
if (o === null || o === undefined) {
|
||||
this.warnOnce(`атрибут ${e.a} у None`);
|
||||
return null;
|
||||
@@ -285,6 +297,7 @@ export class Python {
|
||||
const o = this.eval(e.o);
|
||||
const i = this.eval(e.i);
|
||||
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
|
||||
if (!safeProperty(i) || typeof o === "function") return null;
|
||||
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
|
||||
return o?.[i] ?? null;
|
||||
}
|
||||
@@ -362,6 +375,7 @@ export class Python {
|
||||
bind(t: Expr, value: any, frame?: Map<string, any>) {
|
||||
switch (t.k) {
|
||||
case "name":
|
||||
if (!safeProperty(t.id)) throw new Error("Служебное имя недоступно");
|
||||
if (frame) frame.set(t.id, value);
|
||||
else this.scope.set(t.id, value);
|
||||
return;
|
||||
@@ -372,6 +386,7 @@ export class Python {
|
||||
}
|
||||
case "attr": {
|
||||
const o = this.eval(t.o);
|
||||
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
||||
if (o instanceof PyDict) o.set(t.a, value);
|
||||
else if (o) o[t.a] = value;
|
||||
return;
|
||||
@@ -380,6 +395,7 @@ export class Python {
|
||||
const o = this.eval(t.o);
|
||||
const i = this.eval(t.i);
|
||||
if (o instanceof PyDict) o.set(i, value);
|
||||
else if (!safeProperty(i) || typeof o === "function") throw new Error("Служебный ключ недоступен");
|
||||
else if (Array.isArray(o)) o[index(o.length, i)] = value;
|
||||
else if (o) o[i] = value;
|
||||
return;
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface ScriptModule {
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
engine?: string;
|
||||
naniExpressions?: Record<string, import("./naninovel").NaniAst>;
|
||||
format: string;
|
||||
title: string | null;
|
||||
resolution: [number, number];
|
||||
@@ -112,11 +114,14 @@ export interface GameData {
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
versionId?: number;
|
||||
revision?: string;
|
||||
format: string;
|
||||
files: Record<string, { hash: string; size: number }>;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
nodeId?: string;
|
||||
label: string;
|
||||
path: number[];
|
||||
// values of label parameters shadowed by a call, restored on return
|
||||
@@ -132,6 +137,7 @@ export interface ShownImage {
|
||||
}
|
||||
|
||||
export interface SaveData {
|
||||
revision?: string;
|
||||
v: 1;
|
||||
cur: Frame;
|
||||
stack: Frame[];
|
||||
|
||||
Reference in new issue
Block a user