A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
564 lines
18 KiB
Rust
564 lines
18 KiB
Rust
// Module 5: Behavior as data and Assemble. Each test matches a heading in scenarios.md.
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use engine::harness::*;
|
|
|
|
/// Every file in the workspace's `cards/` directory, in file-name order: (raw JSON, parsed).
|
|
fn card_files() -> Vec<(serde_json::Value, CardDef)> {
|
|
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/cards");
|
|
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
|
.unwrap_or_else(|e| panic!("cannot read {dir}: {e}"))
|
|
.map(|entry| entry.unwrap().path())
|
|
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
|
.collect();
|
|
paths.sort();
|
|
paths
|
|
.iter()
|
|
.map(|p| {
|
|
let text = std::fs::read_to_string(p).unwrap();
|
|
let raw: serde_json::Value = serde_json::from_str(&text).unwrap();
|
|
let def: CardDef =
|
|
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", p.display()));
|
|
(raw, def)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Names of the minion files with a trigger clause listening for `on`, sorted.
|
|
fn minions_with_trigger(on: &str) -> Vec<String> {
|
|
let mut names: Vec<String> = card_files()
|
|
.into_iter()
|
|
.filter(|(raw, _)| raw["type"] == "minion")
|
|
.filter(|(raw, _)| {
|
|
raw["text"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|c| c["kind"] == "trigger" && c["on"] == on)
|
|
})
|
|
.map(|(raw, _)| raw["name"].as_str().unwrap().to_string())
|
|
.collect();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
fn fillers(n: usize) -> Vec<String> {
|
|
vec!["Test Filler".to_string(); n]
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_regression_data_cards() {
|
|
let files = card_files();
|
|
let names: Vec<&str> = files
|
|
.iter()
|
|
.map(|(raw, _)| raw["name"].as_str().unwrap())
|
|
.collect();
|
|
for used in [
|
|
"Armorsmith",
|
|
"Acolyte of Pain",
|
|
"Loot Hoarder",
|
|
"Harvest Golem",
|
|
"Damaged Golem",
|
|
"Unstable Ghoul",
|
|
"Baron Rivendare",
|
|
"Novice Engineer",
|
|
"Whirlwind",
|
|
"Twisting Nether",
|
|
"Test Echo",
|
|
"Test Reactor",
|
|
"Test Filler",
|
|
] {
|
|
assert!(names.contains(&used), "cards/ has no file for {used}");
|
|
}
|
|
|
|
let mut g = new_game(GameOpts::default());
|
|
g.register_cards(files.iter().map(|(_, def)| def.clone()).collect());
|
|
|
|
for (raw, _) in files.iter().filter(|(raw, _)| raw["type"] == "minion") {
|
|
let name = raw["name"].as_str().unwrap();
|
|
let id = g.summon(name, 0);
|
|
assert_eq!(g.name_of(id), name);
|
|
assert_eq!(
|
|
g.attack(id) as i64,
|
|
raw["attack"].as_i64().unwrap(),
|
|
"{name}"
|
|
);
|
|
assert_eq!(
|
|
g.health(id) as i64,
|
|
raw["health"].as_i64().unwrap(),
|
|
"{name}"
|
|
);
|
|
assert_eq!(g.cost(id) as i64, raw["cost"].as_i64().unwrap(), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_assemble_bottom_half() {
|
|
let mut g = new_game(GameOpts::default());
|
|
let name = g
|
|
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
|
|
.unwrap();
|
|
assert_eq!(name, "Harvest Golem (Assembled)");
|
|
let b = g.summon("Baron Rivendare", 0);
|
|
let f = g.summon("Test Filler", 0);
|
|
let s = g.summon("Test Sponge", 0);
|
|
let a = g.summon(&name, 0);
|
|
let (h0, h1) = (g.hero(0), g.hero(1));
|
|
g.clear_log();
|
|
|
|
g.destroy(a);
|
|
|
|
let activation = vec![
|
|
triggered(a, "deathrattle"),
|
|
damaged(h0, 1),
|
|
damaged(h1, 1),
|
|
damaged(b, 1),
|
|
damaged(f, 1),
|
|
damaged(s, 1),
|
|
];
|
|
let mut expected = vec![
|
|
GameEvent::Destroyed { id: a },
|
|
GameEvent::DeathPhase { round: 1 },
|
|
];
|
|
expected.extend(activation.clone());
|
|
expected.extend(activation);
|
|
expected.push(GameEvent::Destroyed { id: f });
|
|
expected.push(GameEvent::DeathPhase { round: 2 });
|
|
assert_eq!(g.log(), expected);
|
|
assert_eq!(g.health(h0), 28);
|
|
assert_eq!(g.health(h1), 28);
|
|
assert_eq!(g.health(b), 5);
|
|
assert_eq!(g.health(s), 1);
|
|
assert_eq!(g.zone_of(f), Zone::Graveyard);
|
|
assert_eq!(g.board(0), vec![b, s]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_assembled_keeps_base_stats() {
|
|
let mut g = new_game(GameOpts::default());
|
|
let name = g
|
|
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
|
|
.unwrap();
|
|
|
|
let a = g.add_to_hand(&name, 0);
|
|
|
|
assert_eq!(g.name_of(a), name);
|
|
assert!(name.contains("Harvest Golem"));
|
|
assert_eq!(g.cost(a), 3);
|
|
assert_eq!(g.attack(a), 2);
|
|
assert_eq!(g.health(a), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_assemble_must_change_text() {
|
|
let mut g = new_game(GameOpts::default());
|
|
|
|
let bottom = g.assemble("Loot Hoarder", "Loot Hoarder", Half::Bottom);
|
|
let top = g.assemble("Loot Hoarder", "Loot Hoarder", Half::Top);
|
|
|
|
assert!(bottom.is_err(), "{bottom:?}");
|
|
assert!(top.is_err(), "{top:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_assemble_never_rewrites_an_earlier_card() {
|
|
let mut g = new_game(GameOpts::default());
|
|
let first = g
|
|
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
|
|
.unwrap();
|
|
let a = g.add_to_hand(&first, 0);
|
|
// The base changes (a new cost, the same text), then the same Assemble is made again.
|
|
let (_, mut golem) = card_files()
|
|
.into_iter()
|
|
.find(|(_, def)| def.name == "Harvest Golem")
|
|
.unwrap();
|
|
golem.cost = 5;
|
|
g.register_cards(vec![golem]);
|
|
|
|
let second = g
|
|
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
|
|
.unwrap();
|
|
let b = g.add_to_hand(&second, 0);
|
|
|
|
// A different card, so a different name: the first card in hand is still the one it was.
|
|
assert_ne!(second, first);
|
|
assert_eq!(g.cost(a), 3);
|
|
assert_eq!(g.cost(b), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_top_half_swap() {
|
|
let mut g = new_game(GameOpts {
|
|
decks: Some([fillers(5), vec![]]),
|
|
});
|
|
let name = g
|
|
.assemble("Loot Hoarder", "Novice Engineer", Half::Top)
|
|
.unwrap();
|
|
let e = g.add_to_hand(&name, 0);
|
|
g.clear_log();
|
|
|
|
g.play(e);
|
|
|
|
let hand = g.hand(0);
|
|
assert_eq!(hand.len(), 1);
|
|
let c = hand[0];
|
|
assert_eq!(
|
|
g.log(),
|
|
vec![
|
|
GameEvent::Summoned { id: e },
|
|
triggered(e, "battlecry"),
|
|
GameEvent::Drew { player: 0, id: c },
|
|
]
|
|
);
|
|
assert_eq!(g.attack(e), 2);
|
|
assert_eq!(g.health(e), 1);
|
|
|
|
g.clear_log();
|
|
g.destroy(e);
|
|
|
|
assert_eq!(
|
|
g.log(),
|
|
vec![
|
|
GameEvent::Destroyed { id: e },
|
|
GameEvent::DeathPhase { round: 1 },
|
|
]
|
|
);
|
|
assert_eq!(g.hand(0).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_assemble_effect_with_chooser() {
|
|
let mut g = new_game(GameOpts::default());
|
|
let t = g.add_to_hand("Test Filler", 0);
|
|
let calls: Rc<RefCell<Vec<(String, Vec<String>)>>> = Rc::default();
|
|
let record = Rc::clone(&calls);
|
|
g.set_chooser(move |kind: &str, options: &[String]| {
|
|
record
|
|
.borrow_mut()
|
|
.push((kind.to_string(), options.to_vec()));
|
|
match kind {
|
|
"base" => "Novice Engineer",
|
|
"donor" => "Harvest Golem",
|
|
_ => "bottom",
|
|
}
|
|
.to_string()
|
|
});
|
|
g.clear_log();
|
|
|
|
g.cast("Test Workshop", 0, None);
|
|
|
|
let calls = calls.borrow().clone();
|
|
let kinds: Vec<&str> = calls.iter().map(|(k, _)| k.as_str()).collect();
|
|
assert_eq!(kinds, vec!["base", "donor", "half"]);
|
|
assert_eq!(calls[0].1, minions_with_trigger("battlecry"));
|
|
assert!(calls[1].1.contains(&"Harvest Golem".to_string()));
|
|
assert!(!calls[1].1.contains(&"Novice Engineer".to_string()));
|
|
assert_eq!(calls[2].1, vec!["top".to_string(), "bottom".to_string()]);
|
|
|
|
let hand = g.hand(0);
|
|
assert_eq!(hand.len(), 2);
|
|
assert_eq!(hand[0], t);
|
|
let a = hand[1];
|
|
assert_eq!(g.name_of(a), "Novice Engineer (Assembled)");
|
|
assert_eq!(g.cost(a), 2);
|
|
assert_eq!(g.attack(a), 2);
|
|
assert_eq!(g.health(a), 2);
|
|
assert_eq!(
|
|
g.log(),
|
|
vec![
|
|
GameEvent::SpellCast {
|
|
card: "Test Workshop".to_string()
|
|
},
|
|
GameEvent::StatsChanged {
|
|
id: t,
|
|
attack: 2,
|
|
health: 2
|
|
},
|
|
GameEvent::StatsChanged {
|
|
id: a,
|
|
attack: 2,
|
|
health: 2
|
|
},
|
|
]
|
|
);
|
|
|
|
g.clear_log();
|
|
g.play(a);
|
|
|
|
let board = g.board(0);
|
|
assert_eq!(board.len(), 2);
|
|
assert_eq!(board[0], a);
|
|
let d = board[1];
|
|
assert_eq!(g.name_of(d), "Damaged Golem");
|
|
assert_eq!(
|
|
g.log(),
|
|
vec![
|
|
GameEvent::Summoned { id: a },
|
|
triggered(a, "battlecry"),
|
|
GameEvent::Summoned { id: d },
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_additive_multipliers_stack() {
|
|
let mut g = new_game(GameOpts {
|
|
decks: Some([fillers(5), vec![]]),
|
|
});
|
|
g.summon("Test Chorus", 0);
|
|
g.summon("Test Chorus", 0);
|
|
let h = g.summon("Loot Hoarder", 0);
|
|
g.clear_log();
|
|
|
|
g.deal_damage(h, 1);
|
|
|
|
assert_eq!(g.hand(0).len(), 3);
|
|
let activations = g
|
|
.log()
|
|
.into_iter()
|
|
.filter(|e| *e == triggered(h, "deathrattle"))
|
|
.count();
|
|
assert_eq!(activations, 3);
|
|
}
|
|
|
|
/// A one-off card, written as data in the test.
|
|
fn card(json: &str) -> CardDef {
|
|
serde_json::from_str(json).unwrap()
|
|
}
|
|
|
|
/// The `Triggered` events of a log, in order.
|
|
fn triggers(log: &[GameEvent]) -> Vec<GameEvent> {
|
|
log.iter()
|
|
.filter(|e| matches!(e, GameEvent::Triggered { .. }))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// "Deathrattle: Summon a Baron Rivendare."
|
|
const TEST_HERALD: &str = r#"{ "name": "Test Herald", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Baron Rivendare", "count": 1 } }] }"#;
|
|
|
|
/// "Battlecry: Draw a card. Your Battlecries trigger twice."
|
|
const TEST_BRAGGART: &str = r#"{ "name": "Test Braggart", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "draw", "count": 1 } }, { "kind": "aura", "modifier": { "type": "triggerMultiplier", "trigger": "battlecry", "times": 2, "stacking": "max" } }] }"#;
|
|
|
|
#[test]
|
|
fn test_05_summoned_multiplier_counts_for_later_entries() {
|
|
// Herald is older: its Deathrattle resolves first and summons Baron before the Hoarder's starts.
|
|
let mut a = new_game(GameOpts {
|
|
decks: Some([fillers(5), vec![]]),
|
|
});
|
|
a.register_cards(vec![card(TEST_HERALD)]);
|
|
a.summon("Test Herald", 0);
|
|
let hoarder_a = a.summon("Loot Hoarder", 0);
|
|
a.clear_log();
|
|
|
|
a.cast("Twisting Nether", 0, None);
|
|
|
|
let of_hoarder = |log: &[GameEvent], h: Id| {
|
|
log.iter()
|
|
.filter(|e| matches!(e, GameEvent::Triggered { source, .. } if *source == h))
|
|
.count()
|
|
};
|
|
assert_eq!(of_hoarder(&a.log(), hoarder_a), 2);
|
|
assert_eq!(a.hand(0).len(), 2);
|
|
let names: Vec<String> = a.board(0).iter().map(|&id| a.name_of(id)).collect();
|
|
assert_eq!(names, vec!["Baron Rivendare"]);
|
|
|
|
// The Hoarder is older: its Deathrattle has started, and finished, before any Baron exists.
|
|
let mut b = new_game(GameOpts {
|
|
decks: Some([fillers(5), vec![]]),
|
|
});
|
|
b.register_cards(vec![card(TEST_HERALD)]);
|
|
let hoarder_b = b.summon("Loot Hoarder", 0);
|
|
b.summon("Test Herald", 0);
|
|
b.clear_log();
|
|
|
|
b.cast("Twisting Nether", 0, None);
|
|
|
|
assert_eq!(of_hoarder(&b.log(), hoarder_b), 1);
|
|
assert_eq!(b.hand(0).len(), 1);
|
|
let names: Vec<String> = b.board(0).iter().map(|&id| b.name_of(id)).collect();
|
|
assert_eq!(names, vec!["Baron Rivendare"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_own_aura_misses_own_battlecry() {
|
|
let mut g = new_game(GameOpts {
|
|
decks: Some([fillers(5), vec![]]),
|
|
});
|
|
g.register_cards(vec![card(TEST_BRAGGART)]);
|
|
let first = g.add_to_hand("Test Braggart", 0);
|
|
let second = g.add_to_hand("Test Braggart", 0);
|
|
g.clear_log();
|
|
|
|
g.play(first);
|
|
|
|
// Playing is not summoning: the Braggart's own aura joins at the end of the Phase.
|
|
assert_eq!(triggers(&g.log()), vec![triggered(first, "battlecry")]);
|
|
assert_eq!(g.hand(0).len(), 2);
|
|
|
|
g.clear_log();
|
|
g.play(second);
|
|
|
|
// The first Braggart was already in play: the second one's Battlecry triggers twice.
|
|
assert_eq!(
|
|
triggers(&g.log()),
|
|
vec![triggered(second, "battlecry"), triggered(second, "battlecry")]
|
|
);
|
|
assert_eq!(g.hand(0).len(), 3);
|
|
}
|
|
|
|
/// "Whenever a minion takes damage, deal 1 damage to all minions."
|
|
const TEST_BOMB: &str = r#"{ "name": "Test Bomb", "type": "minion", "cost": 1, "attack": 1, "health": 50, "split": 1, "text": [{ "kind": "trigger", "on": "anyMinionDamaged" }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] }"#;
|
|
|
|
#[test]
|
|
fn test_05_trigger_does_not_nest_in_itself() {
|
|
let mut g = new_game(GameOpts::default());
|
|
g.register_cards(vec![card(TEST_BOMB)]);
|
|
let b1 = g.summon("Test Bomb", 0);
|
|
let b2 = g.summon("Test Bomb", 0);
|
|
g.clear_log();
|
|
|
|
g.deal_damage(b1, 1);
|
|
|
|
// A trigger is never queued while that same minion's same trigger is resolving.
|
|
let expected: Vec<GameEvent> = [b1, b2, b2, b2, b1, b1]
|
|
.iter()
|
|
.map(|&source| triggered(source, "damaged"))
|
|
.collect();
|
|
assert_eq!(triggers(&g.log()), expected);
|
|
let damage = g
|
|
.log()
|
|
.iter()
|
|
.filter(|e| matches!(e, GameEvent::Damaged { .. }))
|
|
.count();
|
|
assert_eq!(damage, 13);
|
|
assert_eq!(g.health(b1), 43);
|
|
assert_eq!(g.health(b2), 44);
|
|
}
|
|
|
|
/// "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions."
|
|
const TEST_EMBER: &str = r#"{ "name": "Test Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Ember", "count": 1 } }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] }"#;
|
|
|
|
#[test]
|
|
fn test_05_death_phase_limit() {
|
|
let mut g = new_game(GameOpts::default());
|
|
g.register_cards(vec![card(TEST_EMBER)]);
|
|
let e = g.summon("Test Ember", 0);
|
|
g.clear_log();
|
|
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.deal_damage(e, 1)));
|
|
|
|
let payload = result.expect_err("more than 64 Death Phases must panic");
|
|
let message = payload
|
|
.downcast_ref::<String>()
|
|
.cloned()
|
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
|
.unwrap_or_default();
|
|
assert!(message.contains("Death Phase limit"), "{message}");
|
|
// Death Phases 1 to 64 ran; the 65th was refused before it started.
|
|
let phases: Vec<GameEvent> = g
|
|
.log()
|
|
.into_iter()
|
|
.filter(|e| matches!(e, GameEvent::DeathPhase { .. }))
|
|
.collect();
|
|
assert_eq!(phases.len(), 64);
|
|
assert_eq!(phases[63], GameEvent::DeathPhase { round: 64 });
|
|
// The panic is raised between Death Phases, where no trigger is resolving: the next event is at depth 0.
|
|
g.clear_log();
|
|
let f = g.summon("Test Filler", 0);
|
|
assert_eq!(g.log_tree(), format!("Summoned{{id: {f}}}\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_05_assemble_rejects_ill_formed_text() {
|
|
let mut g = new_game(GameOpts::default());
|
|
|
|
// Top: "Draw a card." with no trigger. Bottom: a Deathrattle with no effect, then Baron's aura.
|
|
let top = g.assemble("Loot Hoarder", "Baron Rivendare", Half::Top);
|
|
let bottom = g.assemble("Loot Hoarder", "Baron Rivendare", Half::Bottom);
|
|
assert!(top.is_err(), "{top:?}");
|
|
assert!(bottom.is_err(), "{bottom:?}");
|
|
|
|
let calls: Rc<RefCell<Vec<(String, Vec<String>)>>> = Rc::default();
|
|
let record = Rc::clone(&calls);
|
|
g.set_chooser(move |kind: &str, options: &[String]| {
|
|
record
|
|
.borrow_mut()
|
|
.push((kind.to_string(), options.to_vec()));
|
|
match kind {
|
|
"base" => "Novice Engineer",
|
|
"donor" => "Loot Hoarder",
|
|
_ => "top",
|
|
}
|
|
.to_string()
|
|
});
|
|
g.cast("Test Workshop", 0, None);
|
|
|
|
// A minion whose whole text is one aura has no half that fits around a trigger and its effect.
|
|
let aura_only: Vec<String> = card_files()
|
|
.into_iter()
|
|
.filter(|(raw, _)| raw["type"] == "minion")
|
|
.filter(|(raw, _)| {
|
|
let text = raw["text"].as_array().unwrap();
|
|
!text.is_empty() && text.iter().all(|c| c["kind"] == "aura")
|
|
})
|
|
.map(|(raw, _)| raw["name"].as_str().unwrap().to_string())
|
|
.collect();
|
|
assert!(aura_only.contains(&"Baron Rivendare".to_string()));
|
|
assert!(aura_only.contains(&"Bolf Ramshield".to_string()));
|
|
let donors = calls.borrow()[1].1.clone();
|
|
assert!(donors.contains(&"Loot Hoarder".to_string()));
|
|
for name in &aura_only {
|
|
assert!(!donors.contains(name), "{name} offered: {donors:?}");
|
|
}
|
|
}
|
|
|
|
/// "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers."
|
|
const TEST_BROOD: &str = r#"{ "name": "Test Brood", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }, { "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }] }"#;
|
|
|
|
#[test]
|
|
fn test_05_multi_summon_order() {
|
|
let mut g = new_game(GameOpts::default());
|
|
g.register_cards(vec![card(TEST_BROOD)]);
|
|
let f1 = g.summon("Test Filler", 0);
|
|
let b = g.add_to_hand("Test Brood", 0);
|
|
g.clear_log();
|
|
|
|
g.play(b);
|
|
|
|
// Ids are issued in summon order: the first token is the smaller id, and it stands further left.
|
|
let summoned = |log: Vec<GameEvent>| -> Vec<Id> {
|
|
log.into_iter()
|
|
.filter_map(|e| match e {
|
|
GameEvent::Summoned { id } if id != b => Some(id),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
};
|
|
let tokens = summoned(g.log());
|
|
let (t1, t2) = (tokens[0], tokens[1]);
|
|
assert!(t1 < t2);
|
|
assert_eq!(g.board(0), vec![f1, b, t1, t2]);
|
|
|
|
let f2 = g.summon("Test Filler", 0);
|
|
g.clear_log();
|
|
g.destroy(b);
|
|
|
|
let tokens = summoned(g.log());
|
|
let (t3, t4) = (tokens[0], tokens[1]);
|
|
assert!(t3 < t4);
|
|
assert_eq!(g.board(0), vec![f1, t3, t4, t1, t2, f2]);
|
|
}
|