diff --git a/course/05-assemble/lesson.md b/course/05-assemble/lesson.md
index ab4240b..14c0abc 100644
--- a/course/05-assemble/lesson.md
+++ b/course/05-assemble/lesson.md
@@ -505,7 +505,7 @@ The same log, provided you also recorded the *inputs*: every harness call that s
Two things break replay, and both are worth trying. Iterating over an unordered map somewhere in the engine (TypeScript objects keep insertion order; a Rust `HashMap` does not, so on the Rust track this can bite) and ids that depend on something outside the game. If your replayed log differs, the first differing event points at the place.
-Replay is the foundation for three things the course doesn't build: saving a game as its inputs, undo by replaying all but the last input, and reproducing a bug report exactly. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter makes the same observation about recorded input: "We can take the player's input, push it over the network to another machine, and then replay it." [Module 13](../13-break-your-engine/lesson.md) uses replay to build a solver.
+Replay is the foundation for three things the course's engines don't build: saving a game as its inputs, undo by replaying all but the last input, and reproducing a bug report exactly. The second is on the course website: its [Play SpellGrid](../../site/src/pages/play.astro) page undoes by replaying. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter makes the same observation about recorded input: "We can take the player's input, push it over the network to another machine, and then replay it." [Module 13](../13-break-your-engine/lesson.md) uses replay to build a solver.
diff --git a/course/10-spellgrid/lesson.md b/course/10-spellgrid/lesson.md
index 0868ead..8a8b21a 100644
--- a/course/10-spellgrid/lesson.md
+++ b/course/10-spellgrid/lesson.md
@@ -352,7 +352,9 @@ node tools/play-spellgrid.mjs rust l1 # Rust: cargo run --bin spellgrid --
node tools/play-spellgrid.mjs ts l1 # TypeScript: npx tsx src/cli.ts levels/l1-double-tap.json
```
-The level is `l1` to `l10` (or a path to a level file of your own). `--dir
` stages into a directory you choose and keeps it, and `--stage-only` prints the command without running it. The tool prints where it staged, so you can run the command there again.
+Or play in a browser: the course website's [Play SpellGrid](../../site/src/pages/play.astro) page has all ten levels. It runs the course's newest TypeScript reference, copied unmodified, with the course's cards and these levels; the page only draws the grid and turns clicks and keys into harness calls (`step`, `cast`, `play`, the chooser). It shows the rules in force, each minion's Health and the log tree of your last input. SpellGrid has no `undo` (see [Module 13](../13-break-your-engine/lesson.md#state-undo-and-replay)), so the page's Undo replays every input but the last on a fresh level.
+
+On the command line, the level is `l1` to `l10` (or a path to a level file of your own). `--dir ` stages into a directory you choose and keeps it, and `--stage-only` prints the command without running it. The tool prints where it staged, so you can run the command there again.
`w`, `a`, `s`, `d` play a turn; `c [x y]` casts a spell (at the minion on that tile, if given) or plays a minion (onto that empty tile); `q` quits. Graft asks for its base, donor and half; answer with a number or a name. After a command that ran Part I Sequences, it prints their events under `Log:` as an indented tree (`logTree()`, from Module 2). Writing your own is optional: the course's tests don't call it. The Rust and the TypeScript command lines print the same text for the same input, and `node tools/diff-tracks.mjs` checks that on random levels and scripts.
diff --git a/course/11-conditions/lesson.md b/course/11-conditions/lesson.md
index f030cd1..64fa673 100644
--- a/course/11-conditions/lesson.md
+++ b/course/11-conditions/lesson.md
@@ -335,6 +335,8 @@ The real game pays the same costs, and the Order of Operations page shows where
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. The engine knows the role `condition`; `ON` is in content. The audit flags nouns only, but the rule is the same for condition words: the engine doesn't name them.
+**Play it.** The course website's [Play SpellGrid](../../site/src/pages/play.astro) page runs the newest TypeScript reference, unmodified, on Module 10's levels. None of them writes a condition, so every rule there has no guard to check, and every level plays as it did in Module 10: a rule without a condition gets the same answer from `hasProperty` as before.
+
## Expansion Drop
A new word arrives: `NEAR`, a second condition. From the wiki's [NEAR](https://babaiswiki.fandom.com/wiki/NEAR) page: "NEAR is an infix condition that makes a rule apply when an object is on a target or in one of the eight spaces around it". `PILLAR NEAR FLAG IS PINK` makes a pillar pink "if a Flag is ON the Pillar, directly adjacent, or diagonally adjacent".
diff --git a/course/12-combat/lesson.md b/course/12-combat/lesson.md
index 6321f13..995b9b9 100644
--- a/course/12-combat/lesson.md
+++ b/course/12-combat/lesson.md
@@ -344,6 +344,8 @@ The course has answered ordering questions with data before. Module 4 put the st
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows "attack", "Taunt" and "end of turn". It must not know Unstable Ghoul or Drakkari Enchanter.
+**Play it.** The course website's [Play SpellGrid](../../site/src/pages/play.astro) page runs the newest TypeScript reference, combat included, unmodified, on Module 10's levels. SpellGrid has no attack input, so combat never happens there. What does happen there is the Queue's new order: every Deathrattle on those levels is now queued by priority first, all at the same priority, and the levels still play as they did in Module 10.
+
## Expansion Drop
A new card arrives: Bonechewer Brawler, 2-mana 2/3, "Taunt. Whenever this minion takes damage, gain +2 Attack." ([Bonechewer Brawler](https://hearthstone.wiki.gg/wiki/Bonechewer_Brawler)).
diff --git a/course/13-break-your-engine/lesson.md b/course/13-break-your-engine/lesson.md
index ad14bce..ce84cdc 100644
--- a/course/13-break-your-engine/lesson.md
+++ b/course/13-break-your-engine/lesson.md
@@ -118,7 +118,7 @@ When it was written, on Module 10, runs of 500, 1,000 and 5,000 seeds found noth
**The real state is larger than it looks.** Ids, the id counter, the order-of-play counter, the age order of objects on a tile, `won`, the contents of each deck in order, the chooser's future answers. None of these appear when you draw the board, and every one of them can change what happens next. The honest way to find the list is the round-trip test: snapshot, restore into a fresh engine, play both forward with the same random inputs, and compare.
-An engine that is deterministic gets a second kind of undo for nothing: record the inputs, replay all but the last. It needs no snapshot and can't forget a field, which is why it works for SpellGrid, where the snapshot doesn't. The solver in the next section is built entirely on it. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter is the short introduction to both.
+An engine that is deterministic gets a second kind of undo for nothing: record the inputs, replay all but the last. It needs no snapshot and can't forget a field, which is why it works for SpellGrid, where the snapshot doesn't: the website's [Play SpellGrid](../../site/src/pages/play.astro) page undoes that way. The solver in the next section is built entirely on it. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter is the short introduction to both.
## Solving your own levels
diff --git a/site/.gitignore b/site/.gitignore
index 0fbd153..2a990ce 100644
--- a/site/.gitignore
+++ b/site/.gitignore
@@ -2,3 +2,4 @@ node_modules/
dist/
.astro/
src/content/docs/course/
+src/game/generated/
diff --git a/site/README.md b/site/README.md
index 2650c30..c75b5dd 100644
--- a/site/README.md
+++ b/site/README.md
@@ -4,4 +4,13 @@ Astro Starlight site built from the lessons in `course/`.
Run locally: `npm ci && npm run dev` (from this directory), then open http://localhost:4321.
+`npm run sync` (run before `dev` and `build`) generates two git-ignored directories, so nothing is copied by hand:
+
+- `src/content/docs/course/`: one page per `course/*/lesson.md`, and `course/harness.md`.
+- `src/game/generated/`: the play page's game. The TypeScript reference of the highest module that has one (`engine/`, `harness.ts`, `content/`), copied unmodified, except that `content/index.ts` (which reads the cards with `node:fs`) is replaced by one that re-exports `src/game/content.browser.ts`; plus `cards.json` and `levels.json`, which that module imports statically.
+
+The play page (`/play/`, `src/pages/play.astro`) is SpellGrid in the browser. `src/game/session.ts` holds the harness and the list of inputs (Undo replays them; Assemble's questions are answered by replaying with one more answer), and `src/game/play.ts` draws it. Neither knows a rule. `scripts/game.test.mjs` loads the same files under plain Node and plays Level 3 to the win (`npm run test:site` from the repository root).
+
+A lesson links to a site page by its source file, such as `[Play SpellGrid](../../site/src/pages/play.astro)`: the sync rewrites that to `/play/`, and the link still works on the repository's web view.
+
`COURSE_HOST` (e.g. `course.example.com`) sets the public site URL at build time. Unset, the site uses `http://localhost:4321`. Deploys must set it; see `../deploy/README.md`.
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index ba9c484..40f50c7 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -23,6 +23,7 @@ export default defineConfig({
sidebar: [
{ label: "Start here", items: ["disclaimer", "about"] },
{ label: "Modules", items: [{ autogenerate: { directory: "course" } }] },
+ { label: "Play SpellGrid", link: "/play/" },
],
}),
],
diff --git a/site/scripts/game.test.mjs b/site/scripts/game.test.mjs
new file mode 100644
index 0000000..ab103d7
--- /dev/null
+++ b/site/scripts/game.test.mjs
@@ -0,0 +1,137 @@
+// 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");
+});
diff --git a/site/scripts/sync-content.mjs b/site/scripts/sync-content.mjs
index a2c8d3f..5014635 100644
--- a/site/scripts/sync-content.mjs
+++ b/site/scripts/sync-content.mjs
@@ -1,6 +1,7 @@
-// Generates site/src/content/docs/course/** from course/**/lesson.md and course/harness.md.
-// Nothing is copied by hand: the lessons stay the single source of truth.
-import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from "node:fs";
+// Generates site/src/content/docs/course/** from course/**/lesson.md and course/harness.md,
+// and site/src/game/generated/** (the play page's game) from the highest TypeScript reference.
+// Nothing is copied by hand: the lessons and the references stay the single source of truth.
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync, cpSync } from "node:fs";
import { dirname, join, resolve, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -80,7 +81,8 @@ export function headingSlugs(md) {
const LINK = /\]\(([^)\s]+)((?:\s+"[^"]*")?)\)/g;
/**
- * Rewrites relative links. `ctx` = { repoRoot, file (absolute path of the source), lessonFiles: Map, readMd }.
+ * Rewrites relative links: to a lesson's page, to a site page's URL, or to the file in
+ * the repository. `ctx` = { repoRoot, file (absolute path of the source), lessonFiles: Map, readMd }.
*/
export function rewriteLinks(prose, ctx) {
return prose.replace(LINK, (whole, href, title) => {
@@ -107,6 +109,12 @@ export function rewriteLinks(prose, ctx) {
return `](${siteUrl}${anchor ? "#" + anchor : ""}${title})`;
}
const rel = relative(ctx.repoRoot, target).split(sep).join("/");
+ // A page of the site itself (site/src/pages/play.astro) links to its URL (/play/).
+ const page = rel.match(/^site\/src\/pages\/(.+)\.astro$/);
+ if (page) {
+ const url = page[1] === "index" ? "/" : `/${page[1].replace(/\/index$/, "")}/`;
+ return `](${url}${anchor ? "#" + anchor : ""}${title})`;
+ }
return `](${BLOB_BASE}${rel}${anchor ? "#" + anchor : ""}${title})`;
});
}
@@ -240,10 +248,91 @@ export function syncContent({ repoRoot, outDir }) {
return jobs.map((j) => j.out);
}
+// ---- the playable SpellGrid (/play/) ------------------------------------------
+
+/** First module whose workspace gets course/cards (as COURSE_CARDS_FROM in tools/lib.mjs). */
+const COURSE_CARDS_FROM = 5;
+
+/** The idea of each level, from the module's lesson: `**Level 3, "Title".** Idea. Hand: ...` */
+export function levelIdeas(lessonMd) {
+ const ideas = new Map();
+ for (const m of lessonMd.matchAll(/^\*\*Level (\d+), "([^"]+)"\.\*\*\s+(.*)$/gm)) {
+ const idea = m[3].replace(/\s*Hand: [^.]*\.\s*$/, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
+ ideas.set(Number(m[1]), { title: m[2], idea });
+ }
+ return ideas;
+}
+
+/**
+ * Generates the sources of the play page into `outDir` (site/src/game/generated):
+ * the TypeScript reference of the highest module that has one, copied unmodified
+ * (`engine/`, `harness.ts`, and `content/` except its `index.ts`, which reads the cards
+ * with node:fs); a `content/index.ts` that re-exports the browser content module
+ * (`site/src/game/content.browser.ts`); and, for that module to import statically,
+ * `cards.json` (the cards a workspace at that module has, in file-name order) and
+ * `levels.json` (every level, with its title and idea from the lesson).
+ */
+export function syncGame({ repoRoot, outDir }) {
+ const courseDir = join(repoRoot, "course");
+ const modules = readdirSync(courseDir).filter((d) => /^\d\d-/.test(d)).sort();
+ const module = modules.filter((d) => existsSync(join(courseDir, d, "reference/ts/src/harness.ts"))).at(-1);
+ if (!module) throw new Error("no module has a TypeScript reference with a harness");
+ const upTo = modules.filter((d) => d <= module);
+ const src = join(courseDir, module, "reference/ts/src");
+
+ rmSync(outDir, { recursive: true, force: true });
+ mkdirSync(join(outDir, "content"), { recursive: true });
+ cpSync(join(src, "engine"), join(outDir, "engine"), { recursive: true });
+ cpSync(join(src, "harness.ts"), join(outDir, "harness.ts"));
+ for (const f of readdirSync(join(src, "content"))) {
+ if (f !== "index.ts") cpSync(join(src, "content", f), join(outDir, "content", f));
+ }
+ writeFileSync(
+ join(outDir, "content", "index.ts"),
+ `// Generated by site/scripts/sync-content.mjs: the browser's content module, in place of\n` +
+ `// course/${module}/reference/ts/src/content/index.ts (which reads the cards with node:fs).\n` +
+ `export * from "../../content.browser";\n`,
+ );
+
+ // Cards: as tools/play-spellgrid.mjs stages them, one directory over another, in file-name order.
+ const cardDirs = [
+ ...(Number(module.slice(0, 2)) >= COURSE_CARDS_FROM ? [join(courseDir, "cards")] : []),
+ ...upTo.map((d) => join(courseDir, d, "cards")),
+ ].filter((d) => existsSync(d));
+ const cardFiles = new Map();
+ for (const dir of cardDirs) {
+ for (const f of readdirSync(dir)) if (f.endsWith(".json")) cardFiles.set(f, join(dir, f));
+ }
+ const cards = [...cardFiles.keys()].sort().map((f) => JSON.parse(readFileSync(cardFiles.get(f), "utf8")));
+ writeFileSync(join(outDir, "cards.json"), JSON.stringify(cards));
+
+ const levels = [];
+ for (const d of upTo) {
+ const dir = join(courseDir, d, "levels");
+ if (!existsSync(dir)) continue;
+ const lesson = join(courseDir, d, "lesson.md");
+ const ideas = levelIdeas(existsSync(lesson) ? readFileSync(lesson, "utf8") : "");
+ const files = readdirSync(dir)
+ .filter((f) => f.endsWith(".json") && f !== "proofs.json")
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
+ for (const f of files) {
+ const name = f.slice(0, -".json".length);
+ const n = Number(name.match(/^l(\d+)/)?.[1]);
+ const about = ideas.get(n);
+ if (!about) throw new Error(`course/${d}/lesson.md: no **Level ${n}, "Title".** line for ${f}`);
+ levels.push({ name, ...about, level: JSON.parse(readFileSync(join(dir, f), "utf8")) });
+ }
+ }
+ writeFileSync(join(outDir, "levels.json"), JSON.stringify(levels));
+ return { module, cards: cards.length, levels: levels.length };
+}
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "../..");
const outDir = resolve(here, "../src/content/docs/course");
const written = syncContent({ repoRoot, outDir });
console.log(`sync-content: wrote ${written.length} pages`);
+ const game = syncGame({ repoRoot, outDir: resolve(here, "../src/game/generated") });
+ console.log(`sync-content: the play page runs course/${game.module} (${game.cards} cards, ${game.levels} levels)`);
}
diff --git a/site/scripts/sync-content.test.mjs b/site/scripts/sync-content.test.mjs
index 5d134e0..dd479be 100644
--- a/site/scripts/sync-content.test.mjs
+++ b/site/scripts/sync-content.test.mjs
@@ -93,3 +93,15 @@ test("sync_writes_harness_and_ignores_missing_dirs", () => {
assert.match(page("harness.mdx"), /title: "Harness contract"/);
assert.deepEqual(readdirSync(join(root, "out")).sort(), ["01-a.mdx", "harness.mdx"]);
});
+
+test("sync_links_site_pages_to_their_urls", () => {
+ const root = fixture(
+ { "01-a": "# A\n\n[play](../../site/src/pages/play.astro) and [home](../../site/src/pages/index.astro)\n" },
+ { "site/src/pages/play.astro": "---\n---\n", "site/src/pages/index.astro": "---\n---\n" },
+ );
+ const page = run(root)("01-a.mdx");
+ assert.match(page, /\[play\]\(\/play\/\)/);
+ assert.match(page, /\[home\]\(\/\)/);
+ const dead = fixture({ "01-a": "# A\n\n[play](../../site/src/pages/nope.astro)\n" });
+ assert.throws(() => run(dead), /dead link/);
+});
diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx
index eeeda32..dc60d06 100644
--- a/site/src/content/docs/index.mdx
+++ b/site/src/content/docs/index.mdx
@@ -21,7 +21,7 @@ A free course on **systemic game design**. You learn by rebuilding the rules mac
- **Part I: Hearthstone**: cards, zones, triggers, death phases, auras, text assembly.
- **Part II: Baba Is You**: rules as pushable text, parsing, movement, win and lose.
-- **Part III: SpellGrid**: a small original game that combines both systems.
+- **Part III: SpellGrid**: a small original game that combines both systems. [Play it in your browser](/play/).
Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine.
@@ -53,6 +53,9 @@ Every code sample and exercise exists in **Rust** and **TypeScript**. Pick one a
- [Module 8: Push chains: validate, then commit](/course/08-push-chains/)
- [Module 9: The turn pipeline](/course/09-turn-pipeline/)
- [Module 10: SpellGrid](/course/10-spellgrid/)
+- [Module 11: Conditions](/course/11-conditions/)
+- [Module 12: Combat](/course/12-combat/)
+- [Module 13: Break your own engine](/course/13-break-your-engine/)
- [Harness contract](/course/harness/) (reference)
See also the [disclaimer](/disclaimer/) and [about](/about/) pages.
diff --git a/site/src/game/content.browser.ts b/site/src/game/content.browser.ts
new file mode 100644
index 0000000..0ab6a0b
--- /dev/null
+++ b/site/src/game/content.browser.ts
@@ -0,0 +1,43 @@
+// The play page's content module: what the reference's `src/content/index.ts` provides
+// (cards, words, seams, conditions, hazards, movement), by static imports instead of
+// node:fs, plus the levels. The harness imports it through `generated/content/index.ts`.
+// Everything under `generated/` is written by `site/scripts/sync-content.mjs` from the
+// highest module's TypeScript reference and the course's card and level data.
+
+import type { CardDef, puzzle, SeamWords } from "./generated/engine";
+import cardList from "./generated/cards.json";
+import levelList from "./generated/levels.json";
+import seamWords from "./generated/content/seams.json";
+import wordList from "./generated/content/words.json";
+
+export { conditions } from "./generated/content/conditions";
+export { hazards } from "./generated/content/hazards";
+export { responses as movement } from "./generated/content/movement";
+
+/** Every card a workspace at the reference's module has, in file-name order. */
+export function cards(): CardDef[] {
+ return cardList as CardDef[];
+}
+
+/** The words of Baba Is You the parser can read, besides the engine's own. */
+export function words(): puzzle.WordDef[] {
+ return wordList as puzzle.WordDef[];
+}
+
+/** The words that cross between the engines. */
+export function seams(): SeamWords {
+ return seamWords as SeamWords;
+}
+
+/** A level as the page offers it: its file name, and its title and idea from the lesson. */
+export interface LevelEntry {
+ name: string; // "l3-simultaneous-paradox"
+ title: string; // "Simultaneous Paradox"
+ idea: string; // one paragraph of the lesson, inline code in backticks
+ level: puzzle.LevelDef;
+}
+
+/** Every level, in number order. */
+export function levels(): LevelEntry[] {
+ return levelList as unknown as LevelEntry[];
+}
diff --git a/site/src/game/play.ts b/site/src/game/play.ts
new file mode 100644
index 0000000..070d715
--- /dev/null
+++ b/site/src/game/play.ts
@@ -0,0 +1,321 @@
+// The play page's DOM glue: draws a Session and turns clicks and keys into inputs.
+// Every question about the rules (can this card go there? what is on this tile? who
+// won?) is asked of the harness; nothing here knows a card, a noun or a rule.
+
+import { levels, type LevelEntry } from "./content.browser";
+import { Session, type Dir, type Input, type Question } from "./session";
+
+const KEYS: Record = {
+ ArrowUp: "up",
+ ArrowLeft: "left",
+ ArrowDown: "down",
+ ArrowRight: "right",
+ w: "up",
+ a: "left",
+ s: "down",
+ d: "right",
+};
+
+/** A tile a chosen card can go to: cast at the minion there, or play onto it. */
+interface Target {
+ x: number;
+ y: number;
+ input: Input;
+}
+
+export function mount(root: HTMLElement): void {
+ const find = (name: string): T => {
+ const el = root.querySelector(`[data-${name}]`);
+ if (!el) throw new Error(`play page: no [data-${name}]`);
+ return el;
+ };
+ const ui = {
+ level: find("level"),
+ idea: find("idea"),
+ count: find("count"),
+ undo: find("undo"),
+ restart: find("restart"),
+ status: find("status"),
+ prompt: find("prompt"),
+ board: find("board"),
+ grid: find("grid"),
+ hand: find("hand"),
+ minions: find("minions"),
+ rules: find("rules"),
+ tree: find("tree"),
+ pad: [...root.querySelectorAll("[data-dir]")],
+ };
+
+ const all = levels();
+ for (const entry of all) {
+ ui.level.append(new Option(`${entry.name.match(/^l(\d+)/)?.[1]}. ${entry.title}`, entry.name));
+ }
+
+ let entry: LevelEntry = all[0];
+ let session = new Session(entry.level);
+ let tiles: HTMLButtonElement[] = [];
+ /** The card waiting for a tile, and the tiles it can go to. */
+ let choosing: { card: string; targets: Target[] } | undefined;
+ /** An input waiting for an answer to Assemble's question. */
+ let asking: { input: Input & { type: "cast" }; question: Question } | undefined;
+ let message: { text: string; tone?: "bad" } | undefined;
+
+ function load(name: string): void {
+ entry = all.find((l) => l.name === name) ?? all[0];
+ ui.level.value = entry.name;
+ ui.idea.replaceChildren(...inlineCode(entry.idea));
+ session = new Session(entry.level);
+ const { width, height } = entry.level;
+ ui.grid.style.setProperty("--sg-cols", String(width));
+ tiles = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const tile = document.createElement("button");
+ tile.type = "button";
+ tile.className = "sg-tile";
+ tile.addEventListener("click", () => {
+ const target = choosing?.targets.find((t) => t.x === x && t.y === y);
+ if (target) act(target.input);
+ });
+ tiles.push(tile);
+ }
+ }
+ ui.grid.replaceChildren(...tiles);
+ reset();
+ }
+
+ function reset(): void {
+ choosing = undefined;
+ asking = undefined;
+ message = undefined;
+ render();
+ }
+
+ /** Drop the card waiting for a tile, or the question waiting for an answer. */
+ function cancel(): void {
+ const card = choosing?.card ?? asking?.input.card;
+ reset();
+ // The tile or answer that had focus is gone: give focus back to the card.
+ [...ui.hand.querySelectorAll("button")].find((b) => b.textContent === card)?.focus();
+ }
+
+ function act(input: Input): void {
+ choosing = undefined;
+ asking = undefined;
+ message = undefined;
+ const outcome = session.apply(input);
+ if ("question" in outcome) asking = { input: input as Input & { type: "cast" }, question: outcome.question };
+ else if ("error" in outcome) message = { text: outcome.error, tone: "bad" };
+ render();
+ if (asking) ui.prompt.querySelector("button")?.focus();
+ }
+
+ function chooseCard(card: string): void {
+ const g = session.grid;
+ if (choosing?.card === card) return cancel();
+ if (g.checkCast(card) === null) return act({ type: "cast", card, picks: [] });
+ const targets: Target[] = [];
+ const { width, height } = entry.level;
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const minion = g.minionAt(x, y);
+ if (minion !== null && g.checkCast(card, minion) === null) targets.push({ x, y, input: { type: "cast", card, at: [x, y], picks: [] } });
+ else if (g.checkPlay(card, x, y) === null) targets.push({ x, y, input: { type: "play", card, at: [x, y] } });
+ }
+ }
+ asking = undefined;
+ if (targets.length === 0) {
+ choosing = undefined;
+ message = { text: `No tile can take ${card} now.`, tone: "bad" };
+ } else {
+ choosing = { card, targets };
+ message = undefined;
+ }
+ render();
+ if (choosing) tiles[targets[0].y * width + targets[0].x].focus();
+ }
+
+ function answer(pick: number): void {
+ if (!asking) return;
+ const { input } = asking;
+ act({ ...input, picks: [...input.picks, pick] });
+ }
+
+ function render(): void {
+ const g = session.grid;
+ const { width, height } = entry.level;
+ const over = g.won() || g.lost();
+
+ // Status line.
+ let text: string;
+ let tone: string | undefined;
+ if (asking) {
+ text = `${asking.input.card} asks for the ${asking.question.kind}.`;
+ tone = "ask";
+ } else if (choosing) {
+ text = `Choose a tile for ${choosing.card}, or press Esc.`;
+ tone = "ask";
+ } else if (message) {
+ text = message.text;
+ tone = message.tone;
+ } else if (g.won()) {
+ text = `You win, in ${session.inputs.length} input${session.inputs.length === 1 ? "" : "s"}.`;
+ tone = "win";
+ } else if (g.lost()) {
+ text = "Nothing is YOU. You lose: Undo or Restart.";
+ tone = "bad";
+ } else {
+ text = "Your move.";
+ }
+ ui.status.textContent = text;
+ if (tone) ui.status.dataset.tone = tone;
+ else delete ui.status.dataset.tone;
+
+ // Assemble's question.
+ ui.prompt.hidden = !asking;
+ if (asking) {
+ const options = asking.question.options.map((option, i) => button(option, () => answer(i)));
+ ui.prompt.replaceChildren(...options, button("Cancel", cancel));
+ } else ui.prompt.replaceChildren();
+
+ // The grid.
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const tile = tiles[y * width + x];
+ const stack = g.stackAt(x, y);
+ const minion = g.minionAt(x, y);
+ const target = choosing?.targets.some((t) => t.x === x && t.y === y) ?? false;
+ const parts: Node[] = stack.map((kind) =>
+ kind.startsWith("text:") ? span("sg-word", kind.slice(5)) : span(minion !== null ? "sg-thing is-minion" : "sg-thing", kind),
+ );
+ if (minion !== null) parts.push(span("sg-hp", `♥${g.health(minion)}`));
+ tile.replaceChildren(...parts);
+ tile.classList.toggle("is-empty", stack.length === 0);
+ tile.classList.toggle("is-minion", minion !== null);
+ tile.classList.toggle("is-word", stack.length > 0 && stack.every((kind) => kind.startsWith("text:")));
+ tile.classList.toggle("is-target", target);
+ tile.disabled = !target;
+ const what = stack.map((k) => (k.startsWith("text:") ? `text ${k.slice(5)}` : k)).join(", ") || "empty";
+ const who = minion === null ? "" : `; ${g.nameOf(minion)}, ${g.health(minion)} Health`;
+ tile.setAttribute("aria-label", `(${x}, ${y}): ${what}${who}`);
+ tile.title = minion === null ? `(${x}, ${y})` : `(${x}, ${y}) ${g.nameOf(minion)}, ${g.health(minion)} Health`;
+ }
+ }
+
+ // Hand, moves, tools.
+ const hand = g.hand();
+ ui.hand.replaceChildren(
+ ...(hand.length === 0
+ ? [paragraph("sg-empty", "Empty.")]
+ : hand.map((card) => {
+ const b = button(card, () => chooseCard(card));
+ b.disabled = over;
+ b.setAttribute("aria-pressed", String(choosing?.card === card));
+ return b;
+ })),
+ );
+ for (const b of ui.pad) b.disabled = over;
+ ui.undo.disabled = session.inputs.length === 0;
+ ui.restart.disabled = session.inputs.length === 0;
+ ui.count.textContent = `${session.inputs.length} input${session.inputs.length === 1 ? "" : "s"}`;
+
+ // Minions and rules.
+ const minions: HTMLElement[] = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const id = g.minionAt(x, y);
+ if (id !== null) minions.push(item(`${g.nameOf(id)} (${x}, ${y}): ${g.health(id)} Health`));
+ }
+ }
+ ui.minions.replaceChildren(...(minions.length > 0 ? minions : [item("None.")]));
+ const rules = g.activeRules();
+ ui.rules.replaceChildren(
+ ...(rules.length > 0 ? rules.map((rule) => itemOf(code(rule))) : [item("None.")]),
+ );
+
+ // Part I's log of the last input.
+ ui.tree.textContent = session.tree === "" ? "No card events." : session.tree.trimEnd();
+ }
+
+ ui.level.addEventListener("change", () => {
+ history.replaceState(null, "", `#${ui.level.value}`);
+ load(ui.level.value);
+ // Arrow keys on the select would change the level again: hand them to the board.
+ ui.board.focus();
+ });
+ window.addEventListener("hashchange", () => load(location.hash.slice(1)));
+ ui.undo.addEventListener("click", () => {
+ session.undo();
+ reset();
+ });
+ ui.restart.addEventListener("click", () => {
+ session.restart();
+ reset();
+ });
+ for (const b of ui.pad) b.addEventListener("click", () => act({ type: "step", dir: b.dataset.dir as Dir }));
+
+ document.addEventListener("keydown", (e) => {
+ if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
+ const t = e.target as HTMLElement | null;
+ if (t?.closest("input, select, textarea, [contenteditable], dialog")) return;
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
+ if (key === "Escape" && (choosing || asking)) {
+ cancel();
+ } else if (key in KEYS) {
+ if (session.grid.won() || session.grid.lost()) return;
+ act({ type: "step", dir: KEYS[key] });
+ } else if (key === "u") {
+ if (!session.undo()) return;
+ reset();
+ } else return;
+ e.preventDefault();
+ });
+
+ load(location.hash.slice(1));
+ root.hidden = false;
+}
+
+function span(className: string, text: string): HTMLSpanElement {
+ const el = document.createElement("span");
+ el.className = className;
+ el.textContent = text;
+ return el;
+}
+
+function code(text: string): HTMLElement {
+ const el = document.createElement("code");
+ el.textContent = text;
+ return el;
+}
+
+function button(label: string, onClick: () => void): HTMLButtonElement {
+ const el = document.createElement("button");
+ el.type = "button";
+ el.textContent = label;
+ el.addEventListener("click", onClick);
+ return el;
+}
+
+function paragraph(className: string, text: string): HTMLParagraphElement {
+ const el = document.createElement("p");
+ el.className = className;
+ el.textContent = text;
+ return el;
+}
+
+function item(text: string): HTMLLIElement {
+ const el = document.createElement("li");
+ el.textContent = text;
+ return el;
+}
+
+function itemOf(child: Node): HTMLLIElement {
+ const el = document.createElement("li");
+ el.append(child);
+ return el;
+}
+
+/** A lesson sentence with its `inline code` as . */
+function inlineCode(text: string): Node[] {
+ return text.split(/`([^`]+)`/).map((part, i) => (i % 2 === 1 ? code(part) : document.createTextNode(part)));
+}
diff --git a/site/src/game/session.ts b/site/src/game/session.ts
new file mode 100644
index 0000000..1851da2
--- /dev/null
+++ b/site/src/game/session.ts
@@ -0,0 +1,106 @@
+// One game of SpellGrid on the play page: the reference's harness, plus the list of the
+// player's inputs. No rules live here. SpellGrid has no undo of its own (its harness
+// says "not supported"), so Undo replays every input but the last on a fresh level; the
+// engine is deterministic, so that is the same game.
+//
+// Assemble's chooser is called in the middle of a Sequence, and a page can't stop there
+// to wait for a click. So a cast carries the answers given so far (`picks`, indexes into
+// the options). When the engine asks a question the input has no answer for, the cast
+// finishes with the first option, the game is replayed without it, and the question is
+// returned: answer it and apply the input again with one more pick (as tools/solver does).
+
+import { newSpellGrid, type LevelDef, type SpellGrid } from "./generated/harness";
+
+export type Dir = "up" | "down" | "left" | "right";
+
+export type Input =
+ | { type: "step"; dir: Dir }
+ | { type: "cast"; card: string; at?: [number, number]; picks: number[] } // at: the target minion's tile
+ | { type: "play"; card: string; at: [number, number] };
+
+export interface Question {
+ kind: string; // "base" | "donor" | "half"
+ options: readonly string[];
+}
+
+export type Outcome = { ok: true } | { ok: false; error: string } | { ok: false; question: Question };
+
+export class Session {
+ readonly inputs: Input[] = [];
+ grid: SpellGrid;
+ /** Part I's log of the last input, as an indented tree (`logTree()`); "" if it ran no Sequence. */
+ tree = "";
+
+ constructor(readonly level: LevelDef) {
+ this.grid = this.replay();
+ }
+
+ /** Play one input. It is recorded only if it is legal and every question it raised has an answer. */
+ apply(input: Input): Outcome {
+ if (this.grid.won() || this.grid.lost()) return { ok: false, error: "The level is over: Undo or Restart." };
+ let question: Question | undefined;
+ try {
+ run(this.grid, input, (kind, options) => {
+ question ??= { kind, options };
+ return options[0];
+ });
+ } catch (e) {
+ // A failed input may have changed the game part-way: rebuild it.
+ this.grid = this.replay();
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
+ }
+ if (question !== undefined) {
+ this.grid = this.replay();
+ return { ok: false, question };
+ }
+ this.inputs.push(structuredClone(input));
+ this.tree = this.grid.logTree();
+ return { ok: true };
+ }
+
+ /** Take back the last input. False if there is none. */
+ undo(): boolean {
+ if (this.inputs.length === 0) return false;
+ this.inputs.pop();
+ this.grid = this.replay();
+ return true;
+ }
+
+ /** Back to the level's start. */
+ restart(): void {
+ this.inputs.length = 0;
+ this.grid = this.replay();
+ }
+
+ /** A fresh level with every recorded input played on it. */
+ private replay(): SpellGrid {
+ const g = newSpellGrid(this.level, []);
+ for (const input of this.inputs) {
+ run(g, input, (_kind, options) => options[0]);
+ }
+ this.tree = g.logTree();
+ return g;
+ }
+}
+
+/**
+ * Play `input` on `g`, after clearing the log. Assemble's questions are answered from
+ * `input.picks` in order; `unanswered` answers any question beyond them.
+ */
+function run(g: SpellGrid, input: Input, unanswered: (kind: string, options: readonly string[]) => string): void {
+ g.clearLog();
+ if (input.type === "step") return g.step(input.dir);
+ if (input.type === "play") {
+ g.play(input.card, ...input.at);
+ return;
+ }
+ let asked = 0;
+ g.setChooser((kind, options) => {
+ const pick = input.picks[asked++];
+ return pick !== undefined && pick < options.length ? options[pick] : unanswered(kind, options);
+ });
+ if (input.at === undefined) return g.cast(input.card);
+ const target = g.minionAt(...input.at);
+ if (target === null) throw new Error(`no minion on (${input.at.join(", ")})`);
+ g.cast(input.card, target);
+}
diff --git a/site/src/pages/play.astro b/site/src/pages/play.astro
new file mode 100644
index 0000000..878e9d8
--- /dev/null
+++ b/site/src/pages/play.astro
@@ -0,0 +1,384 @@
+---
+// /play/: SpellGrid in the browser. The game is the highest module's TypeScript reference,
+// copied unmodified by scripts/sync-content.mjs; src/game/play.ts only draws it and
+// turns clicks and keys into harness calls.
+import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
+---
+
+
+