Files
systemic-game-design-course/course/10-spellgrid/tests/rust/test_10.rs
T
Lucas WintherandClaude Opus 5.5 570e5d6826 feat: age-aware solver, verify-levels, seven solver-proved SpellGrid levels (l4-l10)
The solver's position key reads stackAt (each tile oldest first). New tool
tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each
level's shortest solution, and that it exhausts without its idea (neverRule)
or on the naive engine. Levels 4-10, one course idea each, with
test_10_level_N_solution in both tracks, scenarios and Lesson 10's list;
l1 no longer matches l10, and proofs.json is not listed as a level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:22:58 +01:00

688 lines
18 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)
}
/// Play one turn per direction, in order.
fn steps(g: &mut SpellGrid, dirs: &[Dir]) {
for &dir in dirs {
g.step(dir);
}
}
#[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());
}
/// The events of a Spark that kills `minion`, whose Deathrattle summons `summoned`.
fn spark_kills_summoner(minion: Id, summoned: Id) -> Vec<GameEvent> {
vec![
GameEvent::SpellCast {
card: "Spark".to_string(),
},
GameEvent::Damaged {
target: minion,
amount: 1,
},
GameEvent::Destroyed { id: minion },
GameEvent::DeathPhase { round: 1 },
GameEvent::Triggered {
source: minion,
trigger: "deathrattle".to_string(),
},
GameEvent::Summoned { id: summoned },
]
}
#[test]
fn test_10_summoned_minion_enters_grid() {
let mut g = new_spell_grid(
level(
5,
3,
&[("SHARD", 0, 0), ("IS", 1, 0), ("PUSH", 2, 0)],
&[],
&[("Clay Golem", 2, 2)],
&["Spark", "Spark"],
),
vec![],
);
let golem = minion(&g, 2, 2);
g.cast("Spark", Some(golem));
g.clear_log();
g.cast("Spark", Some(golem));
let shard = minion(&g, 2, 2);
assert_eq!(g.log(), spark_kills_summoner(golem, shard));
assert_eq!(g.kinds_at(2, 2), vec!["shard"]);
assert_eq!(g.pos_of(golem), None);
assert_eq!(g.name_of(shard), "Clay Shard");
assert_eq!(g.health(shard), 1);
assert!(has(&g.active_rules(), "SHARD IS PUSH"));
assert!(g.has_property(shard, "PUSH"));
for older in [golem, g.hero(0), g.hero(1)] {
assert!(shard > older);
}
assert_eq!(g.place("rock", 4, 2), shard + 1);
}
#[test]
fn test_10_summon_without_noun_stays_off_grid() {
let mut g = new_spell_grid(
level(3, 1, &[], &[], &[], &["Graft", "Spark", "Spark"]),
vec![],
);
g.set_chooser(|kind, _| {
match kind {
"base" => "Clay Golem",
"donor" => "Harvest Golem",
_ => "bottom",
}
.to_string()
});
g.cast("Graft", None);
assert_eq!(g.hand(), vec!["Spark", "Spark", "Clay Golem (Assembled)"]);
let golem = g.play("Clay Golem (Assembled)", 1, 0);
g.cast("Spark", Some(golem));
g.clear_log();
g.cast("Spark", Some(golem));
let Some(&GameEvent::Summoned { id: token }) = g.log().last() else {
panic!("the last event is not a Summoned: {:?}", g.log());
};
assert_eq!(g.log(), spark_kills_summoner(golem, token));
assert_eq!(g.name_of(token), "Damaged Golem");
assert_eq!(g.health(token), 1);
assert_eq!(g.pos_of(token), None);
assert_eq!(g.kinds_at(1, 0), Vec::<String>::new());
assert_eq!(g.minion_at(1, 0), None);
}
#[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());
}
#[test]
fn test_10_level_4_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l4-crossed-out.json")),
vec![],
);
let keg = minion(&g, 1, 6);
let hedge = minion(&g, 6, 4);
assert!(has(&g.rules(), "DEATHRATTLE IS DOUBLE"));
assert!(!has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
steps(&mut g, &[Dir::Right, Dir::Down]);
assert!(!has(&g.rules(), "DEATHRATTLE IS NOT DOUBLE"));
assert!(has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
steps(&mut g, &[Dir::Right, Dir::Right]);
g.cast("Spark", Some(keg));
assert_eq!(deathrattles(&g, keg), 2);
assert_eq!(g.pos_of(hedge), None);
steps(&mut g, &[Dir::Right, Dir::Up]);
assert!(g.won());
}
#[test]
fn test_10_level_5_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l5-sealed-keg.json")),
vec![],
);
let keg = minion(&g, 1, 5);
steps(&mut g, &[Dir::Left, Dir::Left, Dir::Down, Dir::Left]);
assert_eq!(g.kinds_at(1, 5), vec!["keg"]);
g.step(Dir::Up);
assert!(has(&g.active_rules(), "KEG IS NOT KEG"));
assert!(!has(&g.active_rules(), "KEG IS KEG"));
assert_eq!(deathrattles(&g, keg), 1);
assert_eq!(g.pos_of(keg), None);
assert_eq!(g.kinds_at(1, 5), vec!["flag"]);
g.step(Dir::Left);
assert!(g.won());
}
#[test]
fn test_10_level_6_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l6-melt-before-defeat.json")),
vec![],
);
let knight = minion(&g, 0, 6);
let keg = minion(&g, 3, 6);
assert_eq!(g.stack_at(3, 6), vec!["keg", "shard"]);
steps(&mut g, &[Dir::Right, Dir::Right, Dir::Up, Dir::Down]);
assert!(has(&g.active_rules(), "LAVA IS HOT"));
g.step(Dir::Right);
// The knight stepped onto the DEFEAT shard as the keg went into the lava. HOT/MELT runs
// first: the keg's Deathrattle kills the shard, and DEFEAT finds nothing on the knight's tile.
assert_eq!(deathrattles(&g, keg), 1);
assert_eq!(g.pos_of(keg), None);
assert_eq!(g.pos_of(knight), Some((3, 6)));
assert_eq!(g.kinds_at(3, 6), vec!["knight"]);
assert_eq!(g.health(knight), 2);
assert!(!g.lost());
steps(&mut g, &[Dir::Right, Dir::Right]);
assert!(g.won());
}
#[test]
fn test_10_level_7_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l7-clay-key.json")),
vec![],
);
let golem = minion(&g, 4, 5);
steps(&mut g, &[Dir::Right, Dir::Up, Dir::Right]);
assert!(has(&g.active_rules(), "SHARD IS OPEN"));
g.cast("Spark", Some(golem));
g.cast("Spark", Some(golem));
assert_eq!(g.pos_of(golem), None);
assert_eq!(g.kinds_at(4, 5), vec!["shard"]);
assert_eq!(g.name_of(minion(&g, 4, 5)), "Clay Shard");
steps(&mut g, &[Dir::Right, Dir::Right]);
assert_eq!(g.kinds_at(6, 5), Vec::<String>::new());
steps(&mut g, &[Dir::Right, Dir::Up]);
assert!(g.won());
}
#[test]
fn test_10_level_8_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l8-nothing-dies.json")),
vec![],
);
g.set_chooser(|kind, _| {
match kind {
"base" => "Clay Golem",
"donor" => "Novice Engineer",
_ => "top",
}
.to_string()
});
g.step(Dir::Left);
assert!(has(&g.active_rules(), "SHARD IS WIN"));
g.cast("Graft", None);
g.clear_log();
let golem = g.play("Clay Golem (Assembled)", 4, 3);
// Battlecry, not Deathrattle: the golem is alive and its shard already stands on its tile.
assert!(g.log().contains(&GameEvent::Triggered {
source: golem,
trigger: "battlecry".to_string(),
}));
assert_eq!(g.pos_of(golem), Some((4, 3)));
assert_eq!(g.stack_at(4, 3), vec!["golem", "shard"]);
g.step(Dir::Right);
assert!(g.won());
}
#[test]
fn test_10_level_9_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l9-skull-river.json")),
vec![],
);
g.step(Dir::Right);
assert_eq!(g.kinds_at(4, 5), vec!["rock", "skull"]);
assert_eq!(g.kinds_at(3, 5), vec!["rock"]);
steps(&mut g, &[Dir::Down, Dir::Left]);
// SKULL IS SINK: at the end of the turn the skull under the rock takes the rock with it.
assert!(has(&g.active_rules(), "SKULL IS SINK"));
assert_eq!(g.kinds_at(4, 5), Vec::<String>::new());
steps(&mut g, &[Dir::Up, Dir::Right, Dir::Right, Dir::Right]);
assert_eq!(g.kinds_at(5, 5), Vec::<String>::new());
steps(&mut g, &[Dir::Right, Dir::Right]);
assert!(g.won());
}
#[test]
fn test_10_level_10_solution() {
let mut g = new_spell_grid(
level_json(include_str!("../levels/l10-friendly-fire.json")),
vec![],
);
let shard = minion(&g, 1, 6);
let knight = minion(&g, 5, 5);
let hedge = minion(&g, 6, 4);
steps(&mut g, &[Dir::Left, Dir::Up]);
assert!(has(&g.active_rules(), "KNIGHT IS YOU"));
assert!(!has(&g.active_rules(), "SHARD IS YOU"));
g.step(Dir::Up);
g.cast("Whirlwind", None);
// Whirlwind and the keg's Deathrattle: 2 damage to everything. The shard you were dies;
// the knight you are now is left on 1.
assert_eq!(g.pos_of(shard), None);
assert_eq!(g.pos_of(hedge), None);
assert_eq!(g.health(knight), 1);
assert!(!g.lost());
steps(&mut g, &[Dir::Right, Dir::Up]);
assert!(g.won());
}