Files
systemic-game-design-course/course/10-spellgrid/reference/ts/src/cli.ts
T
Lucas WintherandClaude Opus 5.5 a0cf9be214 feat: module 10 spellgrid
SpellGrid capstone: Part I's card engine and Part II's grid engine joined at
three seams (rules -> aura data, hazard victims -> one destroy Sequence,
Sequence end -> write text, remove the dead, reparse). Three levels with
scripted solutions, a naive-timing test via a harness-only hook that
reverses the END_OF_PHASE list, CLIs in both tracks, the BATTLECRY IS DOUBLE
Expansion Drop, SpellGrid cards and words in course/cards and the catalog,
level schema cards/hand, and tools staging/copying of module levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:01:27 +01:00

130 lines
5.1 KiB
TypeScript

// SpellGrid on the command line: `npx tsx src/cli.ts levels/l1-double-tap.json`.
//
// A thin loop over the harness: it draws the level, reads a command, and calls
// `step`, `cast` or `play`. No rules live here.
//
// w a s d one turn: up, left, down, right
// c <card> [x y] cast a spell from your hand (at the minion on (x, y), if given),
// or play a minion from your hand onto the empty tile (x, y)
// q quit
//
// Assemble's choices (Graft) are asked for one at a time: answer with a number or a name.
import { readFileSync, readSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { newSpellGrid, type LevelDef, type SpellGrid } from "./harness";
const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const;
/** One cell of the drawing: the newest object on the tile, as 3 letters; TEXT in capitals. */
function cell(g: SpellGrid, x: number, y: number): string {
const kinds = g.kindsAt(x, y);
if (kinds.length === 0) return " . ";
// kindsAt is sorted; prefer text, which is what the player reads.
const kind = kinds.find((k) => k.startsWith("text:")) ?? kinds[0];
const name = kind.startsWith("text:") ? kind.slice(5).toUpperCase() : kind.toLowerCase();
return name.slice(0, 3).padEnd(3);
}
/** The level as text: the grid, the rules, the minions and the hand. */
export function render(g: SpellGrid, level: LevelDef): string {
const lines: string[] = [];
for (let y = 0; y < level.height; y++) {
const row: string[] = [];
for (let x = 0; x < level.width; x++) row.push(cell(g, x, y));
lines.push(row.join(" "));
}
lines.push(`Rules: ${g.activeRules().join(", ")}`);
const minions: string[] = [];
for (let y = 0; y < level.height; y++) {
for (let x = 0; x < level.width; x++) {
const id = g.minionAt(x, y);
if (id !== null) minions.push(`${g.nameOf(id)} (${x},${y}) ${g.health(id)} Health`);
}
}
lines.push(`Minions: ${minions.join("; ") || "none"}`);
lines.push(`Hand: ${g.hand().join(", ") || "empty"}`);
return lines.join("\n");
}
/** A `c` command: `c <card words...> [x y]`. */
function parseCast(args: string[]): { card: string; at?: [number, number] } {
const n = args.length;
if (n >= 3 && /^\d+$/.test(args[n - 2]) && /^\d+$/.test(args[n - 1])) {
return { card: args.slice(0, n - 2).join(" "), at: [Number(args[n - 2]), Number(args[n - 1])] };
}
return { card: args.join(" ") };
}
/**
* Play `levelText` (a level in the course's format), reading commands from
* `readLine` (undefined at the end of input) and writing to `write`. Returns
* `"won"`, `"lost"` or `"quit"`.
*/
export function run(levelText: string, readLine: () => string | undefined, write: (s: string) => void): string {
const level = JSON.parse(levelText) as LevelDef;
const g = newSpellGrid(level, []);
g.setChooser((kind, options) => {
write(`Choose the ${kind} (a number or a name):\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}\n? `);
const answer = readLine()?.trim() ?? "";
return options.includes(answer) ? answer : (options[Number(answer) - 1] ?? options[0]);
});
for (;;) {
write(`${render(g, level)}\n`);
if (g.won()) {
write("You win!\n");
return "won";
}
if (g.lost()) {
write("Nothing is YOU. You lose.\n");
return "lost";
}
write("> ");
const line = readLine();
if (line === undefined) return "quit";
const [command, ...args] = line.trim().split(/\s+/);
try {
if (command === "q") return "quit";
if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]);
else if (command === "c" && args.length > 0) {
const { card, at } = parseCast(args);
const target = at === undefined ? null : g.minionAt(...at);
if (at === undefined) g.cast(card);
else if (target !== null) g.cast(card, target);
else g.play(card, ...at);
} else write("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)\n");
} catch (e) {
write(`${e instanceof Error ? e.message : String(e)}\n`);
}
}
}
/** Lines from standard input, read synchronously, so the chooser can ask mid-Sequence. */
function stdinLines(): () => string | undefined {
const buffer = Buffer.alloc(4096);
let pending = "";
let ended = false;
return () => {
while (!pending.includes("\n") && !ended) {
const n = readSync(0, buffer, 0, buffer.length, null);
if (n === 0) ended = true;
else pending += buffer.toString("utf8", 0, n);
}
if (pending === "" && ended) return undefined;
const end = pending.indexOf("\n");
const line = end < 0 ? pending : pending.slice(0, end);
pending = end < 0 ? "" : pending.slice(end + 1);
return line;
};
}
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
const path = process.argv[2];
if (path === undefined) {
process.stderr.write("usage: npx tsx src/cli.ts <level.json>\n");
process.exit(2);
}
const outcome = run(readFileSync(path, "utf8"), stdinLines(), (s) => process.stdout.write(s));
process.exit(outcome === "won" ? 0 : 1);
}