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
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
|
||||
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
|
||||
expr = self.expr(text, first)
|
||||
if expr is None:
|
||||
return None
|
||||
picture: dict[str, Any] = {"expr": expr}
|
||||
rest = line.block[1:]
|
||||
expr = self.expr(first.text, first)
|
||||
if expr is None or not rest:
|
||||
return None
|
||||
issues: list[tuple[str, str]] = []
|
||||
props, animation = self.atl_spec(rest, issues.append, set())
|
||||
if issues or not animation:
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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