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,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");
|
||||
});
|
||||
Reference in new issue
Block a user