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

+77
View File
@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { Engine } from "../src/core/engine.js";
import { SAVE_VERSION } from "../src/core/save.js";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
const fresh = () => {
let now = 0;
const e = new Engine(structuredClone(example), { clock: () => now });
e.newGame();
return { e, advance: (ms) => { now += ms; e.tick(ms); } };
};
test("save/load round-trip restores the whole world", () => {
const { e, advance } = fresh();
e.selectVerb("use");
e.click({ kind: "object", id: "mug", tags: ["container"] });
e.goTo("tavern");
e.world.setFlag("didSomething", true);
e.world.addVar("score", 42);
const save = e.save();
const e2 = new Engine(structuredClone(example), { clock: () => 0 });
e2.load(save);
assert.equal(e2.world.room, "tavern");
assert.deepEqual(e2.world.inventory, ["mug"]);
assert.equal(e2.world.flags.didSomething, true);
assert.equal(e2.world.vars.score, 42);
assert.equal(e2.phase, "playing");
});
test("save format is versioned and game-tagged", () => {
const { e } = fresh();
const data = JSON.parse(e.save());
assert.equal(data.version, SAVE_VERSION);
assert.equal(data.game, "skeleton-demo");
assert.ok(data.savedAt);
});
test("rejects saves from another game", () => {
const { e } = fresh();
const save = e.save();
const other = structuredClone(example);
other.meta.name = "different-game";
const e2 = new Engine(other, { clock: () => 0 });
assert.throws(() => e2.load(save), /save is for game/);
});
test("rejects future save versions", () => {
const { e } = fresh();
const data = JSON.parse(e.save());
data.version = 999;
assert.throws(() => e.load(JSON.stringify(data)), /newer than engine/);
});
test("rejects garbage saves", () => {
const { e } = fresh();
assert.throws(() => e.load("not json"), /not valid JSON/);
assert.throws(() => e.load("{}"), /no version/);
});
test("mid-dialogue save resumes at the saved node", () => {
const { e } = fresh();
e.goTo("tavern");
e.selectVerb("talk");
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
assert.equal(e.dialogue.current().node, "greet");
const save = e.save();
const e2 = new Engine(structuredClone(example), { clock: () => 0 });
e2.load(save);
assert.ok(e2.dialogue.active);
assert.equal(e2.dialogue.current().node, "greet");
assert.equal(e2.dialogueChoices().length, 2);
});