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

88 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.