26 KiB
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. 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. It is now required, and it does more than in Module 1: see Spec.- Twisting Nether from Module 3.
If you skipped either drop, do it first. Each is small.
Observe
Baron Rivendare, 4-mana 1/7 Legendary (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 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, 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.
- You played Baron Rivendare, then Loot Hoarder, then Harvest Golem, and the Golem has taken 2 damage. You cast Whirlwind. List the
Triggeredevents in order. Is it Hoarder, Hoarder, Golem, Golem, or Hoarder, Golem, Hoarder, Golem? Where do the two Damaged Golems end up? - 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?
- 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?
- 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?
- 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?
Discussion
-
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.
-
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.
-
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.
-
The Phase function (
reactin 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. -
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.
Spec
From the course repository, copy the module's tests into your workspace:
node tools/start-module.mjs 04 ~/my-engine
This module adds no new harness function, 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 five tests (full statements in course/04-interceptors/scenarios.md). Every test gives player 0 a deck of five Test Filler.
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 singleTriggered(Hoarder).test_04_battlecry_on_play: play a Novice Engineer from hand. Exact log:Summoned,Triggered(battlecry),Drew.
The timing test is the one that pins down when the multiplier is read:
#[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("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 and Novice Engineer don't exist yet); Modules 1–3 still pass. That is RED.
Build
Make the five tests pass without breaking Modules 1–3. 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, stackingmax". 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 containsif deathrattle && baronInPlay, you've rebuilt Module 0. - The Battlecry trigger, the
playaction and the Aura Update are rules machinery insrc/engine/. Novice Engineer is a card insrc/content/. src/harnessstays thin.playhands one action to the engine.
Hint 1 — nudge
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.
Hint 2 — concept
- Aura as data. A card definition gets a list of auras. A trigger multiplier has
trigger(which kind of trigger),timesandstacking. 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,maxmultipliers raise the base to theirtimes,additiveones addtimes - 1extra. Return the result. - Apply it once per queue entry. When an entry starts to resolve, ask
activationsonce, then run the effect that many times, recordingTriggeredfor each, back to back. - Battlecry:
playmoves 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 fromplay, or no multiplier will ever see it.
Hint 3 — pseudo-code
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
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). 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 test checks this, because no card in the course summons a multiplier.
- Playing is not summoning. The rulebook's update point is "whenever a minion is summoned (but not played)" (same source). So
playdoesn'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
additivebranch is there, and no Hearthstone card in the course uses it. It is two lines, and it is what makesstackinga 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). 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, 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). The course has no card that triggers another minion's Deathrattle.
- The course has no mana, so
playcosts 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:
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;
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 ...
}
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
for (const active of world.activeAuras()) {
const { trigger, times, stacking } = active.aura;
if (active.owner !== 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:
// "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,
}),
// "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, puts the doubling inside the Deathrattle action: class Deathrattle in 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, 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 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, 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.
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 needs only the first kind; the other two 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".
Expansion Drop
A new card arrives: Brann Bronzebeard, 3-mana 2/4, "Your Battlecries trigger twice." (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.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 4 done". - Copy the expansion test for your track from
course/04-interceptors/expansion/tests/rust/test_04x.rsorcourse/04-interceptors/expansion/tests/ts/test_04x.test.tsinto your workspace'stests/. Run it and watch it fail. - Add Brann Bronzebeard to your content.
- When the tests pass, run the check from the course repository:
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.
Discussion: what this drop measures
If your multiplier is keyed by trigger kind and your Battlecry goes through the Phase function, Brann is one entry in your content library:
// Expansion Drop: "Your Battlecries trigger twice."
CardDef::minion("Brann Bronzebeard", 2, 4).aura(Aura::TriggerMultiplier {
trigger: TriggerOn::Battlecry,
times: 2,
stacking: Stacking::Max,
}),
// 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. The course's sources don't say how two Branns combine, and the test uses one, so the reference simply reuses Baron's max.
Shapes that fail this drop:
- A doubling flag. If Baron sets something like
extraDeathrattleson 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.
playcalls the Battlecry directly. Ifplayruns 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.
Further reading
- Baron Rivendare, hearthstone.wiki.gg: the notes section, on queuing, stacking and simultaneous death.
- Advanced rulebook, hearthstone.wiki.gg: 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".
- Wizards of the Coast, Magic: The Gathering Comprehensive 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.ktandduplicateETBOrLTBTriggersinTriggerDetector.kt(the file is long; search for the function name). Compare the card file with your Baron entry, and the function with youractivations. - Argentum,
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 Deathrattleinfireplace/actions.pyat commit47a2572: the "before" picture. Ask what the next "trigger twice" card would cost there.