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 <[email protected]>
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
@@ -3,7 +3,7 @@
The lesson's [Expansion Drop](../lesson.md#expansion-drop) beat explains the task. This file holds the scenario, the test locations and the check.
- Word: `BATTLECRY`, a noun that names a trigger, as `DEATHRATTLE` does. With it, `BATTLECRY IS DOUBLE` written on the grid makes every Battlecry trigger twice, like Brann Bronzebeard ("Your Battlecries trigger twice.", Module 4).
- Card: Town Crier, a SpellGrid card that is already in `course/cards/` (`"game": "spellgrid"`): a 1-cost 1/1 minion, noun `CRIER`, "Battlecry: Deal 1 damage to the enemy hero."
- Card: Town Crier, a SpellGrid card that is already in `course/10-spellgrid/cards/`, the module's own cards (`"game": "spellgrid"`): a 1-cost 1/1 minion, noun `CRIER`, "Battlecry: Deal 1 damage to the enemy hero."
- Tests: `tests/rust/test_10x.rs` and `tests/ts/test_10x.test.ts`. Copy the one for your track into your workspace's `tests/`.
- Reference answer, two data files and no code:
- `solution/words.json`: the Module 10 reference's `src/content/words.json` with one entry added, `{ "name": "BATTLECRY", "role": "noun" }`.
@@ -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": "BATTLECRY", "role": "noun" },
{ "name": "YOU", "role": "property" },
+6 -4
View File
@@ -61,7 +61,7 @@ From the course repository, copy the module's tests and levels into your workspa
node tools/start-module.mjs 10 ~/my-engine
```
It copies `tests/test_10.*` and the three levels into `levels/`. The tests load them from there.
It copies `tests/test_10.*`, SpellGrid's cards into `cards/`, and the three levels into `levels/`. The tests load them from there.
### The harness
@@ -90,7 +90,7 @@ The three seams:
2. **Hazards mark, the death step removes.** A minion that a turn destroys (a hazard, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy, in one Sequence, and Module 3's Death Creation Step removes it. Its Deathrattle resolves.
3. **Grid changes reparse.** When a Sequence ends, the text its effects asked for is written (`spawnText`: "write the word on this minion's tile"), the minions that died leave the grid, and the rules are parsed again, before anything else.
SpellGrid's cards, all in `course/cards/`:
SpellGrid's cards, in `course/10-spellgrid/cards/`. `start-module 10` copies them into your `cards/` with the course's other cards; earlier modules never see them.
| Card | Type | Stats | Noun | Text |
|---|---|---|---|---|
@@ -121,6 +121,7 @@ The full statements are in `course/10-spellgrid/scenarios.md`.
- `test_10_grid_and_card_doublers_take_max`: Predict question 1. Two, not four.
- `test_10_hazard_feeds_death_step`: seam 2. The log is exactly `Destroyed`, `DeathPhase`, `Triggered`, `Damaged`.
- `test_10_spawned_text_triggers_reparse`: seam 3. `BABA IS WIN` is a rule straight after the cast.
- `test_10_targeted_spell_needs_target`: Spark without a target is an error, and the card stays in the hand.
- `test_10_level_1_solution`, `test_10_level_2_solution`, `test_10_level_3_solution`: each level, played from a script to `won()`.
- `test_10_level_3_naive_would_differ`: level 3 with `withNaiveAuraTiming()`. Same inputs, and the knight dies.
@@ -164,7 +165,7 @@ knight . . . hedge flag #
## Build
Make the nine tests pass, and keep Modules 1–9 green. The shape to aim for:
Make the ten tests pass, and keep Modules 1–9 green. The shape to aim for:
```text
Part I: the card engine Part II: the grid engine
@@ -341,10 +342,11 @@ export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUp
> - Baba Is You reparses "when any parsable object changes position or is destroyed" ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). SpellGrid reparses after each Sequence whether or not it changed the grid. That is cheaper to reason about and gives the same rules.
> - A minion that a conversion turns into something else (`KEG IS ROCK`) keeps its card in the reference: the object changes kind, the minion stays in play. No test or level does this, and a real design would have to decide.
> - A minion that a card effect summons (Harvest Golem's Damaged Golem) enters play but not the grid. No SpellGrid card summons.
> - Module 8 planned every `YOU` object's move "under the rules in force before the first". In SpellGrid, a minion destroyed during movement (`OPEN`/`SHUT`) dies in a Sequence on the spot, and seam 3 reparses when it ends, so the `YOU` objects that move after it use the new rules. The reference accepts this on purpose: a Sequence's grid changes always reparse at once, wherever the Sequence runs, and no level or test depends on the difference. A design that wanted Module 8's rule exactly would hold the reparse until movement ends.
## Expansion Drop
A new trigger word: `BATTLECRY`. With it, `BATTLECRY IS DOUBLE` on the grid does what Brann Bronzebeard's "Your Battlecries trigger twice." does ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)), the Module 4 Expansion Drop. The test plays a Town Crier ("Battlecry: Deal 1 damage to the enemy hero.", already in `course/cards/`) under the rule.
A new trigger word: `BATTLECRY`. With it, `BATTLECRY IS DOUBLE` on the grid does what Brann Bronzebeard's "Your Battlecries trigger twice." does ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)), the Module 4 Expansion Drop. The test plays a Town Crier ("Battlecry: Deal 1 damage to the enemy hero.", already in your `cards/`) under the rule.
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either.
+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 crate 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 crate 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.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the three seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::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.rs`: `END_OF_PHASE` (`[DeathCreation, AuraUpdate]`) is the list of steps at the end of the outermost Phase, read from the world. `Action::SpawnText` records a `TextSpawned` event.
+16 -24
View File
@@ -14,7 +14,6 @@
use std::cell::RefCell;
use std::io::{BufRead, Write};
use std::panic::{self, AssertUnwindSafe};
use std::rc::Rc;
use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid};
@@ -138,13 +137,7 @@ pub fn run(level_text: &str, input: Box<dyn BufRead>, output: Box<dyn Write>) ->
.and_then(|n| options.get(n.wrapping_sub(1)));
picked.unwrap_or(&options[0]).clone()
});
// The harness panics on a move the rules don't allow (a card not in the hand, a
// full tile). The CLI reports the message instead of stopping.
let hook = panic::take_hook();
panic::set_hook(Box::new(|_| {}));
let outcome = play(&mut g, &level, &io);
panic::set_hook(hook);
outcome
play(&mut g, &level, &io)
}
/// Draw, read a command, do it; until the level is won or lost, or the player quits.
@@ -172,27 +165,26 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc<RefCell<Io>>) -> Outcome {
Some(&"d") => Some(Dir::Right),
_ => None,
};
let result = panic::catch_unwind(AssertUnwindSafe(|| match (dir, words.as_slice()) {
(Some(dir), _) => g.step(dir),
// The harness panics on a move the rules don't allow (a card not in the hand, a
// full tile). The CLI asks first, and reports the reason instead.
let done = match (dir, words.as_slice()) {
(Some(dir), _) => Ok(g.step(dir)),
(None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) {
(card, None) => g.cast(&card, None),
(card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)),
(card, Some((x, y))) => match g.minion_at(x, y) {
Some(target) => g.cast(&card, Some(target)),
None => {
Some(target) => g
.check_cast(&card, Some(target))
.map(|()| g.cast(&card, Some(target))),
None => g.check_play(&card, x, y).map(|()| {
g.play(&card, x, y);
}
}),
},
},
_ => io
.borrow_mut()
.write("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)\n"),
}));
if let Err(payload) = result {
let message = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_else(|| "that can't be done".to_string());
_ => {
Err("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)".to_string())
}
};
if let Err(message) = done {
io.borrow_mut().write(&format!("{message}\n"));
}
}
@@ -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" },
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use super::aura::aura_update_other;
use super::card::{Aura, CardLibrary, CardType, Stacking, TriggerOn};
use super::card::{Aura, CardLibrary, CardType, Effect, Stacking, Target, TriggerOn};
use super::event::Event;
use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef};
use super::queue::{run_sequence, Action};
@@ -180,9 +180,62 @@ impl SpellGrid {
self.side.grid_changed(&mut self.level);
}
/// Why `cast(card, target)` can't be done now, or `Ok` 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 either way.
pub fn check_cast(&self, card: &str, target: Option<ObjectId>) -> Result<(), String> {
self.side.check_hand(card, CardType::Spell)?;
let chooses = self.side.world.card(card).on_cast.iter().any(|e| {
matches!(
e,
Effect::DealDamage {
target: Target::Chosen,
..
} | Effect::GainAttack {
target: Target::Chosen,
..
} | Effect::Destroy {
target: Target::Chosen
}
)
});
match target {
None if chooses => Err(format!("{card:?} needs a target minion")),
None => Ok(()),
Some(id) => {
// Every id in `entities` was issued by the board, so `find` may ask about it.
let card = self.side.entities.contains_key(&id);
if card && self.level.board().find(id).is_some() {
Ok(())
} else {
Err(format!("{id} is not a minion on the grid"))
}
}
}
}
/// Why `play(card, x, y)` can't be done now, or `Ok` if it can.
pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> {
self.side.check_hand(card, CardType::Minion)?;
if self.side.world.card(card).def.noun.is_none() {
return Err(format!("{card:?} has no noun: it can't stand on the grid"));
}
if !self.level.board().in_bounds(x, y) {
return Err(format!("({x}, {y}) is off the grid"));
}
if self.level.board().at(x, y).next().is_some() {
return Err(format!("({x}, {y}) is not empty"));
}
Ok(())
}
/// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id.
/// Panics if `check_cast` says no.
pub fn cast(&mut self, card: &str, target: Option<ObjectId>) {
let in_hand = self.side.from_hand(card, CardType::Spell);
if let Err(problem) = self.check_cast(card, target) {
panic!("{problem}");
}
let in_hand = self.side.in_hand(card).expect("checked");
self.side.world.move_to(in_hand, Zone::Graveyard);
let target = target.map(|id| self.side.entity(id));
let action = Action::Cast {
@@ -194,13 +247,12 @@ impl SpellGrid {
}
/// Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence.
/// Returns its new id.
/// Returns its new id. Panics if `check_play` says no.
pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId {
let in_hand = self.side.from_hand(card, CardType::Minion);
assert!(
self.level.board().at(x, y).next().is_none(),
"({x}, {y}) is not empty"
);
if let Err(problem) = self.check_play(card, x, y) {
panic!("{problem}");
}
let in_hand = self.side.in_hand(card).expect("checked");
let kind = self.side.noun_of(card);
let id = self.level.board_mut().place(kind, x, y);
self.side.link(in_hand, id);
@@ -241,19 +293,26 @@ impl CardSide {
}
}
fn from_hand(&self, card: &str, card_type: CardType) -> EntityId {
let in_hand = self
.world
fn in_hand(&self, card: &str) -> Option<EntityId> {
self.world
.zone(0, Zone::Hand)
.iter()
.copied()
.find(|&id| self.world.card_name(id) == card)
.unwrap_or_else(|| panic!("{card:?} is not in the hand"));
assert!(
self.world.card(card).def.card_type == card_type,
"{card:?} is not a {card_type:?}"
);
in_hand
}
fn check_hand(&self, card: &str, card_type: CardType) -> Result<(), String> {
if self.in_hand(card).is_none() {
return Err(format!("{card:?} is not in the hand"));
}
if self.world.card(card).def.card_type != card_type {
let kind = match card_type {
CardType::Minion => "minion",
CardType::Spell => "spell",
};
return Err(format!("{card:?} is not a {kind}"));
}
Ok(())
}
/// Seam 2. Objects that aren't cards leave the board. Minions are marked pending
@@ -436,6 +436,14 @@ impl SpellGrid {
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.grid.world_mut().set_chooser(Box::new(chooser));
}
/// For the command line: why `cast` would panic, or `Ok` if it wouldn't.
pub fn check_cast(&self, card: &str, target: Option<Id>) -> Result<(), String> {
self.grid.check_cast(card, target)
}
/// For the command line: why `play` would panic, or `Ok` if it wouldn't.
pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> {
self.grid.check_play(card, x, y)
}
/// Test hook: the naive engine reads the auras before the dead leave play, the
/// same end-of-Phase steps reversed.
pub fn with_naive_aura_timing(&mut self) {
+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,
};
}
+9 -1
View File
@@ -8,7 +8,7 @@ SpellGrid is the course's own small game: Part I's card engine and Part II's gri
- The level format gains two optional fields (`course/schema/level.schema.json`): `cards`, a list of `{ card, x, y }`, and `hand`, a list of card names. Each entry of `cards` is a minion in play for player 0 **and** a grid object at (x, y) whose noun is the card's `"noun"` field (`"noun": "KEG"` makes an object of kind `"keg"`). Each name in `hand` is a card in player 0's hand.
- `cards` (the constructor's second argument) is a list of extra card definitions, added to the course's cards. Every test here passes an empty list.
- One id space: a minion's id is its grid object's id. `place`, `posOf`, `hasProperty` and `health` all take it, and `log()` reports it. A hero, or a card that isn't on the grid, gets an id no grid object has.
- `cast(card, target?)` (Rust: `cast(card, Some(target))` or `None`) casts a spell from player 0's hand, as one Sequence. The card leaves the hand. Casting a card that isn't in the hand is an error.
- `cast(card, target?)` (Rust: `cast(card, Some(target))` or `None`) casts a spell from player 0's hand, as one Sequence. The card leaves the hand. Casting a card that isn't in the hand is an error, and so is casting a spell that hits a chosen minion (Spark) without a target; neither changes anything.
- `play(card, x, y)` plays a minion from player 0's hand onto the empty tile (x, y), as one Sequence (Module 4's `play`: it enters the board, then its Battlecry resolves), and returns its id. Playing onto a tile that holds anything is an error.
- `setChooser(choose)` is Module 5's: it makes Assemble's choices (`"base"`, `"donor"`, `"half"`).
- `health(id)`, `hero(player)`, `nameOf(id)`, `log()` and `clearLog()` are Part I's, with ids translated as above. `minionAt(x, y)` is the id of the minion on that tile, or `null` (Rust: `Option<Id>`). `hand()` lists the card names in player 0's hand, in order.
@@ -92,6 +92,14 @@ New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNI
- Action 2: `step("left")`.
- Assertions 3: `won()` is true.
### test_10_targeted_spell_needs_target
- Setup: a level 3 wide and 1 high with card Bramble Wall at (0,0); hand `["Spark"]`.
- Action 1: `cast("Spark")`, with no target.
- Assertions 1: it is an error (Rust: a panic; TypeScript: it throws), and nothing changed: `hand()` is still `["Spark"]` and `health(bramble)` is 3. Spark's effect hits a chosen minion, so a cast without one would waste the card.
- Action 2: `cast("Spark", bramble)`.
- Assertions 2: `health(bramble)` is 2 and `hand()` is empty.
### test_10_level_1_solution
Level `levels/l1-double-tap.json`. The Bramble Wall (3 Health) is `STOP` and blocks the only way to the flag. Whirlwind and one Powder Keg Deathrattle do 2 damage, one short.
+20
View File
@@ -243,6 +243,26 @@ fn test_10_spawned_text_triggers_reparse() {
assert!(g.won());
}
#[test]
fn test_10_targeted_spell_needs_target() {
let mut g = new_spell_grid(
level(3, 1, &[], &[], &[("Bramble Wall", 0, 0)], &["Spark"]),
vec![],
);
let bramble = minion(&g, 0, 0);
let cast = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Spark", None)));
assert!(cast.is_err());
assert_eq!(g.hand(), vec!["Spark"]);
assert_eq!(g.health(bramble), 3);
g.cast("Spark", Some(bramble));
assert_eq!(g.health(bramble), 2);
assert_eq!(g.hand(), Vec::<String>::new());
}
#[test]
fn test_10_level_1_solution() {
let mut g = new_spell_grid(
@@ -206,6 +206,21 @@ test("test_10_spawned_text_triggers_reparse", () => {
expect(g.won()).toBe(true);
});
test("test_10_targeted_spell_needs_target", () => {
const g = newSpellGrid(level(3, 1, { cards: [["Bramble Wall", 0, 0]], hand: ["Spark"] }), []);
const bramble = minion(g, 0, 0);
expect(() => g.cast("Spark")).toThrow();
expect(g.hand()).toEqual(["Spark"]);
expect(g.health(bramble)).toBe(3);
g.cast("Spark", bramble);
expect(g.health(bramble)).toBe(2);
expect(g.hand()).toEqual([]);
});
test("test_10_level_1_solution", () => {
const g = newSpellGrid(l1 as LevelDef, []);
const keg = minion(g, 0, 4);
+11 -2
View File
@@ -411,7 +411,8 @@
"role": "meta",
"text": "a trigger type, so DEATHRATTLE IS DOUBLE doubles deathrattles",
"source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md",
"game": "spellgrid"
"game": "spellgrid",
"note": "Lexicon role: SpellGrid's words.json lists it as a noun, so it can be the subject of a rule. The catalog keeps role meta: it names the engine's own trigger, which the coupling audit must not flag (only role-noun words are audited)."
},
{
"name": "Powder Keg",
@@ -548,6 +549,13 @@
"text": "Tin Knight's noun (a SpellGrid minion on the grid)",
"game": "spellgrid"
},
{
"name": "CRIER",
"kind": "word",
"role": "noun",
"text": "Town Crier's noun (a SpellGrid minion on the grid)",
"game": "spellgrid"
},
{
"name": "Town Crier",
"kind": "card",
@@ -563,6 +571,7 @@
"kind": "word",
"role": "meta",
"text": "a trigger type, so BATTLECRY IS DOUBLE doubles battlecries (the Module 10 Expansion Drop)",
"game": "spellgrid"
"game": "spellgrid",
"note": "Lexicon role: SpellGrid's words.json lists it as a noun, so it can be the subject of a rule. The catalog keeps role meta: it names the engine's own trigger, which the coupling audit must not flag (only role-noun words are audited)."
}
]
+1 -1
View File
@@ -152,4 +152,4 @@ From M10, `LevelDef` has two more optional fields, `cards: { card, x, y }[]` and
SpellGrid has one id space: a minion's id is its grid object's id, so `posOf`, `hasProperty` and `health` take the same id, and `log()` reports it. A hero or a card in hand gets an id that no grid object has; a card played from hand gets its grid object's id (returned by `play`). `cast(card, target?)` casts a spell from player 0's hand as one Sequence (target: a minion's id); `play(card, x, y)` plays a minion from player 0's hand onto an empty tile as one Sequence (it enters the board, then its Battlecry). Both are errors for a card not in the hand, and `play` for a tile that holds anything. Casting is not a turn: `won()` changes only in `step`.
The three seams: (1) a rule in force `<TRIGGER> IS <MULTIPLIER>` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and `<TRIGGER> IS NOT <MULTIPLIER>` caps that trigger at one activation, whatever multiplies it; rule auras join the snapshot at the next Aura Update. (2) A minion that a turn destroys (hazards, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy in one Sequence, and the Death Creation Step removes it. (3) When a Sequence ends, text its effects asked for (`spawnText`) is written on the minion's tile, minions that died leave the grid, and the rules are reparsed. `withNaiveAuraTiming()` reverses the two end-of-Phase steps (Aura Update, then Death Creation Step) for that game; only `test_10_level_3_naive_would_differ` uses it.
The three seams: (1) a rule in force `<TRIGGER> IS <MULTIPLIER>` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and `<TRIGGER> IS NOT <MULTIPLIER>` caps that trigger at one activation, whatever multiplies it; rule auras join the snapshot at the next Aura Update. (2) A minion that a turn destroys (hazards, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy in one Sequence, and the Death Creation Step removes it. (3) When a Sequence ends, text its effects asked for (`spawnText`) is written on the minion's tile, minions that died leave the grid, and the rules are reparsed. Trigger words (`DEATHRATTLE`, and `BATTLECRY` after the Expansion Drop) are nouns in the lexicon (`words.json`), so they can be the subject of a rule, but `role: "meta"` in `course/catalog.json`: they name the engine's own triggers, and the coupling audit checks only role-noun words. `checkCast(card, target?)` and `checkPlay(card, x, y)` (Rust: `check_cast`, `check_play`, returning `Result<(), String>`) say why `cast`/`play` would fail, or `null`/`Ok`; the command line uses them, the tests don't. `withNaiveAuraTiming()` reverses the two end-of-Phase steps (Aura Update, then Death Creation Step) for that game; only `test_10_level_3_naive_would_differ` uses it.
+13
View File
@@ -48,6 +48,19 @@ export function scenarioNames(path, file = "scenarios.md") {
return [...names].sort();
}
/**
* The card data a workspace at module `upTo` has: course/cards (every module's, from
* Module 5), then the cards/ of each module up to `upTo` that has its own (Module 10's
* SpellGrid cards), so earlier modules never see later modules' cards.
*/
export function cardDirs(upTo) {
const shared = join(courseRoot(), "cards");
const local = moduleDirs()
.filter((m) => Number(m.num) <= Number(upTo))
.map((m) => join(m.path, "cards"));
return [shared, ...local].filter((d) => existsSync(d));
}
export function loadCatalog() {
const f = join(courseRoot(), "catalog.json");
if (!existsSync(f)) throw new Error(`catalog not found: ${f}`);
+7 -8
View File
@@ -2,7 +2,7 @@
// Copies module NN's tests for the learner's track into <workspace>/tests/.
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs";
import { cardDirs, moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs";
const [num, ws] = process.argv.slice(2);
if (!/^\d\d$/.test(num || "") || !ws) fail("usage: start-module.mjs <NN> <workspaceDir>");
@@ -32,13 +32,12 @@ mkdirSync(join(ws, "tests"), { recursive: true });
for (const f of files) cpSync(f, join(ws, "tests", basename(f)));
console.log(`Copied ${files.length} test file(s) for module ${num} into ${join(ws, "tests")}`);
// The course's card data (read by your content from Module 5). Refreshed on every start;
// card files you add yourself are kept.
const cards = join(courseRoot(), "cards");
if (existsSync(cards)) {
cpSync(cards, join(ws, "cards"), { recursive: true });
console.log(`Copied the course card data into ${join(ws, "cards")}`);
}
// The course's card data (read by your content from Module 5): course/cards, plus the cards
// of each module up to this one that has its own (Module 10's SpellGrid cards). Refreshed on
// every start; card files you add yourself are kept.
const cardSources = cardDirs(num);
for (const cards of cardSources) cpSync(cards, join(ws, "cards"), { recursive: true });
if (cardSources.length) console.log(`Copied the course card data into ${join(ws, "cards")}`);
// The module's puzzle levels (Module 10's tests load them from levels/).
const levels = join(mod.path, "levels");
@@ -0,0 +1,9 @@
{
"name": "Module Fixture",
"type": "minion",
"cost": 1,
"attack": 1,
"health": 1,
"split": 0,
"text": []
}
+15 -3
View File
@@ -55,14 +55,17 @@ test("card_schema_accepts_fixtures", () => {
}
});
// Card data the course ships: course/cards (every card the references load) and the
// Card data the course ships: course/cards (every module's cards), module-local cards in
// course/NN-*/cards (Module 10's SpellGrid cards, staged from that module on), and the
// Expansion Drop answers in course/NN-*/expansion/solution/cards.
function courseCardFiles() {
const courseDir = join(here, "../../course");
const dirs = [join(courseDir, "cards")];
for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) {
const d = join(courseDir, m, "expansion/solution/cards");
if (existsSync(d)) dirs.push(d);
for (const sub of ["cards", "expansion/solution/cards"]) {
const d = join(courseDir, m, sub);
if (existsSync(d)) dirs.push(d);
}
}
return dirs.flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)));
}
@@ -141,3 +144,12 @@ test("course_levels_valid", () => {
for (const card of level.hand ?? []) assert.ok(cards.has(card), `${f}: no card data for ${card}`);
}
});
test("spellgrid_cards_are_module_local", () => {
// course/cards reaches every workspace from Module 5; SpellGrid's cards must not reach
// Modules 5-9 (their spawnText effect and their place in Assemble pools are Module 10's).
const shared = join(here, "../../course/cards");
for (const f of readdirSync(shared).filter((f) => f.endsWith(".json"))) {
assert.notEqual(json(join(shared, f)).game, "spellgrid", `${f} belongs in course/10-spellgrid/cards`);
}
});
+14
View File
@@ -21,6 +21,20 @@ test("start_module_copies_course_cards", () => {
assert.ok(existsSync(join(ws, "cards/test-fixture.json")));
});
test("start_module_copies_module_cards_up_to_the_module", () => {
const early = copyFixture("ws-start");
assert.equal(run("start-module.mjs", ["01", early], env).code, 0);
assert.ok(existsSync(join(early, "cards/test-fixture.json")));
assert.ok(!existsSync(join(early, "cards/module-fixture.json")));
const later = copyFixture("ws-start");
mkdirSync(join(later, "tests"), { recursive: true });
writeFileSync(join(later, "tests/m02.rs"), "");
const r = run("start-module.mjs", ["03", later], env);
assert.equal(r.code, 0, r.out);
assert.ok(existsSync(join(later, "cards/test-fixture.json")));
assert.ok(existsSync(join(later, "cards/module-fixture.json")));
});
test("start_module_copies_module_levels", () => {
const ws = copyFixture("ws-start");
mkdirSync(join(ws, "tests"), { recursive: true });
+15
View File
@@ -76,3 +76,18 @@ test("verify_stage_copies_module_levels", async () => {
assert.ok(existsSync(join(stage(plans[2], "ts"), "levels", "fixture-level.json")));
assert.ok(!existsSync(join(stage(plans[1], "ts"), "levels")));
});
test("verify_stage_copies_module_cards_up_to_the_module", async () => {
// Module-local cards (Module 10's SpellGrid cards) reach that module and later ones only.
process.env.COURSE_ROOT = root;
const { planVerification, stage } = await import("../verify-references.mjs");
const { existsSync } = await import("node:fs");
const plans = planVerification(moduleDirs(), 3);
for (const lang of ["rust", "ts"]) {
assert.ok(!existsSync(join(stage(plans[0], lang), "cards", "module-fixture.json")), lang);
assert.ok(existsSync(join(stage(plans[1], lang), "cards", "module-fixture.json")), lang);
const later = stage(plans[2], lang);
assert.ok(existsSync(join(later, "cards", "module-fixture.json")), lang);
assert.ok(existsSync(join(later, "cards", "test-fixture.json")), lang);
}
});
+5 -5
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env node
// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests.
// course/cards (the card data, loaded by the references from Module 5) is staged as <dir>/cards,
// and each tested module's levels/ (the puzzle levels its tests load, from Module 10) as <dir>/levels.
// The card data (loaded by the references from Module 5) is staged as <dir>/cards: course/cards
// plus the cards/ of each module up to the one verified. Each tested module's levels/ (the
// puzzle levels its tests load, from Module 10) is staged as <dir>/levels.
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { courseRoot, moduleDirs, testFiles, fail } from "./lib.mjs";
import { cardDirs, moduleDirs, testFiles, fail } from "./lib.mjs";
// A staged reference has no node_modules, so TS installs the reference's own
// pinned devDependencies first (a bare `npx vitest` would fetch an unrelated version).
@@ -38,8 +39,7 @@ export function stage(plan, lang) {
for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f)));
if (existsSync(join(t.path, "levels"))) cpSync(join(t.path, "levels"), join(dir, "levels"), { recursive: true });
}
const cards = join(courseRoot(), "cards");
if (existsSync(cards)) cpSync(cards, join(dir, "cards"), { recursive: true });
for (const cards of cardDirs(plan.num)) cpSync(cards, join(dir, "cards"), { recursive: true });
return dir;
}