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 WintherandClaude Opus 5.5 committed 2026-10-29 05:11:09 +01:00
1 parent a0cf9be214
commit 6f576940b7
34 files changed
+295 -79

No files matched your search

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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. 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). - 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/`. - 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: - 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" }`. - `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": "MOTH", "role": "noun" },
{ "name": "SCRIBE", "role": "noun" }, { "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" }, { "name": "KNIGHT", "role": "noun" },
{ "name": "CRIER", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" }, { "name": "DEATHRATTLE", "role": "noun" },
{ "name": "BATTLECRY", "role": "noun" }, { "name": "BATTLECRY", "role": "noun" },
{ "name": "YOU", "role": "property" }, { "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 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 ### 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. 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. 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 | | 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_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_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_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_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. - `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 ## 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 ```text
Part I: the card engine Part II: the grid engine 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. > - 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 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. > - 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 ## 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. 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. 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. - `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/`): - 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. - `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::cell::RefCell;
use std::io::{BufRead, Write}; use std::io::{BufRead, Write};
use std::panic::{self, AssertUnwindSafe};
use std::rc::Rc; use std::rc::Rc;
use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid}; 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))); .and_then(|n| options.get(n.wrapping_sub(1)));
picked.unwrap_or(&options[0]).clone() picked.unwrap_or(&options[0]).clone()
}); });
// The harness panics on a move the rules don't allow (a card not in the hand, a play(&mut g, &level, &io)
// 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
} }
/// Draw, read a command, do it; until the level is won or lost, or the player quits. /// 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), Some(&"d") => Some(Dir::Right),
_ => None, _ => None,
}; };
let result = panic::catch_unwind(AssertUnwindSafe(|| match (dir, words.as_slice()) { // The harness panics on a move the rules don't allow (a card not in the hand, a
(Some(dir), _) => g.step(dir), // 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) { (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) { (card, Some((x, y))) => match g.minion_at(x, y) {
Some(target) => g.cast(&card, Some(target)), Some(target) => g
None => { .check_cast(&card, Some(target))
.map(|()| g.cast(&card, Some(target))),
None => g.check_play(&card, x, y).map(|()| {
g.play(&card, x, y); g.play(&card, x, y);
} }),
}, },
}, },
_ => io _ => {
.borrow_mut() Err("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)".to_string())
.write("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)\n"), }
})); };
if let Err(payload) = result { if let Err(message) = done {
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());
io.borrow_mut().write(&format!("{message}\n")); io.borrow_mut().write(&format!("{message}\n"));
} }
} }
@@ -15,6 +15,7 @@
{ "name": "MOTH", "role": "noun" }, { "name": "MOTH", "role": "noun" },
{ "name": "SCRIBE", "role": "noun" }, { "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" }, { "name": "KNIGHT", "role": "noun" },
{ "name": "CRIER", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" }, { "name": "DEATHRATTLE", "role": "noun" },
{ "name": "YOU", "role": "property" }, { "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" }, { "name": "WIN", "role": "property" },
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
use serde::Deserialize; use serde::Deserialize;
use super::aura::aura_update_other; 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::event::Event;
use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef}; use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef};
use super::queue::{run_sequence, Action}; use super::queue::{run_sequence, Action};
@@ -180,9 +180,62 @@ impl SpellGrid {
self.side.grid_changed(&mut self.level); 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. /// 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>) { 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); self.side.world.move_to(in_hand, Zone::Graveyard);
let target = target.map(|id| self.side.entity(id)); let target = target.map(|id| self.side.entity(id));
let action = Action::Cast { 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. /// 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 { pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId {
let in_hand = self.side.from_hand(card, CardType::Minion); if let Err(problem) = self.check_play(card, x, y) {
assert!( panic!("{problem}");
self.level.board().at(x, y).next().is_none(), }
"({x}, {y}) is not empty" let in_hand = self.side.in_hand(card).expect("checked");
);
let kind = self.side.noun_of(card); let kind = self.side.noun_of(card);
let id = self.level.board_mut().place(kind, x, y); let id = self.level.board_mut().place(kind, x, y);
self.side.link(in_hand, id); self.side.link(in_hand, id);
@@ -241,19 +293,26 @@ impl CardSide {
} }
} }
fn from_hand(&self, card: &str, card_type: CardType) -> EntityId { fn in_hand(&self, card: &str) -> Option<EntityId> {
let in_hand = self self.world
.world
.zone(0, Zone::Hand) .zone(0, Zone::Hand)
.iter() .iter()
.copied() .copied()
.find(|&id| self.world.card_name(id) == card) .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, fn check_hand(&self, card: &str, card_type: CardType) -> Result<(), String> {
"{card:?} is not a {card_type:?}" if self.in_hand(card).is_none() {
); return Err(format!("{card:?} is not in the hand"));
in_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 /// 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) { pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.grid.world_mut().set_chooser(Box::new(chooser)); 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 /// Test hook: the naive engine reads the auras before the dead leave play, the
/// same end-of-Phase steps reversed. /// same end-of-Phase steps reversed.
pub fn with_naive_aura_timing(&mut self) { 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. 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. - `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/`): - 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. - `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": "MOTH", "role": "noun" },
{ "name": "SCRIBE", "role": "noun" }, { "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" }, { "name": "KNIGHT", "role": "noun" },
{ "name": "CRIER", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" }, { "name": "DEATHRATTLE", "role": "noun" },
{ "name": "YOU", "role": "property" }, { "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" }, { "name": "WIN", "role": "property" },
@@ -150,29 +150,62 @@ export class SpellGrid {
this.gridChanged(); 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 { cast(card: string, target?: puzzle.ObjectId): void {
const inHand = this.fromHand(card, "spell"); const problem = this.checkCast(card, target);
this.world.moveTo(inHand, "graveyard"); if (problem !== undefined) throw new Error(problem);
this.world.moveTo(this.inHand(card)!, "graveyard");
const chosen = target === undefined ? undefined : this.entity(target); const chosen = target === undefined ? undefined : this.entity(target);
this.sequence({ type: "cast", card, player: 0, target: chosen }); 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 { play(card: string, x: number, y: number): puzzle.ObjectId {
const inHand = this.fromHand(card, "minion"); const problem = this.checkPlay(card, x, y);
if (this.level.board.at(x, y).length > 0) throw new Error(`(${x}, ${y}) is not empty`); if (problem !== undefined) throw new Error(problem);
const inHand = this.inHand(card)!;
const id = this.level.board.place(this.nounOf(card), x, y); const id = this.level.board.place(this.nounOf(card), x, y);
this.link(inHand, id); this.link(inHand, id);
this.sequence({ type: "play", id: inHand }); this.sequence({ type: "play", id: inHand });
return id; return id;
} }
private fromHand(card: string, type: "minion" | "spell"): EntityId { private inHand(card: string): EntityId | undefined {
const inHand = this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card); return 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 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 ---- // ---- The seams ----
@@ -187,6 +187,9 @@ export interface SpellGrid extends Baba {
clearLog(): void; clearLog(): void;
setChooser(choose: Chooser): void; // makes Assemble's choices setChooser(choose: Chooser): void; // makes Assemble's choices
withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step 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. */ /** 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), setChooser: (choose) => g.world.setChooser(choose),
// The naive engine reads the auras before the dead leave play: the same steps, reversed. // The naive engine reads the auras before the dead leave play: the same steps, reversed.
withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()), 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. - 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. - `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. - 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. - `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"`). - `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. - `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")`. - Action 2: `step("left")`.
- Assertions 3: `won()` is true. - 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 ### 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. 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()); 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] #[test]
fn test_10_level_1_solution() { fn test_10_level_1_solution() {
let mut g = new_spell_grid( let mut g = new_spell_grid(
@@ -206,6 +206,21 @@ test("test_10_spawned_text_triggers_reparse", () => {
expect(g.won()).toBe(true); 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", () => { test("test_10_level_1_solution", () => {
const g = newSpellGrid(l1 as LevelDef, []); const g = newSpellGrid(l1 as LevelDef, []);
const keg = minion(g, 0, 4); const keg = minion(g, 0, 4);
+11 -2
View File
@@ -411,7 +411,8 @@
"role": "meta", "role": "meta",
"text": "a trigger type, so DEATHRATTLE IS DOUBLE doubles deathrattles", "text": "a trigger type, so DEATHRATTLE IS DOUBLE doubles deathrattles",
"source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md", "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", "name": "Powder Keg",
@@ -548,6 +549,13 @@
"text": "Tin Knight's noun (a SpellGrid minion on the grid)", "text": "Tin Knight's noun (a SpellGrid minion on the grid)",
"game": "spellgrid" "game": "spellgrid"
}, },
{
"name": "CRIER",
"kind": "word",
"role": "noun",
"text": "Town Crier's noun (a SpellGrid minion on the grid)",
"game": "spellgrid"
},
{ {
"name": "Town Crier", "name": "Town Crier",
"kind": "card", "kind": "card",
@@ -563,6 +571,7 @@
"kind": "word", "kind": "word",
"role": "meta", "role": "meta",
"text": "a trigger type, so BATTLECRY IS DOUBLE doubles battlecries (the Module 10 Expansion Drop)", "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`. 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(); 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() { export function loadCatalog() {
const f = join(courseRoot(), "catalog.json"); const f = join(courseRoot(), "catalog.json");
if (!existsSync(f)) throw new Error(`catalog not found: ${f}`); 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/. // Copies module NN's tests for the learner's track into <workspace>/tests/.
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path"; 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); const [num, ws] = process.argv.slice(2);
if (!/^\d\d$/.test(num || "") || !ws) fail("usage: start-module.mjs <NN> <workspaceDir>"); 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))); 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")}`); 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; // The course's card data (read by your content from Module 5): course/cards, plus the cards
// card files you add yourself are kept. // of each module up to this one that has its own (Module 10's SpellGrid cards). Refreshed on
const cards = join(courseRoot(), "cards"); // every start; card files you add yourself are kept.
if (existsSync(cards)) { const cardSources = cardDirs(num);
cpSync(cards, join(ws, "cards"), { recursive: true }); for (const cards of cardSources) cpSync(cards, join(ws, "cards"), { recursive: true });
console.log(`Copied the course card data into ${join(ws, "cards")}`); 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/). // The module's puzzle levels (Module 10's tests load them from levels/).
const levels = join(mod.path, "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. // Expansion Drop answers in course/NN-*/expansion/solution/cards.
function courseCardFiles() { function courseCardFiles() {
const courseDir = join(here, "../../course"); const courseDir = join(here, "../../course");
const dirs = [join(courseDir, "cards")]; const dirs = [join(courseDir, "cards")];
for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) { for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) {
const d = join(courseDir, m, "expansion/solution/cards"); for (const sub of ["cards", "expansion/solution/cards"]) {
if (existsSync(d)) dirs.push(d); 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))); 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}`); 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"))); 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", () => { test("start_module_copies_module_levels", () => {
const ws = copyFixture("ws-start"); const ws = copyFixture("ws-start");
mkdirSync(join(ws, "tests"), { recursive: true }); 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[2], "ts"), "levels", "fixture-level.json")));
assert.ok(!existsSync(join(stage(plans[1], "ts"), "levels"))); 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 #!/usr/bin/env node
// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests. // 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, // The card data (loaded by the references from Module 5) is staged as <dir>/cards: course/cards
// and each tested module's levels/ (the puzzle levels its tests load, from Module 10) as <dir>/levels. // 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 { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { basename, join } from "node:path"; 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 // 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). // 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))); 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 }); if (existsSync(join(t.path, "levels"))) cpSync(join(t.path, "levels"), join(dir, "levels"), { recursive: true });
} }
const cards = join(courseRoot(), "cards"); for (const cards of cardDirs(plan.num)) cpSync(cards, join(dir, "cards"), { recursive: true });
if (existsSync(cards)) cpSync(cards, join(dir, "cards"), { recursive: true });
return dir; return dir;
} }