From 3ac4f0371f5f1ea6d945a3eb350679b4e56e37b2 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:40:36 +0300 Subject: [PATCH] feat: eval/exec of strings, gallery Replay, callable actions; iNSight and SomethingBetter now pass - pyparse.ts: parser for expressions and simple statements built at run time, producing the sandbox's own IR; backs eval() and exec() (e.g. exec("persistent." + var + " = 0")) - Replay/EndReplay and renpy.end_replay: the engine stashes the full game state, runs the label with the given scope and restores everything when it ends; locked replays are insensitive. Save loading shared as applySave() - Screen actions are callable (MainMenu(confirm=False)()) - jump main_menu_screen is Ren'Py's own label: it opens the player's main menu - Screens: a closing quote ends the word before a letter ("text"size 15) but not before an operator ("a"+b); Text(...) pictures in image blocks - README: current capabilities and limits of the Ren'Py support Strict compatibility check on the local collection: Tomie, Twisted-Thoughts, Hooked, iNSight-of-you and SomethingBetter pass; TheFixer (custom AlphaMask wardrobe, missing labels) and That_New_Teacher (custom statements, Render API) do not. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 25 ++- backend/app/converter/compatibility.py | 11 +- backend/app/converter/rpy_helpers.py | 2 +- backend/app/converter/rpy_parser.py | 2 + backend/app/converter/screens.py | 8 +- backend/tests/test_screens.py | 10 + frontend/src/player/engine.ts | 82 +++++-- frontend/src/player/pyparse.ts | 297 +++++++++++++++++++++++++ frontend/src/player/python.ts | 1 + frontend/src/player/screens.ts | 22 +- frontend/tests/renpy-runtime.test.mjs | 24 +- 11 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 frontend/src/player/pyparse.ts diff --git a/README.md b/README.md index f786fb5..3a9e571 100644 --- a/README.md +++ b/README.md @@ -133,16 +133,23 @@ python -m tests.make_sample sample.zip # тестовая игра (из ba Python игры выполняется в песочнице плеера: свои `def` и `class` (наследование, `super()`, свойства, замыкания, `lambda`, `*args`/`**kwargs`), `try`/`except`/ -`finally`, `raise`, `assert`, `for`/`while … else`, comprehension. ATL: движение -(`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`, -`block`, `on show`/`on hide`, вызов других transform. +`finally`, `raise`, `assert`, `match`, `for`/`while … else`, comprehension, модули +`re`, `copy`, `time`, `datetime`, `collections.defaultdict`, `functools.partial`. +Код, который ждёт игрока (`renpy.input`, `narrator()`, реплики персонажей, `renpy.pause`, +`renpy.display_menu`, `renpy.call_screen`), работает внутри циклов и функций. `eval` и `exec` +строк разбираются своим парсером выражений и простых операторов. + +ATL: движение (`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`, +`block`, `on show`/`on hide`/`on hover`, `contains`, `time`, `function`, смена картинок с +`with dissolve`, параметры и вызов других transform, `matrixcolor`, `blur`. Просмотр сцен из +галереи (`Replay`/`EndReplay`). Экраны игры (`screen`, `call screen`, `show screen`) и их действия — см. [docs/SCREENS.md](docs/SCREENS.md). -Пока **не** поддерживается: в экранах — `input`, `imagemap`, `mousearea`, `drag`, `side`, в ATL — -`function`, `time`, `contains`, `matrixcolor` и шейдеры, -в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы, -`with`, системные модули (`os`, `socket`, …) и картинки, которые игра вычисляет -своими функциями. Такие места перечислены в отчёте конвертации у версии и -останавливают импорт. Игры-«песочницы» на `renpy.ui.*` и `im.*` пока не играбельны. +Пока **не** поддерживается: в экранах — `imagemap`, `mousearea`, `drag`, `side`; шейдеры +(выбрасываются как чисто графические); `AlphaMask` и transform, в теле которого картинка; +в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы, `with`, +системные модули (`os`, `socket`, …), а также собственные операторы и внутренности движка +(`renpy.register_statement`, `renpy.Render`). Такие места перечислены в отчёте конвертации +у версии и останавливают импорт. diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 0c6e403..0ec67ac 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -215,10 +215,14 @@ def mark_screen_calls(data) -> None: IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"} +# Labels Ren'Py itself defines (renpy/common); jumping to them goes to the player's own menu +ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"} + # Calls that make the player wait; Python containing them runs on the player's generator interpreter BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen"} -CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes"} +CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes", + "eval", "exec", "Replay", "EndReplay", "renpy.end_replay"} def blocking_names(data) -> list[str]: @@ -340,6 +344,9 @@ def audit_ir(data) -> None: is_method = (function.get("k") == "attr" and not name.startswith(("renpy.", "math.", "random.")) and (function.get("a") in METHODS or function.get("a") in user_methods or function.get("a") in user_names)) + # `MainMenu(confirm=False)()`: calling the action a screen action constructor returned + if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS: + is_method = True if name not in CALLS and name not in user_names and not is_method: data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location) if value.get("op") in {"jump", "call"} and value.get("target"): @@ -349,7 +356,7 @@ def audit_ir(data) -> None: visit(child, location) from app.converter.pipeline import anchors - labels = {label["name"] for module in data.modules.values() for label in module} + labels = {label["name"] for module in data.modules.values() for label in module} | ENGINE_LABELS labels |= {name for module in data.modules.values() for name in anchors(module)} for path, module in data.modules.items(): visit(module, path) diff --git a/backend/app/converter/rpy_helpers.py b/backend/app/converter/rpy_helpers.py index fce1b64..bddc8fc 100644 --- a/backend/app/converter/rpy_helpers.py +++ b/backend/app/converter/rpy_helpers.py @@ -18,7 +18,7 @@ _SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} _NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$") _CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(") _DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed", - "ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip", + "ConditionSwitch", "Image", "Text", "im.Scale", "im.FactorScale", "im.Flip", "im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"} _MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus", "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index 0d7a853..5761ba0 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -632,6 +632,8 @@ class RpyParser: args, kw = e["a"], e["kw"] if fname == "Solid" and args: return {"color": _const(args[0])} + if fname == "Text" and args: + return {"textExpr": args[0]} if fname in ("Movie",): play = kw.get("play") or (args[0] if args else None) return {"movie": _const(play)} if play else None diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index 6f8e222..dfdfb52 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -79,8 +79,13 @@ def ignorable(name: str) -> bool: def tokens(text: str) -> list[str]: """Split on whitespace outside brackets and quotes (Ren'Py "simple expressions").""" - out, cur, depth, quote, escaped = [], [], 0, "", False + out, cur, depth, quote, escaped, closed = [], [], 0, "", False, False for ch in text: + if closed: # `"text"size 15`: a quote that ends a word ends the token + closed = False + if (ch.isalpha() or ch == "_") and depth == 0: + out.append("".join(cur)) + cur = [] if quote: cur.append(ch) if escaped: @@ -89,6 +94,7 @@ def tokens(text: str) -> list[str]: escaped = True elif ch == quote: quote = "" + closed = True elif ch in "\"'": quote = ch cur.append(ch) diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py index a8dd838..6f9798b 100644 --- a/backend/tests/test_screens.py +++ b/backend/tests/test_screens.py @@ -159,3 +159,13 @@ def test_computed_atl_values_make_a_transform_animated(): assert block["params"][0]["n"] == "x" assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}} assert data.transforms["shift"] == {} + + +def test_a_closing_quote_ends_the_word_and_text_pictures_are_understood(): + data = parse( + 'screen hud():\n text "D [a] A [b]"size 15\n text "x" size 20\n' + 'image log_line:\n Text(log.message())\n alpha 0.0\n linear 0.5 alpha 1.0\n' + 'label start:\n call screen hud\n') + first = data.screens["hud"]["body"][0] + assert first["props"]["size"] == {"k": "const", "v": 15} + assert data.images["log_line"]["textExpr"]["k"] == "call" diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 9ae7b47..3f58a47 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -8,6 +8,7 @@ import { languageName, rememberLanguage, type Translator } from "./i18n"; import { naniText } from "./naninovel"; import { ScreenManager, type ScreenHost } from "./screens"; import { pythonModules } from "./pymodules"; +import { parseExpression, parseStatements } from "./pyparse"; import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util"; import type { CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, @@ -39,6 +40,7 @@ export class Engine { private waiters: (() => void)[] = []; private screens: ScreenManager; private screenFonts = new Map(); + private replayStash: SaveData | null = null; private seenImages = new Set(); private running = false; @@ -344,7 +346,10 @@ export class Engine { version: () => "pgvn", ...this.screens.renpyApi(), // presentation-only: nothing to roll back or replay, and the next transition is chosen by `with` - end_replay: () => null, + end_replay: () => { + this.endReplay(); + return null; + }, partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args), loadable: (path: any) => this.fileUrl(String(path)) !== null, is_init_phase: () => false, @@ -391,6 +396,13 @@ export class Engine { // a read-only view: `"name" in globals()` works, writes do not reach the game globals: () => new PyDict(Object.entries(this.store)), print: () => null, + // code built at run time ("persistent." + name + " = 0") goes through a parser producing the same + // sandboxed IR; there is no other way to run source text + eval: (source: any) => this.py.eval(parseExpression(pyStr(source))), + exec: (source: any) => { + this.py.exec(parseStatements(pyStr(source))); + return null; + }, Borders: (...a: number[]) => a, ...pythonModules(), _window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; }, @@ -614,6 +626,8 @@ export class Engine { if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer"); }, setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"), + startReplay: (label, scope) => this.startReplay(label, scope), + endReplay: () => this.endReplay(), quickSave: () => void this.save("quick"), quickLoad: () => void this.load("quick"), transform: (name) => this.game.transforms[name] ?? null, @@ -726,6 +740,7 @@ export class Engine { } private jump(target: string, args?: { a: any[]; kw: Record }, frame?: Frame) { + if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__"; if (target === "__main_menu__" || target === "__quit__") { throw new Restart(target); } @@ -776,6 +791,10 @@ export class Engine { private doReturn() { const frame = this.stack.pop(); if (!frame) { + if (this.replayStash) { + this.endReplay(); // the replayed label ran out: back to the game + return; + } this.cur = { label: "", path: [0] }; return; } @@ -1459,27 +1478,54 @@ export class Engine { frames.forEach((f, i) => Object.assign(f, mapped[i])); } if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return; + this.replayStash = null; + this.restart(() => this.applySave(data)); + } + + /** Put the game into the state described by a save (also used to come back from a replay). */ + private applySave(data: SaveData) { + this.resetStore(); + Object.assign(this.store, decodeValue(data.store)); + this.screens.restore(decodeValue(data.screens ?? [])); + this.renderer.scene(); + for (const s of data.scene) { + const img = this.resolveImage(s.name); + if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder }); + } + this.renderer.commit(); + this.audio.stopAll(); + if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 }); + this.windowShown = data.window; + this.renderer.camera(data.camera ?? []); + this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) })); + this.cur = data.cur; + this.lastSay = null; + this.history = []; + } + + /** Gallery replay: run a label with the given variables, then return to exactly where the game was. */ + private startReplay(label: string, scope: Record | null) { + if (!this.labels.has(label)) { + this.notify(`Метка «${label}» не найдена`); + return; + } + if (!this.replayStash) this.replayStash = this.snapshotState(); this.restart(() => { - this.resetStore(); - Object.assign(this.store, decodeValue(data.store)); - this.screens.restore(decodeValue(data.screens ?? [])); - this.renderer.scene(); - for (const s of data.scene) { - const img = this.resolveImage(s.name); - if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder }); - } - this.renderer.commit(); - this.audio.stopAll(); - if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 }); - this.windowShown = data.window; - this.renderer.camera(data.camera ?? []); - this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) })); - this.cur = data.cur; - this.lastSay = null; - this.history = []; + for (const [key, value] of Object.entries(scope ?? {})) this.store[key] = value; + this.store._in_replay = label; + this.stack = []; + this.cur = { label: "", path: [0] }; + this.guardedJump(label); }); } + private endReplay() { + const stash = this.replayStash; + if (!stash) return; + this.replayStash = null; + this.restart(() => this.applySave(stash)); + } + private setSkip(on: boolean) { this.skip = on; this.renderer.fast = on; diff --git a/frontend/src/player/pyparse.ts b/frontend/src/player/pyparse.ts new file mode 100644 index 0000000..2f9ed53 --- /dev/null +++ b/frontend/src/player/pyparse.ts @@ -0,0 +1,297 @@ +// A small parser for Python source built at run time (eval/exec of strings): expressions and simple +// statements only. It produces the same IR as the converter, so the sandbox's rules apply unchanged. + +import type { Expr, PyStmt } from "./types"; + +type Token = { t: "num" | "str" | "name" | "op" | "end"; v: any }; + +const OPS = ["**=", "//=", ">>=", "<<=", "**", "//", "==", "!=", "<=", ">=", "<<", ">>", "+=", "-=", "*=", "/=", "%=", + "&=", "|=", "^=", "->", "+", "-", "*", "/", "%", "<", ">", "=", "(", ")", "[", "]", "{", "}", ",", ":", ".", "&", "|", "^", + "~", ";"]; +const MAX_SOURCE = 20000; + +function fail(message: string): never { + throw new SyntaxError(`Синтаксическая ошибка в строке кода: ${message}`); +} + +function tokenize(source: string): Token[] { + if (source.length > MAX_SOURCE) fail("слишком длинный код"); + const out: Token[] = []; + let i = 0; + while (i < source.length) { + const ch = source[i]; + if (ch === "\n") { out.push({ t: "op", v: "\n" }); i++; continue; } + if (/\s/.test(ch)) { i++; continue; } + if (ch === "#") { while (i < source.length && source[i] !== "\n") i++; continue; } + if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] ?? ""))) { + const m = /^(0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(i))!; + out.push({ t: "num", v: Number(m[0]) }); + i += m[0].length; + continue; + } + if (/[A-Za-z_]/.test(ch)) { + const m = /^[A-Za-z_]\w*/.exec(source.slice(i))!; + const next = source[i + m[0].length]; + if (/^[rRuUbB]$/.test(m[0]) && (next === "'" || next === '"')) { i += m[0].length; continue; } // string prefix + out.push({ t: "name", v: m[0] }); + i += m[0].length; + continue; + } + if (ch === "'" || ch === '"') { + const triple = source.startsWith(ch.repeat(3), i); + const quote = triple ? ch.repeat(3) : ch; + let j = i + quote.length; + let text = ""; + while (j < source.length && !source.startsWith(quote, j)) { + if (source[j] === "\\" && j + 1 < source.length) { + const e = source[j + 1]; + text += ({ n: "\n", t: "\t", r: "\r", "0": "\0" } as Record)[e] ?? e; + j += 2; + } else text += source[j++]; + } + if (j >= source.length) fail("строка не закрыта"); + out.push({ t: "str", v: text }); + i = j + quote.length; + continue; + } + const op = OPS.find((o) => source.startsWith(o, i)); + if (!op) fail(`неизвестный символ «${ch}»`); + out.push({ t: "op", v: op }); + i += op.length; + } + out.push({ t: "end", v: null }); + return out; +} + +const KEYWORDS = new Set(["and", "or", "not", "in", "is", "if", "else", "lambda", "for", "while", "def", "class", "import", "from", + "return", "yield", "with", "try", "except", "raise", "global", "nonlocal", "assert", "del", "pass", "elif", "async", "await"]); +const BIN_LEVELS: string[][] = [["|"], ["^"], ["&"], ["<<", ">>"], ["+", "-"], ["*", "/", "//", "%"]]; +const COMPARE = new Set(["<", ">", "==", "!=", "<=", ">=", "in", "is"]); + +class Parser { + private pos = 0; + constructor(private tokens: Token[]) {} + + private peek(): Token { return this.tokens[this.pos]; } + private next(): Token { return this.tokens[this.pos++]; } + private isOp(v: string) { const t = this.peek(); return t.t === "op" && t.v === v; } + private isName(v: string) { const t = this.peek(); return t.t === "name" && t.v === v; } + private eat(v: string) { if (this.isOp(v)) { this.pos++; return true; } return false; } + private expect(v: string) { if (!this.eat(v)) fail(`ожидалось «${v}»`); } + atEnd() { return this.peek().t === "end"; } + + expression(): Expr { + const body = this.orTest(); + if (this.isName("if")) { + this.next(); + const cond = this.orTest(); + if (!this.isName("else")) fail("ожидалось else"); + this.next(); + return { k: "if", c: cond, t: body, e: this.expression() }; + } + return body; + } + + private orTest(): Expr { + const parts = [this.andTest()]; + while (this.isName("or")) { this.next(); parts.push(this.andTest()); } + return parts.length === 1 ? parts[0] : { k: "bool", op: "or", v: parts }; + } + + private andTest(): Expr { + const parts = [this.notTest()]; + while (this.isName("and")) { this.next(); parts.push(this.notTest()); } + return parts.length === 1 ? parts[0] : { k: "bool", op: "and", v: parts }; + } + + private notTest(): Expr { + if (this.isName("not")) { this.next(); return { k: "unary", op: "not", v: this.notTest() }; } + return this.comparison(); + } + + private comparison(): Expr { + const left = this.binary(0); + const ops: string[] = []; + const rights: Expr[] = []; + for (;;) { + const t = this.peek(); + let op: string | null = null; + if (t.t === "op" && COMPARE.has(t.v)) { op = t.v; this.next(); } + else if (t.t === "name" && t.v === "in") { op = "in"; this.next(); } + else if (t.t === "name" && t.v === "is") { this.next(); op = this.isName("not") ? (this.next(), "is not") : "is"; } + else if (t.t === "name" && t.v === "not" && this.tokens[this.pos + 1]?.v === "in") { this.pos += 2; op = "not in"; } + if (!op) break; + ops.push(op); + rights.push(this.binary(0)); + } + return ops.length ? { k: "cmp", l: left, ops, r: rights } : left; + } + + private binary(level: number): Expr { + if (level >= BIN_LEVELS.length) return this.factor(); + let left = this.binary(level + 1); + for (;;) { + const t = this.peek(); + if (t.t === "op" && BIN_LEVELS[level].includes(t.v)) { + this.next(); + left = { k: "bin", op: t.v, l: left, r: this.binary(level + 1) }; + } else return left; + } + } + + private factor(): Expr { + const t = this.peek(); + if (t.t === "op" && (t.v === "-" || t.v === "+" || t.v === "~")) { + this.next(); + return { k: "unary", op: t.v, v: this.factor() }; + } + return this.power(); + } + + private power(): Expr { + const base = this.primary(); + if (this.eat("**")) return { k: "bin", op: "**", l: base, r: this.factor() }; + return base; + } + + private primary(): Expr { + let e = this.atom(); + for (;;) { + if (this.eat(".")) { + const t = this.next(); + if (t.t !== "name") fail("ожидалось имя после точки"); + e = { k: "attr", o: e, a: t.v }; + } else if (this.eat("(")) { + const args: Expr[] = []; + const kw: Record = {}; + while (!this.isOp(")")) { + const t = this.peek(); + if (t.t === "name" && this.tokens[this.pos + 1]?.v === "=" && this.tokens[this.pos + 1].t === "op") { + this.pos += 2; + kw[t.v] = this.expression(); + } else args.push(this.expression()); + if (!this.eat(",")) break; + } + this.expect(")"); + e = { k: "call", f: e, a: args, kw }; + } else if (this.eat("[")) { + let lo: Expr | null = null; + if (!this.isOp(":")) lo = this.expression(); + if (this.eat(":")) { + const hi = this.isOp("]") || this.isOp(":") ? null : this.expression(); + const st = this.eat(":") && !this.isOp("]") ? this.expression() : null; + e = { k: "slice", o: e, lo, hi, st }; + } else e = { k: "sub", o: e, i: lo! }; + this.expect("]"); + } else return e; + } + } + + private atom(): Expr { + const t = this.next(); + if (t.t === "num") return { k: "const", v: t.v }; + if (t.t === "str") { + let text = t.v as string; + while (this.peek().t === "str") text += this.next().v; + return { k: "const", v: text }; + } + if (t.t === "name") { + if (t.v === "True") return { k: "const", v: true }; + if (t.v === "False") return { k: "const", v: false }; + if (t.v === "None") return { k: "const", v: null }; + if (KEYWORDS.has(t.v)) fail(`«${t.v}» здесь не поддерживается`); + return { k: "name", id: t.v }; + } + if (t.t === "op" && t.v === "(") { + if (this.eat(")")) return { k: "tuple", v: [] }; + const first = this.expression(); + if (this.eat(",")) { + const items = [first]; + while (!this.isOp(")")) { items.push(this.expression()); if (!this.eat(",")) break; } + this.expect(")"); + return { k: "tuple", v: items }; + } + this.expect(")"); + return first; + } + if (t.t === "op" && t.v === "[") { + const items: Expr[] = []; + while (!this.isOp("]")) { items.push(this.expression()); if (!this.eat(",")) break; } + this.expect("]"); + return { k: "list", v: items }; + } + if (t.t === "op" && t.v === "{") { + const pairs: [Expr, Expr][] = []; + while (!this.isOp("}")) { + const key = this.expression(); + this.expect(":"); + pairs.push([key, this.expression()]); + if (!this.eat(",")) break; + } + this.expect("}"); + return { k: "dict", v: pairs }; + } + return fail(`неожиданный элемент «${t.v ?? "конец"}»`); + } + + /** One simple statement: assignment, augmented assignment, pass or an expression. */ + statement(): PyStmt[] { + if (this.isName("pass")) { this.next(); return []; } + const first = this.expressionOrTuple(); + const t = this.peek(); + if (t.t === "op" && t.v === "=") { + const targets: Expr[] = [first]; + let value: Expr = first; + while (this.eat("=")) { + value = this.expressionOrTuple(); + if (this.isOp("=")) targets.push(value); + } + return targets.map((target) => ({ op: "set", t: asTarget(target), v: value })); + } + if (t.t === "op" && /^(\*\*|\/\/|>>|<<|[+\-*/%&|^])=$/.test(t.v)) { + this.next(); + return [{ op: "aug", t: asTarget(first), o: t.v.slice(0, -1), v: this.expressionOrTuple() }]; + } + return [{ op: "eval", e: first }]; + } + + private expressionOrTuple(): Expr { + const first = this.expression(); + if (!this.isOp(",")) return first; + const items = [first]; + while (this.eat(",")) { + if (this.atEnd() || this.isOp("=") || this.isOp("\n") || this.isOp(";")) break; + items.push(this.expression()); + } + return { k: "tuple", v: items }; + } + + separators() { + while (this.isOp("\n") || this.isOp(";")) this.pos++; + } +} + +function asTarget(e: Expr): Expr { + if (e.k === "name" || e.k === "attr" || e.k === "sub") return e; + if (e.k === "tuple" || e.k === "list") return { k: "tuple", v: e.v.map(asTarget) }; + return fail("в эту часть нельзя присваивать"); +} + +export function parseExpression(source: string): Expr { + const parser = new Parser(tokenize(source.trim())); + const e = parser.expression(); + parser.separators(); + if (!parser.atEnd()) fail("лишний текст после выражения"); + return e; +} + +export function parseStatements(source: string): PyStmt[] { + const parser = new Parser(tokenize(source)); + const out: PyStmt[] = []; + parser.separators(); + while (!parser.atEnd()) { + out.push(...parser.statement()); + parser.separators(); + } + return out; +} diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index 09c0ae4..4de1fe2 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -585,6 +585,7 @@ export class Python { } const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args; if (f instanceof PyClass) return this.instantiate(f, call); + if (f && typeof f === "object" && typeof f.__call__ === "function") return f.__call__(...call); // screen actions if (typeof f !== "function") { this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`); return null; diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index ebcd7ed..e47db6a 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -38,6 +38,8 @@ export interface ScreenHost { openUrl(url: string): void; quickSave(): void; quickLoad(): void; + startReplay(label: string, scope: Record | null): void; + endReplay(): void; setLanguage(code: string | null): void; transform(name: string): Record | null; atl(name: string): AtlBlock | null; @@ -49,6 +51,8 @@ class ActionAbort extends Error {} /** A screen action such as Jump("x"); run when the player activates a widget. */ export class ScreenAction { + /** Actions are callable in Ren'Py: `MainMenu(confirm=False)()` performs the action at once. */ + __call__: () => null = () => null; constructor(public kind: string, public args: any[], public kw: Props, public screen: Instance | null) {} } @@ -1084,7 +1088,8 @@ export class ScreenManager { private actionSensitive(action: any): boolean { const list = Array.isArray(action) ? action : [action]; - return list.every((a) => !(a instanceof ScreenAction) || a.kind !== "SensitiveIf" || truthy(a.args[0])); + return list.every((a) => !(a instanceof ScreenAction) || + (a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true)); } private actionSelected(action: any): boolean { @@ -1169,6 +1174,12 @@ export class ScreenManager { } case "SetLocalVariable": case "SetScreenVariable": owner?.vars.set(a, b); return; + case "Replay": { + const scope = action.kw.scope ?? b; + this.host.startReplay(String(a), scope instanceof PyDict ? Object.fromEntries([...scope.map.entries()].map(([k, v]) => [String(k), v])) : null); + throw new ActionAbort(); + } + case "EndReplay": this.host.endReplay(); throw new ActionAbort(); case "QuickSave": this.host.quickSave(); return; case "QuickLoad": this.host.quickLoad(); throw new ActionAbort(); case "ToggleScreenVariable": { @@ -1217,13 +1228,18 @@ export class ScreenManager { const make = (kind: string) => (...args: any[]) => { const last = args[args.length - 1]; const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); - return new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering); + const action = new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering); + action.__call__ = () => { + this.run(action, action.screen); + return null; + }; + return action; }; const out: Record = {}; for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField", "SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play", "Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu", - "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad"]) out[kind] = make(kind); + "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay"]) out[kind] = make(kind); const shape = (kind: string) => (...args: any[]) => { const last = args[args.length - 1]; const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); diff --git a/frontend/tests/renpy-runtime.test.mjs b/frontend/tests/renpy-runtime.test.mjs index fc00ef3..1a60391 100644 --- a/frontend/tests/renpy-runtime.test.mjs +++ b/frontend/tests/renpy-runtime.test.mjs @@ -9,12 +9,12 @@ Object.assign(globalThis, { window, document }); globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4); const compiled = await build({ - stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules";`, + stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules"; export * from "./pyparse";`, resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" }, bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent", }); const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); -const { Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m; +const { parseExpression, parseStatements, Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const name = (id) => ({ k: "name", id }); const attr = (o, a) => ({ k: "attr", o, a }); @@ -130,3 +130,23 @@ test("on hover handlers run when the button reports the event", async () => { await wait(20); assert.equal(props.alpha, 1); }); + +test("eval/exec of source built at run time use the sandbox's own rules", () => { + const { py, store } = python(); + store.score = 7; + store.persistent = new PyDict(); + const evaluate = (source) => py.eval(parseExpression(source)); + assert.equal(evaluate("score * 2 + 1"), 15); + assert.equal(evaluate("'a' + 'b' if score > 5 else 'c'"), "ab"); + assert.equal(evaluate("not (score < 3) and score in [7, 8]"), true); + assert.deepEqual(evaluate("[1, 2, (3, 4)][2]"), [3, 4]); + assert.equal(evaluate("score < 10 < 20"), true); + py.exec(parseStatements("score += 3; other = score ** 2\npersistent.seen = 1")); + assert.equal(store.score, 10); + assert.equal(store.other, 100); + assert.equal(store.persistent.get("seen"), 1); + assert.throws(() => parseExpression("score +"), /Синтаксическая ошибка/); + assert.throws(() => parseExpression("lambda: 1"), /не поддерживается/); + assert.throws(() => parseStatements("import os"), /не поддерживается/); + assert.equal(py.eval(parseExpression("__import__")), null, "dunder names stay unreachable"); +});