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,68 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { check, conditionRefs } from "../src/core/conditions.js";
|
||||
|
||||
const world = () => ({
|
||||
flags: { metOtto: true, lied: false },
|
||||
vars: { gold: 5, trust: 0 },
|
||||
inventory: ["mug"],
|
||||
room: "tavern",
|
||||
characters: { otto: { room: "tavern" }, ann: { room: "deck" } },
|
||||
dialogueState: { otto1: { node: "greet" } },
|
||||
seenScenes: { intro: true },
|
||||
});
|
||||
|
||||
test("flag conditions", () => {
|
||||
assert.equal(check({ flag: "metOtto" }, world()), true);
|
||||
assert.equal(check({ flag: "lied" }, world()), false);
|
||||
assert.equal(check({ flag: "nonexistent" }, world()), false);
|
||||
assert.equal(check(null, world()), true);
|
||||
});
|
||||
|
||||
test("not / all / any", () => {
|
||||
assert.equal(check({ not: { flag: "lied" } }, world()), true);
|
||||
assert.equal(check({ all: [{ flag: "metOtto" }, { has: "mug" }] }, world()), true);
|
||||
assert.equal(check({ all: [{ flag: "metOtto" }, { has: "rope" }] }, world()), false);
|
||||
assert.equal(check({ any: [{ flag: "lied" }, { has: "mug" }] }, world()), true);
|
||||
assert.equal(check({ any: [] }, world()), false);
|
||||
assert.equal(check({ all: [] }, world()), true);
|
||||
});
|
||||
|
||||
test("var comparisons", () => {
|
||||
for (const [op, want] of [["eq", true], ["ne", false], ["lt", false],
|
||||
["lte", true], ["gt", false], ["gte", true]]) {
|
||||
assert.equal(check({ var: "gold", op, value: 5 }, world()), want, op);
|
||||
}
|
||||
assert.equal(check({ var: "missing", op: "eq", value: 0 }, world()), true);
|
||||
});
|
||||
|
||||
test("has / at / here / seen / dialogue", () => {
|
||||
assert.equal(check({ has: "mug" }, world()), true);
|
||||
assert.equal(check({ at: "tavern" }, world()), true);
|
||||
assert.equal(check({ at: "deck" }, world()), false);
|
||||
assert.equal(check({ here: "otto" }, world()), true);
|
||||
assert.equal(check({ here: "ann" }, world()), false);
|
||||
assert.equal(check({ seen: "intro" }, world()), true);
|
||||
assert.equal(check({ dialogue: "otto1", node: "greet" }, world()), true);
|
||||
assert.equal(check({ dialogue: "otto1", node: "other" }, world()), false);
|
||||
});
|
||||
|
||||
test("malformed conditions throw", () => {
|
||||
assert.throws(() => check({ bogus: 1 }, world()), /unknown condition key/);
|
||||
assert.throws(() => check({ flag: "x", has: "y" }, world()), /exactly one key/);
|
||||
assert.throws(() => check("nope", world()), /bad condition/);
|
||||
});
|
||||
|
||||
test("conditionRefs collects ids for dangling-ref checks", () => {
|
||||
const refs = conditionRefs({ all: [
|
||||
{ flag: "a" }, { var: "b" }, { has: "mug" }, { at: "tavern" },
|
||||
{ here: "otto" }, { dialogue: "d1" }, { seen: "intro" },
|
||||
] });
|
||||
assert.deepEqual(refs.flags, ["a"]);
|
||||
assert.deepEqual(refs.vars, ["b"]);
|
||||
assert.deepEqual(refs.objects, ["mug"]);
|
||||
assert.deepEqual(refs.rooms, ["tavern"]);
|
||||
assert.deepEqual(refs.characters, ["otto"]);
|
||||
assert.deepEqual(refs.dialogues, ["d1"]);
|
||||
assert.deepEqual(refs.scenes, ["intro"]);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { validate, compile, SpecError } from "../src/core/loader.js";
|
||||
|
||||
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
|
||||
|
||||
test("the shipped example validates clean", () => {
|
||||
const { errors, warnings } = validate(example);
|
||||
assert.deepEqual(errors, []);
|
||||
// exit symmetry: jetty<->tavern, so no symmetry warnings either
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("compile indexes everything", () => {
|
||||
const c = compile(example);
|
||||
assert.equal(c.rooms.tavern.id, "tavern");
|
||||
assert.equal(c.objects.mug.name, "a suspicious mug");
|
||||
assert.equal(c.characters.player.room, "jetty");
|
||||
assert.equal(c.verbs.look.label, "Look");
|
||||
assert.ok(c.interactions.every((it) => typeof it._order === "number"));
|
||||
});
|
||||
|
||||
test("every problem reported in one pass", () => {
|
||||
const bad = structuredClone(example);
|
||||
bad.meta = {}; // missing name
|
||||
bad.startRoom = "nowhere"; // dangling start
|
||||
bad.rooms[0].walkable = []; // no polygons
|
||||
bad.characters[0].id = "walker"; // player missing
|
||||
bad.interactions[0].verb = "telepathy"; // unknown verb
|
||||
bad.interactions[1].noun = "ghost-mug"; // unknown noun
|
||||
const { errors } = validate(bad);
|
||||
assert.ok(errors.length >= 6, `expected >=6 errors, got ${errors.length}: ${errors.join("|")}`);
|
||||
const joined = errors.join("\n");
|
||||
for (const needle of ["meta.name", "startRoom", "walkable", "'player'", "telepathy", "ghost-mug"])
|
||||
assert.ok(joined.includes(needle), `missing error about ${needle}: ${joined}`);
|
||||
});
|
||||
|
||||
test("compile throws SpecError listing everything", () => {
|
||||
assert.throws(() => compile({ meta: {} }), SpecError);
|
||||
try { compile({ meta: {} }); } catch (e) {
|
||||
assert.ok(e.errors.length >= 3);
|
||||
}
|
||||
});
|
||||
|
||||
test("dangling structural refs are errors, unknown flags are not checked", () => {
|
||||
const bad = structuredClone(example);
|
||||
bad.interactions.push({ verb: "use", noun: "mug", effects: [{ give: "nonexistent-item" }] });
|
||||
bad.interactions.push({ verb: "look", noun: "post", effects: [{ playMusic: "nope-track" }] });
|
||||
bad.interactions.push({ verb: "look", noun: "post", conditions: { flag: "createdAtRuntime" }, response: "ok" });
|
||||
const { errors } = validate(bad);
|
||||
const joined = errors.join("\n");
|
||||
assert.ok(joined.includes("nonexistent-item"));
|
||||
assert.ok(joined.includes("nope-track"));
|
||||
assert.ok(!joined.includes("createdAtRuntime"));
|
||||
});
|
||||
|
||||
test("dialogue nodes must connect", () => {
|
||||
const bad = structuredClone(example);
|
||||
bad.dialogues.barkeep_intro.nodes.greet.choices[0].to = "phantom";
|
||||
const { errors } = validate(bad);
|
||||
assert.ok(errors.some((e) => e.includes("phantom")));
|
||||
});
|
||||
|
||||
test("scene steps validated", () => {
|
||||
const bad = structuredClone(example);
|
||||
bad.scenes.intro = [{ explode: true }, { goto: "nowhere" }, { slide: "no-such-asset" }];
|
||||
const { errors } = validate(bad);
|
||||
const joined = errors.join("\n");
|
||||
assert.ok(joined.includes("explode"));
|
||||
assert.ok(joined.includes("nowhere"));
|
||||
assert.ok(joined.includes("no-such-asset"));
|
||||
});
|
||||
|
||||
test("duplicate ids caught", () => {
|
||||
const bad = structuredClone(example);
|
||||
bad.rooms.push({ ...bad.rooms[0] });
|
||||
const { errors } = validate(bad);
|
||||
assert.ok(errors.some((e) => e.includes("duplicate id 'jetty'")));
|
||||
});
|
||||
|
||||
test("missing return exit is a warning, not an error", () => {
|
||||
const bad = structuredClone(example);
|
||||
bad.rooms[1].exits = []; // tavern no longer leads back
|
||||
const { errors, warnings } = validate(bad);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(warnings.some((w) => w.includes("no return exit")));
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { AssetManifest } from "../src/render/assets.js";
|
||||
import { HeadlessAudio } from "../src/render/audio.js";
|
||||
import { RoomRenderer } from "../src/render/canvas.js";
|
||||
import { HudModel } from "../src/render/hud.js";
|
||||
import { Engine } from "../src/core/engine.js";
|
||||
|
||||
const example = JSON.parse(readFileSync(new URL("../example/game.json", import.meta.url), "utf8"));
|
||||
|
||||
// ---- assets ---------------------------------------------------------------
|
||||
test("asset manifest resolves and degrades gracefully", () => {
|
||||
const am = new AssetManifest(example.assets);
|
||||
assert.equal(am.get("bg_jetty").file, "assets/img/jetty.png");
|
||||
const missing = am.get("nope");
|
||||
assert.equal(missing.missing, true);
|
||||
assert.equal(am.get("nope").missing, true); // second call: no crash
|
||||
});
|
||||
|
||||
test("animation frame cycling", () => {
|
||||
const am = new AssetManifest([{ id: "p", file: "p.png", frames: { walk: [0, 1, 1, 0] }, anim: { walk: 100 } }]);
|
||||
const a = am.get("p");
|
||||
assert.equal(am.frameFor(a, "walk", 0), 0);
|
||||
assert.equal(am.frameFor(a, "walk", 100), 1);
|
||||
assert.equal(am.frameFor(a, "walk", 300), 0);
|
||||
assert.equal(am.frameFor(a, "unknownAnim", 0), 0); // falls back
|
||||
});
|
||||
|
||||
// ---- audio ----------------------------------------------------------------
|
||||
test("headless audio records calls and preserves loop points", () => {
|
||||
const au = new HeadlessAudio();
|
||||
au.playMusic({ id: "calypso", file: "c.ogg", loopStartMs: 8460, loopEndMs: 76920 });
|
||||
au.playSfx({ id: "click", file: "k.ogg" });
|
||||
au.setVolume(2); // clamped
|
||||
assert.deepEqual(au.calls[0], { op: "playMusic", id: "calypso", file: "c.ogg",
|
||||
loopStartMs: 8460, loopEndMs: 76920 });
|
||||
assert.equal(au.calls[1].op, "playSfx");
|
||||
assert.equal(au.volume, 1);
|
||||
au.stopMusic();
|
||||
assert.equal(au.current, null);
|
||||
});
|
||||
|
||||
// ---- canvas renderer (fake recording context) ------------------------------
|
||||
function fakeCtx() {
|
||||
const ops = [];
|
||||
const noop = (name) => (...args) => ops.push({ op: name, args });
|
||||
return { ops, drawImage: noop("drawImage"), fillRect: noop("fillRect"),
|
||||
fillText: noop("fillText"), beginPath: noop("beginPath"),
|
||||
lineTo: noop("lineTo"), moveTo: noop("moveTo"), closePath: noop("closePath"),
|
||||
fill: noop("fill"), save: noop("save"), restore: noop("restore") };
|
||||
}
|
||||
|
||||
test("renderer draws bg, placeholders, and exit labels", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const am = new AssetManifest(example.assets);
|
||||
const rr = new RoomRenderer(e.compiled, am, new Map()); // no images loaded
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0 });
|
||||
assert.ok(ctx.ops.some((o) => o.op === "fillRect")); // bg placeholder
|
||||
assert.ok(ctx.ops.some((o) => o.op === "fillText" && o.args[0] === "Tavern"));
|
||||
// player + objects drawn as labelled placeholders
|
||||
const labels = ctx.ops.filter((o) => o.op === "fillText").map((o) => o.args[0]);
|
||||
assert.ok(labels.includes("player"));
|
||||
assert.ok(labels.includes("post"));
|
||||
assert.ok(labels.includes("mug"));
|
||||
});
|
||||
|
||||
test("renderer uses loaded images when present", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const am = new AssetManifest(example.assets);
|
||||
const images = new Map([["bg_jetty", { width: 640, height: 480 }]]);
|
||||
const rr = new RoomRenderer(e.compiled, am, images);
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0 });
|
||||
assert.ok(ctx.ops.some((o) => o.op === "drawImage"));
|
||||
});
|
||||
|
||||
test("object placeholder boxes match hotspot rects (see-what-you-click)", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0 });
|
||||
const rects = ctx.ops.filter((o) => o.op === "fillRect").map((o) => o.args);
|
||||
// post hotspot {480,300,40,90} and mug {200,360,40,30} drawn exactly
|
||||
assert.ok(rects.some((r) => r[0] === 480 && r[1] === 300 && r[2] === 40 && r[3] === 90),
|
||||
`post box not at hotspot: ${JSON.stringify(rects)}`);
|
||||
assert.ok(rects.some((r) => r[0] === 200 && r[1] === 360 && r[2] === 40 && r[3] === 30),
|
||||
`mug box not at hotspot: ${JSON.stringify(rects)}`);
|
||||
});
|
||||
|
||||
test("walkable debug draws polygons", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const rr = new RoomRenderer(e.compiled, new AssetManifest(example.assets), new Map());
|
||||
const ctx = fakeCtx();
|
||||
rr.draw(e, ctx, { timeMs: 0, debugWalkable: true });
|
||||
assert.ok(ctx.ops.some((o) => o.op === "closePath"));
|
||||
});
|
||||
|
||||
// ---- HUD model --------------------------------------------------------------
|
||||
test("hud exposes verbs, inventory, dialogue choices, save gate", () => {
|
||||
const e = new Engine(structuredClone(example), { clock: () => 0 });
|
||||
e.newGame();
|
||||
const hud = new HudModel(e);
|
||||
assert.equal(hud.verbs().length, 5);
|
||||
e.selectVerb("look");
|
||||
assert.equal(hud.verbs().find((v) => v.id === "look").selected, true);
|
||||
assert.deepEqual(hud.inventory(), []);
|
||||
e.selectVerb("use");
|
||||
e.click({ kind: "object", id: "mug", tags: ["container"] });
|
||||
assert.deepEqual(hud.inventory(), [{ id: "mug", name: "a suspicious mug" }]);
|
||||
assert.equal(hud.canSave(), true);
|
||||
e.goTo("tavern");
|
||||
e.selectVerb("talk");
|
||||
e.click({ kind: "character", id: "barkeep", tags: ["talker"] });
|
||||
assert.equal(hud.inDialogue(), true);
|
||||
assert.equal(hud.dialogueChoices().length, 2);
|
||||
assert.equal(hud.canSave(), false, "no saving mid-dialogue");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { compile } from "../src/core/loader.js";
|
||||
import { VerbResolver } from "../src/core/verbs.js";
|
||||
import { GameWorld } from "../src/core/world.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 r = new VerbResolver(c);
|
||||
r.bindWorld(w);
|
||||
return { c, w, r };
|
||||
}
|
||||
|
||||
test("exact noun beats tag beats wildcard", () => {
|
||||
const { r } = setup();
|
||||
const it = r.resolve("look", { kind: "object", id: "post", tags: [] });
|
||||
assert.equal(it.response, "Sturdy. Smells of rope and regret.");
|
||||
const tagIt = r.resolve("look", { kind: "character", id: "barkeep", tags: ["talker"] });
|
||||
assert.ok(tagIt.response.includes("rather not be looked at"));
|
||||
});
|
||||
|
||||
test("conditions gate matches and fall through to next candidate", () => {
|
||||
const { w, r } = setup();
|
||||
// give-with-mug: first candidate requires not-gaveMug
|
||||
let it = r.resolve("give", { kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
|
||||
assert.ok(it.effects.some((e) => e.setFlag === "gaveMug"));
|
||||
w.flags.gaveMug = true;
|
||||
it = r.resolve("give", { kind: "character", id: "barkeep", tags: ["talker"] }, "mug");
|
||||
assert.equal(it.response, "You already handed it over.");
|
||||
});
|
||||
|
||||
test("with-item matching is strict both ways", () => {
|
||||
const { r } = setup();
|
||||
// give without item: no give-with interaction matches
|
||||
assert.equal(r.resolve("give", { kind: "character", id: "barkeep", tags: [] }, null), null);
|
||||
// give with wrong item: no match
|
||||
assert.equal(r.resolve("give", { kind: "character", id: "barkeep", tags: [] }, "rope"), null);
|
||||
});
|
||||
|
||||
test("conditional look on taken mug", () => {
|
||||
const { w, r } = setup();
|
||||
let it = r.resolve("look", { kind: "object", id: "mug", tags: ["container"] });
|
||||
assert.ok(it.response.includes("suspicious"));
|
||||
w.give("mug");
|
||||
it = r.resolve("look", { kind: "object", id: "mug", tags: ["container"] });
|
||||
assert.ok(it.response.includes("pocket"));
|
||||
});
|
||||
|
||||
test("default responses per verb", () => {
|
||||
const { r } = setup();
|
||||
assert.equal(r.defaultResponse("use", { kind: "object", id: "post" }), "That doesn't seem to work.");
|
||||
assert.equal(r.resolve("nope", { kind: "object", id: "post" }), null);
|
||||
});
|
||||
|
||||
test("priority overrides order at equal specificity", () => {
|
||||
const spec = structuredClone(example);
|
||||
spec.interactions.unshift({ verb: "look", noun: "post", response: "HIGH", priority: 100 });
|
||||
const c = compile(spec);
|
||||
const w = new GameWorld(c);
|
||||
const r = new VerbResolver(c); r.bindWorld(w);
|
||||
const it = r.resolve("look", { kind: "object", id: "post", tags: [] });
|
||||
assert.equal(it.response, "HIGH");
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pointInPolygon, nearestPointInPolygon, WalkArea } from "../src/core/walkable.js";
|
||||
|
||||
const square = [[0, 0], [100, 0], [100, 100], [0, 100]];
|
||||
|
||||
test("point in polygon", () => {
|
||||
assert.equal(pointInPolygon([50, 50], square), true);
|
||||
assert.equal(pointInPolygon([150, 50], square), false);
|
||||
const tri = [[0, 0], [100, 0], [50, 100]];
|
||||
assert.equal(pointInPolygon([50, 90], tri), true);
|
||||
assert.equal(pointInPolygon([5, 90], tri), false);
|
||||
});
|
||||
|
||||
test("nearest point clamps to boundary", () => {
|
||||
const p = nearestPointInPolygon([150, 50], [square]);
|
||||
assert.deepEqual(p, [100, 50]);
|
||||
});
|
||||
|
||||
test("walk area contains/clamp", () => {
|
||||
const wa = new WalkArea([square]);
|
||||
assert.equal(wa.contains([10, 10]), true);
|
||||
assert.deepEqual(wa.clamp([150, 50]), [100, 50]);
|
||||
assert.deepEqual(wa.clamp([10, 10]), [10, 10]);
|
||||
});
|
||||
|
||||
test("straight path inside one polygon", () => {
|
||||
const wa = new WalkArea([square], { sampleStep: 20 });
|
||||
const path = wa.path([10, 50], [90, 50]);
|
||||
assert.ok(path, "path found");
|
||||
assert.deepEqual(path[0], [10, 50]);
|
||||
assert.deepEqual(path.at(-1), [90, 50]);
|
||||
});
|
||||
|
||||
test("path between two polygons sharing an edge (doorway)", () => {
|
||||
const left = [[0, 0], [100, 0], [100, 100], [0, 100]];
|
||||
const right = [[100, 0], [200, 0], [200, 100], [100, 100]];
|
||||
const wa = new WalkArea([left, right], { sampleStep: 20, portalEpsilon: 5 });
|
||||
const path = wa.path([10, 50], [190, 50]);
|
||||
assert.ok(path, "cross-polygon path found");
|
||||
assert.deepEqual(path.at(-1), [190, 50]);
|
||||
// every waypoint must be inside the walkable area
|
||||
for (const p of path) assert.ok(wa.contains(p) || wa.clamp(p)[0] === p[0], `waypoint ${p} off-area`);
|
||||
});
|
||||
|
||||
test("unreachable area returns null", () => {
|
||||
const left = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const far = [[500, 500], [510, 500], [510, 510], [500, 510]];
|
||||
const wa = new WalkArea([left, far], { sampleStep: 4, portalEpsilon: 5 });
|
||||
assert.equal(wa.path([5, 5], [505, 505]), null);
|
||||
});
|
||||
|
||||
test("click outside the area walks to the nearest edge", () => {
|
||||
const wa = new WalkArea([square], { sampleStep: 20 });
|
||||
const path = wa.path([50, 50], [150, 50]);
|
||||
assert.ok(path);
|
||||
assert.deepEqual(path.at(-1), wa.clamp([150, 50]));
|
||||
});
|
||||
Reference in new issue
Block a user