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

+91
View File
@@ -0,0 +1,91 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { compile } from "../src/core/loader.js";
import { GameWorld } from "../src/core/world.js";
import { DialogueRunner } from "../src/core/dialogue.js";
import { Emitter } from "../src/core/events.js";
import { readFileSync } from "node:fs";
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
function setup() {
const c = compile(example);
const w = new GameWorld(c);
const em = new Emitter();
const lines = [];
em.on("dialogueLine", (ev) => lines.push(ev));
let now = 0;
const d = new DialogueRunner(c, w, em);
d.clock = () => now;
return { c, w, em, d, lines, advance: (ms) => { now += ms; d.tick(now); } };
}
test("start emits first line and exposes choices", () => {
const { d, lines } = setup();
d.start("barkeep_intro");
assert.equal(lines.length, 1);
assert.equal(lines[0].speaker, "barkeep");
const choices = d.choices();
assert.equal(choices.length, 2);
assert.equal(choices[0].text, "What's in the mug?");
});
test("choosing branches and auto-advancing nodes time out", () => {
const { d, lines, advance } = setup();
d.start("barkeep_intro");
d.choose(0); // -> mug node (auto-advance, has next)
assert.equal(lines.length, 2);
assert.ok(lines[1].text.includes("never a mug"));
assert.equal(d.world.flags.heardAboutMug, true); // onEnter fired
advance(10000); // hold elapsed -> back to greet
assert.equal(lines.length, 3);
assert.equal(d.current().node, "greet");
});
test("end choice closes the dialogue", () => {
const { d, em } = setup();
let ended = 0;
em.on("dialogueEnd", () => ended++);
d.start("barkeep_intro");
d.choose(1);
assert.equal(d.active, null);
assert.equal(ended, 1);
});
test("choice conditions hide options", () => {
const c = compile(structuredClone(example));
c.dialogues.barkeep_intro.nodes.greet.choices[0].conditions = { flag: "hasClue" };
const w = new GameWorld(c);
const d = new DialogueRunner(c, w, new Emitter());
d.start("barkeep_intro");
assert.equal(d.choices().length, 1);
w.flags.hasClue = true;
assert.equal(d.choices().length, 2);
});
test("mid-dialogue state survives snapshot/restore", () => {
const { d, w } = setup();
d.start("barkeep_intro");
d.choose(0);
const snap = w.snapshot();
assert.equal(snap.dialogueState.barkeep_intro.node, "mug");
const w2 = new GameWorld(setup().c);
w2.restore(snap);
assert.equal(w2.dialogueState.barkeep_intro.node, "mug");
});
test("choice effects apply to the world", () => {
const spec = structuredClone(example);
spec.dialogues.barkeep_intro.nodes.greet.choices[0].effects = [{ setFlag: "askedAboutMug" }];
const c = compile(spec);
const w = new GameWorld(c);
const d = new DialogueRunner(c, w, new Emitter());
d.start("barkeep_intro");
d.choose(0);
assert.equal(w.flags.askedAboutMug, true);
});
test("unknown dialogue/node errors loudly", () => {
const { d } = setup();
assert.throws(() => d.start("nope"), /unknown dialogue/);
});