fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)

- SpellGrid cards move to course/10-spellgrid/cards; start-module and
  verify-references stage course/cards plus module-local cards up to the module,
  so M5-M9 workspaces get exactly the pre-M10 card set.
- cast() rejects a chosen-target spell without a target (checkCast/checkPlay);
  new test_10_targeted_spell_needs_target.
- Rust CLI pre-validates instead of swapping the global panic hook.
- Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role.
- Lesson: design note on mid-movement reparse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 05:11:09 +01:00
co-authored by Claude Opus 5.5
parent a0cf9be214
commit 6f576940b7
34 changed files with 295 additions and 79 deletions
+1 -1
View File
@@ -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/` and `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`.
- `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,7 @@
{ "name": "MOTH", "role": "noun" },
{ "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" },
{ "name": "CRIER", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
@@ -150,29 +150,62 @@ export class SpellGrid {
this.gridChanged();
}
/** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. */
/**
* Why `cast(card, target)` can't be done now, or `undefined` if it can: the card must
* be a spell in player 0's hand, and a spell that hits a chosen minion needs a target
* minion on the grid. Nothing changes when the answer is no.
*/
checkCast(card: string, target?: puzzle.ObjectId): string | undefined {
const problem = this.checkHand(card, "spell");
if (problem !== undefined) return problem;
const chooses = this.world.card(card).onCast.some((e) => "target" in e && e.target === "chosen");
if (target === undefined) return chooses ? `"${card}" needs a target minion` : undefined;
const object = this.level.board.find(target);
if (object === undefined || !this.entities.has(target)) return `${target} is not a minion on the grid`;
return undefined;
}
/** Why `play(card, x, y)` can't be done now, or `undefined` if it can. */
checkPlay(card: string, x: number, y: number): string | undefined {
const problem = this.checkHand(card, "minion");
if (problem !== undefined) return problem;
if (this.world.card(card).def.noun === undefined) return `"${card}" has no noun: it can't stand on the grid`;
if (!this.level.board.inBounds(x, y)) return `(${x}, ${y}) is off the grid`;
if (this.level.board.at(x, y).length > 0) return `(${x}, ${y}) is not empty`;
return undefined;
}
/** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. Throws if `checkCast` says no. */
cast(card: string, target?: puzzle.ObjectId): void {
const inHand = this.fromHand(card, "spell");
this.world.moveTo(inHand, "graveyard");
const problem = this.checkCast(card, target);
if (problem !== undefined) throw new Error(problem);
this.world.moveTo(this.inHand(card)!, "graveyard");
const chosen = target === undefined ? undefined : this.entity(target);
this.sequence({ type: "cast", card, player: 0, target: chosen });
}
/** Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence. Returns its new id. */
/**
* Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence.
* Returns its new id. Throws if `checkPlay` says no.
*/
play(card: string, x: number, y: number): puzzle.ObjectId {
const inHand = this.fromHand(card, "minion");
if (this.level.board.at(x, y).length > 0) throw new Error(`(${x}, ${y}) is not empty`);
const problem = this.checkPlay(card, x, y);
if (problem !== undefined) throw new Error(problem);
const inHand = this.inHand(card)!;
const id = this.level.board.place(this.nounOf(card), x, y);
this.link(inHand, id);
this.sequence({ type: "play", id: inHand });
return id;
}
private fromHand(card: string, type: "minion" | "spell"): EntityId {
const inHand = this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card);
if (inHand === undefined) throw new Error(`"${card}" is not in the hand`);
if (this.world.card(card).def.type !== type) throw new Error(`"${card}" is not a ${type}`);
return inHand;
private inHand(card: string): EntityId | undefined {
return this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card);
}
private checkHand(card: string, type: "minion" | "spell"): string | undefined {
if (this.inHand(card) === undefined) return `"${card}" is not in the hand`;
if (this.world.card(card).def.type !== type) return `"${card}" is not a ${type}`;
return undefined;
}
// ---- The seams ----
@@ -187,6 +187,9 @@ export interface SpellGrid extends Baba {
clearLog(): void;
setChooser(choose: Chooser): void; // makes Assemble's choices
withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step
// For the command line: why `cast`/`play` would fail, or null if they wouldn't
checkCast(card: string, target?: Id): string | null;
checkPlay(card: string, x: number, y: number): string | null;
}
/** A SpellGrid level; `extra` cards are added to the course's cards, replacing any with the same name. */
@@ -246,5 +249,7 @@ export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGri
setChooser: (choose) => g.world.setChooser(choose),
// The naive engine reads the auras before the dead leave play: the same steps, reversed.
withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()),
checkCast: (card, target) => g.checkCast(card, target) ?? null,
checkPlay: (card, x, y) => g.checkPlay(card, x, y) ?? null,
};
}