diff --git a/course/10-spellgrid/lesson.md b/course/10-spellgrid/lesson.md index f8c5ca2..6c4ceca 100644 --- a/course/10-spellgrid/lesson.md +++ b/course/10-spellgrid/lesson.md @@ -249,13 +249,15 @@ syncRules(): // seam 1 ### Play it -The reference ships a command line, kept thin over the harness. From a workspace with `cards/` and `levels/`: +The reference ships a command line, kept thin over the harness. It reads `cards/` and `levels/` from the directory it runs in, so `tools/play-spellgrid.mjs` stages the reference with the course's cards and levels into a temporary directory and starts it. From the course repository: ```sh -cargo run --bin spellgrid -- levels/l1-double-tap.json # Rust -npx tsx src/cli.ts levels/l1-double-tap.json # TypeScript +node tools/play-spellgrid.mjs rust l1 # Rust: cargo run --bin spellgrid -- levels/l1-double-tap.json +node tools/play-spellgrid.mjs ts l1 # TypeScript: npx tsx src/cli.ts levels/l1-double-tap.json ``` +The level is `l1`, `l2` or `l3` (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. Writing your own is optional: the course's tests don't call it. ## Name it diff --git a/course/10-spellgrid/reference/rust/README.md b/course/10-spellgrid/reference/rust/README.md index e09bbeb..688a014 100644 --- a/course/10-spellgrid/reference/rust/README.md +++ b/course/10-spellgrid/reference/rust/README.md @@ -2,7 +2,7 @@ The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds. -- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`. +- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs` (and by `tools/play-spellgrid.mjs`, to play the command line). - `src/engine/spellgrid.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the three seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id. - Part I (`src/engine/`, outside `puzzle/`): - `queue.rs`: `END_OF_PHASE` (`[DeathCreation, AuraUpdate]`) is the list of steps at the end of the outermost Phase, read from the world. `Action::SpawnText` records a `TextSpawned` event. @@ -15,6 +15,6 @@ The course's own engine as of the end of Module 10: Part I's card engine and Par - `grid.rs`: `Board::reserve_id`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid), with `LevelCard`. - `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. - `src/harness.rs`: Module 10 adds `new_spell_grid` and `SpellGrid`, including the test hook `with_naive_aura_timing()`, which reverses `END_OF_PHASE` for one game. -- `src/cli.rs` and `src/bin/spellgrid.rs` (new): the command line, `cargo run --bin spellgrid -- levels/l1-double-tap.json` from a staged workspace (it needs `cards/` and `levels/`). `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.rs` plays it from a script; it is the reference's own check, not a course test. +- `src/cli.rs` and `src/bin/spellgrid.rs` (new): the command line, `cargo run --bin spellgrid -- levels/l1-double-tap.json` run from a staged directory (it needs `cards/` and `levels/`): `node tools/play-spellgrid.mjs rust l1` from the course repository stages one in a temporary directory and starts it. `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.rs` plays it from a script; it is the reference's own check, not a course test. Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository. diff --git a/course/10-spellgrid/reference/ts/README.md b/course/10-spellgrid/reference/ts/README.md index 3bc05c4..f57b624 100644 --- a/course/10-spellgrid/reference/ts/README.md +++ b/course/10-spellgrid/reference/ts/README.md @@ -2,7 +2,7 @@ The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds. -- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`. +- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` plus `course/10-spellgrid/cards/` (SpellGrid's own cards, which only Module 10 on receives), and of `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs` (and by `tools/play-spellgrid.mjs`, to play the command line). - `src/engine/spellgrid.ts` (new): the only file that imports both engines. `SpellGrid` keeps a `World` and a `puzzle.Level` in step through the three seams: `ruleAuras` (rules in force → auras), the destroy function (hazard victims → one `destroy` Sequence → the Death Creation Step), and `sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id. - Part I (`src/engine/`, outside `puzzle/`): - `queue.ts`: `END_OF_PHASE` (`["deathCreation", "auraUpdate"]`) is the list of steps at the end of the outermost Phase, read from the world. The `spawnText` action records a `TextSpawned` event. @@ -15,6 +15,6 @@ The course's own engine as of the end of Module 10: Part I's card engine and Par - `grid.ts`: `Board.reserveId`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid). - `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. `index.ts` now reads `cards/` with `node:fs`, so the command line, which runs without Vite, reads the same cards. - `src/harness.ts`: Module 10 adds `newSpellGrid` and the `SpellGrid` interface, including the test hook `withNaiveAuraTiming()`, which reverses `END_OF_PHASE` for one game. -- `src/cli.ts` (new): the command line, `npx tsx src/cli.ts levels/l1-double-tap.json` from a staged workspace (it needs `cards/` and `levels/`). `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.test.ts` plays it from a script; it is the reference's own check, not a course test. +- `src/cli.ts` (new): the command line, `npx tsx src/cli.ts levels/l1-double-tap.json` run from a staged directory (it needs `cards/` and `levels/`): `node tools/play-spellgrid.mjs ts l1` from the course repository stages one in a temporary directory and starts it. `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.test.ts` plays it from a script; it is the reference's own check, not a course test. Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository. diff --git a/package.json b/package.json index 69fe080..284e459 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:tools": "node --test tools/test/*.test.mjs", "check": "node tools/sync-check.mjs", "test:site": "node --test site/scripts/*.test.mjs", - "check:links": "node tools/check-links.mjs" + "check:links": "node tools/check-links.mjs", + "play:spellgrid": "node tools/play-spellgrid.mjs" } } diff --git a/tools/play-spellgrid.mjs b/tools/play-spellgrid.mjs new file mode 100644 index 0000000..fe8a0f9 --- /dev/null +++ b/tools/play-spellgrid.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Play the SpellGrid capstone (Module 10) on the command line, using the course's reference engine. +// +// node tools/play-spellgrid.mjs [level] [--dir ] [--stage-only] +// npm run play:spellgrid -- rust l1 +// +// Stages course/10-spellgrid/reference/ with its card data (course/cards plus +// course/10-spellgrid/cards) and course/10-spellgrid/levels into (default: a new temp +// directory), installs the TypeScript dependencies if needed, then runs the CLI in that +// directory. `level` is a level of course/10-spellgrid/levels by name or prefix (`l1`, +// `l2-spliced-corpse`), or a path to a level file; default `l1`. With --stage-only it prints +// the command instead of running it. Commands are read from stdin, so a script can be piped in. +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { cardDirs, fail, isMain, moduleDirs } from "./lib.mjs"; + +const MODULE = "10"; +const MARKER = ".play-spellgrid"; + +const COMMANDS = { + rust: (level) => ["cargo", ["run", "--quiet", "--bin", "spellgrid", "--", level]], + ts: (level) => ["npx", ["tsx", "src/cli.ts", level]], +}; + +export function parseArgs(argv) { + const opts = { lang: null, level: "l1", dir: null, stageOnly: false }; + const rest = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--dir") opts.dir = argv[++i]; + else if (argv[i] === "--stage-only") opts.stageOnly = true; + else rest.push(argv[i]); + } + [opts.lang, opts.level = "l1"] = rest; + return opts; +} + +/** The level file to play: a path that exists, else a level of the module by name or prefix. */ +export function resolveLevel(levelsDir, level) { + if (existsSync(level) && level.endsWith(".json")) return { path: resolve(level), staged: false }; + const files = existsSync(levelsDir) ? readdirSync(levelsDir).filter((f) => f.endsWith(".json")).sort() : []; + const match = files.find((f) => f === level || f === `${level}.json`) ?? + files.find((f) => f.startsWith(`${level}-`)); + if (!match) return null; + return { path: join("levels", match), staged: true }; +} + +/** Copy the reference, its cards and the levels into `dir`. Returns `dir`. */ +export function stagePlay(lang, dir) { + const mod = moduleDirs().find((m) => m.num === MODULE); + if (!mod) fail(`no module ${MODULE} in the course`); + const ref = join(mod.path, "reference", lang); + if (!existsSync(ref)) fail(`no ${lang} reference in ${mod.path}`); + if (dir) { + if (existsSync(dir) && readdirSync(dir).length && !existsSync(join(dir, MARKER))) { + fail(`${dir} is not empty and was not staged by play-spellgrid; give an empty or new directory`); + } + mkdirSync(dir, { recursive: true }); + } else { + dir = mkdtempSync(join(tmpdir(), `spellgrid-${lang}-`)); + } + cpSync(ref, dir, { recursive: true }); + for (const cards of cardDirs(MODULE)) cpSync(cards, join(dir, "cards"), { recursive: true }); + if (existsSync(join(mod.path, "levels"))) cpSync(join(mod.path, "levels"), join(dir, "levels"), { recursive: true }); + writeFileSync(join(dir, MARKER), `${lang}\n`); + return dir; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!COMMANDS[opts.lang]) fail("usage: play-spellgrid.mjs [level] [--dir ] [--stage-only]"); + const dir = resolve(stagePlay(opts.lang, opts.dir)); + const level = resolveLevel(join(dir, "levels"), opts.level); + if (!level) { + const names = readdirSync(join(dir, "levels")).filter((f) => f.endsWith(".json")).map((f) => basename(f, ".json")); + fail(`no level "${opts.level}". Levels: ${names.join(", ")}`); + } + if (opts.lang === "ts" && !existsSync(join(dir, "node_modules"))) { + console.error(`Installing the TypeScript reference's dependencies in ${dir} ...`); + const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, stdio: ["ignore", "inherit", "inherit"] }); + if (r.status !== 0) fail("npm install failed"); + } + const [cmd, args] = COMMANDS[opts.lang](level.path); + const shown = `cd ${dir} && ${cmd} ${args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`; + if (opts.stageOnly) { + console.log(`Staged the Module ${MODULE} ${opts.lang} reference in ${dir}. Play with:\n ${shown}`); + return; + } + console.error(`Staged in ${dir} (re-run there with: ${shown})`); + console.error("w a s d: one turn; c [x y]: cast or play a card; q: quit.\n"); + const r = spawnSync(cmd, args, { cwd: dir, stdio: "inherit" }); + process.exit(r.status ?? 1); +} + +if (isMain(import.meta.url)) main(); diff --git a/tools/test/play-spellgrid.test.mjs b/tools/test/play-spellgrid.test.mjs new file mode 100644 index 0000000..5efe2c5 --- /dev/null +++ b/tools/test/play-spellgrid.test.mjs @@ -0,0 +1,37 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "./helpers.mjs"; +import { parseArgs } from "../play-spellgrid.mjs"; + +test("play_spellgrid_parses_args", () => { + assert.deepEqual(parseArgs(["rust"]), { lang: "rust", level: "l1", dir: null, stageOnly: false }); + assert.deepEqual(parseArgs(["ts", "l2", "--dir", "x", "--stage-only"]), { lang: "ts", level: "l2", dir: "x", stageOnly: true }); +}); + +test("play_spellgrid_stages_reference_cards_and_levels", () => { + // Uses the real course: the Module 10 Rust reference with every card it reads and the levels. + const dir = join(mkdtempSync(join(tmpdir(), "sdc-play-")), "ws"); + const r = run("play-spellgrid.mjs", ["rust", "l3", "--dir", dir, "--stage-only"]); + assert.equal(r.code, 0, r.out); + for (const f of ["Cargo.toml", "src/cli.rs", "cards/whirlwind.json", "cards/powder-keg.json", "levels/l1-double-tap.json"]) { + assert.ok(existsSync(join(dir, f)), f); + } + assert.match(r.out, /cargo run --quiet --bin spellgrid -- levels\/l3-simultaneous-paradox\.json/); + // Re-staging the same directory is allowed. + assert.equal(run("play-spellgrid.mjs", ["rust", "--dir", dir, "--stage-only"]).code, 0); +}); + +test("play_spellgrid_refuses_unknown_level_and_foreign_dir", () => { + const foreign = mkdtempSync(join(tmpdir(), "sdc-play-")); + writeFileSync(join(foreign, "mine.txt"), "keep me"); + const r = run("play-spellgrid.mjs", ["rust", "--dir", foreign, "--stage-only"]); + assert.equal(r.code, 1); + assert.match(r.out, /not empty/); + const bad = run("play-spellgrid.mjs", ["rust", "l9", "--dir", join(foreign, "ws"), "--stage-only"]); + assert.equal(bad.code, 1); + assert.match(bad.out, /no level "l9"\. Levels: l1-double-tap, l2-spliced-corpse, l3-simultaneous-paradox/); + assert.equal(run("play-spellgrid.mjs", ["java"]).code, 1); +});