import { readFileSync } from "node:fs"; import assert from "node:assert/strict"; import { test } from "node:test"; import { makeEngine } from "./helpers/renpy-engine.mjs"; const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, files: {}, defaults: [], init: [], characters: {}, channels: {}, images: { background: { file: "background.webp" } }, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} }, modules: [{ labels: [{ name: "start", body: [{ op: "return" }] }], anchors: {} }] }); test("enabling Skip interrupts an already running timed pause", async () => { const { engine } = makeEngine(runtime()); try { const pause = engine.pause(120); assert.equal(typeof engine.onAdvance, 'function'); engine.setSkip(true); await Promise.race([pause, new Promise((_, reject) => setTimeout(() => reject(new Error('Skip left the pause running')), 100))]); assert.equal(engine.onAdvance, null); assert.equal(engine.skipTick, null); } finally { engine.destroy(); } }); test("runtime image declarations produce scaled thumbnails and survive saving", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); engine.fileUrl = (file) => `/mock/${file}`; const image = engine.builtins.im.Scale(engine.builtins.ImageReference("background"), 384, 216); engine.builtins.renpy.image("thumbnail", image); const element = engine.screenImage("thumbnail"); assert.equal(element.style.width, "384px"); assert.equal(element.style.height, "216px"); assert.equal(element.src, "/mock/background.webp"); const saved = structuredClone(engine.snapshotState()); engine.newStore(); assert.equal(engine.resolveImage(["thumbnail"]), null); engine.applySave(saved); assert.deepEqual(engine.resolveImage(["thumbnail"]).def.props.size, [384, 216]); const before = Object.getPrototypeOf(engine.game.images); engine.builtins.renpy.image("__proto__", "safe.webp"); assert.equal(Object.getPrototypeOf(engine.game.images), before); } finally { engine.destroy(); } }); test("a missing original label stops execution instead of advancing into another branch", () => { const { engine } = makeEngine(runtime()); try { engine.cur = { label: "start", path: [0] }; assert.throws(() => engine.jump("missing_original_label"), /не найдена/); assert.deepEqual(engine.cur, { label: "start", path: [0] }); engine.screens.run(engine.builtins.Jump("missing_original_label"), null); assert.match(engine.notifyEl.textContent, /missing_original_label/); assert.equal(engine.running, false); engine.newStore(); assert.equal(engine.screens.layer.style.visibility, ""); } finally { engine.destroy(); } }); test("native Gallery instances keep their methods and registration target after a save", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); const cls = engine.builtins.Gallery; const gallery = engine.py.instantiate(cls, []); cls.find("button").value(gallery, "art"); cls.find("image").value(gallery, "background"); engine.store.gallery = gallery; const saved = structuredClone(engine.snapshotState()); engine.applySave(saved); const restored = engine.store.gallery; assert.equal(typeof engine.py.attrOf(restored, "make_button"), "function"); cls.find("condition").value(restored, "False"); assert.equal(cls.find("Action").value(restored, "art"), null); } finally { engine.destroy(); } }); test("an animated preview discarded before its first timer does not keep rendering", async () => { const { engine } = makeEngine(runtime()); let rendered = 0; try { engine.fileUrl = (file) => { rendered++; return `/mock/${file}`; }; engine.renderer.displayable({ repeat: true, frames: [ { img: { file: "a.webp" }, d: 0.01 }, { img: { file: "b.webp" }, d: 0.01 }, ] }); await new Promise((resolve) => setTimeout(resolve, 40)); assert.equal(rendered, 1); } finally { engine.destroy(); } }); test("random generators: choices, sample, seeded Random and frozenset", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); const { renpy, frozenset } = engine.builtins; assert.deepEqual(frozenset([1, 2, 2, 3]), [1, 2, 3]); const picked = renpy.random.choices(["a", "b"], { __kw: { weights: [0, 1], k: 5 } }); assert.deepEqual(picked, ["b", "b", "b", "b", "b"]); const sample = renpy.random.sample([1, 2, 3, 4], 3); assert.equal(new Set(sample).size, 3); const first = renpy.random.Random("seed"), second = renpy.random.Random("seed"); assert.deepEqual([first.randint(1, 1000), first.randint(1, 1000)], [second.randint(1, 1000), second.randint(1, 1000)]); assert.ok(renpy.random.randrange(0, 10, 5) % 5 === 0); } finally { engine.destroy(); } }); test("datetime date arithmetic, dir() and style.rebuild", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); const { datetime, renpy, dir } = engine.builtins; const start = datetime.date(2025, 1, 31); const next = start.__binop__("+", datetime.timedelta({ __kw: { days: 1 } })); assert.deepEqual([next.year, next.month, next.day], [2025, 2, 1]); assert.equal(next.weekday(), 5); // Saturday assert.throws(() => datetime.date(2025, 2, 30), /ValueError/); const back = next.__binop__("-", datetime.timedelta({ __kw: { days: 32 } })); assert.deepEqual([back.year, back.month, back.day], [2024, 12, 31]); assert.equal(next.__binop__("-", start).days, 1); engine.store.known_name = 1; assert.ok(dir().includes("known_name")); assert.equal(renpy.style.rebuild(), null); assert.throws(() => renpy.image_size("missing"), /IOError/); } finally { engine.destroy(); } }); test("File* actions work on slots of the game's own save screens", async () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); const b = engine.builtins; assert.equal(b.FileLoadable(1), false); assert.equal(b.FileTime(1, { __kw: { empty: "—" } }), "—"); engine.slotCache.set("file:1-1", { title: "Привет", updated_at: "2026-01-02T03:04:05Z" }); assert.equal(b.FileLoadable(1), true); assert.equal(b.FileSaveName(1), "Привет"); assert.match(b.FileTime(1), /^Jan 0[12], \d\d:\d\d$/); assert.equal(b.FileScreenshot(1), null); assert.equal(engine.screens.host.filePage, "1"); engine.screens.run(b.FilePage("3"), null); assert.equal(b.FilePageName(), "3"); engine.screens.run(b.FilePageNext(), null); assert.equal(b.FileCurrentPage(), "4"); engine.screens.run(b.FilePagePrevious(), null); engine.screens.run(b.FilePagePrevious(), null); engine.screens.run(b.FilePagePrevious(), null); assert.equal(b.FileCurrentPage(), "1"); engine.screens.run(b.FilePagePrevious(), null); assert.equal(b.FilePageName({ __kw: { auto: "A" } }), "quick" === b.FileCurrentPage() ? "Quick " : b.FileCurrentPage()); assert.equal(typeof engine.builtins.renpy.translation.translate_string, "function"); } finally { engine.destroy(); } }); test("hashlib, re.fullmatch, type/id/vars, dict spread and small renpy helpers", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); const b = engine.builtins; assert.equal(b.hashlib.md5("").hexdigest(), "d41d8cd98f00b204e9800998ecf8427e"); assert.equal(b.hashlib.md5("abc").hexdigest(), "900150983cd24fb0d6963f7d28e17f72"); assert.equal(b.hashlib.sha256("abc").hexdigest(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); const pattern = b.re.compile("[a-z]+"); assert.ok(pattern.fullmatch("abc")); assert.equal(pattern.fullmatch("abc1"), null); assert.equal(b.type(3), b.int); assert.equal(b.type("x"), b.str); assert.equal(b.id([]) === b.id([]), false); const same = {}; assert.equal(b.id(same), b.id(same)); const spread = engine.py.eval({ k: "dict", v: [[{ k: "star", v: { k: "dict", v: [[{ k: "const", v: "a" }, { k: "const", v: 1 }]] } }, { k: "const", v: null }], [{ k: "const", v: "b" }, { k: "const", v: 2 }]] }); assert.deepEqual([...spread.map.entries()], [["a", 1], ["b", 2]]); assert.equal(b.renpy.has_screen("nothing"), false); assert.deepEqual(b.renpy.get_mouse_pos(), [0, 0]); assert.equal(b.renpy.log("x"), null); assert.equal(b.math.isfinite(1), true); b.renpy.random.seed("a"); const one = b.renpy.random.randint(1, 1000000); b.renpy.random.seed("a"); assert.equal(b.renpy.random.randint(1, 1000000), one); assert.equal(typeof b.defaultdict, "function"); assert.equal(typeof b.LiveComposite, "function"); } finally { engine.destroy(); } }); test("a game's renpy.Displayable subclass renders a tree of pieces through renpy.Render", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); engine.py.exec(JSON.parse(readFileSync(new URL("./fixtures/displayable.ir.json", import.meta.url), "utf8"))); const meter = engine.store.meter; const tree = engine.screens.renderHost.renderOf(meter, 200, 100, 1.5, 1.5); assert.deepEqual(tree.get_size(), [200, 20]); assert.equal(tree.pieces.length, 3); // a fill, drawing commands and the picture assert.equal(tree.pieces[0].fill, "#fff"); assert.equal(tree.pieces[1].commands[0].kind, "circle"); assert.deepEqual([tree.pieces[2].x, tree.pieces[2].y], [3, 4]); } finally { engine.destroy(); } }); test("starred items spread into tuples and lists", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); engine.store.rest = [2, 3]; const spread = engine.py.eval({ k: "tuple", v: [{ k: "const", v: 1 }, { k: "star", v: { k: "name", id: "rest" } }, { k: "const", v: 4 }] }); assert.deepEqual(spread, [1, 2, 3, 4]); } finally { engine.destroy(); } }); test("pictures on a named layer stay on it, layers stack as config.layers says, and a save keeps both", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); engine.config.layers = ["bottom", "master", "screens", "top"]; engine.syncLayers(); const picture = engine.resolveImage(["background"]); engine.renderer.show(picture, "bg", [], {}); engine.renderer.show(picture, "cloud", [], { layer: "top" }); engine.renderer.show(picture, "floor", [], { layer: "bottom" }); const depth = (name) => Number(engine.renderer.stage.querySelector(`[data-layer=${name}]`).style.zIndex); assert.ok(depth("bottom") < depth("master") && depth("master") < 0 && depth("top") > 0); assert.equal(engine.renderer.isShown("cloud"), false); assert.equal(engine.renderer.isShown("cloud", "top"), true); engine.renderer.layerAt("top", [{ props: { xoffset: 30 } }]); const saved = structuredClone(engine.snapshotState()); assert.deepEqual(saved.scene.map((s) => [s.tag, s.layer ?? "master"]).sort(), [["bg", "master"], ["cloud", "top"], ["floor", "bottom"]]); assert.deepEqual(saved.layers, { top: [{ props: { xoffset: 30 } }] }); engine.newStore(); engine.applySave(saved); assert.equal(engine.renderer.isShown("cloud", "top"), true); assert.equal(engine.renderer.isShown("floor", "bottom"), true); engine.renderer.scene("top"); assert.equal(engine.renderer.isShown("cloud", "top"), false); assert.equal(engine.renderer.isShown("bg"), true); } finally { engine.destroy(); } }); test("a layer added by the game is part of the save and comes back with it", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); engine.builtins.renpy.add_layer("hud", { __kw: { above: "master" } }); assert.deepEqual(engine.config.layers, ["master", "hud", "transient", "screens", "overlay"]); const saved = structuredClone(engine.snapshotState()); engine.newStore(); assert.deepEqual(engine.config.layers, ["master", "transient", "screens", "overlay"]); engine.applySave(saved); assert.deepEqual(engine.config.layers, ["master", "hud", "transient", "screens", "overlay"]); } finally { engine.destroy(); } }); test("preferences are the player's settings", () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); const prefs = engine.builtins.preferences; assert.equal(prefs.text_cps, engine.settings.cps); prefs.text_cps = 20; assert.equal(engine.settings.cps, 20); prefs.set_volume("music", 0.25); assert.equal(prefs.get_volume("music"), 0.25); prefs.set_mute("music", true); assert.equal(prefs.get_volume("music"), 0); assert.equal(prefs.get_mute("music"), true); prefs.set_mute("music", false); assert.equal(prefs.get_volume("music"), 0.25); engine.py.assignPath("preferences.chaos_on", true); // `default preferences.chaos_on = True` assert.equal(prefs.chaos_on, true); assert.equal(engine.builtins.preferences, prefs); } finally { engine.destroy(); } }); test("Rollback goes back one interaction", async () => { const { engine } = makeEngine(runtime()); try { engine.newStore(); engine.running = true; engine.cur = { label: "start", path: [0] }; engine.store.n = 1; engine.interactionStart(); engine.store.n = 2; engine.cur = { label: "start", path: [0] }; engine.interactionStart(); assert.equal(engine.rollbackLog.length, 2); engine.rollback(); assert.equal(engine.store.n, 1); } finally { engine.destroy(); } });