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:
1 parent
ea03cebf6a
commit
3923ee72fb
11 files changed
+698
-27
No files matched your search
+1
@@ -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}}}]
|
||||
Vendored
+36
@@ -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
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user