From 11f87df712747b3b0649cf15950aed5acb08c8fe Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:39:16 +0300 Subject: [PATCH] feat: player runtime for the Phone library and That New Teacher Named stores (PyNamespace: dotted access, functions that see their own store first, restored after a save is loaded), real generators run step by step (next, send, yield as an expression), len/bool through __len__/__bool__, os/calendar/itertools/pygame_sdl2 helpers, Skip and ToggleLocalVariable actions, text tags {glitch} and {wave} and plain display of explode/gradient/ruby, SnowBlossom and anim.Blink, RoundedCorners as a transform, transforms listed in `at`, ATL choice weights that are flags, and renpy.image_size from the converter's picture sizes. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/player/atl.ts | 4 +- frontend/src/player/engine.ts | 71 ++++++++- frontend/src/player/player.css | 8 + frontend/src/player/pymodules.ts | 25 +++ frontend/src/player/python.ts | 193 ++++++++++++++++++++++-- frontend/src/player/render.ts | 4 + frontend/src/player/renderer.ts | 13 ++ frontend/src/player/screens.ts | 58 ++++++- frontend/src/player/text.ts | 6 +- frontend/src/player/types.ts | 4 + frontend/tests/fixtures/dunders.ir.json | 2 +- frontend/tests/fixtures/dunders.py | 12 ++ frontend/tests/mist-runtime.test.mjs | 32 ++++ frontend/tests/python-classes.test.mjs | 27 ++++ frontend/tests/screens.test.mjs | 22 +++ 15 files changed, 457 insertions(+), 24 deletions(-) diff --git a/frontend/src/player/atl.ts b/frontend/src/player/atl.ts index 79e619b..6cb8754 100644 --- a/frontend/src/player/atl.ts +++ b/frontend/src/player/atl.ts @@ -231,8 +231,10 @@ export class AtlRunner { await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event))); return true; case "choice": { - const weights = step.blocks.map((b) => this.seconds(b.w) || 1); + // a weight is a number or a flag: False (0) means the branch is never taken + const weights = step.blocks.map((b) => { const w = Number(this.seconds(b.w)); return Number.isFinite(w) ? w : 1; }); const total = weights.reduce((s, w) => s + w, 0); + if (total <= 0) return true; let pick = Math.random() * total; const chosen = step.blocks.find((_, i) => (pick -= weights[i]) < 0) ?? step.blocks[step.blocks.length - 1]; if (chosen) await this.run(chosen.block, token, depth + 1, event); diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 749e74d..3786923 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -4,7 +4,7 @@ import { AudioSystem } from "./audio"; import { cutscene } from "./cutscene"; import { VolumeStore } from "./volume-store"; import { LiveImages } from "../imager/live"; -import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python"; +import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, 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"; @@ -226,7 +226,8 @@ export class Engine { // --- files & images ---------------------------------------------------------- - fileUrl(path: string): string | null { + /** The name a file has in the manifest, from the name the game uses. */ + logicalName(path: string): string | undefined { const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, ""); let logical: string | undefined = this.manifest.files[clean] ? clean : undefined; if (!logical) { @@ -236,6 +237,11 @@ export class Engine { if (logical) break; } } + return logical; + } + + fileUrl(path: string): string | null { + const logical = this.logicalName(path); const entry = logical ? this.manifest.files[logical] : undefined; if (!entry || !logical) return null; const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`; @@ -305,6 +311,7 @@ export class Engine { movieLoop: value.kw.loop !== false }; case "text": return { text: pyStr(a) }; case "null": return { composite: [] }; + case "snowblossom": return { snow: { img: this.imageDefinition(a, depth + 1), count: Math.min(60, Number(value.kw.count ?? b ?? 10) || 10), speed: Math.abs(Number(value.kw.yspeed ?? c ?? 100)) / 100 || 1 } }; case "animation": { // Animation(a, 3.0, b, 0.1, c): pictures with how long each stays const frames: { img: ImageDef; d?: number }[] = []; for (let i = 0; i < value.args.length; i += 2) @@ -488,6 +495,16 @@ export class Engine { list_files: () => Object.keys(this.game.files ?? {}), get_mouse_pos: () => [...this.mousePos], has_live2d: () => false, + known_languages: () => (this.game.sourceLang ? [this.game.sourceLang, ...Object.keys(this.game.locales ?? {})] : []), + predict_screen: () => null, + predicting: () => false, + rollback: () => null, + invoke_in_new_context: (fn: any, ...a: any[]) => (typeof fn === "function" ? fn(...a) : null), + add_layer: () => null, + show_layer_at: () => null, + filter_text_tags: (text: any) => pyStr(text).replace(/\{[^}]*\}/g, ""), + text: { text: { Text: (...a: any[]) => new Displayable("text", a, {}) } }, + easy: { displayable_or_none: (d: any) => d ?? null, displayable: (d: any) => d }, capture_focus: (name: any = "default") => { this.screens.captureFocus(pyStr(name)); return null; }, clear_capture_focus: (name: any = "default") => { this.screens.clearFocus(pyStr(name)); return null; }, get_widget: () => null, @@ -514,13 +531,22 @@ export class Engine { restart_interaction: () => { this.screens.refresh(); return null; }, // there is no network access from the player: games that fetch news handle the failure themselves fetch: () => { throw new Error("IOError: сеть недоступна"); }, - display: { layout: { Container: containerClass(this.displayableBase, () => this.py) }, focus: { set_focused: () => null }, draw: { kill_textures: () => null }, render: { free_memory: () => null }, im: { Image: (path: any) => new Displayable("image", [path], {}) } }, + display: { matrix: { Matrix: () => ({}), offset: () => ({}), rotate: () => ({}), perspective: () => ({}), texture_projection: () => ({}), screen_projection: () => ({}) }, + layout: { AdjustTimes: (child: any) => child, Motion: (_move: any, _period: any, child: any) => child, + Container: containerClass(this.displayableBase, () => this.py) }, + focus: { set_focused: () => null }, draw: { kill_textures: () => null }, render: { free_memory: () => null }, im: { Image: (path: any) => new Displayable("image", [path], {}) } }, jump_out_of_context: (label: string) => { throw new JumpSignal("jump", label); }, image_size: (name: any) => { const text = pyStr(name); const resolved = this.resolveImage(text.split(" ")); + const logical = this.logicalName(resolved?.def?.file ?? text); + const known = logical ? this.game.imageSizes?.[logical] : undefined; + if (known) { + const scale = this.game.assetScale?.[logical!] ?? 1; // a replaced picture may be bigger than the original + return [Math.round(known[0] / scale), Math.round(known[1] / scale)]; + } const url = this.fileUrl(resolved?.def?.file ?? text); const probe = url ? new Image() : null; if (probe) probe.src = url!; @@ -670,6 +696,7 @@ export class Engine { // games branch on the Ren'Py language name ("russian"); None means the source language preferences: this.languagePrefs(), _preferences: this.languagePrefs(), + Layer: (name: any) => ({ __layer: pyStr(name) }), gui: { rebuild: () => null }, store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null, set: (_t, k: string, v) => ((this.store[k] = v), true) }), @@ -686,7 +713,7 @@ export class Engine { log1p: Math.log1p, expm1: Math.expm1, isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity, copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1), }, - len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), + len: (x: any) => pyLen(x), // a read-only view: `"name" in globals()` works, writes do not reach the game globals: () => new PyDict(Object.entries(this.store)), print: () => null, @@ -736,6 +763,25 @@ export class Engine { frozenset: (xs: any = []) => [...new Set(xs)], cache: { get_total_size: () => 0, cache_limit: 1 }, FontGroup: () => fontGroup(), + os: { + listdir: (path: any) => { + const prefix = pyStr(path).replaceAll("\\", "/").replace(/^\/+/, "").replace(/\/?$/, "/").toLowerCase(); + const names = new Set(); + for (const file of Object.keys(this.game.files ?? {})) { + const lower = file.toLowerCase(); + if (lower.startsWith(prefix)) names.add(file.slice(prefix.length).split("/")[0]); + } + return [...names]; + }, + path: { + join: (...parts: any[]) => parts.map(pyStr).join("/").replace(/\/+/g, "/"), + basename: (p: any) => pyStr(p).split("/").pop() ?? "", + dirname: (p: any) => pyStr(p).split("/").slice(0, -1).join("/"), + splitext: (p: any) => { const t = pyStr(p), i = t.lastIndexOf("."); return i > t.lastIndexOf("/") && i > 0 ? [t.slice(0, i), t.slice(i)] : [t, ""]; }, + exists: (p: any) => this.fileUrl(pyStr(p)) !== null, + isfile: (p: any) => this.fileUrl(pyStr(p)) !== null, + }, + }, json: { loads: (text: any) => fromJson(JSON.parse(pyStr(text))), dumps: (value: any) => JSON.stringify(toJson(value)), @@ -767,6 +813,8 @@ export class Engine { }, get_mute: () => false, get_volume: () => 1, + set_volume: () => null, + set_mute: () => null, }; } @@ -1029,7 +1077,17 @@ export class Engine { saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm), loadSlot: (slot) => void this.load(slot), deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm), + imageSize: (value) => { + const name = typeof value === "string" ? value : value instanceof Displayable && value.kind === "image" ? pyStr(value.args[0]) : null; + if (!name) return null; + const logical = this.logicalName(this.resolveImage(name.split(" "))?.def?.file ?? name); + const known = logical ? this.game.imageSizes?.[logical] : undefined; + if (!known) return null; + const scale = this.game.assetScale?.[logical!] ?? 1; + return [Math.round(known[0] / scale), Math.round(known[1] / scale)]; + }, ask: (text) => this.confirmAsk(text, "OK"), + setSkipping: (on) => this.setSkip(on), get filePageName() { return String(engine.persistent._file_page_name ?? ""); }, set filePageName(name: string) { engine.persistent._file_page_name = name; }, get filePage() { return String(engine.persistent._file_page ?? "1"); }, @@ -1091,6 +1149,7 @@ export class Engine { private newStore() { this.resetStore(); for (const [name, e] of this.game.defaults) { + if (name.includes(".")) { this.py.assignPath(name, this.safeEval(e)); continue; } // `default phone.data = ...` if (!(name in this.store)) this.store[name] = this.safeEval(e); } } @@ -1919,7 +1978,9 @@ export class Engine { private applySave(data: SaveData) { this.resetStore(); for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition); - Object.assign(this.store, decodeValue(data.store)); + const saved = decodeValue(data.store); + this.py.restoreNamespaces(saved); + Object.assign(this.store, saved); this.screens.restore(decodeValue(data.screens ?? [])); this.renderer.scene(); for (const s of data.scene) { diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 1b300fa..2a849e3 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -376,6 +376,14 @@ .pg-fx-sc { animation: pg-fx-scare 0.12s linear infinite; } .pg-fx-fi { animation: pg-fx-in 0.6s ease-out both; } .pg-fx-rotat { animation: pg-fx-spin 2s linear infinite; } +.pg-fx-glitch { animation: pg-fx-glitch 0.35s steps(2, end) infinite; text-shadow: 1px 0 #f0f, -1px 0 #0ff; } +.pg-fx-wave { animation: pg-fx-wave 1.2s ease-in-out infinite; } +@keyframes pg-fx-glitch { 20% { transform: translate(-1px, 1px); } 40% { transform: translate(1px, -1px); } 60% { transform: translate(-2px, 0); } 80% { transform: translate(2px, 1px); } } +@keyframes pg-fx-wave { 50% { transform: translateY(-4px); } } +/* SnowBlossom: pictures that fall and drift while the scene is shown */ +.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } +.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; } +@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } } @keyframes pg-fx-bounce { 50% { transform: translateY(calc(var(--fx, 6) * -1px)); } } @keyframes pg-fx-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } } @keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } } diff --git a/frontend/src/player/pymodules.ts b/frontend/src/player/pymodules.ts index 1fc273f..47a8699 100644 --- a/frontend/src/player/pymodules.ts +++ b/frontend/src/player/pymodules.ts @@ -254,6 +254,8 @@ class Hash { } /** Namespaces added to the sandbox's builtins. */ +const iterateAll = (v: any): Iterable => (Array.isArray(v) ? v : v && typeof v[Symbol.iterator] === "function" ? v : []); + export function pythonModules(): Record { const re = { I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16, @@ -303,6 +305,8 @@ export function pythonModules(): Record { time: { time: () => Date.now() / 1000, monotonic: () => performance.now() / 1000, + gmtime: (seconds?: any) => { const d = new Date(typeof seconds === "number" ? seconds * 1000 : Date.now()); return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), (d.getUTCDay() + 6) % 7, 1, 0]; }, + localtime: (seconds?: any) => { const d = new Date(typeof seconds === "number" ? seconds * 1000 : Date.now()); return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), (d.getDay() + 6) % 7, 1, 0]; }, sleep: () => null, strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()), }, @@ -344,6 +348,27 @@ export function pythonModules(): Record { .replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i").test(String(name)), }, traceback: { format_exc: () => "", print_exc: () => null }, + itertools: { + chain: Object.assign((...lists: any[]) => lists.flatMap((l) => [...iterateAll(l)]), { + __statics: { from_iterable: (lists: any) => [...iterateAll(lists)].flatMap((l) => [...iterateAll(l)]) } }), + }, + chain: Object.assign((...lists: any[]) => lists.flatMap((l) => [...iterateAll(l)]), { + __statics: { from_iterable: (lists: any) => [...iterateAll(lists)].flatMap((l) => [...iterateAll(l)]) } }), + calendar: { + Calendar: (firstweekday = 0) => ({ + itermonthdays: (year: number, month: number) => { + const first = (new Date(year, month - 1, 1).getDay() + 6) % 7; // Monday = 0 + const length = new Date(year, month, 0).getDate(); + const lead = (first - firstweekday + 7) % 7; + const days = [...Array(lead).fill(0), ...Array.from({ length }, (_, i) => i + 1)]; + while (days.length % 7) days.push(0); + return days; + }, + }), + monthrange: (year: number, month: number) => [(new Date(year, month - 1, 1).getDay() + 6) % 7, new Date(year, month, 0).getDate()], + isleap: (year: number) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0, + }, + pygame_sdl2: { power: { get_power_info: () => ({ percent: 100, state: "no_battery", seconds: -1 }) } }, hashlib: { md5: (data?: any) => new Hash(md5, data), sha256: (data?: any) => new Hash(sha256, data), diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index e3745d8..b837a6d 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -56,6 +56,13 @@ export class BlockingCall { constructor(public kind: string, public args: any[] = [], public kw: Record = {}) {} } +/** What a generator hands to whoever drives it with next() or send(). */ +export class UserYield extends BlockingCall { + constructor(public value: any) { + super("yield"); + } +} + class ReturnSignal { constructor(public value: any) {} } @@ -67,9 +74,23 @@ export type Builtin = ((...args: any[]) => any) & { __py?: true }; // `attrs`, so a plain JSON round trip (saves) restores them given the class registry. const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", - "__getitem__", "__contains__"]); + "__getitem__", "__contains__", "__len__", "__bool__"]); const MAX_CALL_DEPTH = 150; +/** + * A named store (`init python in phone.discussion:`): names defined there live in this object, which the rest of the + * game reaches as `phone.discussion.name`. Its functions see its names first, then the game's. + */ +export class PyNamespace { + vars = new Map(); + constructor(public path: string) {} + toJSON() { + const a: Record = {}; + for (const [k, v] of this.vars) if (typeof v !== "function" && !(v instanceof PyClass)) a[k] = v; + return { __ns__: this.path, a }; + } +} + export class PyClass { attrs = new Map(); constructor(public name: string, public bases: PyClass[]) {} @@ -250,8 +271,22 @@ function instanceText(v: PyInstance): string { return `<${v.cls.name} object>`; } +/** `len(x)`: strings, lists, dicts and instances whose class defines `__len__`. */ +export function pyLen(x: any): number { + if (x instanceof PyDict) return x.map.size; + const own = userMethod(x, "__len__"); + if (own) return Number(own(x)); + return x?.length ?? 0; +} + export function truthy(v: any): boolean { if (v === null || v === undefined || v === false || v === 0 || v === "") return false; + if (v instanceof PyInstance && activePython) { + const flag = v.cls.find("__bool__"); + if (flag && typeof flag.value === "function") return truthy(flag.value(v)); + const size = v.cls.find("__len__"); + if (size && typeof size.value === "function") return Number(size.value(v)) > 0; + } if (Array.isArray(v)) return v.length > 0; if (v instanceof PyDict) return v.map.size > 0; return true; @@ -542,6 +577,8 @@ function methodOf(obj: any, name: string): any { }, rfind: (x: string) => obj.lastIndexOf(x), isdigit: () => /^\d+$/.test(obj), + isnumeric: () => /^\p{N}+$/u.test(obj), + isdecimal: () => /^\p{Nd}+$/u.test(obj), isalpha: () => /^\p{L}+$/u.test(obj), }; return Object.hasOwn(m, name) ? m[name] : undefined; @@ -583,6 +620,7 @@ export class Python { if (!safeProperty(name)) return null; for (let i = this.locals.length - 1; i >= 0; i--) if (this.locals[i].has(name)) return this.locals[i].get(name); + if (this.ns?.vars.has(name)) return this.ns.vars.get(name); if (this.scope.has(name)) return this.scope.get(name); if (this.strict) this.raise("NameError", `имя ${name} не определено`); this.warnOnce(`неизвестное имя ${name}`); @@ -600,6 +638,7 @@ export class Python { return this.lookup(e.id); case "attr": { const o = this.eval(e.o); + if (o instanceof PyNamespace) return o.vars.has(e.a) ? o.vars.get(e.a) : null; if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) { if (!safePythonProperty(o, e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null; const v = this.attrOf(o, e.a); @@ -780,6 +819,7 @@ export class Python { } case "attr": { const o = this.eval(t.o); + if (o instanceof PyNamespace) return void o.vars.set(t.a, value); if (!safePythonProperty(o, t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен"); if (o instanceof PyInstance) { const found = o.cls.find(t.a); @@ -872,8 +912,17 @@ export class Python { case "class": this.defineClass(s); break; - case "yield": this.yielded.at(-1)?.push(s.v ? this.eval(s.v) : null); break; - case "yield_from": this.yielded.at(-1)?.push(...iterate(this.eval(s.v))); break; + case "in_store": { + const store = this.namespaceOf(s.ns); + const outside = this.ns; + this.ns = store; + try { + this.exec(s.body); + } finally { + this.ns = outside; + } + break; + } case "return": if (!this.fn) throw new Error("return вне функции"); throw new ReturnSignal(s.v ? this.eval(s.v) : null); @@ -959,7 +1008,7 @@ export class Python { /** Assign a name in the scope the running code sees: function local, global or enclosing. */ bindName(name: string, value: any) { const f = this.fn; - if (!f || f.globals.has(name)) return void this.scope.set(name, value); + if (!f || f.globals.has(name)) return void (this.ns ? this.ns.vars.set(name, value) : this.scope.set(name, value)); if (f.nonlocals.has(name)) { for (let i = this.locals.length - 2; i >= 0; i--) { if (this.locals[i].has(name)) return void this.locals[i].set(name, value); @@ -972,6 +1021,7 @@ export class Python { makeFunction(def: FnDef, closure: Map[], defaults: any[]): Builtin { const fn: any = (...args: any[]) => this.callUser(fn, args); fn.__py = true; + fn.__ns = this.ns; fn.__def = def; fn.__closure = closure; fn.__defaults = defaults; @@ -1024,6 +1074,8 @@ export class Python { if (this.depth >= MAX_CALL_DEPTH) throw new Error("Слишком глубокая рекурсия в python"); const previousLocals = this.locals; const previousFn = this.fn; + const previousNs = this.ns; + this.ns = fn.__ns ?? null; this.depth++; this.locals = [...fn.__closure, frame]; this.fn = { @@ -1034,27 +1086,125 @@ export class Python { this.depth--; this.locals = previousLocals; this.fn = previousFn; + this.ns = previousNs; }; } - /** What the generator functions that are running have yielded so far (they run to the end, see PyIterator). */ - private yielded: any[][] = []; + /** + * A generator function's result: the body runs in the interpreter that can wait (execG), and every `yield` hands + * control back here. Its own scope is put back before each step and the caller's after it. + */ + private makeGenerator(fn: any, frame: Map) { + const def: FnDef = fn.__def; + const owner = this; + let it: Generator | null = null; + let done = false; + const step = (sent: any): { done: true } | { done: false; value: any } => { + if (done) return { done: true }; + const caller = { locals: this.locals, fn: this.fn, depth: this.depth, strict: this.strict, handling: this.handling }; + let leave: (() => void) | null = null; + try { + if (!it) { + if (sent !== null && sent !== undefined) throw new TypeError("send() первого шага генератора принимает только None"); + leave = this.enterFunction(fn, frame); + it = (function* () { + try { + yield* owner.execG(def.body ?? []); + } catch (err) { + if (!(err instanceof ReturnSignal)) throw err; + } + })(); + } + const next = it.next(sent); + if (next.done) { done = true; return { done: true }; } + if (!(next.value instanceof UserYield)) throw new Error("Ожидание игрока внутри генератора не поддерживается"); + return { done: false, value: next.value.value }; + } catch (err) { + done = true; + throw err; + } finally { + leave?.(); + Object.assign(this, caller); + } + }; + const stop = () => this.raise("StopIteration", ""); + const generator: any = { + __generator: true, + next: () => { const r = step(null); return r.done ? stop() : r.value; }, + send: (value: any = null) => { const r = step(value); return r.done ? stop() : r.value; }, + close: () => { done = true; return null; }, + *[Symbol.iterator]() { for (;;) { const r = step(null); if (r.done) return; yield r.value; } }, + }; + return generator; + } + + /** The store a running function or block belongs to (null: the game's own). */ + ns: PyNamespace | null = null; + + /** `phone.discussion`: the store with this dotted name, made when it is first needed. */ + namespaceOf(path: string): PyNamespace { + const [root, ...rest] = path.split("."); + const known = this.scope.has(root) ? this.scope.get(root) : null; + let current: PyNamespace; + if (known instanceof PyNamespace) current = known; + else { + current = new PyNamespace(root); + this.scope.set(root, current); + } + let name = root; + for (const part of rest) { + name += `.${part}`; + const existing: any = current.vars.get(part); + let child: PyNamespace; + if (existing instanceof PyNamespace) child = existing; + else { + child = new PyNamespace(name); + current.vars.set(part, child); + } + current = child; + } + return current; + } + + /** `default phone.data = ...`: assign to a name inside a store. */ + assignPath(path: string, value: any) { + const at = path.lastIndexOf("."); + if (at < 0) return void this.scope.set(path, value); + this.namespaceOf(path.slice(0, at)).vars.set(path.slice(at + 1), value); + } + + /** After a save is loaded: put its values into the stores the game's init code has just rebuilt. */ + restoreNamespaces(saved: Record) { + const merge = (target: PyNamespace, data: Record) => { + for (const [k, v] of Object.entries(data)) { + if (v && typeof v === "object" && typeof v.__ns__ === "string") { + const child = target.vars.get(k); + merge(child instanceof PyNamespace ? child : this.namespaceOf(v.__ns__), v.a); + } else target.vars.set(k, v); + } + }; + for (const [key, v] of Object.entries(saved)) { + if (v && typeof v === "object" && typeof v.__ns__ === "string") { + merge(this.namespaceOf(v.__ns__), v.a); + delete saved[key]; + } + } + } private callUser(fn: any, given: any[]): any { const def: FnDef = fn.__def; + if (def.gen) return this.makeGenerator(fn, this.bindArguments(fn, given)); const leave = this.enterFunction(fn, this.bindArguments(fn, given)); try { if (def.expr) return this.eval(def.expr); - if (def.gen) this.yielded.push([]); try { this.exec(def.body ?? []); } catch (err) { - if (!(err instanceof ReturnSignal)) throw err; - if (!def.gen) return err.value; + if (err instanceof ReturnSignal) return err.value; + throw err; } - return def.gen ? new PyIterator(this.yielded.at(-1) ?? []) : null; + return null; } finally { - if (def.gen) this.yielded.pop(); leave(); } } @@ -1067,7 +1217,7 @@ export class Python { const known = this.blockMemo.get(node); if (known !== undefined) return known; this.blockMemo.set(node, false); // recursion guard - let result = false; + let result = node.op === "yield" || node.op === "yield_from" || node.k === "yield"; 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 { @@ -1111,6 +1261,12 @@ export class Python { continue; } switch (s.op) { + case "yield": + yield* this.suspend(new UserYield(s.v ? yield* this.evalG(s.v) : null)); + break; + case "yield_from": + for (const item of [...iterate(yield* this.evalG(s.v))]) yield* this.suspend(new UserYield(item)); + break; case "set": this.bind(s.t, yield* this.evalG(s.v)); break; @@ -1208,6 +1364,10 @@ export class Python { /** Evaluate an expression that may contain waiting calls. Everything else is delegated to eval(). */ *evalG(e: Expr | null | undefined): Generator { + if (e && e.k === "yield") { + const value = e.v ? yield* this.evalG(e.v) : null; + return yield* this.suspend(new UserYield(value)); + } if (!e) return null; if (!this.mayBlock(e)) return this.eval(e); const wrap = (value: any): Expr => ({ k: "const", v: value }); @@ -1474,6 +1634,8 @@ export function decodeValue(v: any): any { if (v && typeof v === "object") { if (Array.isArray(v.__dict__)) return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)])); + if (typeof v.__ns__ === "string" && v.a && typeof v.a === "object") + return { __ns__: v.__ns__, a: Object.fromEntries(Object.entries(v.a).map(([k, x]) => [k, decodeValue(x)])) }; if (typeof v.__inst__ === "string" && v.a && typeof v.a === "object") { const attrs: Record = Object.create(null); for (const [k, x] of Object.entries(v.a)) attrs[k] = decodeValue(x); @@ -1523,6 +1685,7 @@ export function userBuiltins(py: Python): Record { const get = (o: any, name: string): any => { if (typeof name !== "string" || (!safePythonProperty(o, name) && !SUPPORTED_DUNDERS.has(name))) return undefined; if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name); + if (o instanceof PyNamespace) return o.vars.get(name); if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined; if (o === null || o === undefined || typeof o === "function") return undefined; return methodOf(o, name) ?? (Object.hasOwn(Object(o), name) ? o[name] : undefined); @@ -1565,6 +1728,12 @@ export function userBuiltins(py: Python): Record { }, iter: (x: any) => new PyIterator([...iterate(x)]), next: (it: any, ...rest: any[]) => { + if (it && it.__generator) { + try { return it.next(); } catch (error) { + if (rest.length && error instanceof PyException) return rest[0]; + throw error; + } + } if (!(it instanceof PyIterator)) throw new Error("next() ожидает итератор"); try { return it.next(rest[0], rest.length > 0); } catch (error) { if ((error as Error).message === "StopIteration") py.raise("StopIteration", ""); diff --git a/frontend/src/player/render.ts b/frontend/src/player/render.ts index f39f535..ebaacd8 100644 --- a/frontend/src/player/render.ts +++ b/frontend/src/player/render.ts @@ -132,6 +132,8 @@ export interface RenderHostOptions { element(value: any): HTMLElement | null; /** Size of the area given to a displayable that has no size of its own. */ area(): [number, number]; + /** Natural size of a picture the game names, known without loading it. */ + imageSize?(value: any): [number, number] | null; /** An event handler returned a value: it ends the interaction. */ finish(value: any): void; } @@ -164,6 +166,8 @@ export class RenderHost { } private measure(value: any): [number, number] { + const known = this.options.imageSize?.(value); + if (known) return known; const element = this.options.element(value); if (!element) return [0, 0]; const box = (this.measuring ??= document.createElement("div")); diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index 7c84cf8..2eb8d1a 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -172,6 +172,19 @@ export class Renderer { d.style.height = `${this.height}px`; return d; } + if (def.snow) { + const field = document.createElement("div"); + field.className = "pg-snow"; + for (let i = 0; i < def.snow.count; i++) { + const piece = this.build(def.snow.img, depth + 1); + piece.style.left = `${(i * 37 + (i * i) % 23) % 100}%`; + piece.style.animationDuration = `${(6 + (i * 7) % 9) / Math.max(0.2, def.snow.speed)}s`; + piece.style.animationDelay = `${-((i * 13) % 11)}s`; + piece.style.setProperty("--drift", `${((i * 29) % 80) - 40}px`); + field.append(piece); + } + return field; + } if (def.alphaMask) { const picture = this.build(def.alphaMask.img, depth + 1); const maskEl = this.build(def.alphaMask.mask, depth + 1); diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 57de33b..ad99aec 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -45,6 +45,8 @@ export interface ScreenHost { openUrl(url: string): void; quickSave(): void; quickLoad(): void; + /** The Skip action: fast-forward through text already seen. */ + setSkipping?(on: boolean): void; /** Ask the player to confirm (Confirm action). */ ask(text: string): Promise; /** The name of the current save page (`FilePageNameInputValue`). */ @@ -54,6 +56,8 @@ export interface ScreenHost { saveSlot(slot: string, confirm: boolean): void; loadSlot(slot: string): void; deleteSlot(slot: string, confirm: boolean): void; + /** Natural size of a named picture (from the converter), or null. */ + imageSize?(value: any): [number, number] | null; /** Size of the game picture, for displayables that ask how much room they have. */ resolution?: [number, number]; /** The page of the save/load screen (`persistent._file_page`). */ @@ -174,6 +178,7 @@ export class ScreenManager { py: this.host.py, element: (value) => this.displayable(value), area: () => this.host.resolution ?? [1920, 1080], + imageSize: (value) => this.host.imageSize?.(value) ?? null, finish: (value) => this.interactionReturn?.(value), })); } @@ -336,6 +341,8 @@ export class ScreenManager { this.pending = requestAnimationFrame(() => { this.pending = 0; if (this.suspended) return; + // A press may have started after this frame was queued by a timer or hover. + if (this.pressed) { this.refreshAfterPress = true; return; } for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst); }); } @@ -1147,6 +1154,25 @@ export class ScreenManager { } return child; } + case "blink": { // anim.Blink(picture): fades in and out + const picture = this.displayable(a, depth + 1); + if (picture) picture.style.animation = "pg-blink 1.2s ease-in-out infinite"; + return picture; + } + case "snowblossom": { // falling pictures + const field = document.createElement("div"); + field.className = "pg-snow"; + const count = Math.min(60, Number(value.kw.count ?? b ?? 10) || 10); + for (let i = 0; i < count; i++) { + const piece = this.displayable(a, depth + 1); + if (!piece) break; + piece.style.left = `${(i * 37 + (i * i) % 23) % 100}%`; + piece.style.animationDuration = `${6 + (i * 7) % 9}s`; + piece.style.animationDelay = `${-((i * 13) % 11)}s`; + field.append(piece); + } + return field; + } case "conditionswitch": { // ConditionSwitch("cond", picture, ...): the first condition that holds for (let i = 0; i + 1 < value.args.length; i += 2) { const holds = typeof value.args[i] === "string" ? truthy(this.host.py.eval(parseExpression(value.args[i]))) : truthy(value.args[i]); @@ -1546,11 +1572,16 @@ export class ScreenManager { const named = (value: any): AtSpec => { const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value : value && typeof value.__transform === "string" ? value.__transform : ""; + if (value && value.__rounded !== undefined) return { props: { rounded: value.__rounded } }; return name ? { name, atl: this.host.atl(name) ? name : undefined } : {}; }; - const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else) - : spec.dyn ? named(this.ev(spec.dyn)) : spec); - const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : []; + const pick = (spec: AtSpec): AtSpec[] => { + if (spec.choose) return pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else); + if (!spec.dyn) return [spec]; + const value = this.ev(spec.dyn); + return (Array.isArray(value) ? value : [value]).filter((v) => v !== null && v !== undefined).map(named); // a list of transforms + }; + const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.flatMap(pick) : []; const live: Props = {}; const apply = (p: Props) => { const zoom = (p.zoom ?? 1); @@ -1562,6 +1593,11 @@ export class ScreenManager { if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); el.style.transform = parts.join(" "); if (p.alpha !== undefined) el.style.opacity = String(p.alpha); + if (p.rounded !== undefined) { // RoundedCorners(radius): one radius or four (the game's own shader) + const radius = Array.isArray(p.rounded) ? p.rounded.map((r: number) => `${r}px`).join(" ") : `${p.rounded}px`; + el.style.borderRadius = radius; + el.style.overflow = "hidden"; + } applyFilters(el, p); }; for (const spec of specs) { @@ -1744,6 +1780,13 @@ export class ScreenManager { case "InvertSelected": this.perform(a, owner); return; case "UploadSync": case "DownloadSync": this.host.notify("Синхронизация недоступна в браузере"); return; + case "Skip": this.host.setSkipping?.(true); return; + case "Rollback": return; // there is no rollback in the browser player + case "ToggleLocalVariable": { + const now = owner?.vars.get(a); + owner?.vars.set(a, !truthy(now)); + return; + } case "Confirm": { const yes = b, no = c; void this.host.ask(pyStr(a)).then((ok) => { this.run(ok ? yes : no, owner); }); @@ -1844,7 +1887,7 @@ export class ScreenManager { "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership", "ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot", "FilePage", "FilePageNext", "FilePagePrevious", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync", - "DownloadSync", "Confirm"]) out[kind] = make(kind); + "DownloadSync", "Confirm", "Skip", "Rollback", "ToggleLocalVariable"]) out[kind] = make(kind); // what an `input` edits: classes games can extend (`class Typing(VariableInputValue)` overrides set_text) const inputBase = new PyClass("InputValue", [OBJECT]); const native = (cls: PyClass, name: string, fn: (self: PyInstance, ...a: any[]) => any) => cls.attrs.set(name, nativeMethod(fn)); @@ -1961,6 +2004,13 @@ export class ScreenManager { out.Text = shape("text"); out.RenpyText = shape("text"); out.Animation = shape("animation"); + out.SnowBlossom = shape("snowblossom"); + out.anim = { Blink: shape("blink") }; + const rounded = (...a: any[]) => ({ __rounded: (a[a.length - 1]?.__kw ?? {}).radius ?? a[1] ?? 0 }); + out.RoundedCorners = rounded; + out.CurriedRoundedCorners = rounded; + out.ExplodeText = (child: any) => child; // the letter-explosion effect is shown as the plain text + out.MouseDisplayable = () => null; // a custom mouse picture: the browser keeps its own cursor out.ConditionSwitch = shape("conditionswitch"); out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText` out.MatrixColor = shape("matrixcolor"); diff --git a/frontend/src/player/text.ts b/frontend/src/player/text.ts index 957f175..10b684d 100644 --- a/frontend/src/player/text.ts +++ b/frontend/src/player/text.ts @@ -99,7 +99,7 @@ export function parseText(text: string): ParsedText { case "size": stack.push({ ...top, size: value, tag: name }); break; - case "bt": case "sc": case "fi": case "rotat": // Kinetic Text Tags + case "bt": case "sc": case "fi": case "rotat": case "glitch": case "wave": // letters that move (text tags of the games) stack.push({ ...top, fx: name, fxArg: value, tag: name }); break; case "w": @@ -121,6 +121,10 @@ export function parseText(text: string): ParsedText { case "vspace": push("\n"); break; + case "explode": case "explodehalf": case "gradient": case "gradient2": case "fadegrad": case "fadegradfi": + case "ruby": case "note": case "chaos": case "swap": case "move": case "omega": // shown as plain text + stack.push({ ...top, tag: name }); + break; case "a": case "font": case "cps": case "alpha": case "k": case "plain": case "outlinecolor": case "rt": case "rb": case "art": case "shader": stack.push({ ...top, tag: name }); // styling we ignore but must still balance diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index 7681509..4598c39 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -26,6 +26,8 @@ export interface ImageDef { composite?: { pos: [number, number]; img: ImageDef }[]; /** `AlphaMask(img, mask)`: the picture, visible where the mask is. */ alphaMask?: { img: ImageDef; mask: ImageDef }; + /** `SnowBlossom(img, count, xspeed, yspeed)`: particles that fall across the picture. */ + snow?: { img: ImageDef; count: number; speed: number }; size?: [number, number] | null; text?: string; textExpr?: Expr; @@ -110,6 +112,8 @@ export interface ScriptModule { export interface GameData { /** JSON files of the game (paths inside game/); their text is in `dataText` once loaded. */ dataFiles?: string[]; + /** Natural size of each converted picture, by its name in the manifest. */ + imageSizes?: Record; dataText?: Record; build?: Record; /** Natural image dimensions are divided by this factor after an asset replacement. */ diff --git a/frontend/tests/fixtures/dunders.ir.json b/frontend/tests/fixtures/dunders.ir.json index f1a28c1..c5980d9 100644 --- a/frontend/tests/fixtures/dunders.ir.json +++ b/frontend/tests/fixtures/dunders.ir.json @@ -1 +1 @@ -[{"op":"class","name":"Perk","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"key","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"v":{"k":"name","id":"key"}}],"decos":[]},{"op":"def","name":"__hash__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"hash"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"key"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__eq__","params":[{"n":"self","d":null,"kind":"p"},{"n":"other","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"ops":["=="],"r":[{"k":"attr","o":{"k":"name","id":"other"},"a":"key"}]}}],"decos":[]}]},{"op":"class","name":"Bag","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"items","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"v":{"k":"name","id":"items"}}],"decos":[]},{"op":"def","name":"__iter__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__getitem__","params":[{"n":"self","d":null,"kind":"p"},{"n":"i","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"sub","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"i":{"k":"name","id":"i"}}}],"decos":[]},{"op":"def","name":"__contains__","params":[{"n":"self","d":null,"kind":"p"},{"n":"x","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"x"},"ops":["in"],"r":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"table"},"v":{"k":"dict","v":[]}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":1}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":2}},{"op":"set","t":{"k":"name","id":"result_size"},"v":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"name","id":"table"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}}},{"op":"set","t":{"k":"name","id":"bag"},"v":{"k":"call","f":{"k":"name","id":"Bag"},"a":[{"k":"list","v":[{"k":"const","v":3},{"k":"const","v":4},{"k":"const","v":5}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_loop"},"v":{"k":"comp","e":{"k":"bin","op":"*","l":{"k":"name","id":"x"},"r":{"k":"const","v":2}},"gens":[{"t":{"k":"name","id":"x"},"it":{"k":"name","id":"bag"},"if":[]}]}},{"op":"set","t":{"k":"name","id":"result_item"},"v":{"k":"sub","o":{"k":"name","id":"bag"},"i":{"k":"const","v":1}}},{"op":"set","t":{"k":"name","id":"result_in"},"v":{"k":"cmp","l":{"k":"const","v":4},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"set","t":{"k":"name","id":"result_out"},"v":{"k":"cmp","l":{"k":"const","v":9},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"def","name":"walk","params":[{"n":"values","d":null,"kind":"p"},{"n":"kind","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"name","id":"seen"},"v":{"k":"list","v":[]}},{"op":"for","t":{"k":"name","id":"value"},"it":{"k":"name","id":"values"},"body":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}},"then":[{"op":"yield","v":{"k":"name","id":"value"}}],"else":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"list"}],"kw":{}},"then":[{"op":"yield_from","v":{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}}}],"else":[]}]}],"else":[]}],"decos":[],"gen":true},{"op":"set","t":{"k":"name","id":"result_found"},"v":{"k":"call","f":{"k":"name","id":"list"},"a":[{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":"a"},{"k":"list","v":[{"k":"const","v":2},{"k":"const","v":"b"},{"k":"list","v":[{"k":"const","v":3}]}]},{"k":"const","v":4}]},{"k":"name","id":"int"}],"kw":{}}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_iter"},"v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"list","v":[{"k":"const","v":7},{"k":"const","v":8}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_first"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_second"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_default"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"},{"k":"const","v":"done"}],"kw":{}}}] \ No newline at end of file +[{"op":"class","name":"Perk","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"key","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"v":{"k":"name","id":"key"}}],"decos":[]},{"op":"def","name":"__hash__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"hash"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"key"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__eq__","params":[{"n":"self","d":null,"kind":"p"},{"n":"other","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"ops":["=="],"r":[{"k":"attr","o":{"k":"name","id":"other"},"a":"key"}]}}],"decos":[]}]},{"op":"class","name":"Bag","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"items","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"v":{"k":"name","id":"items"}}],"decos":[]},{"op":"def","name":"__iter__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__getitem__","params":[{"n":"self","d":null,"kind":"p"},{"n":"i","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"sub","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"i":{"k":"name","id":"i"}}}],"decos":[]},{"op":"def","name":"__contains__","params":[{"n":"self","d":null,"kind":"p"},{"n":"x","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"x"},"ops":["in"],"r":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"table"},"v":{"k":"dict","v":[]}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":1}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":2}},{"op":"set","t":{"k":"name","id":"result_size"},"v":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"name","id":"table"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}}},{"op":"set","t":{"k":"name","id":"bag"},"v":{"k":"call","f":{"k":"name","id":"Bag"},"a":[{"k":"list","v":[{"k":"const","v":3},{"k":"const","v":4},{"k":"const","v":5}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_loop"},"v":{"k":"comp","e":{"k":"bin","op":"*","l":{"k":"name","id":"x"},"r":{"k":"const","v":2}},"gens":[{"t":{"k":"name","id":"x"},"it":{"k":"name","id":"bag"},"if":[]}]}},{"op":"set","t":{"k":"name","id":"result_item"},"v":{"k":"sub","o":{"k":"name","id":"bag"},"i":{"k":"const","v":1}}},{"op":"set","t":{"k":"name","id":"result_in"},"v":{"k":"cmp","l":{"k":"const","v":4},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"set","t":{"k":"name","id":"result_out"},"v":{"k":"cmp","l":{"k":"const","v":9},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"def","name":"walk","params":[{"n":"values","d":null,"kind":"p"},{"n":"kind","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"name","id":"seen"},"v":{"k":"list","v":[]}},{"op":"for","t":{"k":"name","id":"value"},"it":{"k":"name","id":"values"},"body":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}},"then":[{"op":"yield","v":{"k":"name","id":"value"}}],"else":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"list"}],"kw":{}},"then":[{"op":"yield_from","v":{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}}}],"else":[]}]}],"else":[]}],"decos":[],"gen":true},{"op":"set","t":{"k":"name","id":"result_found"},"v":{"k":"call","f":{"k":"name","id":"list"},"a":[{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":"a"},{"k":"list","v":[{"k":"const","v":2},{"k":"const","v":"b"},{"k":"list","v":[{"k":"const","v":3}]}]},{"k":"const","v":4}]},{"k":"name","id":"int"}],"kw":{}}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_iter"},"v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"list","v":[{"k":"const","v":7},{"k":"const","v":8}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_first"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_second"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_default"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"},{"k":"const","v":"done"}],"kw":{}}},{"op":"def","name":"coro","params":[],"body":[{"op":"set","t":{"k":"name","id":"got"},"v":{"k":"yield","v":{"k":"const","v":1}}},{"op":"set","t":{"k":"name","id":"other"},"v":{"k":"yield","v":{"k":"bin","op":"+","l":{"k":"name","id":"got"},"r":{"k":"const","v":1}}}},{"op":"yield","v":{"k":"bin","op":"*","l":{"k":"name","id":"other"},"r":{"k":"const","v":2}}}],"decos":[],"gen":true},{"op":"set","t":{"k":"name","id":"g"},"v":{"k":"call","f":{"k":"name","id":"coro"},"a":[],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_a"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"g"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_b"},"v":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"g"},"a":"send"},"a":[{"k":"const","v":10}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_c"},"v":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"g"},"a":"send"},"a":[{"k":"const","v":5}],"kw":{}}}] \ No newline at end of file diff --git a/frontend/tests/fixtures/dunders.py b/frontend/tests/fixtures/dunders.py index 287aa00..a3b7974 100644 --- a/frontend/tests/fixtures/dunders.py +++ b/frontend/tests/fixtures/dunders.py @@ -49,3 +49,15 @@ result_iter = iter([7, 8]) result_first = next(result_iter) result_second = next(result_iter) result_default = next(result_iter, "done") + + +def coro(): + got = yield 1 + other = yield got + 1 + yield other * 2 + + +g = coro() +result_a = next(g) +result_b = g.send(10) +result_c = g.send(5) diff --git a/frontend/tests/mist-runtime.test.mjs b/frontend/tests/mist-runtime.test.mjs index 3af3f54..78fdc3c 100644 --- a/frontend/tests/mist-runtime.test.mjs +++ b/frontend/tests/mist-runtime.test.mjs @@ -8,6 +8,38 @@ const runtime = original ? JSON.parse(readFileSync(original, "utf8")) : null; const click = (element) => element.onclick({ stopPropagation() {}, preventDefault() {} }); const enter = (engine, label) => engine.restart(() => engine.guardedJump(label)); +test("MIST's actual movie Skip survives a queued redraw and returns before its timer", { skip: !runtime }, async () => { + const { engine, root, window } = makeEngine(runtime); + globalThis.HTMLVideoElement = window.HTMLVideoElement ?? class {}; + const played = [], stopped = []; + engine.audio.play = (...args) => played.push(args); + engine.audio.stop = (...args) => stopped.push(args); + engine.audio.mediaOf = () => null; + try { + engine.newStore(); + Object.assign(engine.store, { movielength: 63, movieplaying: "images/animations/movies/Intro_Movie.webm" }); + const completed = engine.exec({ op: "call_screen", name: "custommoviescreen", args: [], kw: {} }); + const button = [...root.querySelectorAll(".pg-sbtn")].find((element) => element.textContent === "Skip"); + assert.ok(button); + assert.equal(button.classList.contains("pg-sdisabled"), false); + engine.screens.refresh(); + window.dispatchEvent(new window.Event("pointerdown")); + await until(() => engine.store.movielength < 63); + assert.equal(button.isConnected, true); + window.dispatchEvent(new window.Event("pointerup")); + click(button); + await completed; + assert.equal(engine.store._return, 0); + assert.ok(engine.store.movielength > 60, "Skip returns without waiting for the movie timer"); + assert.equal(root.querySelector(".pg-sbtn"), null); + assert.equal(played[0][0], "movie"); + assert.equal(stopped.at(-1)[0], "movie"); + } finally { + window.dispatchEvent(new window.Event("pointercancel")); + engine.destroy(); + } +}); + test("MIST's actual raise-hand loop accepts screen input and resumes after a save", { skip: !runtime }, async () => { const { engine, root } = makeEngine(runtime); try { diff --git a/frontend/tests/python-classes.test.mjs b/frontend/tests/python-classes.test.mjs index 83e35b6..bd018be 100644 --- a/frontend/tests/python-classes.test.mjs +++ b/frontend/tests/python-classes.test.mjs @@ -129,4 +129,31 @@ test("generator functions hand out what they yield; iter and next walk a sequenc assert.equal(store.result_first, 7); assert.equal(store.result_second, 8); assert.equal(store.result_default, "done"); + assert.deepEqual([store.result_a, store.result_b, store.result_c], [1, 11, 10]); // send() answers the yield +}); + +test("named stores keep their names apart, are reached through dotted paths, and survive a save", () => { + const { py, store } = runtime(); + const call = (f, ...a) => ({ k: "call", f, a, kw: {} }); + const name = (id) => ({ k: "name", id }); + const attr = (o, a) => ({ k: "attr", o, a }); + const c = (v) => ({ k: "const", v }); + py.exec([{ op: "in_store", ns: "phone.chat", body: [ + { op: "set", t: name("counter"), v: c(0) }, + { op: "def", name: "bump", params: [], body: [ + { op: "global", names: ["counter"] }, + { op: "set", t: name("counter"), v: { k: "bin", op: "+", l: name("counter"), r: c(1) } }, + { op: "return", v: name("counter") }] }, + ] }]); + assert.equal(store.counter, undefined, "the name stays inside the store"); + assert.equal(py.eval(call(attr(attr(name("phone"), "chat"), "bump"))), 1); + assert.equal(py.eval(call(attr(attr(name("phone"), "chat"), "bump"))), 2); + py.assignPath("phone.chat.mode", "night"); + assert.equal(py.eval(attr(attr(name("phone"), "chat"), "mode")), "night"); + const saved = JSON.parse(JSON.stringify({ phone: store.phone })); + assert.equal(saved.phone.a.chat.a.counter, 2); + assert.equal(saved.phone.a.chat.a.bump, undefined, "functions are rebuilt by init, not saved"); + py.assignPath("phone.chat.counter", 99); + py.restoreNamespaces({ phone: decodeValue(saved).phone }); + assert.equal(py.eval(attr(attr(name("phone"), "chat"), "counter")), 2); }); diff --git a/frontend/tests/screens.test.mjs b/frontend/tests/screens.test.mjs index 3e09e18..02b4f09 100644 --- a/frontend/tests/screens.test.mjs +++ b/frontend/tests/screens.test.mjs @@ -294,3 +294,25 @@ test("a redraw keeps the playing video, and one held down button is not rebuilt window.dispatchEvent(new window.Event("pointerup")); await wait(30); }); + +test("a redraw queued before pointerdown preserves the Return button until click", async () => { + const { manager } = setup(); + const returned = []; + try { + manager.show("shop", ["skipped"], {}, { call: true, onReturn: (value) => returned.push(value) }); + const button = buttons(manager.layer).find((element) => element.textContent === "Cancel"); + manager.refresh(); // MIST's movie timer (or hover) queues the next frame. + window.dispatchEvent(new window.Event("pointerdown")); + await settle(); + assert.equal(button.isConnected, true, "a queued redraw must also respect a held pointer"); + window.dispatchEvent(new window.Event("pointerup")); + click(button); + assert.deepEqual(returned, [null]); + await settle(); + assert.notEqual(buttons(manager.layer).find((element) => element.textContent === "Cancel"), button, + "the deferred redraw runs after the click"); + } finally { + window.dispatchEvent(new window.Event("pointercancel")); + manager.destroy(); + } +});