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>
18 KiB
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, stackingmax.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:triggerMultiplierondeathrattle,times2, stackingadditive.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:
registerCardswith 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 whosenameOfis the file'snameand whoseattack,healthandcostequal the file'sattack,healthandcost. - 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.
- The files include every card the Module 1–4 tests use:
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." SummonBaron 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 sameTriggeredand fiveDamagedevents again, thenDestroyed{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)andhealth(H1)are 28,health(B)is 5,health(S)is 1.zoneOf(F)is graveyard andboard(0)is[B, S]. No Damaged Golem: the bottom half that summoned it was replaced.
- The log is exactly, in this order:
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"), thenassemble("Loot Hoarder", "Loot Hoarder", "top"). - Assertions: each fails with
AssembleError(TypeScript: throws anAssembleError; Rust: returnsErr). 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). ThenregisterCardswith Harvest Golem's definition fromcards/withcost5 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 andcost(B)is 5.
test_05_top_half_swap
- Setup:
newGamewith a deck of 5Test Fillerfor 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(), thenplay(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 andhealth(E)is 1: Loot Hoarder's stats.
- Action 2:
clearLog(), thendestroy(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.
- The log is exactly:
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". CallclearLog(). - 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 incards/that have abattlecrytrigger 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 andhealth(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.
- The chooser was called exactly three times, with kinds
- Action 2:
clearLog(), thenplay(A). - Assertions 2:
board(0)has 2 minions, A first, then a new id D withnameOf(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:
newGamewith a deck of 5Test Fillerfor player 0. SummonTest Chorus,Test Chorus, thenLoot Hoarder(id H). CallclearLog(). - Action:
dealDamage(H, 1). - Assertions:
hand(0)has 3 cards.- The log contains exactly three
Triggered{source: H, trigger: "deathrattle"}. Eachadditivemultiplier withtimes2 adds one activation, so two of them give 1 + 1 + 1. (Two Baron Rivendares, stackingmax, 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:
newGamewith a deck of 5Test Fillerfor player 0. RegisterTest Herald. SummonTest Herald, thenLoot Hoarder(id H). CallclearLog(). - Action A:
cast("Twisting Nether"). - Assertions A:
- The log contains exactly two
Triggeredevents with source H, andhand(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".
- The log contains exactly two
- Setup B: a new game, the same deck. Register
Test Herald. SummonLoot Hoarder(id H), thenTest Herald. CallclearLog(). - Action B:
cast("Twisting Nether"). - Assertions B:
- The log contains exactly one
Triggeredevent with source H, andhand(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".
- The log contains exactly one
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:
newGamewith a deck of 5Test Fillerfor player 0. RegisterTest Braggart. Add two to player 0's hand (ids B1, then B2). CallclearLog(). - Action 1:
play(B1). - Assertions 1: the log's
Triggeredevents are exactly one,Triggered{source: B1, trigger: "battlecry"}, andhand(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(), thenplay(B2). - Assertions 2: the log's
Triggeredevents are exactly two, bothTriggered{source: B2, trigger: "battlecry"}, andhand(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(). RegisterTest Bomb. Summon two (ids B1, then B2). CallclearLog(). - 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.
Damaged{target: B1}. Queue: B1, B2. Lock.- 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.- B2 resolves (resolving: B1, B2):
Triggered{source: B2}, both Bombs take 1. Both triggers are resolving: nothing is queued. - B2 resolves again, the second entry (resolving: B1, B2):
Triggered{source: B2}, both take 1, nothing is queued.
- B2 resolves (resolving: B1, B2):
- B2 resolves, the entry from step 1 (resolving: B2):
Triggered{source: B2}, both take 1. Each Damage event queues B1. Queue: B1, B1.- B1 resolves (resolving: B2, B1):
Triggered{source: B1}, both take 1, nothing is queued. - B1 resolves again (resolving: B2, B1):
Triggered{source: B1}, both take 1, nothing is queued.
- B1 resolves (resolving: B2, B1):
- Assertions:
- The log's
Triggeredevents are exactly, in this order:Triggered{source: B1, trigger: "damaged"},Triggered{source: B2, trigger: "damaged"}three times, thenTriggered{source: B1, trigger: "damaged"}twice. Six in all. - The log has exactly 13
Damagedevents: the first, and two for each of the six activations. health(B1)is 43 (1 + 6 damage) andhealth(B2)is 44 (6 damage).
- The log's
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(). RegisterTest Ember. Summon one (id E). CallclearLog(). - Action:
dealDamage(E, 1). TypeScript: expect it to throw. Rust: run it insidestd::panic::catch_unwindand expect a panic. - Assertions:
- The error (TypeScript) or panic message (Rust) contains
Death Phase limit. - The log has exactly 64
DeathPhaseevents, the lastDeathPhase{round: 64}. Death Phases 1 to 64 ran; the 65th was refused before it started. - Then
clearLog()and summon aTest Filler(id F):logTree()is exactlySummoned{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.
- The error (TypeScript) or panic message (Rust) contains
test_05_assemble_rejects_ill_formed_text
- Action 1:
assemble("Loot Hoarder", "Baron Rivendare", "top"), thenassemble("Loot Hoarder", "Baron Rivendare", "bottom"). - Assertions 1: each fails with
AssembleError. Baron'ssplitis 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 castsTest Workshop. - Assertions 2:
- The minion files in
cards/whose text is one or more aura clauses and nothing else includeBaron RivendareandBolf 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].
- The minion files in
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(). RegisterTest Brood. SummonTest Filler(id F1). AddTest Broodto player 0's hand (id B). CallclearLog(). - Action 1:
play(B). - Assertions 1: the log's
Summonedevents 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(), thendestroy(B). - Assertions 2: the log's
Summonedevents 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.