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>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 10:23:59 +01:00
1 parent 06a1b34c5d
commit 97c64ebee8
86 files changed
+1375 -55

No files matched your search

+25 -17
View File
@@ -62,16 +62,20 @@ It prints the harness functions this module adds. They are listed in full in `co
| `cast(card, player?, target?)` | `cast(&mut self, card: &str, player: Player, target: Option<Id>)` |
| `health(id)`, `attack(id)`, `armor(player)` | `health(&self, id) -> i32`, `attack(...)`, `armor(&self, player) -> i32` |
| `log()`, `clearLog()` | `log(&self) -> Vec<GameEvent>`, `clear_log(&mut self)` |
| `logTree()` | `log_tree(&self) -> String` |
`log()` returns every event since the game started or since the last `clearLog()`, in the order they happened. Events are tagged by `type`: `Summoned`, `Damaged{target, amount}`, `Triggered{source, trigger}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `StatsChanged{id, attack, health}`, `SpellCast{card}` (plus `Destroyed` and `DeathPhase`, which arrive in Module 3). In Rust, define `pub enum GameEvent` in `src/harness.rs` with the shape given in `course/harness.md`. Every trigger in this module reports `trigger: "damaged"`.
`logTree()` is the same log as text, one event per line, each line ending with a newline. An event is written as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "damaged"}`. Each line is indented two spaces per level of nesting: the outermost action's own events, and each trigger in the Queue they fill, are at depth 0; what a resolving trigger causes is one level deeper than its `Triggered` line.
Each player has a hero with 30 Health, in play from the start. `hero(player)` returns its id; `board(player)` still lists minions only. `dealDamage` and `cast` each run one complete Sequence: the action and every reaction it causes.
The four tests (full statements in `course/02-action-queue/scenarios.md`):
The five tests (full statements in `course/02-action-queue/scenarios.md`):
- `test_02_aoe_damage_then_triggers`: Armorsmith then Acolyte of Pain, decks of 5 `Test Filler`, Whirlwind. Both `Damaged` events (Armorsmith first) come before any `Triggered` event; Armorsmith triggers twice and Acolyte once; 2 Armor; 1 card in hand.
- `test_02_mortally_wounded_still_triggers`: an Acolyte at 1 Health (after `dealDamage(acolyte, 3)`) still draws from Whirlwind and ends at 0 Health. The test does not ask where the Acolyte ends up: from Module 3 on, it leaves the board once the Whirlwind's reactions are done.
- `test_02_depth_first_nesting`: Test Echo, Armorsmith, Test Reactor; `dealDamage(armorsmith, 1)`. Triggers in order Echo, Reactor, Armorsmith; Reactor's `StatsChanged` before `ArmorGained`.
- `test_02_log_tree_shows_nesting`: the same scenario, printed. `logTree()` shows Reactor's trigger inside Echo's block, and Armorsmith's block after it.
- `test_02_empty_deck_draw_is_fatigue`: an Acolyte with an empty deck takes 1 damage. The log is exactly `Damaged`, `Triggered`, `Fatigue`, and nothing else changes.
The first test does **not** check how Armorsmith's two triggers and Acolyte's one are interleaved. The rulebook pins the big picture (all damage, then every reaction in order of play), and the reference engine's choice is in the Build hints, but the course's sources don't state that detail for this case clearly enough to test it.
@@ -128,15 +132,15 @@ test("test_02_depth_first_nesting", () => {
});
```
Add stubs for the new harness functions and run `cargo test` or `npm test`. The four Module 2 tests fail and the Module 1 tests still pass. That is RED.
Add stubs for the new harness functions and run `cargo test` or `npm test`. The five Module 2 tests fail and the Module 1 tests still pass. That is RED.
## Build
Make the four tests pass without breaking Module 1's. The rules for where code goes still hold:
Make the five tests pass without breaking Module 1's. The rules for where code goes still hold:
- The queue, triggers, damage, drawing and the log are generic machinery: `src/engine/`.
- Armorsmith, Acolyte of Pain, Whirlwind, Test Echo and Test Reactor are cards: `src/content/`. The engine must not name them. The coupling audit checks this.
- `src/harness` stays thin. `dealDamage` and `cast` each hand one action to the engine.
- `src/harness` stays thin. `dealDamage` and `cast` each hand one action to the engine. `logTree` only formats the log: the depth of each event is the engine's to record, because only the engine knows which trigger is resolving.
A card's behaviour can be ordinary code in this module: a function in `src/content/` that the engine calls. Module 5 turns those functions into data. What matters now is that a card's code only *describes* what it wants done, and the engine decides when it happens.
@@ -191,8 +195,10 @@ react(events): // one Phase
if responds(trig.on, m, e): queue.push((m, trig))
for (m, trig) in queue: // the queue is locked now
log Triggered(m, "damaged")
depth += 1 // what it causes is logged one level deeper
for a in trig.effect(ctx(owner = m.owner, source = m)):
perform(a) // depth-first
depth -= 1
responds(on, m, Damaged(t)):
selfDamaged -> t == m
@@ -201,6 +207,8 @@ responds(on, m, Damaged(t)):
heroDamaged -> t is a hero
```
Every `log` records the current `depth` next to the event; `logTree` writes each event indented by its depth.
With this queue order, Whirlwind on Armorsmith and Acolyte gives Armorsmith (its own damage), Armorsmith (Acolyte's damage), Acolyte. The test accepts any interleaving of those three.
</details>
@@ -350,35 +358,35 @@ The guard is not part of the course's tests, and the reference doesn't have it;
</details>
2. Make your engine breadth-first on purpose. Keep one list of pending triggers for the whole Sequence; when a trigger's action causes new triggers, append them to the end. Which of the module's four tests fails, and with what order? What did you gain?
2. Make your engine breadth-first on purpose. Keep one list of pending triggers for the whole Sequence; when a trigger's action causes new triggers, append them to the end. Which of the module's five tests fail, and with what order? What did you gain?
<details><summary>Discussion</summary>
Only `test_02_depth_first_nesting`: the triggers come out Echo, Armorsmith, Reactor, and `ArmorGained` precedes Reactor's `StatsChanged`. The other three pass, which is worth noticing. Area damage first, mortally wounded minions that still react, and fatigue are all independent of the depth-first rule. One test in the module pins it.
`test_02_depth_first_nesting`: the triggers come out Echo, Armorsmith, Reactor, and `ArmorGained` precedes Reactor's `StatsChanged`. `test_02_log_tree_shows_nesting` fails too, because its lines come out in that order. The other three pass, which is worth noticing. Area damage first, mortally wounded minions that still react, and fatigue are all independent of the depth-first rule. One scenario in the module pins it, checked twice.
What you gained is that the breadth-first engine can't overflow the stack: question 1's Bomb fills a list instead (forever, but politely). That is why queues are the pattern people reach for first, and why Nystrom's advice in Name it is to avoid raising events from handlers. If you want depth-first order *and* no recursion, keep an explicit stack and push a trigger's consequences on top of it, to be resolved before what was already waiting. That is Slay the Spire's "add to top" from Further reading, and it is the same order your recursion produces.
</details>
3. Your log is a flat list. The real game's is not. HearthSim's parser for Hearthstone's `Power.log` describes it: "The `BLOCK_START` and `BLOCK_END` packets are serialized into a Block packet, which is nestable." ([python-hslog README](https://github.com/HearthSim/python-hslog)). The block types include `PLAY`, `POWER`, `TRIGGER` and `DEATHS` (`BlockType` in [python-hearthstone's `enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py)). What would your engine have to record so that the Echo / Armorsmith / Reactor log could be printed as an indented tree? Add it, and print the tree.
3. `log()` is a flat list. The real game's log is not. HearthSim's parser for Hearthstone's `Power.log` describes it: "The `BLOCK_START` and `BLOCK_END` packets are serialized into a Block packet, which is nestable." ([python-hslog README](https://github.com/HearthSim/python-hslog)). The block types include `PLAY`, `POWER`, `TRIGGER` and `DEATHS` (`BlockType` in [python-hearthstone's `enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py)). Your `logTree()` prints a nested log too, and `test_02_log_tree_shows_nesting` checks it. If you got it to pass by special-casing, or haven't built it yet, build yours before you read the reference's: what does your engine have to record, and where, so that the Echo / Armorsmith / Reactor log prints as a tree?
<details><summary>Discussion</summary>
One number: the nesting depth at which each event was recorded (or, equivalently, a "block opens" and a "block closes" marker around each trigger's resolution). Your `react` already knows it; it is the depth of the recursion.
One number: the nesting depth at which each event was recorded (or, equivalently, a "block opens" and a "block closes" marker around each trigger's resolution). Your `react` already knows it; it is the depth of the recursion. The reference keeps a counter in the world: `react` raises it after recording a `Triggered` event and lowers it when that trigger has finished, and `record` stores the counter next to each event (`openBlock` / `closeBlock` and `logDepths` in `world.ts`; `open_block` / `close_block` and `log_depths` in `world.rs`). The harness only formats. With Echo as 2, Armorsmith as 3, Reactor as 4 and the enemy hero as 1:
```text
Damaged(Armorsmith, 1)
Triggered(Echo)
Damaged(enemy hero, 1)
Triggered(Reactor)
StatsChanged(Reactor, 2, 5)
Triggered(Armorsmith)
ArmorGained(0, 1)
Damaged{target: 3, amount: 1}
Triggered{source: 2, trigger: "damaged"}
Damaged{target: 1, amount: 1}
Triggered{source: 4, trigger: "damaged"}
StatsChanged{id: 4, attack: 2, health: 5}
Triggered{source: 3, trigger: "damaged"}
ArmorGained{player: 0, amount: 1}
```
Printed this way the depth-first rule is visible at a glance: Reactor's line is *inside* Echo's block, and Armorsmith's block starts only after Echo's has closed. A breadth-first engine can't print this tree at all, because its events have no single parent block that is still open. The nested blocks in the game's own log are consistent with the rulebook's "similar to a depth-first search"; that is the course's reading of the log format, not a statement from Blizzard.
Printed this way the depth-first rule is visible at a glance: Reactor's line is *inside* Echo's block, and Armorsmith's block starts only after Echo's has closed. A breadth-first engine can't print this tree at all, because its events have no single parent block that is still open. The nested blocks in the game's own log are consistent with the rulebook's "similar to a depth-first search"; that is the course's reading of the log format, not a statement from Blizzard. Where a trigger's line sits is the course's choice too: at the depth of the Phase that queued it, with its consequences one level in, rather than under the event it answers.
Keep the tree printer. It is the most useful debugging tool you will have in Modules 3 and 4.
Keep using the tree. It is the most useful debugging tool you will have in Modules 3 and 4, and the SpellGrid command line in Module 10 prints it after every command.
</details>