Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.7 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, assemble fails with AssembleError.
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.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_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.