// 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 [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 [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 [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 \n"); process.exit(2); } const outcome = run(readFileSync(path, "utf8"), stdinLines(), (s) => process.stdout.write(s)); process.exit(outcome === "won" ? 0 : 1); }