Files
systemic-game-design-course/course/05-assemble/scenarios.md
T
Lucas WintherandClaude Opus 5.5 e04dc1a11f feat: module 05 assemble
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>
2026-10-29 03:17:21 +01:00

8.7 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, 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, stacking max.
  • 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_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.