feat: module 04 interceptors

Trigger multipliers as aura data on cards, snapshotted by an Aura Update
(Other) after each Death Creation Step and on summon, and applied by one
generic routine (activations) when each queued trigger starts. Baron
Rivendare is content only (stacking "max"); Brann Bronzebeard is the
content-only Expansion Drop. Adds play (now required) and Battlecry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 02:54:37 +01:00
1 parent 8a4eba1948
commit bcdaca0cd2
40 files changed
+4421 -1

No files matched your search

@@ -0,0 +1,163 @@
// Module 4: Interceptors and "trigger twice". Each test matches a heading in scenarios.md.
use engine::harness::*;
fn game_with_deck() -> Game {
new_game(GameOpts {
decks: Some([vec!["Test Filler".to_string(); 5], vec![]]),
})
}
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 spell_cast(card: &str) -> GameEvent {
GameEvent::SpellCast {
card: card.to_string(),
}
}
#[test]
fn test_04_baron_doubles() {
let mut g = game_with_deck();
let b = g.summon("Baron Rivendare", 0);
let h = g.summon("Loot Hoarder", 0);
let golem = g.summon("Harvest Golem", 0);
g.deal_damage(golem, 2);
g.clear_log();
g.cast("Whirlwind", 0, None);
let hand = g.hand(0);
assert_eq!(hand.len(), 2);
let (c1, c2) = (hand[0], hand[1]);
let board = g.board(0);
assert_eq!(board.len(), 3);
assert_eq!(board[0], b);
let (d1, d2) = (board[1], board[2]);
assert_eq!(
g.log(),
vec![
spell_cast("Whirlwind"),
damaged(b, 1),
damaged(h, 1),
damaged(golem, 1),
GameEvent::Destroyed { id: h },
GameEvent::Destroyed { id: golem },
GameEvent::DeathPhase { round: 1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c2 },
triggered(golem, "deathrattle"),
GameEvent::Summoned { id: d1 },
triggered(golem, "deathrattle"),
GameEvent::Summoned { id: d2 },
]
);
assert_eq!(g.name_of(d1), "Damaged Golem");
assert_eq!(g.name_of(d2), "Damaged Golem");
assert_eq!(g.health(b), 6);
}
#[test]
fn test_04_simultaneous_death_not_doubled() {
let mut g = game_with_deck();
let b = g.summon("Baron Rivendare", 0);
let h = g.summon("Loot Hoarder", 0);
g.clear_log();
g.cast("Twisting Nether", 0, None);
assert_eq!(g.hand(0).len(), 1);
let hoarder_triggers = g
.log()
.into_iter()
.filter(|e| *e == triggered(h, "deathrattle"))
.count();
assert_eq!(hoarder_triggers, 1);
assert_eq!(g.zone_of(b), Zone::Graveyard);
assert_eq!(g.zone_of(h), Zone::Graveyard);
}
#[test]
fn test_04_two_barons_do_not_stack() {
let mut g = game_with_deck();
g.summon("Baron Rivendare", 0);
g.summon("Baron Rivendare", 0);
let h = g.summon("Loot Hoarder", 0);
g.clear_log();
g.deal_damage(h, 1);
let hand = g.hand(0);
assert_eq!(hand.len(), 2);
let (c1, c2) = (hand[0], hand[1]);
assert_eq!(
g.log(),
vec![
damaged(h, 1),
GameEvent::Destroyed { id: h },
GameEvent::DeathPhase { round: 1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c2 },
]
);
}
#[test]
fn test_04_aura_timing_order() {
let mut g = game_with_deck();
let h = g.summon("Loot Hoarder", 0);
let b = g.summon("Baron Rivendare", 0);
g.clear_log();
g.cast("Twisting Nether", 0, None);
let hand = g.hand(0);
assert_eq!(hand.len(), 1);
let c = hand[0];
assert_eq!(
g.log(),
vec![
spell_cast("Twisting Nether"),
GameEvent::Destroyed { id: h },
GameEvent::Destroyed { id: b },
GameEvent::DeathPhase { round: 1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c },
]
);
}
#[test]
fn test_04_battlecry_on_play() {
let mut g = game_with_deck();
let e = g.add_to_hand("Novice Engineer", 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.zone_of(e), Zone::Board);
assert_eq!(g.board(0), vec![e]);
}
@@ -0,0 +1,122 @@
// Module 4: Interceptors and "trigger twice". Each test matches a heading in scenarios.md.
import { expect, test } from "vitest";
import { newGame } from "../src/harness";
const fillers = (n: number): string[] => Array(n).fill("Test Filler");
test("test_04_baron_doubles", () => {
const g = newGame({ decks: [fillers(5), []] });
const b = g.summon("Baron Rivendare", 0);
const h = g.summon("Loot Hoarder", 0);
const golem = g.summon("Harvest Golem", 0);
g.dealDamage(golem, 2);
g.clearLog();
g.cast("Whirlwind", 0);
const hand = g.hand(0);
expect(hand).toHaveLength(2);
const [c1, c2] = hand;
const board = g.board(0);
expect(board).toHaveLength(3);
expect(board[0]).toBe(b);
const [, d1, d2] = board;
expect(g.log()).toEqual([
{ type: "SpellCast", card: "Whirlwind" },
{ type: "Damaged", target: b, amount: 1 },
{ type: "Damaged", target: h, amount: 1 },
{ type: "Damaged", target: golem, amount: 1 },
{ type: "Destroyed", id: h },
{ type: "Destroyed", id: golem },
{ type: "DeathPhase", round: 1 },
{ type: "Triggered", source: h, trigger: "deathrattle" },
{ type: "Drew", player: 0, id: c1 },
{ type: "Triggered", source: h, trigger: "deathrattle" },
{ type: "Drew", player: 0, id: c2 },
{ type: "Triggered", source: golem, trigger: "deathrattle" },
{ type: "Summoned", id: d1 },
{ type: "Triggered", source: golem, trigger: "deathrattle" },
{ type: "Summoned", id: d2 },
]);
expect(g.nameOf(d1)).toBe("Damaged Golem");
expect(g.nameOf(d2)).toBe("Damaged Golem");
expect(g.health(b)).toBe(6);
});
test("test_04_simultaneous_death_not_doubled", () => {
const g = newGame({ decks: [fillers(5), []] });
const b = g.summon("Baron Rivendare", 0);
const h = g.summon("Loot Hoarder", 0);
g.clearLog();
g.cast("Twisting Nether", 0);
expect(g.hand(0)).toHaveLength(1);
const triggers = g.log().filter((e) => e.type === "Triggered" && e.source === h && e.trigger === "deathrattle");
expect(triggers).toHaveLength(1);
expect(g.zoneOf(b)).toBe("graveyard");
expect(g.zoneOf(h)).toBe("graveyard");
});
test("test_04_two_barons_do_not_stack", () => {
const g = newGame({ decks: [fillers(5), []] });
g.summon("Baron Rivendare", 0);
g.summon("Baron Rivendare", 0);
const h = g.summon("Loot Hoarder", 0);
g.clearLog();
g.dealDamage(h, 1);
const hand = g.hand(0);
expect(hand).toHaveLength(2);
const [c1, c2] = hand;
expect(g.log()).toEqual([
{ type: "Damaged", target: h, amount: 1 },
{ type: "Destroyed", id: h },
{ type: "DeathPhase", round: 1 },
{ type: "Triggered", source: h, trigger: "deathrattle" },
{ type: "Drew", player: 0, id: c1 },
{ type: "Triggered", source: h, trigger: "deathrattle" },
{ type: "Drew", player: 0, id: c2 },
]);
});
test("test_04_aura_timing_order", () => {
const g = newGame({ decks: [fillers(5), []] });
const h = g.summon("Loot Hoarder", 0);
const b = g.summon("Baron Rivendare", 0);
g.clearLog();
g.cast("Twisting Nether", 0);
const hand = g.hand(0);
expect(hand).toHaveLength(1);
const c = hand[0];
expect(g.log()).toEqual([
{ type: "SpellCast", card: "Twisting Nether" },
{ type: "Destroyed", id: h },
{ type: "Destroyed", id: b },
{ type: "DeathPhase", round: 1 },
{ type: "Triggered", source: h, trigger: "deathrattle" },
{ type: "Drew", player: 0, id: c },
]);
});
test("test_04_battlecry_on_play", () => {
const g = newGame({ decks: [fillers(5), []] });
const e = g.addToHand("Novice Engineer", 0);
g.clearLog();
g.play(e);
const hand = g.hand(0);
expect(hand).toHaveLength(1);
const c = hand[0];
expect(g.log()).toEqual([
{ type: "Summoned", id: e },
{ type: "Triggered", source: e, trigger: "battlecry" },
{ type: "Drew", player: 0, id: c },
]);
expect(g.zoneOf(e)).toBe("board");
expect(g.board(0)).toEqual([e]);
});