Files
systemic-game-design-course/course/04-interceptors/scenarios.md
T
Lucas WintherandClaude Opus 5.5 372c99c306 feat: replacement effects in Module 4 (Armor absorption, Bolf Ramshield redirect)
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>
2026-11-11 10:44:06 +01:00

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:

  1. 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 the Damaged event names Bolf. With several, the oldest one's (order of play) applies. It applies at most once to one Damage.
  2. Armor. Damage to a hero lowers its owner's Armor first, then Health. A new event, ArmorLost{player, amount}, is recorded just before the Damaged event when Armor absorbed any. The Damaged event 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), then Loot Hoarder (id H), then Harvest 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 2 Summoned events; 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) and nameOf(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), then Loot 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) and zoneOf(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 another Baron Rivendare (id B2), then Loot 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), then Baron 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, but Destroyed{id: B} comes before the Hoarder's only Triggered: 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 and board(0) is [E].

test_04_armor_absorbs_before_health

  • Setup: Summon Armorsmith (id A), then Test Reactor (id R). dealDamage(A, 1) twice, which gives player 0 2 Armor. Let H be hero(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 and health(H) is 30.
  • 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}. Damaged shows the full 5, not the 4 that got through.
    • armor(0) is 0 and health(H) is 26.

test_04_redirect_replaces_hero_damage

  • Setup: Summon Bolf Ramshield (id B), then Armorsmith (id A), then Test Reactor (id R). Let H be hero(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.

test_04_redirect_applies_once

  • Setup: Summon Bolf Ramshield (id B1), then another Bolf Ramshield (id B2). Let H be hero(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.

test_04_redirect_before_armor

  • Setup: Summon Armorsmith (id A). dealDamage(A, 1), which gives player 0 1 Armor. destroy(A). Summon Bolf Ramshield (id B). Let H be hero(0).
  • Action: dealDamage(H, 2).
  • Assertions:
    • The log is exactly Damaged{target: B, amount: 2}. There is no ArmorLost: 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.