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>
73 lines
3.2 KiB
JavaScript
73 lines
3.2 KiB
JavaScript
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");
|
|
});
|