Files
Kevin Hermes a4a2348a54 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.
2026-10-18 00:29:41 +00:00

68 lines
2.7 KiB
JavaScript

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");
});