Files
systemic-game-design-course/course/04-interceptors/lesson.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

557 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Module 4: Interceptors and "trigger twice"
In Module 0 you added Baron Rivendare to the naive engine. To do it, you edited the code that runs Deathrattles. In this module you add him again, as one entry in your content, and the code that runs Deathrattles stays as it is. The engine gains one general rule: a trigger may activate more than once, and the number comes from data on cards in play. Then you build a second kind of interceptor, one that rewrites damage before it lands. At the end, a second "trigger twice" card arrives and you add it without touching the engine.
You need your Module 3 workspace with all Module 1–3 tests passing, under git. This module's tests also use two things from earlier Expansion Drops:
- `play(handId)` from [Module 1](../01-handles/lesson.md#expansion-drop). It is now required, and it does more than in Module 1: see Spec.
- Twisting Nether from [Module 3](../03-death-step/lesson.md#expansion-drop).
If you skipped either drop, do it first. Each is small.
## Observe
Baron Rivendare, 4-mana 1/7 Legendary ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)):
> Your minions trigger their Deathrattles twice.
The card page adds the details that matter for an engine (same source):
- **Queued once, activated twice.** The two activations do not queue or resolve separately: the Deathrattle is queued once and activates twice. "The second activation of the Deathrattle is immediately after the first activation resolves", before the next queued trigger.
- **Two Barons are not four.** "Rivendare's ability is not duplicated or multiplied by having more than one Baron Rivendare in play."
- **Dying with Baron.** "if he dies at the same time as a minion with a Deathrattle, the Deathrattle is not doubled."
- **Why.** "Baron Rivendare's effect, being an aura, only appears and disappears between Phases when Aura Update (Other) runs".
The [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook) places that update. When the outermost Phase ends, "Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and an Aura Update (Other) follows the step. Baron is listed among the "Other" auras. The rulebook also says when auras are *not* recalculated: "Auras are not recalculated due to minions leaving play or due to minions being stolen in the middle of a Phase." It names one more update point: "whenever a minion is summoned (but not played), Hearthstone updates both kinds of Auras." And it groups Baron with other cards of the same kind: "Cards that trigger effects multiple times, like Brann Bronzebeard, Baron Rivendare and Drakkari Enchanter, check before an Event starts." (all from the same source).
Put the two sources together for the Twisting Nether case. Baron and Loot Hoarder are destroyed together. The Death Creation Step removes both. The Aura Update (Other) then runs with no Baron in play, so by the time the Death Phase resolves the Hoarder's Deathrattle, there is no doubling to apply.
Two more cards appear in this module ([Novice Engineer](https://hearthstone.wiki.gg/wiki/Novice_Engineer), [Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)):
- Novice Engineer, 2-mana 1/1: "Battlecry: Draw a card."
- Brann Bronzebeard, 3-mana 2/4: "Your Battlecries trigger twice." Brann is the Expansion Drop.
In this course a Battlecry resolves when its minion is played from hand, and not when an effect summons it.
## Predict
Write your answers down before you open the discussion, for example in `notes/04.md` in your workspace.
1. You played Baron Rivendare, then Loot Hoarder, then Harvest Golem, and the Golem has taken 2 damage. You cast Whirlwind. List the `Triggered` events in order. Is it Hoarder, Hoarder, Golem, Golem, or Hoarder, Golem, Hoarder, Golem? Where do the two Damaged Golems end up?
2. Two Barons and a Loot Hoarder. The Hoarder dies. How many cards do you draw? Your Module 0 engine may have given a different answer: why?
3. You played Loot Hoarder first, then Baron. You cast Twisting Nether. The Death Creation Step creates the Hoarder's Death Event first, while Baron is still on the board. How many cards do you draw? What would an engine have to do to get this wrong?
4. Where in your Module 3 engine is the one place that every trigger passes through just before it runs? What would it need to know to decide "run this twice"? Which of those facts are about a specific card?
5. A Magic: The Gathering card, Panharmonicon, makes certain triggers trigger an additional time, and two copies give *two* additional times. Baron's two copies give none extra. If both kinds of card lived in one engine, where would the difference be written down?
<details><summary>Discussion</summary>
1. Hoarder, Hoarder, Golem, Golem. Baron survives the Whirlwind (1/7), so his aura is in force when the Death Phase starts. Each Deathrattle is queued once, and its two activations run back to back before the next entry in the Queue. The Hoarder draws twice, then the Golem summons twice. Both Damaged Golems appear where the Golem stood.
2. Two. Baron's multiplier doesn't stack. If your Module 0 fix looped over the board and ran the Deathrattle once more per Baron, you drew three. If it checked "is there a Baron", you got two.
3. One. Both leave play in the same Death Creation Step; the Aura Update (Other) runs after the step, finds no Baron, and the Deathrattle resolves once. An engine gets this wrong in two ways. If it decides "twice or once" while it creates each Death Event, Baron is still in play when the older Hoarder's event is created, so the order of play of the two minions decides the answer, exactly as in your Module 0 engine. If it updates auras before the Death Creation Step, Baron is still in the snapshot whatever the order, and the Deathrattle always doubles.
4. The Phase function (`react` in the reference): it takes each queued trigger and runs its effect. To decide how many times, it needs the trigger's kind (Deathrattle, Battlecry, ...), whose minion it is, and which "trigger N times" effects are in force. None of these name a card. "Which effects are in force" is something the cards in play *declare*, as data.
5. In the data. Both are "trigger multiplier" auras; they differ in a stacking policy field: Baron's is `max` (the largest multiplier in force wins), Panharmonicon's is additive. The engine routine reads the field. It doesn't need to know which card it came from.
</details>
## Spec
From the course repository, copy the module's tests into your workspace:
```sh
node tools/start-module.mjs 04 ~/my-engine
```
This module adds no new harness function, only one new event, `ArmorLost{player, amount}` (see [A second interceptor: replacement](#a-second-interceptor-replacement)). But `play` (the Module 1 Expansion Drop) is now required, with more behaviour:
| TypeScript | Rust |
|---|---|
| `play(handId)` | `play(&mut self, hand_id: Id)` |
`play` runs one Sequence. The minion moves from its owner's hand to the right end of the board (`Summoned{id}`, a new order of play, as in Module 1), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). A played minion is not *summoned* in the rulebook's sense: see Build.
The nine tests (full statements in `course/04-interceptors/scenarios.md`). Every test gives player 0 a deck of five `Test Filler`. The first five are about trigger multipliers:
- `test_04_baron_doubles`: Baron, Loot Hoarder, Harvest Golem at 1 Health; Whirlwind. Exact log: each Deathrattle triggers twice, back to back (Hoarder, Hoarder, Golem, Golem). Two draws, two Damaged Golems.
- `test_04_simultaneous_death_not_doubled`: Baron and Loot Hoarder; Twisting Nether. One draw.
- `test_04_two_barons_do_not_stack`: two Barons and a Loot Hoarder; `dealDamage(hoarder, 1)`. Two draws, not three or four.
- `test_04_aura_timing_order`: Loot Hoarder, *then* Baron; Twisting Nether. Exact log: `Destroyed(Hoarder)`, `Destroyed(Baron)`, `DeathPhase 1`, then a single `Triggered(Hoarder)`.
- `test_04_battlecry_on_play`: play a Novice Engineer from hand. Exact log: `Summoned`, `Triggered(battlecry)`, `Drew`.
The last four are about the second interceptor, built after Name it, in [A second interceptor: replacement](#a-second-interceptor-replacement): `test_04_armor_absorbs_before_health`, `test_04_redirect_replaces_hero_damage`, `test_04_redirect_applies_once` and `test_04_redirect_before_armor`.
The timing test is the one that pins down *when* the multiplier is read:
```rust
#[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 },
]
);
}
```
```ts
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 },
]);
});
```
`game_with_deck`, `spell_cast` and `triggered` are small helpers at the top of the Rust test file.
Run `cargo test` or `npm test`. The Module 4 tests fail (Baron Rivendare, Novice Engineer and Bolf Ramshield don't exist yet); Modules 1–3 still pass. That is RED.
## Build
Make the first five tests pass without breaking Modules 1–3. The other four wait until after Name it. The rules for where code goes are stricter than before, because this is the module the course is built around:
- **Baron Rivendare is data in `src/content/`.** His card definition declares an aura: "trigger multiplier: `deathrattle`, 2 times, stacking `max`". The course's card schema already has this shape: `{kind: "aura", modifier: {type: "triggerMultiplier", trigger, times, stacking: "max" | "additive"}}` (`course/schema/card.schema.json`). Module 5 loads cards from that format; for now, write the same fields in your language.
- **One generic routine in `src/engine/` reads multipliers**, keyed by trigger kind and owner. It must not mention Baron, Deathrattle-specific doubling, or any card. If your engine contains `if deathrattle && baronInPlay`, you've rebuilt Module 0.
- **The Battlecry trigger, the `play` action and the Aura Update are rules machinery** in `src/engine/`. Novice Engineer is a card in `src/content/`.
- `src/harness` stays thin. `play` hands one action to the engine.
<details><summary>Hint 1 — nudge</summary>
Find the loop in your Phase function that takes a queued trigger and runs its effect. Every Deathrattle, every damage trigger and (soon) every Battlecry goes through those few lines. What if they ran the effect `n` times, and something else decided `n`?
Then: *when* is `n` decided? Look at the end-of-outermost-Phase seam you left in Module 3.
</details>
<details><summary>Hint 2 — concept</summary>
- **Aura as data.** A card definition gets a list of auras. A trigger multiplier has `trigger` (which kind of trigger), `times` and `stacking`. It applies to triggers of minions with the same owner as the aura's minion ("Your minions").
- **Snapshot, don't look.** Keep a list of the auras in force (each with the player whose minion has it) in your world. Recompute it in an *Aura Update (Other)*: right after each Death Creation Step, and whenever a minion is summoned. Never recompute it mid-Phase for any other reason. A minion that dies keeps its aura in the snapshot until the next update, and the next update runs straight after the step that removed it.
- **One routine.** `activations(owner, triggerKind)`: start from 1; for each multiplier in the snapshot that matches the owner and kind, `max` multipliers raise the base to their `times`, `additive` ones add `times - 1` extra. Return the result.
- **Apply it once per queue entry.** When an entry starts to resolve, ask `activations` once, then run the effect that many times, recording `Triggered` for each, back to back.
- **Battlecry:** `play` moves the minion from hand to board, then raises a "played" event that only the minion's own Battlecry answers, through the same Phase function as everything else. Don't call the Battlecry effect directly from `play`, or no multiplier will ever see it.
</details>
<details><summary>Hint 3 — pseudo-code</summary>
```text
endOfOutermostPhase():
// Aura Update (Health/Attack) would go first; the course has no such auras
deaths = deathCreationStep()
auraUpdateOther() // after the step: the dead no longer count
return deaths
auraUpdateOther():
world.activeAuras = [(owner(m), a) for m in minionsInPlay for a in card(m).auras]
summon(card, player, at): // the Summon action and the harness both use this
id = create card on player's board at `at`
auraUpdateOther()
return id
activations(owner, on):
max = 1; extra = 0
for (o, a) in world.activeAuras where o == owner and a.trigger == on:
if a.stacking == max: max = maximum(max, a.times)
else: extra += a.times - 1
return max + extra
perform(Play(id)): // no aura update: playing is not summoning
require zone(id) == hand
move id to the right end of its owner's board // logs Summoned
react([Played(id)])
react(events):
... fill and lock the Queue as in Module 3 ...
for (m, trig, e) in queue:
n = activations(owner(m), trig.on) // read once, as the entry starts
repeat n times:
log Triggered(m, label(trig.on))
for a in trig.effect(ctx(...)): perform(a)
responds(battlecry, m, Played(id)) -> id == m
```
</details>
When your tests pass, compare with the reference engine in `course/04-interceptors/reference/rust/src/engine/` or `course/04-interceptors/reference/ts/src/engine/`. The routine and the update are in `aura.rs` / `aura.ts`; the loop that applies them is `react` in `queue.rs` / `queue.ts`.
Decisions in the reference that the tests don't force, but the rules suggest:
- **The count is read once per queue entry, as it starts.** The rulebook says repeat-trigger cards "check before an Event starts", and adds: "if one Battlecry/Deathrattle/End of turn effect summons them, it will not trigger again. But Deathrattles/End of turn effects that follow can benefit, for Aura (Other) is updated when you summon them." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Reading the count before the first activation, and updating the snapshot on summon, gives exactly that: an effect that summons a Baron has already decided its own count, and the next queue entry sees him. No Module 4 test checks this, because no Module 4 card summons a multiplier; Module 5 adds one (see Stress it, question 1).
- **Playing is not summoning.** The rulebook's update point is "whenever a minion is summoned (but not played)" (same source). So `play` doesn't run an Aura Update; the played minion's auras join at the end of the outermost Phase, which is still before any Death Phase it could affect. Its own Battlecry has already resolved by then.
- **The `additive` branch is there, and no Hearthstone card in the course uses it.** It is two lines, and it is what makes `stacking` a real choice rather than a constant: see Name it.
> **How the real game differs**
> - The real game runs an Aura Update (Health/Attack) before the Death Creation Step ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course has no Health or Attack auras, so the reference leaves that step out.
> - The Baron Rivendare page reads the doubling at resolution of a queue entry, while the rulebook says multipliers "check before an Event starts". Both rule out a multiplier that appears mid-Event; neither says more precisely when the check happens ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare), [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The reference reads it when each queue entry starts.
> - "Baron Rivendare's effect also works on remotely triggered Deathrattles such as from Play Dead" ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)). The course has no card that triggers another minion's Deathrattle.
> - The course has no mana, so `play` costs nothing, and a Battlecry here never chooses a target.
## Name it
The pattern is an **interceptor**, also called a modifier pipeline. A step of the engine (here, "run this trigger's effect") consults a list of modifiers before it acts. The modifiers are data contributed by whatever is in play. The step knows the *kinds* of modifier, never the cards that supply them. Adding a card that modifies the step adds data, not a branch.
Here is the whole interceptor in the reference, the routine and the one place that calls it. (The `continue` on the first lines skips the other kind of aura, which arrives in the next section.)
```rust
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
let mut max = 1;
let mut extra = 0;
for active in world.active_auras() {
let Aura::TriggerMultiplier {
trigger,
times,
stacking,
} = active.aura
else {
continue;
};
if active.owner != owner || trigger != on {
continue;
}
match stacking {
Stacking::Max => max = max.max(times),
Stacking::Additive => extra += times - 1,
}
}
max + extra
}
// in react, for each queued (source, trigger, event):
let times = activations(world, world.owner_of(source), trigger.on);
for _ in 0..times {
world.record(Event::Triggered { source, on: trigger.on });
// ... build the context, run the effect, perform its actions ...
}
```
```ts
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
for (const { owner: auraOwner, aura } of world.activeAuras()) {
if (aura.type !== "triggerMultiplier") continue;
const { trigger, times, stacking } = aura;
if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
return max + extra;
}
// in react, for each queued [source, trigger, event]:
const times = activations(world, world.ownerOf(source), trigger.on);
for (let i = 0; i < times; i++) {
world.record({ type: "Triggered", source, on: trigger.on });
// ... build the context, run the effect, perform its actions ...
}
```
And Baron Rivendare, the card that took a Deathrattle rewrite in Module 0:
```rust
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
CardDef::minion("Baron Rivendare", 1, 7).aura(Aura::TriggerMultiplier {
trigger: TriggerOn::Deathrattle,
times: 2,
stacking: Stacking::Max,
}),
```
```ts
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
minion("Baron Rivendare", 1, 7, [], [
{ type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" },
]),
```
**Before.** Fireplace, the Python Hearthstone simulator from [Module 0](../00-rewrite-trap/lesson.md), puts the doubling inside the Deathrattle action: `class Deathrattle` in [`fireplace/actions.py`](https://github.com/jleclanche/fireplace/blob/47a2572/fireplace/actions.py) (commit `47a2572`) has a branch on `target.controller.extra_deathrattles` that queues the actions again. Read it. The flag is a boolean set by Baron's aura, so two Barons can't stack there either. In that shape, Brann would need a flag of his own and a branch in whatever code runs Battlecries: each "trigger N times" card is an edit to the code that runs that trigger.
**After.** Magic: The Gathering has the rule in general form. The [Comprehensive Rules](https://magic.wizards.com/en/rules), 603.2c: "An ability triggers only once each time its trigger event occurs." 603.2d: "An ability may state that a triggered ability triggers additional times." … "then that ability triggers that many times." A multiplier is a modifier on how often a trigger fires, not a change to the ability itself.
The Argentum engine (an MTG rules engine in Kotlin) implements it the way this module does. The card [`Panharmonicon.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/mtg-sets/2008-2016/src/main/kotlin/com/wingedsheep/mtg/sets/definitions/kld/cards/Panharmonicon.kt) declares a static ability `AdditionalETBOrLTBTriggers(...)`: pure data, with a filter for which objects' enter/leave triggers it applies to. One generic routine, `duplicateETBOrLTBTriggers` in [`TriggerDetector.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/rules-engine/src/main/kotlin/com/wingedsheep/engine/event/TriggerDetector.kt), consumes every such ability. Its doc comment states the stacking policy: "Multiple copies are additive: N copies add N extra copies of each trigger." Baron's page says the opposite for Baron. Same mechanism, different policy, which is why `stacking` is a field of the modifier and not a rule of the engine.
Hearthstone's own data appears to draw the same line. python-hearthstone's `GameTag` (the tag names from Module 0) has `EXTRA_DEATHRATTLES_BASE = 882` next to `EXTRA_DEATHRATTLES_ADDITIONAL = 1131`, and the same pair for Battlecries: `EXTRA_BATTLECRIES_BASE = 411` and `EXTRA_BATTLECRIES_ADDITIONAL = 1126` ([`enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py)). The file gives names and numbers and nothing else, so what follows is the course's reading of the names: a *base* count that a second Baron can't raise, and *additional* activations that add up. If that reading is right, the game met the stacking question and answered it the way this module does, with two kinds of data and one consumer.
Argentum's Panharmonicon file also carries a ruling: "Replacement effects are unaffected by Panharmonicon's ability". That is the line between two kinds of interceptor. A trigger multiplier changes how many times a trigger fires. A **replacement effect** rewrites an event before it happens: Comprehensive Rules 614.1a, "Effects that use the word 'instead' are replacement effects." 614.5 adds the guard every rewrite system needs: "A replacement effect doesn't invoke itself repeatedly; it gets only one opportunity to affect an event". And when many modifiers touch the same values, rule 613 orders them in layers: "all applicable continuous effects are applied in a series of layers in the following order". The course builds the first two, the second in the next section; layers are the same idea (data consulted by one step, with an explicit ordering) at a larger scale.
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows "aura", "trigger multiplier", "Battlecry" and "play". It must not know "Baron Rivendare".
## A second interceptor: replacement
A trigger multiplier changes how often a trigger runs. The second kind of interceptor changes what an event *is*, before it happens. Your engine already has one place where that matters: damage.
### Bolf and Armor
Bolf Ramshield, 6-mana 3/9 Legendary ([Bolf Ramshield](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield)):
> Whenever your hero takes damage, this minion takes it instead.
"Whenever" reads like a trigger, but the card page's notes say otherwise (same source):
- **A rewrite, not a reaction.** "it prevents the Damage Event targeting your hero and creates a new Damage Event". Bolf's ability is one of the "predamage triggers, that trigger when your hero takes damage, before the damage is taken".
- **Several Bolfs.** "the earliest played one is the one that will intercept the damage, due to play order."
- **A dying Bolf.** "Bolf Ramshield can still intercept damage while mortally wounded (0 or less Health) up until the current Phase ends".
And Armor, the number you have kept since Module 2 ([Armor](https://hearthstone.wiki.gg/wiki/Armor)): "Damage taken will always be deducted from Armor before Health." and "Damage taken while protected by Armor still counts as taking damage for game purposes."
Both rules act on the same step: one Damage, to one target, before the world changes. When both apply, one of them has to go first.
### The rules
Applying a Damage to one target runs the steps of a list first, in order, as the end of the outermost Phase does (Module 3):
```text
DAMAGE_STEPS = [redirect, armor]
```
- **Redirect.** Bolf's text is an aura: `{type: "damageRedirect", from: "friendlyHero", to: "self"}` (`course/schema/card.schema.json`). While it is in the aura snapshot, damage that would be dealt to its owner's hero is dealt to Bolf instead. The `Damaged` event names Bolf, so "a hero takes damage" triggers don't fire and "a minion takes damage" triggers do. With several, the oldest Bolf's (order of play) applies, and it applies at most once to one Damage.
- **Armor.** Damage to a hero lowers its owner's Armor first, then Health. When Armor absorbed any, a new event, `ArmorLost{player, amount}`, comes just before the `Damaged` event. `Damaged` shows the full amount: the hero still took damage, even if its Health didn't change.
The redirect is an aura, so it follows the Aura Update schedule like Baron: a Bolf that dies stays in the snapshot until the update after its Death Creation Step. That is the "mortally wounded" note above, for free.
The four tests (full statements in `scenarios.md`):
- `test_04_armor_absorbs_before_health`: Armorsmith and Test Reactor, 2 Armor. Damage of 1 to the hero: `ArmorLost` 1, `Damaged` 1, and Reactor still grows. Then damage of 5: `ArmorLost` 1, `Damaged` 5, Health 26.
- `test_04_redirect_replaces_hero_damage`: Bolf, Armorsmith, Test Reactor; 4 damage to the hero. Exact log: `Damaged(Bolf, 4)`, then Armorsmith's trigger. The hero is untouched and Reactor doesn't trigger.
- `test_04_redirect_applies_once`: two Bolfs. The older one takes all 3; the other is untouched.
- `test_04_redirect_before_armor`: 1 Armor and a Bolf; 2 damage to the hero. Exact log: `Damaged(Bolf, 2)`. No `ArmorLost`, and the Armor is still there.
Make the four tests pass, with the same rules as before: Bolf is data in `src/content/`, and nothing in `src/engine/` names him.
<details><summary>Hint — concept</summary>
- **One place.** Find the line in `perform` that applies damage to one target. Everything below happens there, before the world changes, so no trigger is involved.
- **The snapshot needs the minion.** A trigger multiplier only needed the owner of the minion that has it. A redirect needs the minion itself. Store it next to each aura in force.
- **Each step once.** Walk the list once per Damage. The rewritten Damage is never offered to the redirect again, so two Bolfs can't pass the damage back and forth. That is Magic's guard, quoted in Name it: a replacement effect "gets only one opportunity to affect an event".
</details>
When your tests pass, compare with the reference: `DAMAGE_STEPS` and the function that walks it are in `queue.rs` / `queue.ts`, and the redirect lookup is `redirect_for` / `redirectFor` in `aura.rs` / `aura.ts`.
```rust
pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
let mut taker = target;
let mut absorbed = 0;
for step in DAMAGE_STEPS {
match step {
DamageStep::Redirect => {
if world.is_hero(taker) {
taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
}
}
DamageStep::Armor => {
if world.is_hero(taker) {
let player = world.owner_of(taker);
absorbed = world.armor(player).min(amount);
if absorbed > 0 {
world.lose_armor(player, absorbed);
}
}
}
}
}
world.damage(taker, amount, absorbed);
Event::Damaged {
target: taker,
amount,
}
}
```
```ts
export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
function applyDamage(world: World, target: EntityId, amount: number): Event {
let taker = target;
let absorbed = 0;
for (const step of DAMAGE_STEPS) {
switch (step) {
case "redirect":
if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
break;
case "armor":
if (world.isHero(taker)) {
const player = world.ownerOf(taker);
absorbed = Math.min(world.armor(player), amount);
if (absorbed > 0) world.loseArmor(player, absorbed);
}
break;
}
}
world.damage(taker, amount, absorbed);
return { type: "Damaged", target: taker, amount };
}
```
`world.damage` lowers Health by `amount - absorbed` and records `Damaged` with the full `amount`.
Decisions in the reference that the tests don't force:
- **The rewritten Damage keeps its place.** Area damage creates its Damage Events in order of play of the original targets. A redirected one stays where the hero's would have been.
- **Any damage to the hero is redirected**, from either player's cards, since the card text doesn't say whose.
> **How the real game differs**
> - The Bolf Ramshield page doesn't mention Armor. Redirect before Armor is the course's choice. It fits the page's "before the damage is taken": Armor absorbing damage is part of taking it.
> - The real game has more predamage effects, and they have an order of their own: "Animated Armor will always reduce the damage to 1 before Bolf Ramshield intercepts it." ([Bolf Ramshield](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield)). The course has two steps.
> - The course's Bolf is an aura, read from the snapshot. The wiki calls it a predamage trigger; the course has no predamage triggers, so it models the effect as the data it acts like.
### The name
This is a **replacement effect**: Comprehensive Rules 614.1a, quoted in Name it above, "Effects that use the word 'instead' are replacement effects." Bolf's text has the word. 614.5 is the guard the reference keeps by walking the list once. And the list itself is the move Stress it question 4 asks you to make for multipliers: when two modifiers touch the same event, don't let the order of the *cards* decide; fix the order of the *kinds*, once, in a list you can read.
Run the coupling audit again. Your engine now knows "redirect" and "Armor". It must not know "Bolf Ramshield".
## Expansion Drop
A new card arrives: Brann Bronzebeard, 3-mana 2/4, "Your Battlecries trigger twice." ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)). This is the Module 0 question: in your naive engine, Brann meant another edit to the code that runs triggers.
The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't need to change either. Only `src/content/` should.
1. Commit your workspace and note the commit: `git add -A && git commit -m "module 4 done"`.
2. Copy the expansion test for your track from `course/04-interceptors/expansion/tests/rust/test_04x.rs` or `course/04-interceptors/expansion/tests/ts/test_04x.test.ts` into your workspace's `tests/`. Run it and watch it fail.
3. Add Brann Bronzebeard to your content.
4. When the tests pass, run the check from the course repository:
```sh
node tools/expansion-check.mjs ~/my-engine HEAD
```
The test, `test_04x_brann_doubles_battlecry`, summons Brann, then plays a Novice Engineer from hand. Its Battlecry is queued once and activates twice, back to back: two `Triggered(battlecry)` events, two draws. The full scenario is in `course/04-interceptors/expansion/README.md`.
<details><summary>Discussion: what this drop measures</summary>
If your multiplier is keyed by trigger kind and your Battlecry goes through the Phase function, Brann is one entry in your content library:
```rust
// Expansion Drop: "Your Battlecries trigger twice."
CardDef::minion("Brann Bronzebeard", 2, 4).aura(Aura::TriggerMultiplier {
trigger: TriggerOn::Battlecry,
times: 2,
stacking: Stacking::Max,
}),
```
```ts
// Expansion Drop: "Your Battlecries trigger twice."
minion("Brann Bronzebeard", 2, 4, [], [
{ type: "triggerMultiplier", trigger: "battlecry", times: 2, stacking: "max" },
]),
```
That is the reference answer in `course/04-interceptors/expansion/solution/`. It is Baron with one word changed, `max` included: the card's page says "Brann's effect does not stack with itself" ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)). The test uses one Brann.
Shapes that fail this drop:
- **A doubling flag.** If Baron sets something like `extraDeathrattles` on the player (Fireplace's shape), Brann needs a second flag, and the engine has to read it somewhere.
- **The multiplier lives in the Death Phase.** If only the code that resolves Death Events asks "how many times?", Battlecries never ask. The question belongs in the one loop every trigger passes through.
- **`play` calls the Battlecry directly.** If `play` runs the effect itself instead of raising an event for the Phase function, no multiplier, and no other rule of the queue, ever sees a Battlecry.
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
</details>
## Stress it
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
1. Add a fictional card: Test Herald, 1/1, "Deathrattle: Summon a Baron Rivendare." Case A: you played Herald, then Loot Hoarder, and cast Twisting Nether. Case B: Loot Hoarder first, then Herald, then Twisting Nether. How many cards do you draw in each?
<details><summary>Discussion</summary>
Two in case A, one in case B. Both minions leave in the same Death Creation Step and both Deathrattles sit in one locked Queue, in order of play. In A the Herald's resolves first and summons Baron; a summon runs an Aura Update, so when the Hoarder's entry *starts*, Baron is in the snapshot and it activates twice. In B the Hoarder's entry starts before any Baron exists.
This is the rulebook passage quoted under Build: a multiplier summoned by one Deathrattle doesn't help that Deathrattle, "But Deathrattles/End of turn effects that follow can benefit, for Aura (Other) is updated when you summon them." The reference has behaved this way since this module and no Module 4 test checks it, because no Module 4 card summons a multiplier. Module 5 adds the test (`test_05_summoned_multiplier_counts_for_later_entries`) once a card can be written as data inside a test.
Note what decides the answer: order of play, again. The same six words of card text, the same spell, and the draw count depends on which minion was played first.
</details>
2. Replace your snapshot with a live scan: make `activations` look at the board every time it is called, and delete the Aura Updates. Run the Module 1–4 tests. Then explain the result.
<details><summary>Discussion</summary>
They all pass. In the reference, the same change breaks no test in Modules 1 to 9.
The snapshot differs from a live scan only when a multiplier's presence on the board changes *between* Aura Updates. In this engine minions leave play only in the Death Creation Step, which is followed at once by an update, and a summon runs an update too. That leaves one gap: a minion that is **played**. Its aura isn't in the snapshot until the outermost Phase ends, and a live scan would see it at once. So the two engines differ only if something asks "how many times?" between the play and the end of the Phase, about a trigger the new minion multiplies. That something is the played minion's own Battlecry.
Module 4 has no card with both a Battlecry and a Battlecry multiplier, so the difference is invisible here. Module 5 can write one as data, and its `test_05_own_aura_misses_own_battlecry` does: "Battlecry: Draw a card. Your Battlecries trigger twice." draws one card under the snapshot and two under a live scan. The real game sides with the snapshot: "The double Battlecry effect will only take place if Brann was already present on the board." ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)).
The method matters more than the card. You changed a design decision, the tests stayed green, and that told you the tests didn't pin the decision. Then you worked out the smallest scenario that tells the two designs apart. That is **mutation testing** done by hand, and [Module 13](../13-break-your-engine/lesson.md) does it to the whole course.
</details>
3. Brann is in play. You play a minion whose Battlecry destroys Brann. The wiki says the second Battlecry still happens: "This is because Auras like Brann do not update mid-Phase." ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)). Your engine agrees. Name every separate reason it agrees.
<details><summary>Discussion</summary>
There are three, and any one would be enough:
- **Deferred death** (Module 3). "Destroy" only marks Brann. He is on the board until the Death Creation Step, after the whole Battlecry entry has finished.
- **The snapshot.** Even if Brann left the board at once, the list of auras in force isn't recomputed until an Aura Update.
- **The count is read once.** `activations` is asked when the queue entry starts, and the loop runs that many times whatever happens to the snapshot meanwhile.
Three mechanisms guarding one behaviour means none of them is tested by it. Question 2 found the same thing from the other side. When you want a test to pin *one* decision, build the scenario in which the other safeguards don't apply.
</details>
4. `activations` returns `max + extra`: the largest `max` multiplier, plus every additive one's extra activations. Baron (`max`, 2) with two Test Chorus (`additive`, 2) gives 4. Is that order-independent: does it matter which minion's aura the loop reads first? Now invent a third policy, `multiply` ("your Deathrattles trigger three times as often"). Can you keep the answer order-independent?
<details><summary>Discussion</summary>
`max + extra` is order-independent because it never applies one aura *to the result of* another. It folds all the `max` values with `max` and all the extras with `+`, each of which gives the same answer in any order, and combines the two totals once at the end.
`multiply` breaks that unless you decide where it sits. With one additive +1 and one ×3 you can get (1 + 1) × 3 = 6 or 1 × 3 + 1 = 4. The fix is the same move again: don't let the order of the *auras* matter; fix the order of the *kinds*. "All max, then all additive, then all multiply" is a three-entry list, written once. That list is a tiny version of Magic's rule 613, quoted in Name it: continuous effects are "applied in a series of layers", and only inside a layer does a timestamp break ties.
The reference's choice (`max + extra`) is itself only one of the defensible ones. "Additive extras, then at least the base" (`max(base, 1 + extra)`) is another, and it gives 3 for Baron plus two Chorus. No source decides between them, since Test Chorus is the course's card. What a design must not do is leave the choice to the order of a loop.
</details>
## Further reading
- [Baron Rivendare, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Baron_Rivendare): the notes section, on queuing, stacking and simultaneous death.
- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): the aura rules (Rule 4 and its notes): the two kinds of Aura Update, when each runs, and the repeat-trigger cards that "check before an Event starts".
- [Bolf Ramshield, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield): the notes section, on predamage triggers, several Bolfs and a mortally wounded Bolf.
- [Armor, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Armor): Armor absorbs before Health, and the damage still counts.
- Wizards of the Coast, [Magic: The Gathering Comprehensive Rules](https://magic.wizards.com/en/rules): 603.2c–d (triggering additional times), 614 (replacement effects, especially 614.1 and 614.5) and 613.1 (layers). Read 603.2d first: it is this module in one sentence.
- Argentum, [`Panharmonicon.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/mtg-sets/2008-2016/src/main/kotlin/com/wingedsheep/mtg/sets/definitions/kld/cards/Panharmonicon.kt) and `duplicateETBOrLTBTriggers` in [`TriggerDetector.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/rules-engine/src/main/kotlin/com/wingedsheep/engine/event/TriggerDetector.kt) (the file is long; search for the function name). Compare the card file with your Baron entry, and the function with your `activations`.
- Argentum, [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md), §2.3 "Rule 613: Base State vs. Projected State" and §2.7 on replacement effects as data. The same pattern, grown to a full rules engine.
- Fireplace, `class Deathrattle` in [`fireplace/actions.py`](https://github.com/jleclanche/fireplace/blob/47a2572/fireplace/actions.py) at commit `47a2572`: the "before" picture. Ask what the next "trigger twice" card would cost there.