SCUMM-style web adventure engine v0.1

Headless, data-driven core (no DOM, no eval): loader with one-pass
validation, verb-coin interaction resolution, polygon walkables with
Dijkstra pathfinding, branching dialogue with mid-conversation saves,
declarative JSON conditions/effects, versioned JSON saves.

Thin integration layer: asset manifest with graceful placeholders,
WebAudio music loop points (midi-playlist convention), canvas renderer
(hotspot boxes drawn where hitTest clicks), HUD model.

Web harness (web/) + skeleton example game exercising every feature.
69 tests (node --test), tools/run_all.sh gate, docs/SPEC.md design spec.
Verified end-to-end in a real browser: intro scene, walking, look/take,
room exits, dialogue choices.
This commit is contained in:
Kevin Hermes committed 2026-10-18 00:29:41 +00:00
1 parent ea0df1d475
commit a4a2348a54
32 files changed
+2849 -1

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { Engine } from "../src/core/engine.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
function fresh() {
let now = 0;
const e = new Engine(structuredClone(example), { clock: () => now });
const events = [];
e.emitter.on("*", (ev) => events.push(ev));
e.newGame();
const advance = (ms) => { now += ms; e.tick(ms); };
return { e, events, advance };
}
test("intro scene runs on the clock and lands the player in the start room", () => {
const { e, events, advance } = fresh();
e.startScene("intro");
assert.ok(events.some((ev) => ev.event === "playMusic" && ev.track === "calypso"));
assert.ok(events.some((ev) => ev.event === "slide"));
advance(4000); // slide done
advance(3500); // text done -> goto jetty
assert.equal(e.scene, null);
assert.equal(e.world.room, "jetty");
assert.equal(e.world.seenScenes.intro, true);
});
test("scene skip jumps to the final goto", () => {
const { e } = fresh();
e.startScene("intro");
e.skipScene();
assert.equal(e.scene, null);
assert.equal(e.world.room, "jetty");
});
test("clicking a walkable point schedules a walk that completes over time", () => {
const { e, advance } = fresh();
const p = e.world.characters.player;
const startX = p.x;
e.clickPoint(400, 380);
assert.ok(e.walk, "walk scheduled");
advance(1000); // 80 px/s -> 80px
assert.ok(p.x > startX);
advance(5000);
assert.equal(e.walk, null);
assert.ok(Math.abs(p.x - 400) < 1, `arrived at x=400, got ${p.x}`);
});
test("click outside the walkable area clamps to the nearest edge", () => {
const { e, advance } = fresh();
e.clickPoint(700, 380); // beyond the jetty polygon (x<=600)
advance(10000);
const p = e.world.characters.player;
assert.ok(p.x <= 600 && p.x >= 590, `clamped near x=600, got ${p.x}`);
});
test("verb + object click: take the mug, then it leaves the room", () => {
const { e } = fresh();
e.selectVerb("use");
const resp = e.click({ kind: "object", id: "mug", tags: ["container"] });
assert.ok(resp.includes("pocket"));
assert.deepEqual(e.world.inventory, ["mug"]);
assert.ok(!e.world.objectsHere().some((o) => o.id === "mug"), "taken object leaves the room");
});
test("unmatched verb gets the default response — the engine always answers", () => {
const { e } = fresh();
e.selectVerb("give");
const resp = e.click({ kind: "object", id: "post", tags: [] });
assert.equal(resp, "They're not interested.");
});
test("walk verb + exit click changes rooms and fires onEnter (music)", () => {
const { e, events } = fresh();
e.selectVerb("walk");
e.click({ kind: "exit", id: "tavern", tags: ["exit"] });
assert.equal(e.world.room, "tavern");
assert.equal(e.world.characters.player.room, "tavern");
assert.ok(events.some((ev) => ev.event === "roomEnter" && ev.room === "tavern"));
});
test("talk verb starts the dialogue; clicks are swallowed mid-dialogue", () => {
const { e } = fresh();
e.goTo("tavern");
e.selectVerb("talk");
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
assert.equal(e.dialogue.active, "barkeep_intro");
const before = e.world.flags;
const resp = e.click({ kind: "object", id: "post", tags: [] });
assert.equal(resp, null, "clicks ignored during dialogue");
assert.deepEqual(e.world.flags, before);
});
test("dialogue choice with give-mug chain: full interaction flow", () => {
const { e, advance } = fresh();
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] }); // take mug
e.goTo("tavern");
e.selectVerb("give");
const resp = e.click({ kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
assert.ok(resp.includes("very quiet"));
assert.deepEqual(e.world.inventory, []);
assert.equal(e.world.flags.gaveMug, true);
assert.equal(e.world.vars.barkeepTrust, 1);
});
test("hitTest finds characters, objects, exits, then walkable", () => {
const { e } = fresh();
assert.equal(e.hitTest(500, 340).id, "post");
assert.equal(e.hitTest(220, 375).id, "mug");
assert.equal(e.hitTest(580, 340).kind, "exit");
assert.equal(e.hitTest(300, 420).kind, "walkable");
e.goTo("tavern");
assert.equal(e.hitTest(400, 300).id, "barkeep");
});
test("endGame effect ends the run and blocks further clicks", () => {
const spec = structuredClone(example);
spec.interactions.push({ verb: "use", noun: "post", response: "The world ends.",
effects: [{ endGame: "win" }] });
const e = new Engine(spec, { clock: () => 0 });
e.newGame();
e.selectVerb("use");
e.click({ kind: "object", id: "post", tags: [] });
assert.equal(e.phase, "ended");
assert.equal(e.world.ended, "win");
assert.equal(e.click({ kind: "object", id: "post", tags: [] }), null);
});
test("room onEnter effects fire on entry", () => {
const spec = structuredClone(example);
spec.rooms[1].onEnter = [{ setFlag: "visitedTavern" }];
const e = new Engine(spec, { clock: () => 0 });
e.newGame();
e.goTo("tavern");
assert.equal(e.world.flags.visitedTavern, true);
});
test("newGame resets everything", () => {
const { e } = fresh();
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] });
e.goTo("tavern");
e.newGame();
assert.equal(e.world.room, "jetty");
assert.deepEqual(e.world.inventory, []);
assert.equal(e.world.flags.tookMug, undefined);
});