feat(tools): play-spellgrid stages the M10 reference with cards and levels and runs its CLI
Lesson 10 and both M10 reference READMEs now give the real recipe (node tools/play-spellgrid.mjs <rust|ts> [l1|l2|l3]); root script play:spellgrid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ad0701395d
commit
bb98be089a
@@ -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 <rust|ts> [level] [--dir <dir>] [--stage-only]
|
||||
// npm run play:spellgrid -- rust l1
|
||||
//
|
||||
// Stages course/10-spellgrid/reference/<lang> with its card data (course/cards plus
|
||||
// course/10-spellgrid/cards) and course/10-spellgrid/levels into <dir> (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 <rust|ts> [level] [--dir <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 <card> [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();
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user