Files
systemic-game-design-course/course/12-combat/tests/rust/test_12.rs
T
Lucas WintherandClaude Opus 5.5 fb9d0a4eb4 feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)
Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks,
the cumulative reference (Module 11's plus combat) and the drop. The Damage
action carries per-target hits with a priority; react stable-sorts its Queue by
priority before order of play, so the triggers answering the defender's Damage
Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack
because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari
Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness
contract, catalog, research notes and earlier lessons' forward pointers follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:34:20 +01:00

314 lines
8.7 KiB
Rust

// Module 12: Combat. Each test matches a heading in scenarios.md.
use engine::harness::*;
fn game_with_decks() -> Game {
new_game(GameOpts {
decks: Some([
vec!["Test Filler".to_string(); 5],
vec!["Test Filler".to_string(); 5],
]),
})
}
fn damaged(target: Id, amount: i32) -> GameEvent {
GameEvent::Damaged { target, amount }
}
fn triggered(source: Id, trigger: &str) -> GameEvent {
GameEvent::Triggered {
source,
trigger: trigger.to_string(),
}
}
fn card(json: &str) -> CardDef {
serde_json::from_str(json).unwrap()
}
/// The `source` of each `Triggered` event, in log order.
fn triggered_sources(log: &[GameEvent]) -> Vec<Id> {
log.iter()
.filter_map(|e| match e {
GameEvent::Triggered { source, .. } => Some(*source),
_ => None,
})
.collect()
}
/// The id of the one `Summoned` event in a log.
fn summoned(log: &[GameEvent]) -> Id {
let ids: Vec<Id> = log
.iter()
.filter_map(|e| match e {
GameEvent::Summoned { id } => Some(*id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 1, "{log:?}");
ids[0]
}
#[test]
fn test_12_combat_is_simultaneous() {
let mut g = game_with_decks();
let a = g.summon("Loot Hoarder", 0);
let d = g.summon("Loot Hoarder", 1);
g.clear_log();
g.order_attack(a, d).unwrap();
let (c0, c1) = (g.hand(0)[0], g.hand(1)[0]);
assert_eq!(
g.log(),
vec![
damaged(a, 2),
damaged(d, 2),
GameEvent::Destroyed { id: a },
GameEvent::Destroyed { id: d },
GameEvent::DeathPhase { round: 1 },
triggered(a, "deathrattle"),
GameEvent::Drew { player: 0, id: c0 },
triggered(d, "deathrattle"),
GameEvent::Drew { player: 1, id: c1 },
]
);
assert_eq!(g.zone_of(a), Zone::Graveyard);
assert_eq!(g.zone_of(d), Zone::Graveyard);
}
#[test]
fn test_12_defender_triggers_first() {
let mut g = game_with_decks();
let a = g.summon("Acolyte of Pain", 0);
let d = g.summon("Acolyte of Pain", 1);
g.clear_log();
// The attacker is older: order of play alone would put its trigger first.
g.order_attack(a, d).unwrap();
let (c0, c1) = (g.hand(0)[0], g.hand(1)[0]);
assert_eq!(
g.log(),
vec![
damaged(a, 1),
damaged(d, 1),
triggered(d, "damaged"),
GameEvent::Drew { player: 1, id: c1 },
triggered(a, "damaged"),
GameEvent::Drew { player: 0, id: c0 },
]
);
// The other way round: the attacker is younger, and the defender still goes first.
g.clear_log();
g.order_attack(d, a).unwrap();
assert_eq!(triggered_sources(&g.log()), vec![a, d]);
assert_eq!(g.health(a), 2);
assert_eq!(g.health(d), 2);
}
#[test]
fn test_12_taunt_restricts_targets() {
let mut g = game_with_decks();
let h = g.summon("Loot Hoarder", 1);
let t = g.summon("Unstable Ghoul", 1);
let a = g.summon("Harvest Golem", 0);
let enemy_hero = g.hero(1);
g.clear_log();
let reason = g.can_attack(a, h).expect("the Hoarder is behind a Taunt");
assert!(reason.contains("Taunt"), "{reason}");
let reason = g
.can_attack(a, enemy_hero)
.expect("the hero is behind a Taunt");
assert!(reason.contains("Taunt"), "{reason}");
assert_eq!(g.can_attack(a, t), None);
assert_eq!(g.order_attack(a, h), Err(g.can_attack(a, h).unwrap()));
assert_eq!(g.log(), vec![]);
assert_eq!(g.health(h), 1);
g.order_attack(a, t).unwrap();
assert_eq!(g.log(), vec![damaged(t, 2), damaged(a, 1)]);
assert_eq!(g.health(t), 1);
assert_eq!(g.health(a), 2);
}
#[test]
fn test_12_zero_attack_cannot_attack() {
let mut g = game_with_decks();
let bell = g.summon("Test Bell", 0);
let f0 = g.summon("Test Filler", 0);
let f1 = g.summon("Test Filler", 1);
let (hero0, hero1) = (g.hero(0), g.hero(1));
g.clear_log();
let reason = g.can_attack(bell, f1).expect("0 Attack");
assert!(reason.contains("Attack"), "{reason}");
assert!(g.order_attack(bell, f1).is_err());
assert!(g.can_attack(f0, bell).is_some());
assert!(g.can_attack(hero0, f1).is_some());
assert_eq!(g.can_attack(f0, f1), None);
assert_eq!(g.can_attack(f0, hero1), None);
assert_eq!(g.log(), vec![]);
}
#[test]
fn test_12_attack_hero_through_armor() {
let mut g = game_with_decks();
let s = g.summon("Armorsmith", 1);
g.deal_damage(s, 1);
g.deal_damage(s, 1);
g.destroy(s);
let b = g.summon("Bolf Ramshield", 0);
let enemy_hero = g.hero(1);
assert_eq!(g.armor(1), 2);
g.clear_log();
g.order_attack(b, enemy_hero).unwrap();
assert_eq!(
g.log(),
vec![
GameEvent::ArmorLost {
player: 1,
amount: 2
},
damaged(enemy_hero, 3),
]
);
assert_eq!(g.armor(1), 0);
assert_eq!(g.health(enemy_hero), 29);
assert_eq!(g.health(b), 9);
}
#[test]
fn test_12_redirect_only_for_the_hero() {
let mut g = game_with_decks();
let r = g.summon("Bolf Ramshield", 1);
let h = g.summon("Loot Hoarder", 1);
let a = g.summon("Harvest Golem", 0);
let enemy_hero = g.hero(1);
g.clear_log();
g.order_attack(a, h).unwrap();
let c = g.hand(1)[0];
assert_eq!(
g.log(),
vec![
damaged(h, 2),
damaged(a, 2),
GameEvent::Destroyed { id: h },
GameEvent::DeathPhase { round: 1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 1, id: c },
]
);
assert_eq!(g.health(r), 9);
g.clear_log();
g.order_attack(a, enemy_hero).unwrap();
assert_eq!(g.log(), vec![damaged(r, 2)]);
assert_eq!(g.health(enemy_hero), 30);
assert_eq!(g.health(r), 7);
assert_eq!(g.health(a), 1);
}
#[test]
fn test_12_end_of_turn_trigger() {
let mut g = game_with_decks();
let b = g.summon("Test Bell", 0);
let (hero0, hero1) = (g.hero(0), g.hero(1));
g.clear_log();
g.end_turn(1);
assert_eq!(g.log(), vec![]);
g.end_turn(0);
assert_eq!(g.log(), vec![triggered(b, "endOfTurn"), damaged(hero1, 1)]);
assert_eq!(g.health(hero1), 29);
assert_eq!(g.health(hero0), 30);
}
#[test]
fn test_12_drakkari_doubles_end_of_turn() {
let mut g = game_with_decks();
let b0 = g.summon("Test Bell", 0);
g.summon("Drakkari Enchanter", 0);
let b1 = g.summon("Test Bell", 1);
let (hero0, hero1) = (g.hero(0), g.hero(1));
g.clear_log();
g.end_turn(0);
assert_eq!(
g.log(),
vec![
triggered(b0, "endOfTurn"),
damaged(hero1, 1),
triggered(b0, "endOfTurn"),
damaged(hero1, 1),
]
);
// Drakkari doubles its owner's end of turn effects only.
g.clear_log();
g.end_turn(1);
assert_eq!(g.log(), vec![triggered(b1, "endOfTurn"), damaged(hero0, 1)]);
assert_eq!(g.health(hero1), 28);
assert_eq!(g.health(hero0), 29);
}
/// "At the end of your turn, summon a Drakkari Enchanter."
const TEST_CONJURER: &str = r#"{ "name": "Test Conjurer", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "endOfTurn" }, { "kind": "effect", "effect": { "type": "summon", "card": "Drakkari Enchanter", "count": 1 } }] }"#;
#[test]
fn test_12_drakkari_summoned_at_end_of_turn() {
// Conjurer first: the Bell's entry starts after Drakkari's summon updated the auras.
let mut g = game_with_decks();
g.register_cards(vec![card(TEST_CONJURER)]);
let c = g.summon("Test Conjurer", 0);
let b = g.summon("Test Bell", 0);
let hero1 = g.hero(1);
g.clear_log();
g.end_turn(0);
let k = summoned(&g.log());
assert_eq!(g.name_of(k), "Drakkari Enchanter");
assert_eq!(
g.log(),
vec![
triggered(c, "endOfTurn"),
GameEvent::Summoned { id: k },
triggered(b, "endOfTurn"),
damaged(hero1, 1),
triggered(b, "endOfTurn"),
damaged(hero1, 1),
]
);
// Bell first: its entry has started, and counted, before any Drakkari exists.
let mut g = game_with_decks();
g.register_cards(vec![card(TEST_CONJURER)]);
let b = g.summon("Test Bell", 0);
let c = g.summon("Test Conjurer", 0);
let hero1 = g.hero(1);
g.clear_log();
g.end_turn(0);
let k = summoned(&g.log());
assert_eq!(
g.log(),
vec![
triggered(b, "endOfTurn"),
damaged(hero1, 1),
triggered(c, "endOfTurn"),
GameEvent::Summoned { id: k },
]
);
assert_eq!(g.health(hero1), 29);
}