Applying a Damage to one target now runs DAMAGE_STEPS = [redirect, armor]
first. A damageRedirect aura (Bolf Ramshield, fetched from the wiki) sends
damage meant for its owner's hero to the oldest such minion; Armor absorbs
damage to a hero before Health, logged as the new ArmorLost{player, amount}
just before a full-amount Damaged. Four new Module 4 tests; propagated to the
references of Modules 4-10 in both tracks; card schema, catalog, card JSON,
harness.md, Lessons 2, 4 and 5 updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.6 KiB
Module 4 scenarios: Interceptors and "trigger twice"
Each heading is a test name. The Rust test lives in tests/rust/test_04.rs and the TypeScript test in tests/ts/test_04.test.ts; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests belongs to player 0 and is summoned in the order listed, so the first one listed has the lowest order of play and stands furthest left. Each test calls clearLog() after its setup, so log() holds only the events of the action under test. Every test starts with newGame and a deck of 5 Test Filler for player 0.
Cards used (text from course/catalog.json):
Baron Rivendare, 1/7: "Your minions trigger their Deathrattles twice."Loot Hoarder, 2/1: "Deathrattle: Draw a card."Harvest Golem, 2/3: "Deathrattle: Summon a 2/1 Damaged Golem."Damaged Golem, 2/1, no text. Only Harvest Golem creates it.Novice Engineer, 1/1: "Battlecry: Draw a card."Whirlwind, spell: "Deal 1 damage to ALL minions."Twisting Nether, spell: "Destroy all minions and locations." The course has no locations, so it destroys all minions (the Module 3 Expansion Drop card).Test Filler(fictional), 1/1, no text. Used to fill decks.Bolf Ramshield, 3/9: "Whenever your hero takes damage, this minion takes it instead."Armorsmith, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor." (Module 2)Test Reactor(fictional), 1/5: "Whenever a hero takes damage, gain +1 Attack." (Module 2)
A trigger multiplier is an aura: data on a card that says "triggers of this kind, belonging to my owner's minions, activate N times". The set of active multipliers is read at two points only: when a minion is summoned, and in the Aura Update (Other) that follows each Death Creation Step. A queued trigger reads that set when it starts to resolve and then activates that many times in a row, recording Triggered{source, trigger} for each activation, before the next queued trigger starts. Baron Rivendare's multiplier does not stack: two Barons double, they do not quadruple.
The last four tests are about a second interceptor, replacement. Applying a Damage to one target runs the steps of DAMAGE_STEPS first, in this order, before the world changes:
- Redirect. Bolf Ramshield's text is an aura,
{type: "damageRedirect", from: "friendlyHero", to: "self"}. While it is in the aura snapshot, damage that would be dealt to its owner's hero is dealt to Bolf instead, and theDamagedevent names Bolf. With several, the oldest one's (order of play) applies. It applies at most once to one Damage. - Armor. Damage to a hero lowers its owner's Armor first, then Health. A new event,
ArmorLost{player, amount}, is recorded just before theDamagedevent when Armor absorbed any. TheDamagedevent shows the full amount: the hero still took damage, even if Health didn't change.
test_04_baron_doubles
- Setup: Summon
Baron Rivendare(id B), thenLoot Hoarder(id H), thenHarvest Golem(id G).dealDamage(G, 2), which leaves G at 1 Health. - Action: player 0 casts
Whirlwind. - Assertions:
hand(0)has 2 cards; call them C1 and C2, in hand order. The log has exactly 2Summonedevents; call their ids D1 and D2, in log order.- The log is exactly, in this order:
SpellCast{card: "Whirlwind"},Damaged{target: B, amount: 1},Damaged{target: H, amount: 1},Damaged{target: G, amount: 1},Destroyed{id: H},Destroyed{id: G},DeathPhase{round: 1},Triggered{source: H, trigger: "deathrattle"},Drew{player: 0, id: C1},Triggered{source: H, trigger: "deathrattle"},Drew{player: 0, id: C2},Triggered{source: G, trigger: "deathrattle"},Summoned{id: D1},Triggered{source: G, trigger: "deathrattle"},Summoned{id: D2}. Each Deathrattle is queued once and activates twice back to back: the Hoarder's two activations both finish before the Golem's first one starts. nameOf(D1)andnameOf(D2)are"Damaged Golem".board(0)has 3 minions: B first, then D1 and D2 in either order. Both Damaged Golems appear where the Golem stood; this test doesn't fix their order relative to each other.health(B)is 6: Baron survives the Whirlwind, so his aura is still active when the Death Phase starts.
test_04_simultaneous_death_not_doubled
- Setup: Summon
Baron Rivendare(id B), thenLoot Hoarder(id H). - Action: player 0 casts
Twisting Nether. - Assertions:
hand(0)has exactly 1 card.- The log contains exactly one
Triggered{source: H, trigger: "deathrattle"}. zoneOf(B)andzoneOf(H)are graveyard.- Baron and the Hoarder leave play in the same Death Creation Step. The Aura Update (Other) after that step finds no Baron in play, so the Hoarder's Deathrattle activates once.
test_04_two_barons_do_not_stack
- Setup: Summon
Baron Rivendare(id B1), then anotherBaron Rivendare(id B2), thenLoot Hoarder(id H). - Action:
dealDamage(H, 1). - Assertions:
hand(0)has 2 cards; call them C1 and C2, in hand order.- The log is exactly, in this order:
Damaged{target: H, amount: 1},Destroyed{id: H},DeathPhase{round: 1},Triggered{source: H, trigger: "deathrattle"},Drew{player: 0, id: C1},Triggered{source: H, trigger: "deathrattle"},Drew{player: 0, id: C2}. Two Barons give two activations, not three or four.
test_04_aura_timing_order
- Setup: Summon
Loot Hoarder(id H), thenBaron Rivendare(id B). This time the Hoarder is older than Baron. - Action: player 0 casts
Twisting Nether. - Assertions:
hand(0)has exactly 1 card; call it C.- The log is exactly, in this order:
SpellCast{card: "Twisting Nether"},Destroyed{id: H},Destroyed{id: B},DeathPhase{round: 1},Triggered{source: H, trigger: "deathrattle"},Drew{player: 0, id: C}. The Hoarder's Death Event is created before Baron's, while Baron is still on the board, butDestroyed{id: B}comes before the Hoarder's onlyTriggered: the multiplier is read after the whole Death Creation Step, in the Aura Update (Other), not while each Death Event is created.
test_04_battlecry_on_play
- Setup:
addToHand("Novice Engineer", 0)(id E). - Action:
play(E). - Assertions:
hand(0)has exactly 1 card; call it C.- The log is exactly, in this order:
Summoned{id: E},Triggered{source: E, trigger: "battlecry"},Drew{player: 0, id: C}. Playing a minion from hand puts it on the board, then its Battlecry resolves. zoneOf(E)is board andboard(0)is[E].
test_04_armor_absorbs_before_health
- Setup: Summon
Armorsmith(id A), thenTest Reactor(id R).dealDamage(A, 1)twice, which gives player 0 2 Armor. Let H behero(0). - Action 1:
dealDamage(H, 1). - Assertions:
- The log is exactly, in this order:
ArmorLost{player: 0, amount: 1},Damaged{target: H, amount: 1},Triggered{source: R, trigger: "damaged"},StatsChanged{id: R, attack: 2, health: 5}. Armor took all of it, and the hero still took damage: Reactor triggers. armor(0)is 1 andhealth(H)is 30.
- The log is exactly, in this order:
clearLog(), then Action 2:dealDamage(H, 5).- Assertions:
- The log is exactly, in this order:
ArmorLost{player: 0, amount: 1},Damaged{target: H, amount: 5},Triggered{source: R, trigger: "damaged"},StatsChanged{id: R, attack: 3, health: 5}.Damagedshows the full 5, not the 4 that got through. armor(0)is 0 andhealth(H)is 26.
- The log is exactly, in this order:
test_04_redirect_replaces_hero_damage
- Setup: Summon
Bolf Ramshield(id B), thenArmorsmith(id A), thenTest Reactor(id R). Let H behero(0). - Action:
dealDamage(H, 4). - Assertions:
- The log is exactly, in this order:
Damaged{target: B, amount: 4},Triggered{source: A, trigger: "damaged"},ArmorGained{player: 0, amount: 1}. The Damage was rewritten before it happened: no hero took damage, so Reactor does not trigger, and a friendly minion did, so Armorsmith does. health(H)is 30,health(B)is 5,armor(0)is 1.
- The log is exactly, in this order:
test_04_redirect_applies_once
- Setup: Summon
Bolf Ramshield(id B1), then anotherBolf Ramshield(id B2). Let H behero(0). - Action:
dealDamage(H, 3). - Assertions:
- The log is exactly
Damaged{target: B1, amount: 3}. The older Bolf takes all of it; the rewritten Damage is not offered to B2. health(B1)is 6,health(B2)is 9,health(H)is 30.
- The log is exactly
test_04_redirect_before_armor
- Setup: Summon
Armorsmith(id A).dealDamage(A, 1), which gives player 0 1 Armor.destroy(A). SummonBolf Ramshield(id B). Let H behero(0). - Action:
dealDamage(H, 2). - Assertions:
- The log is exactly
Damaged{target: B, amount: 2}. There is noArmorLost: the redirect runs first, and Armor protects only a hero that takes the damage. armor(0)is 1,health(H)is 30,health(B)is 7.
- The log is exactly