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.
78 lines
2.6 KiB
JavaScript
78 lines
2.6 KiB
JavaScript
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"]);
|
|
});
|