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>
This commit is contained in:
1 parent
e5cde8a556
commit
fb3d280be7
16 files changed
+1120
-8
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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<abs path, url>, 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<abs path, url>, 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)`);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user