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:
1 parent
ea0df1d475
commit
a4a2348a54
32 files changed
+2849
-1
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { apply, applyAll } from "../src/core/effects.js";
|
||||
import { GameWorld } from "../src/core/world.js";
|
||||
|
||||
const compiled = {
|
||||
game: { startRoom: "jetty" },
|
||||
rooms: { jetty: { id: "jetty" }, tavern: { id: "tavern" } },
|
||||
objects: {}, characters: { otto: { id: "otto", room: "jetty" } },
|
||||
verbs: {}, interactions: [], dialogues: {}, scenes: {},
|
||||
};
|
||||
|
||||
const fresh = () => new GameWorld(compiled);
|
||||
|
||||
test("flags and vars", () => {
|
||||
const w = fresh();
|
||||
apply({ setFlag: "x" }, w);
|
||||
assert.equal(w.flags.x, true);
|
||||
apply({ setFlag: "x", value: false }, w);
|
||||
assert.equal(w.flags.x, false);
|
||||
apply({ setVar: "gold", value: 5 }, w);
|
||||
apply({ addVar: "gold", value: -2 }, w);
|
||||
assert.equal(w.vars.gold, 3);
|
||||
});
|
||||
|
||||
test("inventory give/remove are idempotent-ish", () => {
|
||||
const w = fresh();
|
||||
apply({ give: "mug" }, w);
|
||||
apply({ give: "mug" }, w);
|
||||
assert.deepEqual(w.inventory, ["mug"]);
|
||||
apply({ remove: "mug" }, w);
|
||||
apply({ remove: "mug" }, w);
|
||||
assert.deepEqual(w.inventory, []);
|
||||
});
|
||||
|
||||
test("effects return events for engine machinery", () => {
|
||||
const w = fresh();
|
||||
assert.deepEqual(apply({ goTo: "tavern" }, w), { type: "goTo", room: "tavern" });
|
||||
assert.deepEqual(apply({ playMusic: "calypso" }, w), { type: "playMusic", track: "calypso" });
|
||||
assert.deepEqual(apply({ startDialogue: "d1" }, w), { type: "startDialogue", dialogue: "d1" });
|
||||
assert.deepEqual(apply({ endGame: "win" }, w), { type: "endGame", ending: "win" });
|
||||
});
|
||||
|
||||
test("moveTo mutates the world directly", () => {
|
||||
const w = fresh();
|
||||
apply({ moveTo: { character: "otto", room: "tavern" } }, w);
|
||||
assert.equal(w.characters.otto.room, "tavern");
|
||||
});
|
||||
|
||||
test("malformed effects throw", () => {
|
||||
const w = fresh();
|
||||
assert.throws(() => apply({ bogus: 1 }, w), /unknown effect key/);
|
||||
assert.throws(() => apply({ setFlag: "x", give: "y" }, w), /exactly one key/);
|
||||
assert.throws(() => apply({ setVar: "g", value: "five" }, w), /needs numeric value/);
|
||||
});
|
||||
|
||||
test("applyAll skips nulls", () => {
|
||||
const w = fresh();
|
||||
const events = applyAll([{ setFlag: "a" }, null, { give: "mug" }], w);
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test("world snapshot/restore round-trips", () => {
|
||||
const w = fresh();
|
||||
apply({ setFlag: "x" }, w);
|
||||
apply({ give: "mug" }, w);
|
||||
apply({ addVar: "n", value: 7 }, w);
|
||||
const snap = w.snapshot();
|
||||
const w2 = fresh();
|
||||
w2.restore(snap);
|
||||
assert.equal(w2.flags.x, true);
|
||||
assert.deepEqual(w2.inventory, ["mug"]);
|
||||
assert.equal(w2.vars.n, 7);
|
||||
// snapshot is a deep copy: mutating the live world doesn't touch the save
|
||||
w2.give("rope");
|
||||
assert.deepEqual(snap.inventory, ["mug"]);
|
||||
});
|
||||
Reference in new issue
Block a user