diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index c924ee2..0c6e403 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -215,6 +215,53 @@ def mark_screen_calls(data) -> None: IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"} +# 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"} + + +def blocking_names(data) -> list[str]: + """Names of game functions and methods that may wait for the player, directly or through each other.""" + definitions: dict[str, list] = {} + + def collect(statements: list) -> None: + for statement in statements: + if statement.get("op") == "def": + definitions.setdefault(statement["name"], []).append(statement) + elif statement.get("op") == "class": + collect([m for m in statement["body"] if m.get("op") == "def"]) + + collect(data.init_code) + waiting = BLOCKING_BUILTINS | set(data.characters) | {"narrator"} + found: set[str] = set() + + def waits(node, known: set[str]) -> bool: + stack = [node] + while stack: + value = stack.pop() + if isinstance(value, list): + stack.extend(value) + elif isinstance(value, dict): + if value.get("k") == "call": + function = value["f"] + last = function.get("id") if function.get("k") == "name" else function.get("a") + dotted = ("renpy." + function["a"]) if (function.get("k") == "attr" and + function["o"].get("k") == "name" and function["o"]["id"] == "renpy") else last + if dotted in waiting or last in known or last in waiting: + return True + stack.extend(value.values()) + return False + + changed = True + while changed: + changed = False + for name, nodes in definitions.items(): + if name not in found and any(waits(n["body"], found) for n in nodes): + found.add(name) + changed = True + return sorted(found) + def reachable_init_code(data, used_screens) -> list: """The part of `init python` that can run: startup statements plus every function, class and @@ -273,6 +320,7 @@ def audit_ir(data) -> None: user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images, list(used_screens.values())]) user_names |= {name for name, _ in data.defaults} + user_names |= set(data.characters) | {"narrator"} def dotted(value): if value.get("k") == "name": diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index c7e7422..dbffc52 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -19,6 +19,7 @@ from app.converter.compatibility import ( CompatibilityError, Issue, audit_ir, + blocking_names, detect, require_supported_scripts, ) @@ -191,6 +192,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress "images": images, "transforms": data.transforms, "atl": data.atl, + "blocking": blocking_names(data), "screens": {name: data.screens[name] for name in sorted(_used_screens(data))}, "styles": data.styles, "transitions": data.transitions, diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index f5b2a8d..0d7a853 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -717,26 +717,32 @@ class RpyParser: return bool(m and m.group(1) in _DISPLAYABLE_CALLS) def expression_picture(self, line: Line) -> dict[str, Any] | None: - """``image x:`` whose first line is a call computing the picture (``get_face("base")``) followed by - transform lines: the picture is evaluated when the image is shown, the transforms are ATL.""" + """``image x:`` whose first line is an expression computing the picture (``get_face("base")`` or + ``("a_" + str(n))``), optionally followed by transform lines: it is evaluated when the image is shown.""" if not line.block: return None first = line.block[0] - match = _CALL_LINE.match(first.text) + text = first.text.strip() + match = _CALL_LINE.match(text) word = Cursor(first.text).peek_word() or "" - if (not match or self.displayable_line(first) or match.group(1) in self.d.transforms - or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS): + if not (match or text.startswith("(")): return None + if match and (self.displayable_line(first) or match.group(1) in self.d.transforms + or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS): + return None + expr = self.expr(text, first) + if expr is None: + return None + picture: dict[str, Any] = {"expr": expr} rest = line.block[1:] - expr = self.expr(first.text, first) - if expr is None or not rest: - return None - issues: list[tuple[str, str]] = [] - props, animation = self.atl_spec(rest, issues.append, set()) - if issues or not animation: - return None + if rest: + issues: list[tuple[str, str]] = [] + props, animation = self.atl_spec(rest, issues.append, set()) + if issues or not animation: + return None + picture["at"] = [{"props": props, "atl": animation}] self.d.warn("Изображение задано выражением", self.where(first), blocking=False) - return {"expr": expr, "at": [{"props": props, "atl": animation}]} + return picture def atl_image(self, line: Line) -> dict[str, Any] | None: if (picture := self.expression_picture(line)) is not None: @@ -1231,8 +1237,7 @@ class RpyParser: name = c.word() if not name: return [] - self.d.screen_used.add(name) - return [{"op": "hide_screen", "name": name}] + return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless spec = self.image_spec(c, line) node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]} self.show_clauses(c, line, node) diff --git a/backend/tests/test_converter.py b/backend/tests/test_converter.py index a21b1a2..73f40bd 100644 --- a/backend/tests/test_converter.py +++ b/backend/tests/test_converter.py @@ -195,3 +195,24 @@ def test_gui_variants_and_renpy_store_are_understood(): assert d.init_code[0]["decos"] == [] and d.init_code[0]["name"] == "twice" assert d.init_code[1]["e"]["f"]["o"] == {"k": "name", "id": "seen"} assert not d.blockers + + +def test_functions_that_wait_for_the_player_are_listed_transitively(): + from app.converter.compatibility import audit_ir, blocking_names + + d = parse( + "define e = Character('E')\n" + "init python:\n" + " def ask_name():\n" + " return renpy.input('Name?')\n" + " def outer():\n" + " n = ask_name()\n" + " e('hi ' + n)\n" + " def plain(x):\n" + " return x + 1\n" + "label start:\n" + " $ outer()\n" + " $ y = plain(2)\n") + audit_ir(d) + assert not d.blockers + assert blocking_names(d) == ["ask_name", "outer"] diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index a6f8595..9ae7b47 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -1,7 +1,7 @@ // PGVN interpreter + in-game UI. import { AudioSystem } from "./audio"; -import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python"; +import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python"; import { Renderer, type ResolvedImage } from "./renderer"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { languageName, rememberLanguage, type Translator } from "./i18n"; @@ -107,7 +107,11 @@ export class Engine { }; this.py = new Python(scope); this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {}); - this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins() }; + this.py.blockingNames = new Set([ + "renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen", + "narrator", ...Object.keys(game.characters), ...(game.blocking ?? []), + ]); + this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() }; const [w, h] = game.resolution; this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306); @@ -302,11 +306,30 @@ export class Engine { call: (label: string) => { throw new JumpSignal("call", label); }, - input: (...a: any[]) => { - const [prompt, def] = kwless(a); + // calls that wait for the player return a request; the generator interpreter answers it + input: (...a: any[]) => new BlockingCall("input", [kwless(a)[0] ?? "", kwless(a)[1] ?? kwOf(a).default ?? ""], kwOf(a)), + say: (who: any, what: any, ...a: any[]) => new BlockingCall("say", [who, what], kwOf(a)), + pause: (...a: any[]) => new BlockingCall("pause", [kwless(a)[0] ?? kwOf(a).delay ?? null], kwOf(a)), + display_menu: (items: any, ...a: any[]) => new BlockingCall("menu", [items], kwOf(a)), + with_statement: (transition: any) => new BlockingCall("with", [transition]), + call_screen: (name: any, ...a: any[]) => new BlockingCall("screen", [name, ...kwless(a)], kwOf(a)), + show: (name: any, ...a: any[]) => { const kw = kwOf(a); - const v = window.prompt(pyStr(prompt ?? ""), pyStr(def ?? kw.default ?? "")); - return (v ?? pyStr(def ?? kw.default ?? "")).slice(0, kw.length ?? 1000); + const parts = String(name).split(" "); + const picture = this.resolveImage(parts); + if (!picture) return null; + const at = ((kw.at_list ?? []) as any[]).filter((t) => t && t.__transform).map((t) => ({ name: t.__transform })); + this.renderer.show(picture, kw.tag ?? parts[0], at, { zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined }); + return null; + }, + hide: (name: any) => { + this.renderer.hide(String(name).split(" ")[0]); + return null; + }, + scene: () => { + this.renderer.scene(); + this.clearNvl(); + return null; }, has_label: (l: string) => this.labels.has(l), seen_label: (l: string) => truthy(this.persistent._seen?.includes?.(l)), @@ -332,6 +355,7 @@ export class Engine { register_shader: () => null, register_statement: () => null, redraw: () => null, + set_tag_attributes: () => null, stop_predict: () => null, map_event: () => null, count_displayables_in_layer: () => 0, @@ -367,6 +391,7 @@ 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, + Borders: (...a: number[]) => a, ...pythonModules(), _window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; }, _window_show: () => { this.windowShown = true; return null; }, @@ -427,6 +452,147 @@ export class Engine { } } + /** Python names that make the player wait: the game's characters and narrator, Character(...) objects. */ + private waitingBuiltins(): Record { + const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {}; + const posOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw ? a.slice(0, -1) : a); + const speak = (who: any) => (...a: any[]) => new BlockingCall("say", [who, posOf(a)[0]], kwOf(a)); + const out: Record = { narrator: speak(null) }; + for (const id of Object.keys(this.game.characters)) out[id] = speak(id); + out.Character = (...a: any[]) => { + const kw = kwOf(a); + const speaker: any = (...b: any[]) => new BlockingCall("say", [speaker, posOf(b)[0]], kwOf(b)); + speaker.__character = true; + speaker.name = typeof posOf(a)[0] === "string" ? posOf(a)[0] : null; + speaker.color = kw.color ?? null; + return speaker; + }; + // transitions are values in Python (`renpy.with_statement(dissolve)`), transforms are named by at_list + const transitions: Record = { + dissolve: { t: "dissolve", d: 0.5 }, fade: { t: "fade", out: 0.5, hold: 0, in: 0.5, color: "#000000" }, + move: { t: "move", d: 0.5 }, ease: { t: "move", d: 0.5 }, vpunch: { t: "punch", dir: "v", d: 0.3 }, + hpunch: { t: "punch", dir: "h", d: 0.3 }, ...this.game.transitions, + }; + Object.assign(out, transitions); + out.Dissolve = (d = 0.5) => ({ t: "dissolve", d }); + out.Fade = (outTime = 0.5, hold = 0, inTime = 0.5, ...a: any[]) => + ({ t: "fade", out: outTime, hold, in: inTime, color: kwOf(a).color ?? "#000000" }); + for (const name of [...Object.keys(this.game.transforms), ...Object.keys(this.game.atl ?? {}), + "left", "right", "center", "truecenter", "topleft", "topright", "top", "default", "reset", "offscreenleft", + "offscreenright"]) { + if (!(name in out)) out[name] = { __transform: name }; + } + return out; + } + + /** Run a generator of the Python interpreter: each waiting call is answered here. */ + private async drive(generator: Generator): Promise { + let step = generator.next(); + while (!step.done) { + let answer: any; + try { + answer = await this.answerBlocking(step.value); + } catch (err) { + if (err instanceof Restart || err instanceof JumpSignal) { + generator.return(undefined as T); + throw err; + } + step = generator.throw(err); // Python code may catch it + continue; + } + step = generator.next(answer); + } + return step.value; + } + + private async answerBlocking(request: BlockingCall): Promise { + const [first, second] = request.args; + switch (request.kind) { + case "say": { + const node: Node = { op: "say", what: "", whatExpr: { k: "const", v: pyStr(second) }, + interact: request.kw.interact !== false }; + if (typeof first === "string") node.who = first; + else if (first && first.__character) node.whoObj = first; + await this.say(node); + return null; + } + case "pause": + await this.pause(first === null || first === undefined ? null : Number(first)); + return null; + case "input": + return this.askText(pyStr(first), pyStr(second), request.kw); + case "menu": + return this.chooseItem(first); + case "with": + await this.renderer.transition(first && typeof first === "object" ? first : null); + return null; + case "screen": { + const name = String(first); + const value = await this.wait((done) => this.screens.show(name, request.args.slice(1), request.kw, { call: true, onReturn: done })); + this.screens.hideCall(name); + return value ?? null; + } + } + throw new Error(`Неизвестный запрос ${request.kind}`); + } + + /** renpy.input(): a text field over the scene. */ + private askText(prompt: string, initial: string, kw: Record): Promise { + this.interactionStart(); + this.setSkip(false); + return this.wait((done) => { + this.menuEl.textContent = ""; + const box = el("div", "pg-input"); + if (prompt) { + const label = el("div", "pg-input-prompt"); + renderText(label, parseText(this.interp(prompt)), Infinity, this.baseSize); + box.append(label); + } + const input = document.createElement("input"); + input.type = "text"; + input.value = initial; + if (typeof kw.length === "number") input.maxLength = kw.length; + const ok = el("button", "pg-choice", "OK"); + const submit = () => { + let value = input.value; + if (typeof kw.exclude === "string") value = [...value].filter((c) => !kw.exclude.includes(c)).join(""); + if (typeof kw.allow === "string") value = [...value].filter((c) => kw.allow.includes(c)).join(""); + done(value); + }; + ok.onclick = (ev) => { ev.stopPropagation(); submit(); }; + input.onkeydown = (ev) => { + ev.stopPropagation(); + if (ev.key === "Enter") { ev.preventDefault(); submit(); } + }; + input.onclick = (ev) => ev.stopPropagation(); + box.append(input, ok); + this.menuEl.append(box); + this.menuEl.classList.remove("pg-hidden"); + input.focus(); + }).finally(() => this.menuEl.classList.add("pg-hidden")); + } + + /** renpy.display_menu(): a list of (caption, value) pairs; a None value is a caption line. */ + private chooseItem(items: any): Promise { + this.interactionStart(); + this.setSkip(false); + return this.wait((done) => { + this.menuEl.textContent = ""; + for (const item of (Array.isArray(items) ? items : []) as any[]) { + const [caption, value] = Array.isArray(item) ? item : [item, item]; + if (value === null || value === undefined) { + this.menuEl.append(el("div", "pg-caption", pyStr(caption))); + continue; + } + const button = el("button", "pg-choice"); + renderText(button, parseText(this.interp(pyStr(caption))), Infinity, this.baseSize); + button.onclick = (ev) => { ev.stopPropagation(); done(value); }; + this.menuEl.append(button); + } + this.menuEl.classList.remove("pg-hidden"); + }).finally(() => this.menuEl.classList.add("pg-hidden")); + } + private screenHost(): ScreenHost { const engine = this; return { @@ -834,7 +1000,8 @@ export class Engine { return; case "exec": try { - this.py.exec(node.code); + if (this.py.mayBlock(node.code)) await this.drive(this.py.execG(node.code)); + else this.py.exec(node.code); } catch (err) { if (err instanceof JumpSignal) throw err; if (this.game.engine === "naninovel") throw err; @@ -899,6 +1066,10 @@ export class Engine { } private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } { + if (node.whoObj) { + const speaker = node.whoObj; + return { char: { color: speaker.color ?? undefined }, name: this.interp(this.tr.str(speaker.name ?? "")), mode: "adv" }; + } let who: string | undefined = node.who; if (node.whoExpr) { const value = this.safeEval(node.whoExpr); diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index ffe33c5..3d7113d 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -360,3 +360,8 @@ .pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; } .pg-sadd { pointer-events: none; } .pg-sviewport { scrollbar-width: thin; } + +/* --- renpy.input / renpy.display_menu ----------------------------------------------- */ +.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; } +.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; } +.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; } diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index 163999f..09c0ae4 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -34,6 +34,16 @@ export class JumpSignal { class BreakSignal {} class ContinueSignal {} +/** + * What a function such as renpy.input() or narrator("...") returns instead of a value: the player has to do + * something (show text, wait, ask) before the Python code can continue. Code that may hold such calls runs + * through the generator interpreter (execG/evalG), which hands the request to the engine and resumes with + * the answer. + */ +export class BlockingCall { + constructor(public kind: string, public args: any[] = [], public kw: Record = {}) {} +} + class ReturnSignal { constructor(public value: any) {} } @@ -133,6 +143,7 @@ type FnCtx = { }; let activePython: Python | null = null; +const NO_SELF = Symbol("no-self"); function instanceText(v: PyInstance): string { for (const name of ["__str__", "__repr__"]) { @@ -405,6 +416,9 @@ export class Python { /** > 0 while a try block runs: lookups that normally yield None raise Python exceptions. */ strict = 0; handling: PyInstance[] = []; + /** Names of functions that may wait for the player (game-defined ones come from the converter). */ + blockingNames = new Set(); + private blockMemo = new WeakMap(); constructor(public scope: Scope) { activePython = this; @@ -575,7 +589,11 @@ export class Python { this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`); return null; } - return f(...call); + const result = f(...call); + if (result instanceof BlockingCall) { + throw new Error(`${result.kind}() здесь вызвать нельзя: код должен выполняться в сценарии`); + } + return result; } } this.warnOnce(`неизвестное выражение ${e.k}`); @@ -792,7 +810,8 @@ export class Python { return fn; } - private callUser(fn: any, given: any[]): any { + /** Bind a call's arguments to the parameters of a user function. */ + private bindArguments(fn: any, given: any[]): Map { const def: FnDef = fn.__def; let args = given; let kw: Record = {}; @@ -828,7 +847,12 @@ export class Python { const kwParam = def.params.find((p: any) => p.kind === "**"); if (kwParam) frame.set(kwParam.n, new PyDict(rest.map((k) => [k, kw[k]]))); else if (rest.length) throw new TypeError(`${def.name}(): неожиданный аргумент ${rest[0]}`); + return frame; + } + /** Make `frame` the running scope of `fn`; returns the function that restores the caller's scope. */ + private enterFunction(fn: any, frame: Map): () => void { + const def: FnDef = fn.__def; if (this.depth >= MAX_CALL_DEPTH) throw new Error("Слишком глубокая рекурсия в python"); const previousLocals = this.locals; const previousFn = this.fn; @@ -838,6 +862,16 @@ export class Python { vars: frame, globals: new Set(), nonlocals: new Set(), owner: fn.__owner, self: def.params.length ? frame.get(def.params[0].n) : undefined, isClassBody: false, index: 0, }; + return () => { + this.depth--; + this.locals = previousLocals; + this.fn = previousFn; + }; + } + + private callUser(fn: any, given: any[]): any { + const def: FnDef = fn.__def; + const leave = this.enterFunction(fn, this.bindArguments(fn, given)); try { if (def.expr) return this.eval(def.expr); try { @@ -848,12 +882,286 @@ export class Python { } return null; } finally { - this.depth--; - this.locals = previousLocals; - this.fn = previousFn; + leave(); } } + // --- code that waits for the player (generator interpreter) -------------------------- + + /** Whether running this code may need to wait for the player. Definitions only matter when called. */ + mayBlock(node: any): boolean { + if (!node || typeof node !== "object") return false; + const known = this.blockMemo.get(node); + if (known !== undefined) return known; + this.blockMemo.set(node, false); // recursion guard + let result = false; + if (Array.isArray(node)) result = node.some((n) => this.mayBlock(n)); + else if (node.op === "def" || node.op === "class" || node.k === "lambda") result = false; + else { + if (node.k === "call") { + const f = node.f; + const last = f.k === "name" ? f.id : f.k === "attr" ? f.a : null; + const dotted = f.k === "attr" && f.o.k === "name" ? `${f.o.id}.${f.a}` : last; + result = !!(last && this.blockingNames.has(last)) || !!(dotted && this.blockingNames.has(dotted)); + } + if (!result) for (const value of Object.values(node)) if (this.mayBlock(value)) { result = true; break; } + } + this.blockMemo.set(node, result); + return result; + } + + private defMayBlock(def: FnDef): boolean { + return this.mayBlock(def.body) || this.mayBlock(def.expr); + } + + /** Hand a request to the engine and continue with its answer. The interpreter's own scope is set + * aside meanwhile: other code (screens) runs while the player decides. */ + private *suspend(request: BlockingCall): Generator { + const saved = { locals: this.locals, fn: this.fn, depth: this.depth, strict: this.strict, handling: this.handling }; + this.locals = []; + this.fn = null; + this.depth = 0; + this.strict = 0; + this.handling = []; + try { + return yield request; + } finally { + Object.assign(this, saved); + } + } + + *execG(stmts: PyStmt[]): Generator { + let guard = 0; + for (const s of stmts) { + if (!this.mayBlock(s)) { + this.exec([s]); + continue; + } + switch (s.op) { + case "set": + this.bind(s.t, yield* this.evalG(s.v)); + break; + case "aug": + this.bind(s.t, binop(s.o, this.eval(s.t), yield* this.evalG(s.v))); + break; + case "eval": + yield* this.evalG(s.e); + break; + case "if": + yield* this.execG(truthy(yield* this.evalG(s.c)) ? s.then : s.else); + break; + case "while": { + let broke = false; + while (truthy(yield* this.evalG(s.c))) { + if (++guard > 100000) throw new Error("бесконечный цикл в python"); + if (yield* this.loopBodyG(s.body)) { + broke = true; + break; + } + } + if (!broke && s.else?.length) yield* this.execG(s.else); + break; + } + case "for": { + let broke = false; + for (const item of [...iterate(yield* this.evalG(s.it))]) { + this.bind(s.t, item); + if (yield* this.loopBodyG(s.body)) { + broke = true; + break; + } + } + if (!broke && s.else?.length) yield* this.execG(s.else); + break; + } + case "try": + yield* this.execTryG(s); + break; + case "return": + if (!this.fn) throw new Error("return вне функции"); + throw new ReturnSignal(s.v ? yield* this.evalG(s.v) : null); + case "assert": + if (!truthy(yield* this.evalG(s.c))) this.raise("AssertionError", s.m ? pyStr(this.eval(s.m)) : ""); + break; + default: + this.exec([s]); + } + } + } + + private *loopBodyG(body: PyStmt[]): Generator { + try { + yield* this.execG(body); + } catch (err) { + if (err instanceof BreakSignal) return true; + if (err instanceof ContinueSignal) return false; + throw err; + } + return false; + } + + private *execTryG(s: PyStmt): Generator { + try { + let handled = false; + this.strict++; + try { + yield* this.execG(s.body); + } catch (err) { + this.strict--; + const exception = this.caught(err); + const handler = exception && s.handlers.find((h: any) => { + if (h.types === null) return true; + const kinds = this.eval(h.types); + return (Array.isArray(kinds) ? kinds : [kinds]).some((k: any) => + k instanceof PyClass && exception.cls.mro().includes(k)); + }); + if (!exception || !handler) throw err; + handled = true; + if (handler.name) this.bindName(handler.name, exception); + this.handling.push(exception); + try { + yield* this.execG(handler.body); + } finally { + this.handling.pop(); + } + return; + } + this.strict--; + if (!handled && s.else?.length) yield* this.execG(s.else); + } finally { + if (s.final?.length) yield* this.execG(s.final); + } + } + + /** Evaluate an expression that may contain waiting calls. Everything else is delegated to eval(). */ + *evalG(e: Expr | null | undefined): Generator { + if (!e) return null; + if (!this.mayBlock(e)) return this.eval(e); + const wrap = (value: any): Expr => ({ k: "const", v: value }); + switch (e.k) { + case "call": + return yield* this.callG(e); + case "attr": + return this.eval({ k: "attr", o: wrap(yield* this.evalG(e.o)), a: e.a }); + case "sub": + return this.eval({ k: "sub", o: wrap(yield* this.evalG(e.o)), i: wrap(yield* this.evalG(e.i)) }); + case "bin": { + const left = yield* this.evalG(e.l); + return this.eval({ k: "bin", op: e.op, l: wrap(left), r: wrap(yield* this.evalG(e.r)) }); + } + case "unary": + return this.eval({ k: "unary", op: e.op, v: wrap(yield* this.evalG(e.v)) }); + case "bool": { + let value: any = null; + for (const part of e.v as Expr[]) { + value = yield* this.evalG(part); + if (e.op === "and" ? !truthy(value) : truthy(value)) return value; + } + return value; + } + case "cmp": { + const operands: Expr[] = [wrap(yield* this.evalG(e.l))]; + for (const r of e.r as Expr[]) operands.push(wrap(yield* this.evalG(r))); + return this.eval({ k: "cmp", l: operands[0], ops: e.ops, r: operands.slice(1) }); + } + case "if": + return yield* this.evalG(truthy(yield* this.evalG(e.c)) ? e.t : e.e); + case "list": + case "tuple": { + const out: any[] = []; + for (const part of e.v as Expr[]) out.push(yield* this.evalG(part)); + return out; + } + case "dict": { + const out = new PyDict(); + for (const [k, v] of e.v as [Expr, Expr][]) { + const key = yield* this.evalG(k); + out.set(key, yield* this.evalG(v)); + } + return out; + } + case "fmt": { + let text = ""; + for (const part of e.v as any[]) text += typeof part === "string" ? part : pyStr(yield* this.evalG(part)); + return text; + } + } + throw new Error(`Ожидание игрока внутри выражения «${e.k}» не поддерживается`); + } + + private *callG(e: Expr): Generator { + let f: any; + let owner: any = NO_SELF; + if (e.f.k === "attr") { + const o = yield* this.evalG(e.f.o); + const raw = o instanceof PyInstance ? o.cls.find(e.f.a) : o instanceof SuperProxy ? this.superMember(o, e.f.a) : null; + if (raw && typeof raw.value === "function" && raw.value.__def) { + f = raw.value; + owner = o instanceof SuperProxy ? o.self : o; + } else { + f = this.eval({ k: "attr", o: { k: "const", v: o }, a: e.f.a }); + } + } else { + f = yield* this.evalG(e.f); + } + const args: any[] = []; + for (const x of e.a as Expr[]) { + if (x.k === "star") args.push(...iterate(yield* this.evalG(x.v))); + else args.push(yield* this.evalG(x)); + } + const kw: Record = {}; + for (const [k, v] of Object.entries(e.kw)) kw[k] = yield* this.evalG(v as Expr); + for (const x of (e.ks ?? []) as Expr[]) { + const m = yield* this.evalG(x); + if (m instanceof PyDict) for (const [k, v] of m.map.entries()) kw[String(k)] = v; + } + const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args; + if (f instanceof PyClass) return yield* this.instantiateG(f, call); + if (typeof f !== "function") { + this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`); + return null; + } + const full = owner !== NO_SELF ? [owner, ...call] : call; + if (f.__def && this.defMayBlock(f.__def)) return yield* this.callUserG(f, full); + const result = f(...full); + return result instanceof BlockingCall ? yield* this.suspend(result) : result; + } + + private superMember(proxy: SuperProxy, name: string) { + for (const base of proxy.owner.bases) { + const found = base.find(name); + if (found) return found; + } + return undefined; + } + + private *callUserG(fn: any, given: any[]): Generator { + const def: FnDef = fn.__def; + const leave = this.enterFunction(fn, this.bindArguments(fn, given)); + try { + if (def.expr) return yield* this.evalG(def.expr); + try { + yield* this.execG(def.body ?? []); + } catch (err) { + if (err instanceof ReturnSignal) return err.value; + throw err; + } + return null; + } finally { + leave(); + } + } + + private *instantiateG(cls: PyClass, call: any[]): Generator { + const instance = new PyInstance(cls); + const init = cls.find("__init__"); + if (init && typeof init.value === "function") { + if (init.value.__def && this.defMayBlock(init.value.__def)) yield* this.callUserG(init.value, [instance, ...call]); + else init.value(instance, ...call); + } else if (call.length) throw new TypeError(`${cls.name}() не принимает аргументов`); + return instance; + } + private defineFunction(s: PyStmt) { const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice(); let value: any = this.makeFunction({ name: s.name, params: s.params, body: s.body }, closure, diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index deec017..8eb68a3 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -114,6 +114,8 @@ export interface GameData { images: Record; transforms: Record>; atl?: Record; + /** Names of game functions that may wait for the player (say, input, pause ...). */ + blocking?: string[]; screens?: Record; styles?: Record; transitions: Record; diff --git a/frontend/tests/fixtures/waiting.ir.json b/frontend/tests/fixtures/waiting.ir.json new file mode 100644 index 0000000..0a3ca7c --- /dev/null +++ b/frontend/tests/fixtures/waiting.ir.json @@ -0,0 +1 @@ +[{"op":"def","name":"greet","params":[{"n":"n","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"name","id":"name"},"v":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"name?"}],"kw":{}}},{"op":"while","c":{"k":"cmp","l":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"name","id":"name"}],"kw":{}},"ops":["<"],"r":[{"k":"const","v":2}]},"body":[{"op":"set","t":{"k":"name","id":"name"},"v":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"again"}],"kw":{}}}],"else":[]},{"op":"return","v":{"k":"bin","op":"+","l":{"k":"const","v":"hi "},"r":{"k":"name","id":"name"}}}],"decos":[]},{"op":"def","name":"helper","params":[{"n":"k","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"name","id":"total"},"v":{"k":"const","v":0}},{"op":"for","t":{"k":"name","id":"i"},"it":{"k":"call","f":{"k":"name","id":"range"},"a":[{"k":"name","id":"k"}],"kw":{}},"body":[{"op":"try","body":[{"op":"set","t":{"k":"name","id":"v"},"v":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"v"}],"kw":{}}},{"op":"aug","t":{"k":"name","id":"total"},"o":"+","v":{"k":"call","f":{"k":"name","id":"int"},"a":[{"k":"name","id":"v"}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"ValueError"},"name":null,"body":[{"op":"continue"}]}],"else":[],"final":[]}],"else":[]},{"op":"return","v":{"k":"name","id":"total"}}],"decos":[]},{"op":"class","name":"Q","bases":[{"k":"name","id":"object"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"v"},"v":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"init"}],"kw":{}}}],"decos":[]},{"op":"def","name":"twice","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"bin","op":"+","l":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"x"}],"kw":{}},"r":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"y"}],"kw":{}}}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"result"},"v":{"k":"bin","op":"+","l":{"k":"call","f":{"k":"name","id":"greet"},"a":[{"k":"const","v":1}],"kw":{}},"r":{"k":"const","v":"!"}}},{"op":"set","t":{"k":"name","id":"t"},"v":{"k":"call","f":{"k":"name","id":"helper"},"a":[{"k":"const","v":3}],"kw":{}}},{"op":"set","t":{"k":"name","id":"q"},"v":{"k":"call","f":{"k":"name","id":"Q"},"a":[],"kw":{}}},{"op":"set","t":{"k":"name","id":"r2"},"v":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"q"},"a":"twice"},"a":[],"kw":{}}},{"op":"set","t":{"k":"name","id":"cond"},"v":{"k":"if","c":{"k":"cmp","l":{"k":"call","f":{"k":"name","id":"ask"},"a":[{"k":"const","v":"c"}],"kw":{}},"ops":["=="],"r":[{"k":"const","v":"yes"}]},"t":{"k":"const","v":"a"},"e":{"k":"const","v":"b"}}},{"op":"set","t":{"k":"name","id":"plain"},"v":{"k":"bin","op":"+","l":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"const","v":"abc"}],"kw":{}},"r":{"k":"const","v":1}}}] \ No newline at end of file diff --git a/frontend/tests/fixtures/waiting.py b/frontend/tests/fixtures/waiting.py new file mode 100644 index 0000000..76f4476 --- /dev/null +++ b/frontend/tests/fixtures/waiting.py @@ -0,0 +1,36 @@ +# Source of waiting.ir.json (Python that waits for the player through ask()). Regenerate from backend/ with: +# python -m tests.make_ir ../frontend/tests/fixtures/waiting.py ../frontend/tests/fixtures/waiting.ir.json + + +def greet(n): + name = ask("name?") + while len(name) < 2: + name = ask("again") + return "hi " + name + + +def helper(k): + total = 0 + for i in range(k): + try: + v = ask("v") + total += int(v) + except ValueError: + continue + return total + + +class Q(object): + def __init__(self): + self.v = ask("init") + + def twice(self): + return ask("x") + ask("y") + + +result = greet(1) + "!" +t = helper(3) +q = Q() +r2 = q.twice() +cond = "a" if ask("c") == "yes" else "b" +plain = len("abc") + 1 diff --git a/frontend/tests/python-waiting.test.mjs b/frontend/tests/python-waiting.test.mjs new file mode 100644 index 0000000..8d048c1 --- /dev/null +++ b/frontend/tests/python-waiting.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/python.ts", import.meta.url))], + bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" }); +const { Python, BlockingCall, userBuiltins } = + await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); +const ir = JSON.parse(readFileSync(new URL("./fixtures/waiting.ir.json", import.meta.url), "utf8")); + +function runtime() { + const store = Object.create(null); + const builtins = { len: (x) => x.length, range: (n) => Array.from({ length: n }, (_, i) => i), + ask: (prompt) => new BlockingCall("ask", [prompt]) }; + const py = new Python({ + get: (n) => (n in store ? store[n] : builtins[n]), + has: (n) => n in store || n in builtins, + set: (n, v) => { store[n] = v; }, + del: (n) => { delete store[n]; }, + }); + Object.assign(builtins, userBuiltins(py)); + py.blockingNames = new Set(["ask", "greet", "helper", "twice", "Q"]); + return { py, store }; +} + +/** Drive a generator like the engine does: answers come from a list, errors can be thrown back in. */ +function drive(gen, answers) { + const asked = []; + let step = gen.next(); + while (!step.done) { + asked.push(step.value.args[0]); + const answer = answers.shift(); + step = answer instanceof Error ? gen.throw(answer) : gen.next(answer); + } + return asked; +} + +test("code that waits for the player runs through loops, try, functions, methods and expressions", () => { + const { py, store } = runtime(); + const asked = drive(py.execG(ir), ["a", "bob", "1", "x", "5", "init", "p", "q", "yes"]); + assert.deepEqual(asked, ["name?", "again", "v", "v", "v", "init", "x", "y", "c"]); + assert.equal(store.result, "hi bob!"); + assert.equal(store.t, 6, "int('x') raised ValueError inside try and the loop went on"); + assert.equal(store.q.attrs.v, "init"); + assert.equal(store.r2, "pq"); + assert.equal(store.cond, "a"); + assert.equal(store.plain, 4); +}); + +test("mayBlock only marks what can really wait; plain code stays on the fast path", () => { + const { py } = runtime(); + assert.equal(py.mayBlock(ir[0]), false, "a definition does not run when it is defined"); + assert.equal(py.mayBlock(ir[ir.length - 1]), false); + assert.equal(py.mayBlock(ir.find((s) => s.op === "set" && s.t.id === "result")), true); +}); + +test("a waiting call outside the generator interpreter is an explicit error, not a silent None", () => { + const { py } = runtime(); + assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "ask" }, a: [{ k: "const", v: "x" }], kw: {} }), + /ask\(\) здесь вызвать нельзя/); +}); + +test("the interpreter scope is set aside while the player decides", () => { + const { py } = runtime(); + const gen = py.execG(ir); + gen.next(); // stops inside greet() at ask("name?") + assert.deepEqual(py.locals, [], "other code (screens) does not see the half-run function's variables"); + assert.equal(py.fn, null); + assert.equal(gen.next("zed").value.args[0], "v", "'zed' is long enough, so greet() ends and helper() asks next"); +});