- 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]>
389 lines
9.6 KiB
Rust
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());
|
|
}
|