Files
systemic-game-design-course/course/12-combat/lesson.md
T
Lucas WintherandClaude Opus 5.5 85959ba5a0
CI / Tool tests and sync-check (push) Successful in 16s
CI / Verify references (all modules, Rust and TS) (push) Successful in 2m4s
CI / Verify Expansion Drop solutions (all modules, Rust and TS) (push) Successful in 2m1s
CI / Coupling audit on references (push) Successful in 5s
CI / Differential fuzzing of the Rust and TS references (300 seeds) (push) Successful in 53s
CI / Mutation check (tools/mutants.json against the highest TS reference) (push) Successful in 34s
CI / Verify the SpellGrid level proofs (solver) (push) Successful in 5m25s
CI / Site build (push) Successful in 15s
CI / Naive engine tests (Module 0) (push) Successful in 20s
fix: final review (engine errors end a Sequence cleanly; nesting guard labelled as the course's; Node 22.15; minor lesson and tool fixes)
I1: run-scenario and both SpellGrid CLIs check moves first and treat any other engine
error as fatal (same text in both tracks); trigger blocks close on error (try/finally,
catch_unwind in Rust); unknown summons rejected. I2, I3, M1-M7, T3, T9a-c, T10a-b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:58:27 +01:00

39 KiB
Raw Blame History

Module 12: Combat

Since Module 2 your minions have taken damage from spells, from triggers and from the harness. They have never hit each other. This module adds the attack: one minion strikes another, and both take damage at once. It also adds the end of a turn, so that a trigger the card format has had since Module 5, endOfTurn, finally fires.

Combat brings a rule that Modules 1 and 2 listed among those the course doesn't model: when two minions trade blows, "the defender ALWAYS triggers first, regardless of order of play" (Advanced rulebook). Every Queue so far has been ordered by order of play and nothing else. This module is about where an exception to that order belongs. The answer the course gives is that the exception is data: a number on each queue entry, read by the same loop that has resolved every trigger since Module 2.

You need your Module 11 workspace with all Module 1–11 tests passing, under git. This module's tests also use Drakkari Enchanter, which has been in course/cards/ since Module 5 and never had anything to do.

Observe

All quotes come from the sources in the course's research notes (docs/research/hearthstone.md): Source 6, fetched for this module, except Redemption's and Poisonous's exceptions to order of play, which come from the rulebook passages recorded in Source 1 for Module 2.

An attack. The Attack page: "The attacker and defender deal damage to each other at the same time". The rulebook calls the two sides "the attacker and defender", and puts the damage in one Phase: "Damage is dealt simultaneously in the order (attack, counterattack) and resolved." (Advanced rulebook). Simultaneous means the same thing as Whirlwind's damage in Module 2: every Damage Event first, then the reactions.

Who may attack. From the Attack page: "Characters with 0 Attack cannot be ordered to attack." Heroes are characters, but "heroes by default have no Attack value", so "enemy minions can hit the hero without harm during the opponent's turn" (same source). A hero that is attacked deals nothing back.

Taunt. The Taunt page: "Enemies must attack minions that have Taunt." While there are any, players "cannot attack any other characters while there are enemy Taunt minions on the battlefield", the hero included. With several, "the player will be able to choose which one of them to attack". And Taunt only binds attacks: "Taunts have no effect on spells, Hero Powers or other damaging effects." Unstable Ghoul's card file has carried the keyword taunt since Module 5. Your engine has kept it and ignored it.

The exception. The rulebook has a section on the order of damage triggers in the Combat Phase. It begins "If both minions trigger when they take damage (such as a Grim Patron and an Imp Gang Boss), the defender" and goes on:

ALWAYS triggers first, regardless of order of play.

Its example: your Grim Patron attacks an enemy Imp Gang Boss, and the Imp Gang Boss's trigger resolves first, because the defender queues first. The same section has the mirror rule for triggers on dealing damage: there, "the attacker ALWAYS triggers first, regardless of order of play." The course has no trigger on dealing damage, so only the first rule matters here.

Put a board together. You played Acolyte of Pain ("Whenever this minion takes damage, draw a card.") on your turn; your opponent played an Acolyte of Pain after it. Your Acolyte attacks theirs. Both take 1 damage, and both triggers answer. In every Queue since Module 2, yours would go first: it is older. In combat, theirs does.

End of turn. The rulebook's section on the start and end of turn: "All 'end of turn' Triggers (as well as the special case Shadow Madness) are Queued and resolved." and "Enchantments that trigger at the start or end of turn Queue in order of play alongside typical triggers." (same source). An end of turn is a Phase like any other, in order of play.

Drakkari Enchanter, 3-mana 1/5: "Your end of turn effects trigger twice." (Drakkari Enchanter). It is a trigger multiplier, the same kind of aura as Baron Rivendare, and Module 4 already quoted its timing rule: cards like Brann, Baron and Drakkari "check before an Event starts". "This means if one Battlecry/Deathrattle/End of turn effect summons them, it will not trigger again." And: "But Deathrattles/End of turn effects that follow can benefit, for Aura (Other) is updated when you summon them." (Advanced rulebook).

Predict

Write your answers down before you open the discussion, for example in notes/12.md in your workspace.

  1. Your Loot Hoarder (2/1, "Deathrattle: Draw a card.") attacks the enemy's Loot Hoarder. Write the log. Do both die? Whose Deathrattle resolves first?
  2. The two Acolytes of Observe. Write the log. Then the enemy's Acolyte attacks yours: what changes?
  3. The enemy has Unstable Ghoul (Taunt) and a Loot Hoarder. Can your Harvest Golem attack the Hoarder? The enemy hero? Can your Whirlwind hit the Hoarder?
  4. The enemy has 2 Armor. Your Bolf Ramshield (3/9) attacks the enemy hero. Write the log, and Bolf's Health afterwards.
  5. The enemy has Bolf Ramshield ("Whenever your hero takes damage, this minion takes it instead.") and a Loot Hoarder. Your Harvest Golem attacks the Hoarder, then the enemy hero. Who takes which damage? Does anything hit the Golem the second time?
  6. You have a minion with "At the end of your turn, summon a Drakkari Enchanter." and, played after it, one with "At the end of your turn, deal 1 damage to the enemy hero." Your turn ends. How much damage does the enemy hero take? And if the two were played the other way round?
  7. Where in your engine would "the defender triggers first" go? In the code that performs an attack? In the order of the Damage Events? In the Queue? What does each choice make easy or hard?
Discussion
  1. Both die, and yours draws first. Both Hoarders deal 2 at the same time, so each deals its damage though the other's kills it. Neither dies on the spot: both are mortally wounded until the outermost Phase ends (Module 3), then both leave in the same Death Creation Step. The Death Phase queues their Deathrattles in order of play, and yours is older. The log: Damaged (yours), Damaged (theirs), Destroyed, Destroyed, DeathPhase 1, your Deathrattle and draw, their Deathrattle and draw. The two Damaged events are in order of play too; see "How the real game differs" under Build for what the rulebook says about that.

  2. Both Damaged events, then the enemy Acolyte's trigger and its draw, then yours. When the enemy attacks, the defender is yours, and yours goes first. Order of play didn't decide either case: the role in the attack did. A Module 11 engine, given the same damage, would put your older Acolyte first both times.

  3. No, and no. While the enemy has a Taunt minion, only Taunt minions can be attacked, and the hero is a character like any other. Whirlwind still hits the Hoarder: Taunt has no effect on spells.

  4. ArmorLost{player: 1, amount: 2}, then Damaged{target: enemy hero, amount: 3}. The hero ends at 29. Combat damage is a Damage like any other, so it goes through Module 4's DAMAGE_STEPS. Bolf keeps his 9 Health: a hero has no Attack and hits nothing back.

  5. The Hoarder takes 2 and the Golem takes 2: Bolf's redirect is for damage to its owner's hero, and the attack was aimed at a minion. The Hoarder dies and draws. Then the attack on the hero: the redirect moves the damage to Bolf, who takes 2. The Golem takes nothing. The counterattack was decided when the attack began, from the defender, and the defender is the hero, with no Attack. That last part is the course's choice; see Build.

  6. Two, then one. The end of your turn queues both triggers once, in order of play. In the first case, the summoner resolves first, and a summon runs an Aura Update, so when the damage trigger's entry starts, Drakkari is in the snapshot: it activates twice. In the second case, the damage trigger started, and counted, before any Drakkari existed. This is the rulebook passage in Observe, and the mechanism is Module 4's, unchanged.

  7. Each place works for the two Acolytes, and each costs something different.

    • In the attack code ("resolve the defender's trigger, then the attacker's"): the attack bypasses the Queue. The nesting guard, the multipliers, the log depth and everything else the Queue does would have to be repeated or forgotten.
    • In the order of the Damage Events: create the defender's event first. That fits the rulebook's "(attack, counterattack)". It makes every entry answering the defender's damage come first, which for combat is the same thing. But it is the wrong tool for the rulebook's other exceptions: Redemption "always Queues last" and Poisonous "resolves in a queue with higher priority" (Advanced rulebook) are about a trigger, not about which event it answers.
    • In the Queue: give each entry a number, its priority, and order the Queue by priority before order of play. The exception becomes data, carried by the entry. That is what this module builds, and Name it explains where it comes from.

Spec

From the course repository, copy the module's tests into your workspace:

node tools/start-module.mjs 12 ~/my-engine

The harness

Three new functions on Game (course/harness.md, M12 line):

TypeScript Rust
orderAttack(attacker, defender) order_attack(&mut self, attacker: Id, defender: Id) -> Result<(), String>
canAttack(attacker, defender): string | null can_attack(&self, attacker: Id, defender: Id) -> Option<String>
endTurn(player) end_turn(&mut self, player: Player)

The attack function is called orderAttack, after the rulebook's "When you order a minion to attack", because attack(id) has read a character's Attack since Module 2.

  • orderAttack runs one Sequence. If the attack is illegal, nothing happens: TypeScript throws an Error whose message is canAttack's reason, and Rust returns Err(reason).
  • canAttack returns why the attack is illegal, or null (None). The reason is yours to word, with two requirements the tests check: it contains Taunt when Taunt is what forbids the attack, and Attack when 0 Attack does.
  • endTurn(player) runs one Sequence: the end of that player's turn. Every trigger of that player's minions that listens for endOfTurn responds. Its Triggered event says trigger: "endOfTurn".

The rules

  • Legality. The attacker is a minion in play. The defender is an enemy minion in play or the enemy hero. The attacker's Attack is more than 0. While the defender's side has a minion with Taunt, the defender must be one of them.
  • Damage. One Damage action with two targets, each with its own amount: the attacker's Attack to the defender, and the defender's Attack to the attacker. Both Damaged events come before any trigger, and they are in order of play, as for any area damage. A hero, or a minion with 0 Attack, deals nothing back: no Damaged event for 0.
  • Priority. The triggers answering the defender's Damaged event go before those answering the attacker's, whatever their order of play. Within each group, the order is the usual one.
  • Then the end of the outermost Phase, as since Module 3.

The course has no mana, no limit of one attack per turn, and no summoning sickness: any minion can attack any number of times, and either player's turn can end at any moment. Those are the course's simplifications.

New card: Test Bell (fictional), 0/3, "At the end of your turn, deal 1 damage to the enemy hero." It is in course/12-combat/cards/, so start-module copies it into your cards/ from this module on. Its 0 Attack is on purpose: it is the course's minion that can't attack.

The tests

The full statements are in course/12-combat/scenarios.md.

  • test_12_combat_is_simultaneous: Predict question 1.
  • test_12_defender_triggers_first: Predict question 2, both ways round.
  • test_12_taunt_restricts_targets: Predict question 3, and an attack refused with the log left empty.
  • test_12_zero_attack_cannot_attack: Test Bell can't attack; nor can a hero, nor can a minion attack a friendly one.
  • test_12_attack_hero_through_armor: Predict question 4.
  • test_12_redirect_only_for_the_hero: Predict question 5.
  • test_12_end_of_turn_trigger: Test Bell answers the end of its owner's turn, and not the other player's.
  • test_12_drakkari_doubles_end_of_turn: with Drakkari, the Bell activates twice, back to back; the enemy's Bell doesn't.
  • test_12_drakkari_summoned_at_end_of_turn: Predict question 6, with Test Conjurer, a card written as data in the test.

The heart of the module is the second test:

#[test]
fn test_12_defender_triggers_first() {
    let mut g = game_with_decks();
    let a = g.summon("Acolyte of Pain", 0);
    let d = g.summon("Acolyte of Pain", 1);
    g.clear_log();

    // The attacker is older: order of play alone would put its trigger first.
    g.order_attack(a, d).unwrap();

    let (c0, c1) = (g.hand(0)[0], g.hand(1)[0]);
    assert_eq!(
        g.log(),
        vec![
            damaged(a, 1),
            damaged(d, 1),
            triggered(d, "damaged"),
            GameEvent::Drew { player: 1, id: c1 },
            triggered(a, "damaged"),
            GameEvent::Drew { player: 0, id: c0 },
        ]
    );

    // The other way round: the attacker is younger, and the defender still goes first.
    g.clear_log();
    g.order_attack(d, a).unwrap();
    assert_eq!(triggered_sources(&g.log()), vec![a, d]);
    assert_eq!(g.health(a), 2);
    assert_eq!(g.health(d), 2);
}
test("test_12_defender_triggers_first", () => {
  const g = gameWithDecks();
  const a = g.summon("Acolyte of Pain", 0);
  const d = g.summon("Acolyte of Pain", 1);
  g.clearLog();

  // The attacker is older: order of play alone would put its trigger first.
  g.orderAttack(a, d);

  const [c0, c1] = [g.hand(0)[0], g.hand(1)[0]];
  expect(g.log()).toEqual([
    { type: "Damaged", target: a, amount: 1 },
    { type: "Damaged", target: d, amount: 1 },
    { type: "Triggered", source: d, trigger: "damaged" },
    { type: "Drew", player: 1, id: c1 },
    { type: "Triggered", source: a, trigger: "damaged" },
    { type: "Drew", player: 0, id: c0 },
  ]);

  // The other way round: the attacker is younger, and the defender still goes first.
  g.clearLog();
  g.orderAttack(d, a);
  expect(triggeredSources(g.log())).toEqual([a, d]);
  expect(g.health(a)).toBe(2);
  expect(g.health(d)).toBe(2);
});

game_with_decks / gameWithDecks gives each player a deck of five Test Filler; damaged, triggered and triggered_sources / triggeredSources are small helpers at the top of the test file.

Add stubs for the three functions and run cargo test or npm test. The nine Module 12 tests fail; Modules 1–11 still pass. That is RED.

Build

Make the nine tests pass, and keep Modules 1–11 green. Where code goes:

  • Combat is rules machinery: src/engine/. Who may attack whom, Taunt, the two amounts and the priority are rules of the game, as the Queue and the Death Creation Step are. The engine may know the keyword taunt, as it knows deathrattle. It must not know Unstable Ghoul.
  • One Sequence, through the Queue. orderAttack hands one action to the engine, and the damage it causes goes through the same perform and the same Phase function as Whirlwind's. Don't resolve the two minions' triggers from the attack code.
  • The exception lives in the Queue. The Phase function gets a priority for each event's entries and orders its Queue by priority first. Nothing else in the Phase function changes.
  • Legality is a question the engine answers. canAttack and orderAttack ask the same function.
  • The end of a turn is an event. endTurn raises it; endOfTurn triggers of that player's minions answer it, through the Phase function, so Drakkari's multiplier applies with no new code.
  • src/harness stays thin.
Hint 1 — nudge

There are three separate jobs, and only one of them is new. Your Damage action deals one amount to all its targets: an attack needs two amounts in one action, or the damage isn't simultaneous. Your Phase function orders its Queue by order of play: an attack needs one group of entries to go first. And endOfTurn needs an event to answer. Which of the three would you still need if Hearthstone had no "regardless of order of play"?

Then: the Queue is filled in order of play and locked before the first entry resolves. Where, between those two moments, could you put the defender's entries first?

Hint 2 — concept
  • Hits. Change the Damage action from "these targets, this amount" to a list of hits: a target, an amount and a priority each. Area damage and spells make hits with one amount and the normal priority. The Damage Events are still created in order of play of the targets.
  • Priority. A number on each queue entry, copied from the hit whose Damage Event the entry answers. After the Queue is filled, sort it by priority, highest first, with a stable sort, so entries of equal priority keep the order of play they were filled in. Then lock it and resolve it as before. Death Events, Battlecries and the end of a turn all have the normal priority.
  • The attack. Check legality, then read both Attacks, then perform one Damage action: the attacker's Attack to the defender, with a higher priority; the defender's Attack to the attacker, with the normal priority, only if it is more than 0.
  • Legality. One function, returning a reason or nothing: attacker on a board, defender an enemy minion on a board or the enemy hero, Attack above 0, and the Taunt rule.
  • Taunt. Your compiled card can keep a list of keywords. A minion has Taunt if taunt is in it.
  • End of turn. An event with the player whose turn ended. endOfTurn responds when the listener's owner is that player.
Hint 3 — pseudo-code
NORMAL = 0
DEFENDER = 1

canAttack(attacker, defender):
    if attacker is not a minion on a board:        return "the attacker is not a minion in play"
    if defender is not an enemy minion on a board
       and not the enemy hero:                     return "the defender is not an enemy character in play"
    if attack(attacker) <= 0:                      return "a minion with 0 Attack cannot attack"
    taunts = enemy board minions with keyword taunt
    if taunts not empty and defender not in taunts: return "a minion with Taunt is in the way"
    return none

perform(Attack(attacker, defender)):
    require canAttack(attacker, defender) is none
    hits = [(defender, attack(attacker), DEFENDER)]          // both read before either is dealt
    if attack(defender) > 0: hits.push((attacker, attack(defender), NORMAL))
    perform(Damage(hits))

perform(Damage(hits)):
    events = []; priorities = []
    for h in hits sorted by orderOfPlay(h.target):
        events.push(applyDamage(h.target, h.amount)); priorities.push(h.priority)
    react(events, priorities)

perform(EndTurn(p)):
    react([TurnEnded(p)], [])

react(events, priorities):
    queue = []
    for i, e in events:                                       // as before, plus the priority
        for m in listeners(e), trig in m.card.triggers:
            if responds(trig.on, m, e) and not resolving(m, trig):
                queue.push((m, trig, e, priorities[i] or NORMAL))
    stable sort queue by priority, highest first              // the only new line
    for (m, trig, e, _) in queue: ... as before ...

responds(endOfTurn, m, TurnEnded(p)) -> owner(m) == p

When your tests pass, compare with the reference engine in course/12-combat/reference/rust/src/engine/ or course/12-combat/reference/ts/src/engine/: the new combat (legality and Taunt), queue (Hit, Priority, the Attack and EndTurn actions, and react), card (keywords) and event (TurnEnded).

Decisions in the reference that the tests don't force:

  • Every entry answering the defender's damage goes first, not only the defender's own trigger. The rulebook's sentence is about two minions that each trigger "when they take damage". An Armorsmith beside the defender answers the same Damage Event, and in the reference it shares the event's priority. The course's reading: the exception is about the event, and the entry carries the event's priority. No test has a third minion.
  • No Damaged for 0. A hero, or a 0 Attack minion, as defender adds no hit at all, so no Damaged{amount: 0} event and no "takes damage" trigger. The Attack page's "hit the hero without harm" suggests nothing happens to the attacker.
  • An illegal attack changes nothing and records nothing. orderAttack asks canAttack before anything changes.
  • A redirected attack has no counterattack from the redirect. When Bolf takes an attack aimed at his hero, the defender is still the hero, whose Attack was read when the attack began. Bolf doesn't hit the attacker. The Bolf Ramshield page confirms that he intercepts attacks: effects of "the attacking character dealing damage" "will still take effect if Bolf Ramshield intercepts the damage". It says nothing about Bolf hitting back. That part is the course's choice.
  • The end of a turn is an engine event the log doesn't show, as Played isn't shown (Module 4). The triggers that answer it are the log's evidence.

How the real game differs

  • The order of the Damaged events. The rulebook says the damage is dealt "in the order (attack, counterattack)" (Advanced rulebook): the defender's Damage Event first. The course creates the two events in order of play, as for every Damage action since Module 2, and puts the exception in the Queue. The triggers come out in the same order either way for every board in this module; only the order of the two Damaged lines differs. Stress it question 1 asks what the difference would buy.
  • Triggers on dealing damage. The rulebook's mirror rule, "the attacker ALWAYS triggers first, regardless of order of play" (same source), isn't modelled: the course has no trigger on dealing damage.
  • The rest of the attack. In the game an attack is "a Sequence of two Phases - "Preparation" and "Attack"" (same source), with a Proposed Attack Event whose triggers "include those that can change the defender", an Attack Event, and an After Attack Event. Between the steps, "Hearthstone checks for win/loss/draw.", and "the Combat sequence additionally checks just before the Combat Phase would begin." (same source). The course's attack is one Phase: the damage.
  • Turns. "A character can usually attack up to one time per turn" (Attack), and new minions have "summoning sickness" (same source). The course models none of these, and only minions attack: heroes with weapons are not in the course. endTurn can be called for either player at any moment, and there is no start of turn.
  • Stealth. "The Taunt effect is temporarily negated by the Stealth or Immunity." (Taunt). The course has neither.
  • Drakkari's exceptions. "Drakkari Enchanter doesn't affect some end of turn effects." (Drakkari Enchanter). The page doesn't list them; the course's Drakkari doubles every endOfTurn trigger of its owner's minions.

Name it

The pattern is priority within a queue. A Queue resolves its entries by a key with two parts: first a priority, then order of play. Almost every entry has the same priority, so order of play decides, as it has since Module 2. An exception to that order is not a branch in the resolver. It is a different number on the entry.

You have seen this before. Module 2's Further reading pointed at the Liquid Fire action system, where every action has a field "public int priority { get; set; }". The article says what it is for: "The “priority” is most important and is a way to override the default sorting in “orderOfPlay”", and how it is used: "The final result will order them first by “priority” in descending order (higher priority goes first)". Its SortActions is the whole rule (Jon Parham, Make a CCG – Action System):

if (x.priority != y.priority) {
return y.priority.CompareTo(x.priority);
} else {
return x.orderOfPlay.CompareTo(y.orderOfPlay);

The reference does the same with one sort, after the Queue is filled and before it is locked:

// Priority first; the sort is stable, so entries of one priority keep the order above.
queue.sort_by_key(|entry| std::cmp::Reverse(entry.4));
// Priority first; the sort is stable, so entries of one priority keep the order above.
queue.sort((a, b) => b[4] - a[4]);

The tie-breaker is the order the Queue was filled in, which is order of play: for each event in order of play, each listener in order of play. That is why the sort must be stable. Rust's sort_by_key is stable, and so is JavaScript's Array.prototype.sort. An unstable sort would keep the defender's entries first but could reorder the rest, so a test that pins order of play among same-priority triggers, as Module 2's do, could pass or fail depending on the sort's implementation.

And the attack, which is where the priority comes from:

Action::Attack { attacker, defender } => {
    if let Some(error) = attack_error(world, attacker, defender) {
        panic!("{error}");
    }
    // Both amounts are read before either is dealt: the damage is simultaneous. A
    // hero has no Attack, so it deals nothing back; nor does a 0 Attack minion.
    let mut attack = vec![Hit {
        target: defender,
        amount: world.attack(attacker),
        priority: DEFENDER,
    }];
    let counter = world.attack(defender);
    if counter > 0 {
        attack.push(Hit {
            target: attacker,
            amount: counter,
            priority: NORMAL,
        });
    }
    perform(world, Action::Damage { hits: attack });
}
case "attack": {
  const { attacker, defender } = action;
  const error = attackError(world, attacker, defender);
  if (error !== undefined) throw new Error(error);
  // Both amounts are read before either is dealt: the damage is simultaneous. A
  // hero has no Attack, so it deals nothing back; nor does a 0 Attack minion.
  const attack: Hit[] = [{ target: defender, amount: world.attack(attacker), priority: DEFENDER }];
  const counter = world.attack(defender);
  if (counter > 0) attack.push({ target: attacker, amount: counter, priority: NORMAL });
  perform(world, { type: "damage", hits: attack });
  break;
}

Notice what the attack does not do. It doesn't resolve a trigger, and it doesn't know which minions have triggers. It describes two hits and says which one matters more; the one Phase function does the rest, with the nesting guard, the multipliers and the log tree it already had.

The course has answered ordering questions with data before. Module 4 put the stacking policy of a multiplier in a field, and the damage steps in a list, DAMAGE_STEPS. Module 9 put the turn's phases in a list, and Module 10 did the same for the steps at the end of the outermost Phase, END_OF_PHASE. Each time, the order became something you can read in one place and change in one place. A priority is the same move made inside a Queue: the Queue's order is still one rule, written once, and the exceptions are values it reads.

Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. Your engine now knows "attack", "Taunt" and "end of turn". It must not know Unstable Ghoul or Drakkari Enchanter.

Play it. The course website's Play SpellGrid page runs the newest TypeScript reference, combat included, unmodified, on Module 10's levels. SpellGrid has no attack input, so combat never happens there. What does happen there is the Queue's new order: every Deathrattle on those levels is now queued by priority first, all at the same priority, and the levels still play as they did in Module 10.

Expansion Drop

A new card arrives: Bonechewer Brawler, 2-mana 2/3, "Taunt. Whenever this minion takes damage, gain +2 Attack." (Bonechewer Brawler).

The constraint, as always: do not edit anything under src/engine/. The harness doesn't change either. Only your content, and your cards/, should.

  1. Commit your workspace and note the commit: git add -A && git commit -m "module 12 done".
  2. Copy the expansion test for your track from course/12-combat/expansion/tests/rust/test_12x.rs or course/12-combat/expansion/tests/ts/test_12x.test.ts into your workspace's tests/. Run it and watch it fail.
  3. Add Bonechewer Brawler to your cards.
  4. When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD

The test, test_12x_bonechewer_brawler, puts a Brawler and a Loot Hoarder on the enemy's side. The Hoarder can't be attacked: the Brawler has Taunt. A Harvest Golem attacks the Brawler. Both take 2; then the Brawler's trigger gives it +2 Attack. The Golem is left at 1 Health: it took the Brawler's Attack as it was when the damage was dealt, not after the trigger. The full scenario is in course/12-combat/expansion/README.md.

Discussion: what this drop measures

If Taunt is a keyword your combat reads, and a trigger is a condition from a small vocabulary plus effects, the Brawler is one card file:

{
  "name": "Bonechewer Brawler",
  "type": "minion",
  "cost": 2,
  "attack": 2,
  "health": 3,
  "split": 1,
  "text": [
    { "kind": "keyword", "keyword": "taunt" },
    { "kind": "trigger", "on": "selfDamaged" },
    { "kind": "effect", "effect": { "type": "gainAttack", "target": "self", "amount": 2 } }
  ]
}

That is the reference answer in course/12-combat/expansion/solution/cards/, where the same clauses are spread over more lines. Every clause is a word the course already had: Unstable Ghoul's taunt, Acolyte of Pain's selfDamaged and Frothing Berserker's gainAttack on self.

Shapes that fail this drop:

  • Taunt by name. If your legality check asks "is an Unstable Ghoul on the board?", the Brawler is a second name. The audit should have caught it. If it slipped through as a flag on the card type, the Brawler needs the flag set in code.
  • The keyword dropped when the card is compiled. If your compiled card kept only triggers and auras, as it could until this module, the Brawler's taunt is lost and the Hoarder can be attacked. The tests of this module only use Unstable Ghoul's Taunt, so check that your engine reads the keyword from the data and not from somewhere else.
  • Damage read after the triggers. If your attack dealt the attacker's damage, resolved the reactions, and only then read the defender's Attack for the counterattack, the Golem takes 4 and dies. The damage has to be decided before anything answers it.

If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.

Stress it

Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.

  1. Take the priority out: delete the sort in your Phase function. Which tests fail? Now put it back, and instead create the two Damaged events of an attack in the rulebook's order, defender first, with no priority at all. Which tests fail now, and why? If both designs give the same triggers, which would you keep?
Discussion

Without the sort, only test_12_defender_triggers_first fails: the older attacker's trigger comes first. One scenario pins the exception, and in the reference no other test in Modules 1–12 notices it is gone, because no other test has two minions answering two Damage Events of different priority.

With the events in the rulebook's order and no priority, the triggers come out the same: filling the Queue "for each event, each listener" already puts everything that answers the defender's event first. What fails is the log, where the attacker is older: test_12_combat_is_simultaneous and the first half of test_12_defender_triggers_first expect the two Damaged events in order of play, and now the defender's comes first. Where the defender is older, as in test_12_taunt_restricts_targets, the two orders agree and nothing fails. Those tests encode the course's choice, not a fact about the game, and "How the real game differs" says so.

Which to keep? The event order is closer to the rulebook's text, and it explains both combat rules at once: on damage taken, the defender's event comes first; on damage dealt, the event the attacker dealt comes first, so "the attacker ALWAYS triggers first". The priority is closer to the rulebook's other exceptions. Poisonous "resolves in a queue with higher priority" and Redemption "always Queues last" (Advanced rulebook): those are about which trigger is queued, and no event order can express them. The course picked the mechanism that covers all three. An engine that also had triggers on dealing damage might want both: events in (attack, counterattack) order, and a priority for the rest.

  1. Add a fictional card: Test Martyr, 1/1, "Whenever a minion takes damage, draw a card." with the rule that it "always queues last", as Redemption does. Where does that priority come from: the event, the trigger, or the card? What happens when a Martyr answers the defender's damage in an attack?
Discussion

From the trigger. In this module the only priority comes from the event (the defender's Damage Event), and every entry answering it inherits it. Redemption's comes from what the trigger is. So an entry now has two sources of priority, and the engine needs a rule to combine them. That is Module 4's Stress it question 4 again: two modifiers touch the same value, and something must decide how they combine. "Queues last" means last in the whole Queue, so the trigger's rule should win: the entry's priority is the trigger's if it has one, else the event's. Or give priorities ranges ("last" is a very low number, "defender" a small positive one) and add them. Either way, write the rule down once, in the function that computes an entry's priority, and not in the sort.

The card format would gain a field on a trigger clause, and the engine would read it. That is an engine change, but a small one, and after it a new "queues first" or "queues last" card is data. The rulebook's wording supports the idea of priority levels: Poisonous "resolves in a queue with higher priority" (same source) reads like a separate level, not a special case.

  1. Hearthstone limits attacks: "A character can usually attack up to one time per turn" (Attack), and a minion that has just been played can't attack yet. What would your engine have to remember to enforce both, where would canAttack read it, and which events would change it?
Discussion

Two facts per minion: how many times it has attacked this turn, and whether it entered play this turn. Both are state on the entity, set by actions the engine already performs: an attack raises the count; entering play (a play, a summon) marks it new. And both are cleared by something the course doesn't have yet: the start of its owner's turn. endTurn(player) raises the end of one player's turn, but nothing says whose turn it is next, and the harness lets a test end either player's turn at any moment.

So the real cost isn't the two counters. It is that "turn" would become state of the game: whose turn it is, which actions that player may take, and a start-of-turn event that resets the counters before any start-of-turn trigger runs. Which of those comes first is another ordering question with an answer to write down. The course's simplification keeps every test free to set up a board and attack straight away; Module 2 made the same trade when it gave the course no mana.

  1. Bolf Ramshield takes the damage of an attack on his hero, and in the reference he doesn't hit the attacker. Argue for the opposite: the redirect changes the defender. What would have to change in the order of things, and what would the Golem in test_12_redirect_only_for_the_hero take?
Discussion

If Bolf became the defender, the Golem would take 3, Bolf's Attack, and die. To build it, the counterattack can no longer be decided when the attack starts: the attack would have to ask "who will actually take this damage?" first, which means running the redirect step of DAMAGE_STEPS before the hits are made, and then again, or not, inside applyDamage. Two places would now know about redirects, and the "applies at most once" guard of Module 4 would have to hold across both.

The game has a separate mechanism for changing a defender: the Proposed Attack Event, whose triggers "include those that can change the defender" (Advanced rulebook). That happens before the Combat Phase, and is a different thing from a predamage redirect like Bolf's. The course has no Proposed Attack Event, and the Bolf Ramshield page says nothing about Bolf hitting back, so the course keeps the simpler rule: the defender is fixed when the attack starts, and a redirect only moves damage.

Further reading

  • Advanced rulebook, hearthstone.wiki.gg: the Combat section (the Preparation and Combat Phases, the Proposed Attack, Attack and After Attack Events, and the ordering of damage triggers in the Combat Phase, with the Grim Patron example), and the section on the start and end of turn.
  • Attack and Taunt, hearthstone.wiki.gg: who may attack, how often, and what Taunt does and doesn't stop.
  • Jon Parham, Make a CCG – Action System (The Liquid Fire, 2017), again. Reread SortActions and the paragraph on priority. Then compare: in that design an action's priority is set when the reaction is created; in the reference it comes from the event the reaction answers. Which would you need for Redemption?
  • Drakkari Enchanter, hearthstone.wiki.gg: its examples (Dreadsteed, Echoing Ooze, Gruul), each an end of turn effect doubled.