Files
systemic-game-design-course/course/05-assemble/scenarios.md
T
Lucas WintherandClaude Opus 5.5 21fb40c6f5 feat: robustness in Module 5 (nesting guard, Death Phase limit, well-formed Assemble, multi-summon order)
A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.

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

18 KiB
Raw Blame History

Module 5 scenarios: Behavior as data and Assemble

Each heading is a test name. The Rust test lives in tests/rust/test_05.rs and the TypeScript test in tests/ts/test_05.test.ts; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests belongs to player 0, and minions are summoned in the order listed. Tests that read the log call clearLog() right before the action under test.

From this module, every card is data: one JSON file per card in the workspace's cards/ directory (copied from course/cards/ by start-module), in the format of course/schema/card.schema.json. newGame loads all of them. The tests read the same files: the Rust tests from <crate root>/cards and parse each with serde_json into the harness's CardDef; the TypeScript tests import them with Vite's import.meta.glob("../cards/*.json").

A card's text is a list of clauses and split divides it in two: the top half is text[0:split], the bottom half text[split:]. Assembling a base card with a donor card replaces one half of the base's text with the same half of the donor's. Everything else (name stem, cost, Attack, Health, type) comes from the base. The new card is named "<base> (Assembled)". If the new text equals the base's text, or is not well-formed, assemble fails with AssembleError. Well-formed (for a minion): every effect clause belongs to a trigger (none comes before the first trigger, or after an aura or keyword), and every trigger has at least one effect clause before the next trigger, aura, keyword or the end. The assemble effect offers only bases, donors and halves that give a changed, well-formed text.

Cards used (text from course/catalog.json; the clauses from course/cards/, written [top | bottom]):

  • Harvest Golem, 3-mana 2/3: "Deathrattle: Summon a 2/1 Damaged Golem." [trigger deathrattle | effect summon "Damaged Golem" ×1], split 1.
  • Dread Infernal, 6-mana 6/6: "Battlecry: Deal 1 damage to ALL other characters." [trigger battlecry | effect dealDamage allOtherCharacters 1], split 1.
  • Loot Hoarder, 2-mana 2/1: "Deathrattle: Draw a card." [trigger deathrattle | effect draw 1], split 1.
  • Novice Engineer, 2-mana 1/1: "Battlecry: Draw a card." [trigger battlecry | effect draw 1], split 1.
  • Baron Rivendare, 4-mana 1/7: "Your minions trigger their Deathrattles twice." One aura clause, stacking max.
  • Bolf Ramshield, 6-mana 3/9: "Whenever your hero takes damage, this minion takes it instead." One aura clause (damageRedirect), split 0.
  • Test Filler (fictional), 1-mana 1/1, no text.
  • Test Sponge (fictional), 1-mana 1/3, no text.
  • Test Chorus (fictional), 1-mana 1/5: "Your minions trigger their Deathrattles an additional time." One aura clause: triggerMultiplier on deathrattle, times 2, stacking additive.
  • Test Workshop (fictional spell), 2 mana: "Assemble a Battlecry minion. Give all minions in your hand +1/+1." [effect assemble {filter: {trigger: battlecry}}, effect buffHand +1/+1].

The harness adds registerCards(defs), assemble(base, donor, half), cost(id) and setChooser(choose). A chooser is called as choose(kind, options) and returns one of options. For an assemble effect it is called three times, in this order: kind "base" with the names of the minions the base may be (sorted); "donor" with the names of the minions that can change the base's text (sorted); "half" with the halves ("top", "bottom", in that order) whose replacement changes the base's text. Random choice is the chooser's job, so tests inject one.

test_05_regression_data_cards

  • Setup: read every file in cards/ and parse it as a card definition. newGame() (no decks).
  • Action: registerCards with all of them.
  • Assertions:
    • The files include every card the Module 1–4 tests use: Armorsmith, Acolyte of Pain, Loot Hoarder, Harvest Golem, Damaged Golem, Unstable Ghoul, Baron Rivendare, Novice Engineer, Whirlwind, Twisting Nether, Test Echo, Test Reactor, Test Filler.
    • For each minion file, in file-name order: summon(name, 0) returns an id whose nameOf is the file's name and whose attack, health and cost equal the file's attack, health and cost.
    • The Module 1–4 tests, which run in the same workspace, are the rest of this regression: they now pass against cards loaded from data.

test_05_assemble_bottom_half

  • Setup: assemble("Harvest Golem", "Dread Infernal", "bottom") returns "Harvest Golem (Assembled)": [trigger deathrattle | effect dealDamage allOtherCharacters 1], "Deathrattle: Deal 1 damage to ALL other characters." Summon Baron Rivendare (id B), Test Filler (id F), Test Sponge (id S), then "Harvest Golem (Assembled)" (id A). Call the heroes H0 = hero(0) and H1 = hero(1).
  • Action: destroy(A).
  • Assertions:
    • The log is exactly, in this order: Destroyed{id: A}, DeathPhase{round: 1}, Triggered{source: A, trigger: "deathrattle"}, Damaged{target: H0, amount: 1}, Damaged{target: H1, amount: 1}, Damaged{target: B, amount: 1}, Damaged{target: F, amount: 1}, Damaged{target: S, amount: 1}, then the same Triggered and five Damaged events again, then Destroyed{id: F}, DeathPhase{round: 2}. The new Deathrattle is a Deathrattle like any other, so Baron doubles it; F, at 0 Health after the first activation, is still in play for the second.
    • health(H0) and health(H1) are 28, health(B) is 5, health(S) is 1.
    • zoneOf(F) is graveyard and board(0) is [B, S]. No Damaged Golem: the bottom half that summoned it was replaced.

test_05_assembled_keeps_base_stats

  • Setup: assemble("Harvest Golem", "Dread Infernal", "bottom") (name N).
  • Action: addToHand(N, 0) (id A).
  • Assertions:
    • nameOf(A) is N, and N contains "Harvest Golem".
    • cost(A) is 3, attack(A) is 2, health(A) is 3: Harvest Golem's, not Dread Infernal's 6, 6, 6.

test_05_assemble_must_change_text

  • Action: assemble("Loot Hoarder", "Loot Hoarder", "bottom"), then assemble("Loot Hoarder", "Loot Hoarder", "top").
  • Assertions: each fails with AssembleError (TypeScript: throws an AssembleError; Rust: returns Err). A minion assembled from itself has the same text.

test_05_assemble_never_rewrites_an_earlier_card

  • Setup: assemble("Harvest Golem", "Dread Infernal", "bottom") (name N1); addToHand(N1, 0) (id A). Then registerCards with Harvest Golem's definition from cards/ with cost 5 instead of 3: the same text, a different card.
  • Action: assemble("Harvest Golem", "Dread Infernal", "bottom") again (name N2); addToHand(N2, 0) (id B).
  • Assertions:
    • N2 is not N1. The new Assembled card differs from the one already registered as N1, so registering it under N1 would silently rewrite the card A already is.
    • cost(A) is 3 and cost(B) is 5.

test_05_top_half_swap

  • Setup: newGame with a deck of 5 Test Filler for player 0. assemble("Loot Hoarder", "Novice Engineer", "top") (name N): Loot Hoarder's top half [trigger deathrattle] is replaced by Novice Engineer's [trigger battlecry], so N is [trigger battlecry | effect draw 1], "Battlecry: Draw a card." addToHand(N, 0) (id E).
  • Action 1: clearLog(), then play(E).
  • Assertions 1:
    • hand(0) has exactly 1 card; call it C.
    • The log is exactly: Summoned{id: E}, Triggered{source: E, trigger: "battlecry"}, Drew{player: 0, id: C}.
    • attack(E) is 2 and health(E) is 1: Loot Hoarder's stats.
  • Action 2: clearLog(), then destroy(E).
  • Assertions 2:
    • The log is exactly: Destroyed{id: E}, DeathPhase{round: 1}. It has no Deathrattle any more.
    • hand(0) still has exactly 1 card.

test_05_assemble_effect_with_chooser

  • Setup: newGame(). addToHand("Test Filler", 0) (id T). Set a chooser that records every call and answers "Novice Engineer" for "base", "Harvest Golem" for "donor" and "bottom" for "half". Call clearLog().
  • Action: player 0 casts Test Workshop.
  • Assertions:
    • The chooser was called exactly three times, with kinds "base", "donor", "half" in that order.
    • The "base" options are exactly the names of the minion files in cards/ that have a battlecry trigger clause, sorted. (With the course's cards: Dread Infernal, Novice Engineer.)
    • The "donor" options include "Harvest Golem" and do not include "Novice Engineer" (the base itself).
    • The "half" options are ["top", "bottom"]: Harvest Golem's top and bottom halves both differ from Novice Engineer's.
    • hand(0) is [T, A] for some new id A. nameOf(A) is "Novice Engineer (Assembled)", cost(A) is 2, attack(A) is 2 and health(A) is 2 (1/1, then +1/+1).
    • The log is exactly: SpellCast{card: "Test Workshop"}, StatsChanged{id: T, attack: 2, health: 2}, StatsChanged{id: A, attack: 2, health: 2}. Getting the Assembled minion records no event; then every minion in hand gets +1/+1, in hand order.
  • Action 2: clearLog(), then play(A).
  • Assertions 2: board(0) has 2 minions, A first, then a new id D with nameOf(D) "Damaged Golem". The log is exactly: Summoned{id: A}, Triggered{source: A, trigger: "battlecry"}, Summoned{id: D}. "Battlecry: Summon a 2/1 Damaged Golem." In the course, a summon from a minion still in play goes to its right; a Deathrattle, whose minion has left play, summons where it stood (Module 3).

test_05_additive_multipliers_stack

  • Setup: newGame with a deck of 5 Test Filler for player 0. Summon Test Chorus, Test Chorus, then Loot Hoarder (id H). Call clearLog().
  • Action: dealDamage(H, 1).
  • Assertions:
    • hand(0) has 3 cards.
    • The log contains exactly three Triggered{source: H, trigger: "deathrattle"}. Each additive multiplier with times 2 adds one activation, so two of them give 1 + 1 + 1. (Two Baron Rivendares, stacking max, give 2: see Module 4.) A new card with a new stacking behaviour, and no new code.

test_05_summoned_multiplier_counts_for_later_entries

Two games with the same cards, played in a different order. Each registers a one-off card with registerCards, written as data in the test: Test Herald, a 1/1 minion, "Deathrattle: Summon a Baron Rivendare." (split 1; a deathrattle trigger clause, then a summon effect clause for Baron Rivendare, count 1).

  • Setup A: newGame with a deck of 5 Test Filler for player 0. Register Test Herald. Summon Test Herald, then Loot Hoarder (id H). Call clearLog().
  • Action A: cast("Twisting Nether").
  • Assertions A:
    • The log contains exactly two Triggered events with source H, and hand(0) has 2 cards. Both minions leave play in one Death Creation Step and both Deathrattles are in one Queue, in order of play. The Herald's resolves first and summons Baron Rivendare; a summon runs an Aura Update, so when the Hoarder's entry starts, the multiplier is in force.
    • board(0) holds one minion, named "Baron Rivendare".
  • Setup B: a new game, the same deck. Register Test Herald. Summon Loot Hoarder (id H), then Test Herald. Call clearLog().
  • Action B: cast("Twisting Nether").
  • Assertions B:
    • The log contains exactly one Triggered event with source H, and hand(0) has 1 card. The Hoarder is older, so its Deathrattle starts before the Herald's has summoned anything. The count is read once, when a queue entry starts (Module 4).
    • board(0) holds one minion, named "Baron Rivendare".

test_05_own_aura_misses_own_battlecry

The test registers a one-off card with registerCards, written as data in the test: Test Braggart, a 1/1 minion, "Battlecry: Draw a card. Your Battlecries trigger twice." (split 2; a battlecry trigger clause, a draw effect clause with count 1, then an aura clause: trigger multiplier, battlecry, 2 times, stacking max).

  • Setup: newGame with a deck of 5 Test Filler for player 0. Register Test Braggart. Add two to player 0's hand (ids B1, then B2). Call clearLog().
  • Action 1: play(B1).
  • Assertions 1: the log's Triggered events are exactly one, Triggered{source: B1, trigger: "battlecry"}, and hand(0) has 2 cards (B2 and the card drawn). Playing is not summoning: no Aura Update runs when B1 enters the board, so its own multiplier is not in the snapshot when its Battlecry resolves. An engine that scans the board every time it counts activations triggers the Battlecry twice here.
  • Action 2: clearLog(), then play(B2).
  • Assertions 2: the log's Triggered events are exactly two, both Triggered{source: B2, trigger: "battlecry"}, and hand(0) has 3 cards. B1's aura joined the snapshot at the end of the first play's outermost Phase.

test_05_trigger_does_not_nest_in_itself

The test registers a one-off card with registerCards, written as data in the test: Test Bomb, a 1/50 minion, "Whenever a minion takes damage, deal 1 damage to all minions." (split 1; an anyMinionDamaged trigger clause, then a dealDamage effect clause, target allMinions, amount 1). Without a guard, its trigger queues itself inside itself forever. The rule (Hearthstone's Advanced rulebook, see Module 2's Stress it question 1): a trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving.

  • Setup: newGame(). Register Test Bomb. Summon two (ids B1, then B2). Call clearLog().
  • Action: dealDamage(B1, 1).
  • Worked out by hand. Each Damage to a minion queues the trigger of every Bomb that is not resolving, once per Damage event. "Resolving" below is the set of Bomb triggers on the stack at that moment.
    1. Damaged{target: B1}. Queue: B1, B2. Lock.
    2. B1 resolves (resolving: B1): Triggered{source: B1}, then B1 and B2 each take 1. Two Damage events; B1's trigger is resolving, so each queues only B2. Queue: B2, B2.
      1. B2 resolves (resolving: B1, B2): Triggered{source: B2}, both Bombs take 1. Both triggers are resolving: nothing is queued.
      2. B2 resolves again, the second entry (resolving: B1, B2): Triggered{source: B2}, both take 1, nothing is queued.
    3. B2 resolves, the entry from step 1 (resolving: B2): Triggered{source: B2}, both take 1. Each Damage event queues B1. Queue: B1, B1.
      1. B1 resolves (resolving: B2, B1): Triggered{source: B1}, both take 1, nothing is queued.
      2. B1 resolves again (resolving: B2, B1): Triggered{source: B1}, both take 1, nothing is queued.
  • Assertions:
    • The log's Triggered events are exactly, in this order: Triggered{source: B1, trigger: "damaged"}, Triggered{source: B2, trigger: "damaged"} three times, then Triggered{source: B1, trigger: "damaged"} twice. Six in all.
    • The log has exactly 13 Damaged events: the first, and two for each of the six activations.
    • health(B1) is 43 (1 + 6 damage) and health(B2) is 44 (6 damage).

test_05_death_phase_limit

The test registers a one-off card with registerCards, written as data in the test: Test Ember, a 1/1 minion, "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions." (split 1; a deathrattle trigger clause, a summon effect clause for Test Ember, count 1, then a dealDamage effect clause, target allMinions, amount 1). Each Death Phase summons a new Ember and kills it, so the Sequence never ends on its own. More than 64 Death Phases in one Sequence is an error: TypeScript throws an Error and Rust panics, both with a message that contains Death Phase limit.

  • Setup: newGame(). Register Test Ember. Summon one (id E). Call clearLog().
  • Action: dealDamage(E, 1). TypeScript: expect it to throw. Rust: run it inside std::panic::catch_unwind and expect a panic.
  • Assertions:
    • The error (TypeScript) or panic message (Rust) contains Death Phase limit.
    • The log has exactly 64 DeathPhase events, the last DeathPhase{round: 64}. Death Phases 1 to 64 ran; the 65th was refused before it started.
    • Then clearLog() and summon a Test Filler (id F): logTree() is exactly Summoned{id: F} and a newline. The error comes between Death Phases, where no trigger is resolving, so it leaves no block open and the next event is at depth 0.

test_05_assemble_rejects_ill_formed_text

  • Action 1: assemble("Loot Hoarder", "Baron Rivendare", "top"), then assemble("Loot Hoarder", "Baron Rivendare", "bottom").
  • Assertions 1: each fails with AssembleError. Baron's split is 0. The top result is [effect draw 1]: an effect with no trigger. The bottom result is [trigger deathrattle | aura]: a trigger with no effect. Both change the text, and neither is well-formed.
  • Action 2: set a chooser that records every call and answers "Novice Engineer" for "base", "Loot Hoarder" for "donor" and "top" for "half". Player 0 casts Test Workshop.
  • Assertions 2:
    • The minion files in cards/ whose text is one or more aura clauses and nothing else include Baron Rivendare and Bolf Ramshield.
    • The "donor" options include "Loot Hoarder" and none of those aura-only minions. With Novice Engineer [trigger battlecry | effect draw 1] as the base, an aura-only donor's top half (empty) leaves [effect draw 1], and its bottom half leaves [trigger battlecry | aura].

test_05_multi_summon_order

The test registers a one-off card with registerCards, written as data in the test: Test Brood, a 1/1 minion, "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers." (split 2; a battlecry trigger clause, a summon effect clause for Test Filler with count 2, a deathrattle trigger clause, then the same summon effect clause). A summon effect with count greater than 1 places its tokens left to right in summon order: each token goes immediately right of the one before.

  • Setup: newGame(). Register Test Brood. Summon Test Filler (id F1). Add Test Brood to player 0's hand (id B). Call clearLog().
  • Action 1: play(B).
  • Assertions 1: the log's Summoned events other than B's name two new ids, T1 then T2, with T1 < T2. board(0) is [F1, B, T1, T2]. A summon from a minion in play goes to its right, and the second token to the right of the first.
  • Action 2: summon Test Filler (id F2), clearLog(), then destroy(B).
  • Assertions 2: the log's Summoned events name two new ids, T3 then T4, with T3 < T4. board(0) is [F1, T3, T4, T1, T2, F2]. The Deathrattle summons where B stood, left of T1 (Module 3), the first token first.