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]>
130 lines
5.1 KiB
TypeScript
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);
|
|
}
|