import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { AssetManifest } from "../src/render/assets.js"; import { HeadlessAudio } from "../src/render/audio.js"; import { RoomRenderer } from "../src/render/canvas.js"; import { HudModel } from "../src/render/hud.js"; import { Engine } from "../src/core/engine.js"; const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8")); // ---- assets --------------------------------------------------------------- test("asset manifest resolves and degrades gracefully", () => { const am = new AssetManifest(example.assets); assert.equal(am.get("bg_jetty").file, "assets/img/jetty.png"); const missing = am.get("nope"); assert.equal(missing.missing, true); assert.equal(am.get("nope").missing, true); // second call: no crash }); test("animation frame cycling", () => { const am = new AssetManifest([{ id: "p", file: "p.png", frames: { walk: [0, 1, 1, 0] }, anim: { walk: 100 } }]); const a = am.get("p"); assert.equal(am.frameFor(a, "walk", 0), 0); assert.equal(am.frameFor(a, "walk", 100), 1); assert.equal(am.frameFor(a, "walk", 300), 0); assert.equal(am.frameFor(a, "unknownAnim", 0), 0); // falls back }); // ---- audio ---------------------------------------------------------------- test("headless audio records calls and preserves loop points", () => { const au = new HeadlessAudio(); au.playMusic({ id: "calypso", file: "c.ogg", loopStartMs: 8460, loopEndMs: 76920 }); au.playSfx({ id: "click", file: "k.ogg" }); au.setVolume(2); // clamped assert.deepEqual(au.calls[0], { op: "playMusic", id: "calypso", file: "c.ogg", loopStartMs: 8460, loopEndMs: 76920 }); assert.equal(au.calls[1].op, "playSfx"); assert.equal(au.volume, 1); au.stopMusic(); assert.equal(au.current, null); }); // ---- canvas renderer (fake recording context) ------------------------------ function fakeCtx() { const ops = []; const noop = (name) => (...args) => ops.push({ op: name, args }); return { ops, drawImage: noop("drawImage"), fillRect: noop("fillRect"), fillText: noop("fillText"), beginPath: noop("beginPath"), lineTo: noop("lineTo"), moveTo: noop("moveTo"), closePath: noop("closePath"), fill: noop("fill"), save: noop("save"), restore: noop("restore") }; } test("renderer draws bg, placeholders, and exit labels", () => { const e = new Engine(structuredClone(example), { clock: () => 0 }); e.newGame(); const am = new AssetManifest(example.assets); const rr = new RoomRenderer(e.compiled, am, new Map()); // no images loaded const ctx = fakeCtx(); rr.draw(e, ctx, { timeMs: 0 }); assert.ok(ctx.ops.some((o) => o.op === "fillRect")); // bg placeholder assert.ok(ctx.ops.some((o) => o.op === "fillText" && o.args[0] === "Tavern")); // player + objects drawn as labelled placeholders const labels = ctx.ops.filter((o) => o.op === "fillText").map((o) => o.args[0]); assert.ok(labels.includes("player")); assert.ok(labels.includes("post")); assert.ok(labels.includes("mug")); }); test("renderer uses loaded images when present", () => { const e = new Engine(structuredClone(example), { clock: () => 0 }); e.newGame(); const am = new AssetManifest(example.assets); const images = new Map([["bg_jetty", { width: 640, height: 480 }]]); const rr = new RoomRenderer(e.compiled, am, images); const ctx = fakeCtx(); rr.draw(e, ctx, { timeMs: 0 }); assert.ok(ctx.ops.some((o) => o.op === "drawImage")); }); test("object placeholder boxes match hotspot rects (see-what-you-click)", () => { const e = new Engine(structuredClone(example), { clock: () => 0 }); e.newGame(); const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map()); const ctx = fakeCtx(); rr.draw(e, ctx, { timeMs: 0 }); const rects = ctx.ops.filter((o) => o.op === "fillRect").map((o) => o.args); // post hotspot {480,300,40,90} and mug {200,360,40,30} drawn exactly assert.ok(rects.some((r) => r[0] === 480 && r[1] === 300 && r[2] === 40 && r[3] === 90), `post box not at hotspot: ${JSON.stringify(rects)}`); assert.ok(rects.some((r) => r[0] === 200 && r[1] === 360 && r[2] === 40 && r[3] === 30), `mug box not at hotspot: ${JSON.stringify(rects)}`); }); test("walkable debug draws polygons", () => { const e = new Engine(structuredClone(example), { clock: () => 0 }); e.newGame(); const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map()); const ctx = fakeCtx(); rr.draw(e, ctx, { timeMs: 0, debugWalkable: true }); assert.ok(ctx.ops.some((o) => o.op === "closePath")); }); // ---- HUD model -------------------------------------------------------------- test("hud exposes verbs, inventory, dialogue choices, save gate", () => { const e = new Engine(structuredClone(example), { clock: () => 0 }); e.newGame(); const hud = new HudModel(e); assert.equal(hud.verbs().length, 5); e.selectVerb("look"); assert.equal(hud.verbs().find((v) => v.id === "look").selected, true); assert.deepEqual(hud.inventory(), []); e.selectVerb("use"); e.click({ kind: "object", id: "mug", tags: ["container"] }); assert.deepEqual(hud.inventory(), [{ id: "mug", name: "a suspicious mug" }]); assert.equal(hud.canSave(), true); e.goTo("tavern"); e.selectVerb("talk"); e.click({ kind: "character", id: "barkeep", tags: ["talker"] }); assert.equal(hud.inDialogue(), true); assert.equal(hud.dialogueChoices().length, 2); assert.equal(hud.canSave(), false, "no saving mid-dialogue"); });