Files
systemic-game-design-course/course/02-action-queue/scenarios.md
T
Lucas WintherandClaude Opus 5.5 97c64ebee8 feat: log tree from Module 2 (logTree / log_tree); SpellGrid CLIs print Log:
Engine events carry the nesting depth they were recorded at; the harness
prints the log as an indented tree. New tests test_02_log_tree_shows_nesting
and test_03_log_tree_death_phase; both SpellGrid CLIs print the tree of the
last command's Sequences under a Log: line.

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

4.1 KiB

Module 2 scenarios: The action queue, depth-first

Each heading is a test name. The Rust test lives in tests/rust/test_02.rs and the TypeScript test in tests/ts/test_02.test.ts; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests is summoned for player 0, in the order listed, so the first one listed has the lowest order of play. Each test calls clearLog() after its setup, so log() holds only the events of the action under test.

Cards used (text from course/catalog.json):

  • Armorsmith, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor."
  • Acolyte of Pain, 1/4: "Whenever this minion takes damage, draw a card."
  • Whirlwind, spell: "Deal 1 damage to ALL minions."
  • Test Echo (fictional), 1/5: "Whenever a minion takes damage, deal 1 damage to the enemy hero." Player 0's enemy hero is hero(1).
  • Test Reactor (fictional), 1/5: "Whenever a hero takes damage, gain +1 Attack."
  • Test Filler (fictional), 1/1, no text. Used to fill decks.

Heroes have 30 Health and 0 Armor at the start of a game.

test_02_aoe_damage_then_triggers

  • Setup: newGame with a deck of 5 Test Filler for each player. Summon Armorsmith (id S), then Acolyte of Pain (id A).
  • Action: player 0 casts Whirlwind.
  • Assertions:
    • The log has two Damaged events, for S then A (area damage is created in order of play), each of amount 1.
    • Every Damaged event comes before every Triggered event in the log.
    • Triggered events: S appears twice (once for each damaged friendly minion, itself included), A once. The test does not check how those three are interleaved.
    • armor(0) is 2.
    • hand(0) has 1 card.

test_02_mortally_wounded_still_triggers

  • Setup: newGame with a deck of 5 Test Filler for player 0. Summon Acolyte of Pain (id A). dealDamage(A, 3), which leaves A at 1 Health and draws one card. Record hand(0)'s length as N.
  • Action: player 0 casts Whirlwind.
  • Assertions:
    • The log contains Triggered{source: A, trigger: "damaged"} and a Drew event for player 0.
    • hand(0) has N + 1 cards.
    • health(A) is 0.
    • The test does not check zoneOf(A). Module 2 has no death step, so A stays on the board at 0 Health. Module 3 adds the step that removes A once the Sequence's reactions have resolved, and this test must keep passing then.

test_02_depth_first_nesting

  • Setup: an empty game. Summon Test Echo (id E), then Armorsmith (id S), then Test Reactor (id R).
  • Action: dealDamage(S, 1).
  • Assertions:
    • The sources of the Triggered events, in log order, are E, R, S. E and S both respond to S taking damage. E resolves first (older), and the hero damage it deals makes R trigger and resolve before S gets its turn.
    • The StatsChanged event for R comes before the ArmorGained event in the log.
    • attack(R) is 2.
    • armor(0) is 1.
    • health(hero(1)) is 29.

test_02_log_tree_shows_nesting

  • Setup: an empty game. Summon Test Echo (id E), then Armorsmith (id S), then Test Reactor (id R). Call hero(1) H.
  • Action: dealDamage(S, 1).
  • Assertions:
    • logTree() is exactly these seven lines, each ending with a newline, with two spaces of indent per level of nesting:

      Damaged{target: S, amount: 1}
      Triggered{source: E, trigger: "damaged"}
        Damaged{target: H, amount: 1}
        Triggered{source: R, trigger: "damaged"}
          StatsChanged{id: R, attack: 2, health: 5}
      Triggered{source: S, trigger: "damaged"}
        ArmorGained{player: 0, amount: 1}
      

      The outermost action's own event is at depth 0, and so is each trigger in the Queue it fills. A trigger's consequences are one level deeper than its Triggered line, including any trigger they cause in turn: Reactor's line is inside Echo's block.

test_02_empty_deck_draw_is_fatigue

  • Setup: an empty game (no decks). Summon Acolyte of Pain (id A).
  • Action: dealDamage(A, 1).
  • Assertions:
    • The log is exactly Damaged{target: A, amount: 1}, Triggered{source: A, trigger: "damaged"}, Fatigue{player: 0}.
    • hand(0) is empty.
    • health(hero(0)) is 30: in this course, Fatigue is only an event.