Files
systemic-game-design-course/site/scripts/game.test.mjs
T
Lucas WintherandClaude Opus 5.5 fb3d280be7 feat: playable SpellGrid page on the site (the highest TS reference, unmodified, in the browser)
The sync step copies the reference's engine, harness and content into
site/src/game/generated/ and replaces only content/index.ts (node:fs) with a
re-export of a browser content module (static imports of cards, words, seams,
levels). /play/ has a level picker, text-tile grid, keyboard and buttons, cards
then tiles, the chooser as buttons, rules, minions, the log tree, Undo by
replay and Restart. test:site plays l3 to the win under plain Node. Lessons
5, 10-13 and the home page link the page; lesson links to site pages go
through their source file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:22:50 +01:00

138 lines
6.2 KiB
JavaScript

// The playable SpellGrid page runs the TypeScript reference unmodified. These tests load
// the game exactly as the page does (the generated sources plus site/src/game/*.ts),
// under plain Node: no npm packages, so `npm run test:site` needs no install.
import { after, test } from "node:test";
import assert from "node:assert/strict";
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { registerHooks, stripTypeScriptTypes } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { syncGame } from "./sync-content.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "../..");
// Stage site/src/game (without its generated directory) into a temp directory and generate there.
const stage = mkdtempSync(join(tmpdir(), "game-"));
after(() => rmSync(stage, { recursive: true, force: true }));
for (const f of readdirSync(join(here, "../src/game"))) {
if (f.endsWith(".ts")) cpSync(join(here, "../src/game", f), join(stage, f));
}
const synced = syncGame({ repoRoot, outDir: join(stage, "generated") });
// stripTypeScriptTypes is experimental in Node 22 and 24; keep its one warning out of the test output.
const emitWarning = process.emitWarning;
process.emitWarning = (warning, ...rest) => {
if (!String(warning).includes("stripTypeScriptTypes")) emitWarning.call(process, warning, ...rest);
};
// What Vite does for the page, for the staged files only: extensionless and directory
// imports, TypeScript, and JSON imports without an import attribute.
const inStage = (url) => url.startsWith(pathToFileURL(stage).href);
registerHooks({
resolve(specifier, context, next) {
if (specifier.startsWith(".") && context.parentURL && inStage(context.parentURL)) {
const base = fileURLToPath(new URL(specifier, context.parentURL));
for (const candidate of [base, `${base}.ts`, join(base, "index.ts")]) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return { url: pathToFileURL(candidate).href, shortCircuit: true };
}
}
}
return next(specifier, context);
},
load(url, context, next) {
if (inStage(url) && url.endsWith(".ts")) {
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), "utf8"), { mode: "transform" });
return { format: "module", source, shortCircuit: true };
}
if (inStage(url) && url.endsWith(".json")) {
return { format: "module", source: `export default ${readFileSync(fileURLToPath(url), "utf8")};`, shortCircuit: true };
}
return next(url, context);
},
});
const { levels } = await import(pathToFileURL(join(stage, "content.browser.ts")).href);
const { Session } = await import(pathToFileURL(join(stage, "session.ts")).href);
const level = (name) => levels().find((l) => l.name.startsWith(`${name}-`));
test("game_sync_copies_the_highest_reference", () => {
assert.match(synced.module, /^\d\d-/);
const ref = join(repoRoot, "course", synced.module, "reference/ts/src");
assert.equal(readFileSync(join(stage, "generated/harness.ts"), "utf8"), readFileSync(join(ref, "harness.ts"), "utf8"));
assert.equal(
readFileSync(join(stage, "generated/engine/spellgrid.ts"), "utf8"),
readFileSync(join(ref, "engine/spellgrid.ts"), "utf8"),
);
const higher = readdirSync(join(repoRoot, "course")).filter(
(d) => /^\d\d-/.test(d) && d > synced.module && existsSync(join(repoRoot, "course", d, "reference/ts/src/harness.ts")),
);
assert.deepEqual(higher, []);
});
test("game_content_lists_every_level_with_its_idea", () => {
const all = levels();
const files = readdirSync(join(repoRoot, "course/10-spellgrid/levels")).filter((f) => f.endsWith(".json") && f !== "proofs.json");
assert.equal(all.length, files.length);
assert.deepEqual(all.slice(0, 3).map((l) => l.name), ["l1-double-tap", "l2-spliced-corpse", "l3-simultaneous-paradox"]);
assert.equal(all.at(-1).name, "l10-friendly-fire");
const l3 = level("l3");
assert.equal(l3.title, "Simultaneous Paradox");
assert.match(l3.idea, /^You are the Tin Knight \(3 Health\)\./);
assert.doesNotMatch(l3.idea, /Hand:/);
for (const l of all) assert.ok(l.idea.length > 0, `${l.name} has no idea`);
});
test("game_solves_l3_with_its_scripted_inputs", () => {
const s = new Session(level("l3").level);
assert.deepEqual(s.grid.hand(), ["Whirlwind"]);
assert.deepEqual(s.apply({ type: "cast", card: "Whirlwind", picks: [] }), { ok: true });
assert.match(s.tree, /^Triggered\{source: \d+, trigger: "deathrattle"\}$/m);
assert.match(s.tree, /^ Damaged\{target: \d+, amount: 1\}$/m);
for (let i = 0; i < 5; i++) s.apply({ type: "step", dir: "right" });
assert.equal(s.grid.won(), true);
});
test("game_undo_replays_the_inputs", () => {
const s = new Session(level("l3").level);
s.apply({ type: "cast", card: "Whirlwind", picks: [] });
for (let i = 0; i < 5; i++) s.apply({ type: "step", dir: "right" });
assert.equal(s.undo(), true);
assert.equal(s.grid.won(), false);
assert.equal(s.inputs.length, 5);
s.apply({ type: "step", dir: "right" });
assert.equal(s.grid.won(), true);
while (s.undo());
assert.deepEqual(s.grid.hand(), ["Whirlwind"]);
assert.equal(s.undo(), false);
});
test("game_rejects_an_illegal_input_and_keeps_the_state", () => {
const s = new Session(level("l3").level);
const r = s.apply({ type: "cast", card: "Spark", picks: [] });
assert.equal(r.ok, false);
assert.match(r.error, /not in the hand/);
assert.deepEqual(s.inputs, []);
assert.deepEqual(s.grid.hand(), ["Whirlwind"]);
});
test("game_asks_the_chooser_questions_one_at_a_time", () => {
const s = new Session(level("l2").level);
const input = { type: "cast", card: "Graft", picks: [] };
const asked = [];
let r = s.apply(input);
while (r.question) {
asked.push(r.question.kind);
assert.deepEqual(s.inputs, [], "nothing is recorded until every question is answered");
assert.deepEqual(s.grid.hand(), ["Graft"]);
input.picks.push(0);
r = s.apply(input);
}
assert.deepEqual(r, { ok: true });
assert.deepEqual(asked, ["base", "donor", "half"]);
assert.equal(s.inputs.length, 1);
assert.equal(s.grid.hand().length, 1);
assert.notEqual(s.grid.hand()[0], "Graft");
});