feat: generator interpreter for Python that waits for the player (Ren'Py)

Python containing renpy.input, narrator("..."), character calls, renpy.say,
renpy.pause, renpy.display_menu, renpy.with_statement or renpy.call_screen now
runs through loops, try, functions, methods and expressions.

Player
- python.ts: execG/evalG/callG run only the code that may wait (mayBlock);
  everything else stays on the synchronous fast path. A waiting call returns a
  BlockingCall; outside the generator interpreter it is an explicit error. The
  interpreter scope is set aside while the player decides
- engine: drives the generator and answers requests (dialogue, text input with
  length/allow/exclude, pause, choice menu, transition, call screen)
- renpy.show/hide/scene, transitions and transforms as Python values,
  Character(...) objects, Borders, set_tag_attributes

Converter
- blocking_names() lists game functions that may wait, transitively, and ships
  them as game.json "blocking"; character calls are allowed
- hide screen of an unknown screen is harmless; images defined by a parenthesised
  expression are supported

Hooked now passes the strict compatibility check (with Tomie and Twisted-Thoughts).
Tests: waiting.py fixture run through the generator interpreter; blocking-name analysis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:22:38 +03:00
1 parent ea03cebf6a
commit 3923ee72fb
11 files changed
+693 -22

No files matched your search

+48
View File
@@ -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":
+2
View File
@@ -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,
+15 -10
View File
@@ -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
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
rest = line.block[1:]
expr = self.expr(first.text, first)
if expr is None or not rest:
expr = self.expr(text, first)
if expr is None:
return None
picture: dict[str, Any] = {"expr": expr}
rest = line.block[1:]
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)
+21
View File
@@ -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"]
+178 -7
View File
@@ -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<string, any> {
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<string, any> = { 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<string, any> = {
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<T>(generator: Generator<BlockingCall, T, any>): Promise<T> {
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<any> {
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<any>((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<string, any>): Promise<string> {
this.interactionStart();
this.setSkip(false);
return this.wait<string>((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<any> {
this.interactionStart();
this.setSkip(false);
return this.wait<any>((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);
+5
View File
@@ -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; }
+313 -5
View File
@@ -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<string, any> = {}) {}
}
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<string>();
private blockMemo = new WeakMap<object, boolean>();
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<string, any> {
const def: FnDef = fn.__def;
let args = given;
let kw: Record<string, any> = {};
@@ -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<string, any>): () => 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<BlockingCall, any, any> {
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<BlockingCall, void, any> {
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<BlockingCall, boolean, any> {
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<BlockingCall, void, any> {
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<BlockingCall, any, any> {
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<BlockingCall, any, any> {
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<string, any> = {};
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<BlockingCall, any, any> {
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<BlockingCall, PyInstance, any> {
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,
+2
View File
@@ -114,6 +114,8 @@ export interface GameData {
images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>;
atl?: Record<string, import("./atl").AtlBlock>;
/** Names of game functions that may wait for the player (say, input, pause ...). */
blocking?: string[];
screens?: Record<string, import("./screens").ScreenDef>;
styles?: Record<string, import("./screens").StyleDef>;
transitions: Record<string, Transition | null>;
+1
View File
@@ -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}}}]
+36
View File
@@ -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
+72
View File
@@ -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");
});