Files
systemic-game-design-course/course/10-spellgrid/tests/rust/test_10.rs
T
Lucas WintherandClaude Opus 5.5 6f576940b7 fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)
- SpellGrid cards move to course/10-spellgrid/cards; start-module and
  verify-references stage course/cards plus module-local cards up to the module,
  so M5-M9 workspaces get exactly the pre-M10 card set.
- cast() rejects a chosen-target spell without a target (checkCast/checkPlay);
  new test_10_targeted_spell_needs_target.
- Rust CLI pre-validates instead of swapping the global panic hook.
- Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role.
- Lesson: design note on mid-movement reparse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:11:09 +01:00

389 lines
9.6 KiB
Rust

// Module 10: SpellGrid. Each test matches a heading in scenarios.md.
use engine::harness::*;
/// A level from its JSON text (the course's level format).
fn level_json(json: &str) -> LevelDef {
serde_json::from_str(json).expect("a level in the course's level format")
}
/// A level `width` wide and `height` high: text written as `(word, x, y)`, other
/// objects as `(kind, x, y)`, minion cards as `(card, x, y)`, and a hand.
fn level(
width: i32,
height: i32,
words: &[(&str, i32, i32)],
objects: &[(&str, i32, i32)],
cards: &[(&str, i32, i32)],
hand: &[&str],
) -> LevelDef {
let words = words.iter().map(|&(w, x, y)| (format!("text:{w}"), x, y));
let objects = objects.iter().map(|&(k, x, y)| (k.to_string(), x, y));
LevelDef {
width,
height,
objects: words
.chain(objects)
.map(|(kind, x, y)| LevelObject { kind, x, y })
.collect(),
cards: cards
.iter()
.map(|&(card, x, y)| LevelCard {
card: card.to_string(),
x,
y,
})
.collect(),
hand: hand.iter().map(|c| c.to_string()).collect(),
}
}
/// The minion on tile (x, y); fails the test if there is none.
fn minion(g: &SpellGrid, x: i32, y: i32) -> Id {
g.minion_at(x, y)
.unwrap_or_else(|| panic!("no minion at ({x}, {y})"))
}
/// How many times `source`'s Deathrattle triggered in the log.
fn deathrattles(g: &SpellGrid, source: Id) -> usize {
g.log()
.iter()
.filter(|e| {
matches!(e, GameEvent::Triggered { source: s, trigger } if *s == source && trigger == "deathrattle")
})
.count()
}
fn has(rules: &[String], rule: &str) -> bool {
rules.iter().any(|r| r == rule)
}
#[test]
fn test_10_spatial_rule_feeds_interceptor() {
let mut g = new_spell_grid(
level(
6,
3,
&[("DEATHRATTLE", 0, 0), ("IS", 1, 0)],
&[],
&[("Powder Keg", 0, 2), ("Bramble Wall", 2, 2)],
&["Spark"],
),
vec![],
);
let keg = minion(&g, 0, 2);
let bramble = minion(&g, 2, 2);
assert!(!has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
g.place("text:DOUBLE", 2, 0);
assert!(has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
g.clear_log();
g.cast("Spark", Some(keg));
assert_eq!(deathrattles(&g, keg), 2);
assert_eq!(g.health(bramble), 1);
assert_eq!(g.pos_of(keg), None);
assert_eq!(g.pos_of(bramble), Some((2, 2)));
assert_eq!(g.hand(), Vec::<String>::new());
}
#[test]
fn test_10_not_cancels_spatial_modifier() {
let mut g = new_spell_grid(
level(
6,
4,
&[
("DEATHRATTLE", 0, 0),
("IS", 1, 0),
("DOUBLE", 2, 0),
("DEATHRATTLE", 0, 1),
("IS", 1, 1),
("NOT", 2, 1),
("DOUBLE", 3, 1),
],
&[],
&[
("Grave Warden", 0, 3),
("Powder Keg", 1, 3),
("Bramble Wall", 2, 3),
],
&["Spark"],
),
vec![],
);
let warden = minion(&g, 0, 3);
let keg = minion(&g, 1, 3);
let bramble = minion(&g, 2, 3);
assert!(has(&g.rules(), "DEATHRATTLE IS DOUBLE"));
assert!(has(&g.rules(), "DEATHRATTLE IS NOT DOUBLE"));
assert!(has(&g.active_rules(), "DEATHRATTLE IS NOT DOUBLE"));
assert!(!has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
g.clear_log();
g.cast("Spark", Some(keg));
assert_eq!(deathrattles(&g, keg), 1);
assert_eq!(g.health(bramble), 2);
assert_eq!(g.pos_of(warden), None);
}
#[test]
fn test_10_grid_and_card_doublers_take_max() {
let mut g = new_spell_grid(
level(
6,
3,
&[("DEATHRATTLE", 0, 0), ("IS", 1, 0), ("DOUBLE", 2, 0)],
&[],
&[
("Grave Warden", 0, 2),
("Powder Keg", 1, 2),
("Bramble Wall", 2, 2),
],
&["Spark"],
),
vec![],
);
let keg = minion(&g, 1, 2);
let bramble = minion(&g, 2, 2);
g.clear_log();
g.cast("Spark", Some(keg));
assert_eq!(deathrattles(&g, keg), 2);
assert_eq!(g.health(bramble), 1);
}
#[test]
fn test_10_hazard_feeds_death_step() {
let mut g = new_spell_grid(
level(
5,
4,
&[
("KEG", 0, 0),
("IS", 1, 0),
("YOU", 2, 0),
("AND", 3, 0),
("MELT", 4, 0),
("LAVA", 0, 1),
("IS", 1, 1),
("HOT", 2, 1),
],
&[("lava", 2, 3)],
&[("Powder Keg", 1, 3), ("Bramble Wall", 4, 3)],
&[],
),
vec![],
);
let keg = minion(&g, 1, 3);
let bramble = minion(&g, 4, 3);
g.clear_log();
g.step(Dir::Right);
assert_eq!(
g.log(),
vec![
GameEvent::Destroyed { id: keg },
GameEvent::DeathPhase { round: 1 },
GameEvent::Triggered {
source: keg,
trigger: "deathrattle".to_string()
},
GameEvent::Damaged {
target: bramble,
amount: 1
},
]
);
assert_eq!(g.pos_of(keg), None);
assert_eq!(g.kinds_at(2, 3), vec!["lava"]);
assert_eq!(g.health(bramble), 2);
assert!(g.lost());
}
#[test]
fn test_10_spawned_text_triggers_reparse() {
let mut g = new_spell_grid(
level(
5,
3,
&[
("BABA", 0, 0),
("IS", 1, 0),
("YOU", 2, 0),
("BABA", 0, 1),
("IS", 1, 1),
],
&[],
&[("Rune Scribe", 2, 1)],
&["Spark"],
),
vec![],
);
let scribe = minion(&g, 2, 1);
let baba = g.place("baba", 4, 2);
assert!(!has(&g.rules(), "BABA IS WIN"));
g.cast("Spark", Some(scribe));
assert_eq!(g.kinds_at(2, 1), vec!["text:WIN"]);
assert!(has(&g.rules(), "BABA IS WIN"));
assert!(g.has_property(baba, "WIN"));
assert!(!g.won());
g.step(Dir::Left);
assert!(g.won());
}
#[test]
fn test_10_targeted_spell_needs_target() {
let mut g = new_spell_grid(
level(3, 1, &[], &[], &[("Bramble Wall", 0, 0)], &["Spark"]),
vec![],
);
let bramble = minion(&g, 0, 0);
let cast = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Spark", None)));
assert!(cast.is_err());
assert_eq!(g.hand(), vec!["Spark"]);
assert_eq!(g.health(bramble), 3);
g.cast("Spark", Some(bramble));
assert_eq!(g.health(bramble), 2);
assert_eq!(g.hand(), Vec::<String>::new());
}
#[test]
fn test_10_level_1_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l1-double-tap.json")),
vec![],
);
let keg = minion(&g, 0, 4);
let bramble = minion(&g, 5, 6);
g.step(Dir::Up);
g.step(Dir::Up);
assert!(has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
g.cast("Whirlwind", None);
assert_eq!(deathrattles(&g, keg), 2);
assert_eq!(g.pos_of(bramble), None);
for dir in [
Dir::Down,
Dir::Down,
Dir::Left,
Dir::Down,
Dir::Down,
Dir::Right,
] {
g.step(dir);
}
assert!(!g.won());
assert_eq!(g.kinds_at(5, 6), vec!["baba"]);
g.step(Dir::Right);
assert!(g.won());
}
#[test]
fn test_10_level_2_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l2-spliced-corpse.json")),
vec![],
);
g.set_chooser(|kind, _| {
match kind {
"base" => "Ember Moth",
"donor" => "Rune Scribe",
_ => "bottom",
}
.to_string()
});
g.cast("Graft", None);
assert_eq!(g.hand(), vec!["Ember Moth (Assembled)"]);
let moth = g.play("Ember Moth (Assembled)", 4, 3);
assert_eq!(g.kinds_at(4, 3), vec!["moth"]);
assert_eq!(g.name_of(moth), "Ember Moth (Assembled)");
g.step(Dir::Left);
g.step(Dir::Left);
assert_eq!(g.pos_of(moth), None);
assert_eq!(g.kinds_at(2, 3), vec!["lava", "text:WIN"]);
assert!(has(&g.rules(), "BABA IS WIN"));
assert!(g.won());
}
/// Level 3's first input, one Whirlwind, after clearing the log. Returns the
/// knight, keg, warden and hedge.
fn cast_level_3_whirlwind(g: &mut SpellGrid) -> (Id, Id, Id, Id) {
let ids = (
minion(g, 0, 4),
minion(g, 1, 5),
minion(g, 2, 5),
minion(g, 4, 4),
);
g.clear_log();
g.cast("Whirlwind", None);
ids
}
#[test]
fn test_10_level_3_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l3-simultaneous-paradox.json")),
vec![],
);
let (knight, keg, warden, hedge) = cast_level_3_whirlwind(&mut g);
assert_eq!(deathrattles(&g, keg), 1);
assert_eq!(g.health(knight), 1);
assert_eq!(g.pos_of(hedge), None);
assert_eq!(g.pos_of(keg), None);
assert_eq!(g.pos_of(warden), None);
for _ in 0..5 {
g.step(Dir::Right);
}
assert!(g.won());
}
#[test]
fn test_10_level_3_naive_would_differ() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l3-simultaneous-paradox.json")),
vec![],
);
g.with_naive_aura_timing();
let (knight, keg, _, _) = cast_level_3_whirlwind(&mut g);
for _ in 0..5 {
g.step(Dir::Right);
}
assert_eq!(deathrattles(&g, keg), 2);
assert_eq!(g.pos_of(knight), None);
assert!(g.lost());
assert!(!g.won());
}