diff --git a/course/cards/bramble-wall.json b/course/10-spellgrid/cards/bramble-wall.json similarity index 100% rename from course/cards/bramble-wall.json rename to course/10-spellgrid/cards/bramble-wall.json diff --git a/course/cards/ember-moth.json b/course/10-spellgrid/cards/ember-moth.json similarity index 100% rename from course/cards/ember-moth.json rename to course/10-spellgrid/cards/ember-moth.json diff --git a/course/cards/graft.json b/course/10-spellgrid/cards/graft.json similarity index 100% rename from course/cards/graft.json rename to course/10-spellgrid/cards/graft.json diff --git a/course/cards/grave-warden.json b/course/10-spellgrid/cards/grave-warden.json similarity index 100% rename from course/cards/grave-warden.json rename to course/10-spellgrid/cards/grave-warden.json diff --git a/course/cards/powder-keg.json b/course/10-spellgrid/cards/powder-keg.json similarity index 100% rename from course/cards/powder-keg.json rename to course/10-spellgrid/cards/powder-keg.json diff --git a/course/cards/rune-scribe.json b/course/10-spellgrid/cards/rune-scribe.json similarity index 100% rename from course/cards/rune-scribe.json rename to course/10-spellgrid/cards/rune-scribe.json diff --git a/course/cards/spark.json b/course/10-spellgrid/cards/spark.json similarity index 100% rename from course/cards/spark.json rename to course/10-spellgrid/cards/spark.json diff --git a/course/cards/thorn-hedge.json b/course/10-spellgrid/cards/thorn-hedge.json similarity index 100% rename from course/cards/thorn-hedge.json rename to course/10-spellgrid/cards/thorn-hedge.json diff --git a/course/cards/tin-knight.json b/course/10-spellgrid/cards/tin-knight.json similarity index 100% rename from course/cards/tin-knight.json rename to course/10-spellgrid/cards/tin-knight.json diff --git a/course/cards/town-crier.json b/course/10-spellgrid/cards/town-crier.json similarity index 100% rename from course/cards/town-crier.json rename to course/10-spellgrid/cards/town-crier.json diff --git a/course/10-spellgrid/expansion/README.md b/course/10-spellgrid/expansion/README.md index 980020d..f1ecbd3 100644 --- a/course/10-spellgrid/expansion/README.md +++ b/course/10-spellgrid/expansion/README.md @@ -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" }`. diff --git a/course/10-spellgrid/expansion/solution/words.json b/course/10-spellgrid/expansion/solution/words.json index e147957..ff78552 100644 --- a/course/10-spellgrid/expansion/solution/words.json +++ b/course/10-spellgrid/expansion/solution/words.json @@ -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" }, diff --git a/course/10-spellgrid/lesson.md b/course/10-spellgrid/lesson.md index 96ada4b..3b2cc34 100644 --- a/course/10-spellgrid/lesson.md +++ b/course/10-spellgrid/lesson.md @@ -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. diff --git a/course/10-spellgrid/reference/rust/README.md b/course/10-spellgrid/reference/rust/README.md index ef5cd41..e09bbeb 100644 --- a/course/10-spellgrid/reference/rust/README.md +++ b/course/10-spellgrid/reference/rust/README.md @@ -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. diff --git a/course/10-spellgrid/reference/rust/src/cli.rs b/course/10-spellgrid/reference/rust/src/cli.rs index ceb235c..1caad8f 100644 --- a/course/10-spellgrid/reference/rust/src/cli.rs +++ b/course/10-spellgrid/reference/rust/src/cli.rs @@ -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, output: Box) -> .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>) -> 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 [x y] (cast or play), q (quit)\n"), - })); - if let Err(payload) = result { - let message = payload - .downcast_ref::() - .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 [x y] (cast or play), q (quit)".to_string()) + } + }; + if let Err(message) = done { io.borrow_mut().write(&format!("{message}\n")); } } diff --git a/course/10-spellgrid/reference/rust/src/content/words.json b/course/10-spellgrid/reference/rust/src/content/words.json index 5c49029..bb7c86c 100644 --- a/course/10-spellgrid/reference/rust/src/content/words.json +++ b/course/10-spellgrid/reference/rust/src/content/words.json @@ -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" }, diff --git a/course/10-spellgrid/reference/rust/src/engine/spellgrid.rs b/course/10-spellgrid/reference/rust/src/engine/spellgrid.rs index 37a6974..1c4e9e9 100644 --- a/course/10-spellgrid/reference/rust/src/engine/spellgrid.rs +++ b/course/10-spellgrid/reference/rust/src/engine/spellgrid.rs @@ -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) -> 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) { - 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 { + 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 diff --git a/course/10-spellgrid/reference/rust/src/harness.rs b/course/10-spellgrid/reference/rust/src/harness.rs index 651fbb2..cd362fa 100644 --- a/course/10-spellgrid/reference/rust/src/harness.rs +++ b/course/10-spellgrid/reference/rust/src/harness.rs @@ -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) -> 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) { diff --git a/course/10-spellgrid/reference/ts/README.md b/course/10-spellgrid/reference/ts/README.md index 12687b6..3bc05c4 100644 --- a/course/10-spellgrid/reference/ts/README.md +++ b/course/10-spellgrid/reference/ts/README.md @@ -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. diff --git a/course/10-spellgrid/reference/ts/src/content/words.json b/course/10-spellgrid/reference/ts/src/content/words.json index 5c49029..bb7c86c 100644 --- a/course/10-spellgrid/reference/ts/src/content/words.json +++ b/course/10-spellgrid/reference/ts/src/content/words.json @@ -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" }, diff --git a/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts b/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts index 9a5e29b..4e2e0ca 100644 --- a/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts +++ b/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts @@ -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 ---- diff --git a/course/10-spellgrid/reference/ts/src/harness.ts b/course/10-spellgrid/reference/ts/src/harness.ts index 15db9cf..90a4172 100644 --- a/course/10-spellgrid/reference/ts/src/harness.ts +++ b/course/10-spellgrid/reference/ts/src/harness.ts @@ -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, }; } diff --git a/course/10-spellgrid/scenarios.md b/course/10-spellgrid/scenarios.md index 3c34930..beced89 100644 --- a/course/10-spellgrid/scenarios.md +++ b/course/10-spellgrid/scenarios.md @@ -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`). `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. diff --git a/course/10-spellgrid/tests/rust/test_10.rs b/course/10-spellgrid/tests/rust/test_10.rs index 85a1142..9bd0e25 100644 --- a/course/10-spellgrid/tests/rust/test_10.rs +++ b/course/10-spellgrid/tests/rust/test_10.rs @@ -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::::new()); +} + #[test] fn test_10_level_1_solution() { let mut g = new_spell_grid( diff --git a/course/10-spellgrid/tests/ts/test_10.test.ts b/course/10-spellgrid/tests/ts/test_10.test.ts index c49673d..f9c63fa 100644 --- a/course/10-spellgrid/tests/ts/test_10.test.ts +++ b/course/10-spellgrid/tests/ts/test_10.test.ts @@ -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); diff --git a/course/catalog.json b/course/catalog.json index 675666c..848fbc1 100644 --- a/course/catalog.json +++ b/course/catalog.json @@ -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)." } ] diff --git a/course/harness.md b/course/harness.md index aa1d79b..b1d4429 100644 --- a/course/harness.md +++ b/course/harness.md @@ -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 ` IS ` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and ` IS NOT ` 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 ` IS ` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and ` IS NOT ` 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. diff --git a/tools/lib.mjs b/tools/lib.mjs index cb47d6c..2d9e91d 100644 --- a/tools/lib.mjs +++ b/tools/lib.mjs @@ -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}`); diff --git a/tools/start-module.mjs b/tools/start-module.mjs index 5842725..276a895 100644 --- a/tools/start-module.mjs +++ b/tools/start-module.mjs @@ -2,7 +2,7 @@ // Copies module NN's tests for the learner's track into /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 "); @@ -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"); diff --git a/tools/test/fixtures/course/02-m02/cards/module-fixture.json b/tools/test/fixtures/course/02-m02/cards/module-fixture.json new file mode 100644 index 0000000..5295c43 --- /dev/null +++ b/tools/test/fixtures/course/02-m02/cards/module-fixture.json @@ -0,0 +1,9 @@ +{ + "name": "Module Fixture", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 1, + "split": 0, + "text": [] +} \ No newline at end of file diff --git a/tools/test/schema.test.mjs b/tools/test/schema.test.mjs index 77c8708..376ac1c 100644 --- a/tools/test/schema.test.mjs +++ b/tools/test/schema.test.mjs @@ -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`); + } +}); diff --git a/tools/test/start-module.test.mjs b/tools/test/start-module.test.mjs index f2bfe6e..77c717e 100644 --- a/tools/test/start-module.test.mjs +++ b/tools/test/start-module.test.mjs @@ -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 }); diff --git a/tools/test/verify-references.test.mjs b/tools/test/verify-references.test.mjs index b4fb2d9..5ca0eea 100644 --- a/tools/test/verify-references.test.mjs +++ b/tools/test/verify-references.test.mjs @@ -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); + } +}); diff --git a/tools/verify-references.mjs b/tools/verify-references.mjs index b02e46a..1937878 100644 --- a/tools/verify-references.mjs +++ b/tools/verify-references.mjs @@ -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 /cards, -// and each tested module's levels/ (the puzzle levels its tests load, from Module 10) as /levels. +// The card data (loaded by the references from Module 5) is staged as /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 /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; }