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
+698 -27

No files matched your search

+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");
});