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