Files
systemic-game-design-course/course/12-combat/scenarios.md
T
Lucas WintherandClaude Opus 5.5 fb9d0a4eb4 feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)
Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks,
the cumulative reference (Module 11's plus combat) and the drop. The Damage
action carries per-target hits with a priority; react stable-sorts its Queue by
priority before order of play, so the triggers answering the defender's Damage
Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack
because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari
Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness
contract, catalog, research notes and earlier lessons' forward pointers follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:34:20 +01:00

10 KiB

Module 12 scenarios: Combat

Each heading is a test name. The Rust test lives in tests/rust/test_12.rs and the TypeScript test in tests/ts/test_12.test.ts; both state exactly what is written here. Player 0 is the first player, player 1 the second. Minions are summoned in the order listed, so the first one listed has the lowest order of play; each test says whose each minion is. Each test calls clearLog() after its setup, so log() holds only the events of the actions under test. Every test starts with newGame and a deck of 5 Test Filler for each player.

This module adds three harness functions:

  • orderAttack(attacker, defender) runs one Sequence: attacker attacks defender. If the attack is illegal, nothing happens: TypeScript throws an Error whose message is canAttack's reason, and Rust returns Err(reason).
  • canAttack(attacker, defender) returns why the attack is illegal (a string), or null (Rust: None) if it is legal.
  • endTurn(player) runs one Sequence: the end of player's turn.

The rules of an attack:

  1. 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, only a minion with Taunt may be attacked: the reason then contains Taunt. A reason for 0 Attack contains Attack.
  2. Damage. The attacker deals its Attack to the defender and, at the same time, the defender deals its Attack to the attacker: one Damage action with two targets, each with its own amount, so both Damaged events come before any trigger. As for all area damage, the Damaged events are in order of play. A hero has no Attack and deals nothing back, nor does a minion with 0 Attack.
  3. Priority. The triggers answering the defender's Damaged event go before those answering the attacker's, whatever their order of play. Within each, the usual order holds.
  4. Then the usual end of the outermost Phase: the dead leave play in one Death Creation Step, and their Deathrattles resolve in a Death Phase.

The end of a turn: every trigger of player's minions that listens for endOfTurn responds, in order of play, and resolves as any trigger does. Its Triggered event says trigger: "endOfTurn". A trigger multiplier for endOfTurn (Drakkari Enchanter) is an aura and follows the Aura Update schedule of Module 4. The course has no mana, no limit of one attack per turn and no summoning sickness.

Cards used (text from course/catalog.json):

  • Loot Hoarder, 2/1: "Deathrattle: Draw a card."
  • Acolyte of Pain, 1/4: "Whenever this minion takes damage, draw a card."
  • Harvest Golem, 2/3: "Deathrattle: Summon a 2/1 Damaged Golem."
  • Unstable Ghoul, 1/3: "Taunt. Deathrattle: Deal 1 damage to all minions."
  • Armorsmith, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor."
  • Bolf Ramshield, 3/9: "Whenever your hero takes damage, this minion takes it instead."
  • Drakkari Enchanter, 1/5: "Your end of turn effects trigger twice."
  • Test Filler (fictional), 1/1, no text.
  • Test Bell (fictional), 0/3: "At the end of your turn, deal 1 damage to the enemy hero."
  • Test Conjurer (fictional, written as data in the test), 1/1: "At the end of your turn, summon a Drakkari Enchanter."

test_12_combat_is_simultaneous

  • Setup: Summon Loot Hoarder for player 0 (id A), then Loot Hoarder for player 1 (id D).
  • Action: orderAttack(A, D).
  • Assertions:
    • Call the card in hand(0) C0 and the card in hand(1) C1.
    • The log is exactly, in this order: Damaged{target: A, amount: 2}, Damaged{target: D, amount: 2}, Destroyed{id: A}, Destroyed{id: D}, DeathPhase{round: 1}, Triggered{source: A, trigger: "deathrattle"}, Drew{player: 0, id: C0}, Triggered{source: D, trigger: "deathrattle"}, Drew{player: 1, id: C1}.
    • zoneOf(A) and zoneOf(D) are graveyard.
    • Both Hoarders deal their damage, though each one's damage kills the other: the damage is simultaneous. Both die in the same Death Creation Step, and their Deathrattles resolve in order of play in one Death Phase.

test_12_defender_triggers_first

  • Setup: Summon Acolyte of Pain for player 0 (id A), then Acolyte of Pain for player 1 (id D). The attacker, A, is older.
  • Action 1: orderAttack(A, D).
  • Assertions 1:
    • Call the card in hand(0) C0 and the card in hand(1) C1.
    • The log is exactly, in this order: Damaged{target: A, amount: 1}, Damaged{target: D, amount: 1}, Triggered{source: D, trigger: "damaged"}, Drew{player: 1, id: C1}, Triggered{source: A, trigger: "damaged"}, Drew{player: 0, id: C0}.
    • The Damaged events are in order of play, but the defender's trigger goes first. In order of play alone, A's would.
  • Action 2: clearLog(), then orderAttack(D, A): now the attacker is the younger one.
  • Assertions 2:
    • The sources of the Triggered events, in order, are A, D: the defender's first again.
    • health(A) and health(D) are both 2.

test_12_taunt_restricts_targets

  • Setup: Summon Loot Hoarder for player 1 (id H), then Unstable Ghoul for player 1 (id T, Taunt), then Harvest Golem for player 0 (id A).
  • Assertions 1:
    • canAttack(A, H) is a string containing Taunt.
    • canAttack(A, hero(1)) is a string containing Taunt: Taunt protects the hero too.
    • canAttack(A, T) is null.
  • Action 1: orderAttack(A, H).
  • Assertions 2: it fails with canAttack(A, H)'s reason (TypeScript: it throws an Error with that message; Rust: it returns Err with that string). The log is empty and health(H) is 1: nothing happened.
  • Action 2: orderAttack(A, T).
  • Assertions 3: the log is exactly Damaged{target: T, amount: 2}, Damaged{target: A, amount: 1} (order of play: T is older). health(T) is 1 and health(A) is 2.

test_12_zero_attack_cannot_attack

  • Setup: Summon Test Bell for player 0 (id B, 0 Attack), then Test Filler for player 0 (id F0), then Test Filler for player 1 (id F1).
  • Assertions:
    • canAttack(B, F1) is a string containing Attack, and orderAttack(B, F1) fails.
    • canAttack(F0, B) is not null: a friendly minion can't be attacked.
    • canAttack(hero(0), F1) is not null: in the course only minions attack.
    • canAttack(F0, F1) and canAttack(F0, hero(1)) are null.
    • The log is empty.

test_12_attack_hero_through_armor

  • Setup: Summon Armorsmith for player 1 (id S). dealDamage(S, 1) twice, which gives player 1 2 Armor, then destroy(S). Summon Bolf Ramshield for player 0 (id B, 3 Attack). armor(1) is 2.
  • Action: orderAttack(B, hero(1)).
  • Assertions:
    • The log is exactly ArmorLost{player: 1, amount: 2}, Damaged{target: hero(1), amount: 3}. Combat damage to a hero goes through DAMAGE_STEPS like any other damage (Module 4).
    • armor(1) is 0, health(hero(1)) is 29.
    • health(B) is 9: the hero dealt no damage back.

test_12_redirect_only_for_the_hero

  • Setup: Summon Bolf Ramshield for player 1 (id R), then Loot Hoarder for player 1 (id H), then Harvest Golem for player 0 (id A).
  • Action 1: orderAttack(A, H).
  • Assertions 1:
    • Call the card in hand(1) C.
    • The log is exactly, in this order: Damaged{target: H, amount: 2}, Damaged{target: A, amount: 2}, Destroyed{id: H}, DeathPhase{round: 1}, Triggered{source: H, trigger: "deathrattle"}, Drew{player: 1, id: C}.
    • health(R) is 9: Bolf's redirect is for damage to its owner's hero, and this damage was aimed at a minion.
  • Action 2: clearLog(), then orderAttack(A, hero(1)).
  • Assertions 2:
    • The log is exactly Damaged{target: R, amount: 2}: the redirect takes the attack's damage.
    • health(hero(1)) is 30, health(R) is 7.
    • health(A) is 1: the Golem took no damage. Bolf took the damage in the hero's place, but Bolf was not the defender, so it deals nothing back.

test_12_end_of_turn_trigger

  • Setup: Summon Test Bell for player 0 (id B).
  • Action 1: endTurn(1).
  • Assertions 1: the log is empty. The Bell answers only the end of its owner's turn.
  • Action 2: endTurn(0).
  • Assertions 2: the log is exactly Triggered{source: B, trigger: "endOfTurn"}, Damaged{target: hero(1), amount: 1}. health(hero(1)) is 29 and health(hero(0)) is 30.

test_12_drakkari_doubles_end_of_turn

  • Setup: Summon Test Bell for player 0 (id B0), then Drakkari Enchanter for player 0, then Test Bell for player 1 (id B1).
  • Action 1: endTurn(0).
  • Assertions 1: the log is exactly Triggered{source: B0, trigger: "endOfTurn"}, Damaged{target: hero(1), amount: 1}, Triggered{source: B0, trigger: "endOfTurn"}, Damaged{target: hero(1), amount: 1}. The Bell's trigger is queued once and activates twice, back to back, as Baron's Deathrattles do.
  • Action 2: clearLog(), then endTurn(1).
  • Assertions 2: the log is exactly Triggered{source: B1, trigger: "endOfTurn"}, Damaged{target: hero(0), amount: 1}. Drakkari doubles its owner's end of turn effects only. health(hero(1)) is 28 and health(hero(0)) is 29.

test_12_drakkari_summoned_at_end_of_turn

Test Conjurer is registered in the test with registerCards: a minion, 1/1, {trigger: endOfTurn} then {effect: summon "Drakkari Enchanter", count 1}.

  • Case A, setup: Summon Test Conjurer for player 0 (id C), then Test Bell for player 0 (id B).
  • Action: endTurn(0).
  • Assertions A:
    • The log has exactly one Summoned event; call its id K. nameOf(K) is "Drakkari Enchanter".
    • The log is exactly, in this order: Triggered{source: C, trigger: "endOfTurn"}, Summoned{id: K}, Triggered{source: B, trigger: "endOfTurn"}, Damaged{target: hero(1), amount: 1}, Triggered{source: B, trigger: "endOfTurn"}, Damaged{target: hero(1), amount: 1}.
    • The Conjurer's own entry counted its activations before Drakkari existed, so it activates once. A summon runs an Aura Update, so when the Bell's entry starts, Drakkari is in the snapshot and the Bell activates twice.
  • Case B, a new game, setup: Summon Test Bell for player 0 (id B), then Test Conjurer for player 0 (id C).
  • Action: endTurn(0).
  • Assertions B:
    • The log has exactly one Summoned event; call its id K.
    • The log is exactly, in this order: Triggered{source: B, trigger: "endOfTurn"}, Damaged{target: hero(1), amount: 1}, Triggered{source: C, trigger: "endOfTurn"}, Summoned{id: K}.
    • health(hero(1)) is 29. The Bell's entry started before any Drakkari existed.