Files
pgames-manager/frontend/tests/python-waiting.test.mjs
T
mrleo1nidandClaude Sonnet 5.5 3923ee72fb 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>
2026-10-25 14:22:38 +03:00

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