From 97c64ebee894ed36459370fa2944cc32692c2165 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 11 Nov 2026 10:23:59 +0100 Subject: [PATCH] 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 --- course/02-action-queue/lesson.md | 42 ++++--- .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../reference/ts/src/harness.ts | 23 ++++ course/02-action-queue/scenarios.md | 19 +++ course/02-action-queue/tests/rust/test_02.rs | 23 ++++ .../02-action-queue/tests/ts/test_02.test.ts | 23 ++++ course/03-death-step/lesson.md | 7 +- .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../03-death-step/reference/ts/src/harness.ts | 23 ++++ course/03-death-step/scenarios.md | 22 ++++ course/03-death-step/tests/rust/test_03.rs | 27 +++++ course/03-death-step/tests/ts/test_03.test.ts | 27 +++++ .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../reference/ts/src/harness.ts | 23 ++++ .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../05-assemble/reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../05-assemble/reference/ts/src/harness.ts | 23 ++++ .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../06-parser/reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ course/06-parser/reference/ts/src/harness.ts | 23 ++++ .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../reference/ts/src/harness.ts | 23 ++++ .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../reference/ts/src/harness.ts | 23 ++++ .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 46 ++++++++ .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../reference/ts/src/harness.ts | 23 ++++ course/10-spellgrid/lesson.md | 2 +- course/10-spellgrid/reference/rust/src/cli.rs | 9 +- .../reference/rust/src/engine/event.rs | 1 + .../reference/rust/src/engine/queue.rs | 3 + .../reference/rust/src/engine/world.rs | 24 ++++ .../reference/rust/src/harness.rs | 109 +++++++++++++----- .../10-spellgrid/reference/rust/tests/cli.rs | 24 ++++ course/10-spellgrid/reference/ts/src/cli.ts | 7 +- .../reference/ts/src/engine/event.ts | 1 + .../reference/ts/src/engine/queue.ts | 3 + .../reference/ts/src/engine/world.ts | 21 ++++ .../10-spellgrid/reference/ts/src/harness.ts | 29 +++++ .../reference/ts/tests/cli.test.ts | 23 ++++ course/harness.md | 8 +- 86 files changed, 1375 insertions(+), 55 deletions(-) diff --git a/course/02-action-queue/lesson.md b/course/02-action-queue/lesson.md index 1ec578a..d2fb232 100644 --- a/course/02-action-queue/lesson.md +++ b/course/02-action-queue/lesson.md @@ -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)` | | `health(id)`, `attack(id)`, `armor(player)` | `health(&self, id) -> i32`, `attack(...)`, `armor(&self, player) -> i32` | | `log()`, `clearLog()` | `log(&self) -> Vec`, `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. @@ -350,35 +358,35 @@ The guard is not part of the course's tests, and the reference doesn't have it; -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?
Discussion -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.
-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?
Discussion -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.
diff --git a/course/02-action-queue/reference/rust/src/engine/event.rs b/course/02-action-queue/reference/rust/src/engine/event.rs index 9a634b7..2921e36 100644 --- a/course/02-action-queue/reference/rust/src/engine/event.rs +++ b/course/02-action-queue/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/02-action-queue/reference/rust/src/engine/queue.rs b/course/02-action-queue/reference/rust/src/engine/queue.rs index 3a8b1af..8833185 100644 --- a/course/02-action-queue/reference/rust/src/engine/queue.rs +++ b/course/02-action-queue/reference/rust/src/engine/queue.rs @@ -130,10 +130,13 @@ fn react(world: &mut World, events: &[Event]) { target: None, }; let actions = (trigger.effect)(&ctx); + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); // Depth-first: each action, and every reaction it causes, finishes before the next. for action in actions { perform(world, action); } + world.close_block(); } } diff --git a/course/02-action-queue/reference/rust/src/engine/world.rs b/course/02-action-queue/reference/rust/src/engine/world.rs index e861af4..c33dd94 100644 --- a/course/02-action-queue/reference/rust/src/engine/world.rs +++ b/course/02-action-queue/reference/rust/src/engine/world.rs @@ -69,6 +69,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, } impl World { @@ -80,6 +84,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, }; for owner in 0..2 { let id = world.entities.len() as EntityId; @@ -211,14 +217,32 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/02-action-queue/reference/rust/src/harness.rs b/course/02-action-queue/reference/rust/src/harness.rs index 6c3b4ba..422789d 100644 --- a/course/02-action-queue/reference/rust/src/harness.rs +++ b/course/02-action-queue/reference/rust/src/harness.rs @@ -49,6 +49,48 @@ impl From<&Event> for GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -136,4 +178,8 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, |event| Some(GameEvent::from(event))) + } } diff --git a/course/02-action-queue/reference/ts/src/engine/event.ts b/course/02-action-queue/reference/ts/src/engine/event.ts index 068dbf4..97bec7d 100644 --- a/course/02-action-queue/reference/ts/src/engine/event.ts +++ b/course/02-action-queue/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/02-action-queue/reference/ts/src/engine/queue.ts b/course/02-action-queue/reference/ts/src/engine/queue.ts index fd66b4f..1ba2899 100644 --- a/course/02-action-queue/reference/ts/src/engine/queue.ts +++ b/course/02-action-queue/reference/ts/src/engine/queue.ts @@ -94,8 +94,11 @@ function react(world: World, events: Event[]): void { for (const [source, trigger] of queue) { world.record({ type: "Triggered", source, on: trigger.on }); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source)); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); // Depth-first: each action, and every reaction it causes, finishes before the next. for (const action of actions) perform(world, action); + world.closeBlock(); } } diff --git a/course/02-action-queue/reference/ts/src/engine/world.ts b/course/02-action-queue/reference/ts/src/engine/world.ts index e6da551..c848963 100644 --- a/course/02-action-queue/reference/ts/src/engine/world.ts +++ b/course/02-action-queue/reference/ts/src/engine/world.ts @@ -32,6 +32,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** A new game: both heroes are in play from the start, so they are the oldest entities. */ constructor(private readonly library: CardLibrary) { @@ -147,14 +151,31 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/02-action-queue/reference/ts/src/harness.ts b/course/02-action-queue/reference/ts/src/harness.ts index 3226642..ed64b2c 100644 --- a/course/02-action-queue/reference/ts/src/harness.ts +++ b/course/02-action-queue/reference/ts/src/harness.ts @@ -27,6 +27,27 @@ function toGameEvent(event: Event): GameEvent { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -46,6 +67,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree } export function newGame(opts?: { decks?: [string[], string[]] }): Game { @@ -80,5 +102,6 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().map(toGameEvent), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), }; } diff --git a/course/02-action-queue/scenarios.md b/course/02-action-queue/scenarios.md index eb3aa52..9b367f8 100644 --- a/course/02-action-queue/scenarios.md +++ b/course/02-action-queue/scenarios.md @@ -45,6 +45,25 @@ Heroes have 30 Health and 0 Armor at the start of a game. - `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: + + ```text + 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). diff --git a/course/02-action-queue/tests/rust/test_02.rs b/course/02-action-queue/tests/rust/test_02.rs index 157abdf..8f0e1fd 100644 --- a/course/02-action-queue/tests/rust/test_02.rs +++ b/course/02-action-queue/tests/rust/test_02.rs @@ -113,6 +113,29 @@ fn test_02_depth_first_nesting() { assert_eq!(g.health(g.hero(1)), 29); } +#[test] +fn test_02_log_tree_shows_nesting() { + let mut g = new_game(GameOpts::default()); + let e = g.summon("Test Echo", 0); + let s = g.summon("Armorsmith", 0); + let r = g.summon("Test Reactor", 0); + let enemy = g.hero(1); + g.clear_log(); + + g.deal_damage(s, 1); + + let expected = [ + format!("Damaged{{target: {s}, amount: 1}}"), + format!("Triggered{{source: {e}, trigger: \"damaged\"}}"), + format!(" Damaged{{target: {enemy}, amount: 1}}"), + format!(" Triggered{{source: {r}, trigger: \"damaged\"}}"), + format!(" StatsChanged{{id: {r}, attack: 2, health: 5}}"), + format!("Triggered{{source: {s}, trigger: \"damaged\"}}"), + " ArmorGained{player: 0, amount: 1}".to_string(), + ]; + assert_eq!(g.log_tree(), expected.join("\n") + "\n"); +} + #[test] fn test_02_empty_deck_draw_is_fatigue() { let mut g = new_game(GameOpts::default()); diff --git a/course/02-action-queue/tests/ts/test_02.test.ts b/course/02-action-queue/tests/ts/test_02.test.ts index 07e28fd..e5d6d34 100644 --- a/course/02-action-queue/tests/ts/test_02.test.ts +++ b/course/02-action-queue/tests/ts/test_02.test.ts @@ -71,6 +71,29 @@ test("test_02_depth_first_nesting", () => { expect(g.health(g.hero(1))).toBe(29); }); +test("test_02_log_tree_shows_nesting", () => { + const g = newGame(); + const e = g.summon("Test Echo", 0); + const s = g.summon("Armorsmith", 0); + const r = g.summon("Test Reactor", 0); + const enemy = g.hero(1); + g.clearLog(); + + g.dealDamage(s, 1); + + expect(g.logTree()).toBe( + [ + `Damaged{target: ${s}, amount: 1}`, + `Triggered{source: ${e}, trigger: "damaged"}`, + ` Damaged{target: ${enemy}, amount: 1}`, + ` Triggered{source: ${r}, trigger: "damaged"}`, + ` StatsChanged{id: ${r}, attack: 2, health: 5}`, + `Triggered{source: ${s}, trigger: "damaged"}`, + ` ArmorGained{player: 0, amount: 1}`, + ].join("\n") + "\n", + ); +}); + test("test_02_empty_deck_draw_is_fatigue", () => { const g = newGame(); const a = g.summon("Acolyte of Pain", 0); diff --git a/course/03-death-step/lesson.md b/course/03-death-step/lesson.md index 37136e6..4afa88e 100644 --- a/course/03-death-step/lesson.md +++ b/course/03-death-step/lesson.md @@ -76,10 +76,11 @@ Two `GameEvent`s, already in your `GameEvent` type since Module 2, now get recor A Deathrattle reports `Triggered{source, trigger: "deathrattle"}`, with the dead minion as `source`. -The six tests (full statements in `course/03-death-step/scenarios.md`) all compare the exact log: +The seven tests (full statements in `course/03-death-step/scenarios.md`) all compare the exact log: - `test_03_removed_only_after_sequence`: Loot Hoarder then Armorsmith, Whirlwind. `Destroyed(Hoarder)` comes after both Armorsmith triggers; then `DeathPhase 1`, the Hoarder's Deathrattle and its draw. - `test_03_chained_death_phases`: Harvest Golem at 1 Health, Unstable Ghoul, Test Reactor; `dealDamage(ghoul, 3)`. Two Death Phases, and the Damaged Golem appears at the Golem's old position, left of the Reactor. +- `test_03_log_tree_death_phase`: the same scenario as a tree, from `logTree()`. The Death Creation Step and the Death Phase run after the outermost Phase, so `Destroyed`, `DeathPhase` and each Deathrattle's `Triggered` are at depth 0, and the Deathrattle's effects at depth 1. - `test_03_simultaneous_removal`: two Loot Hoarders die to Whirlwind. Both `Destroyed` events come before either Deathrattle; the Deathrattles resolve in order of play in one Death Phase. - `test_03_destroy_marks_pending`: `destroy` on a full-Health Harvest Golem. It leaves through the Death Creation Step with its Health untouched. - `test_03_summon_position_after_simultaneous_deaths`: Predict question 5. The Damaged Golem ends up left of the Reactor although a minion to the Golem's left died in the same step. @@ -159,11 +160,11 @@ test("test_03_chained_death_phases", () => { `damaged` and `triggered` are small helpers at the top of the Rust test file that build those two events. -Add a `destroy` stub and run `cargo test` or `npm test`. The six Module 3 tests fail; Modules 1 and 2 still pass. That is RED. +Add a `destroy` stub and run `cargo test` or `npm test`. The seven Module 3 tests fail; Modules 1 and 2 still pass. That is RED. ## Build -Make the six tests pass without breaking Modules 1 and 2. The rules for where code goes still hold: +Make the seven tests pass without breaking Modules 1 and 2. The rules for where code goes still hold: - The death step, the Death Phase loop, pending destroy, "summon at a position" and the Deathrattle trigger condition are generic machinery: `src/engine/`. - Loot Hoarder, Harvest Golem, Damaged Golem and Unstable Ghoul are cards: `src/content/`. The engine must not name them. "Deathrattle" is rules vocabulary, like "a friendly minion takes damage" was in Module 2. diff --git a/course/03-death-step/reference/rust/src/engine/event.rs b/course/03-death-step/reference/rust/src/engine/event.rs index 73a9ed7..0129175 100644 --- a/course/03-death-step/reference/rust/src/engine/event.rs +++ b/course/03-death-step/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/03-death-step/reference/rust/src/engine/queue.rs b/course/03-death-step/reference/rust/src/engine/queue.rs index 34aefa1..376e383 100644 --- a/course/03-death-step/reference/rust/src/engine/queue.rs +++ b/course/03-death-step/reference/rust/src/engine/queue.rs @@ -180,10 +180,13 @@ fn react(world: &mut World, events: &[Event]) { position, }; let actions = (trigger.effect)(&ctx); + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); // Depth-first: each action, and every reaction it causes, finishes before the next. for action in actions { perform(world, action); } + world.close_block(); } } diff --git a/course/03-death-step/reference/rust/src/engine/world.rs b/course/03-death-step/reference/rust/src/engine/world.rs index e3fbf15..b2d4470 100644 --- a/course/03-death-step/reference/rust/src/engine/world.rs +++ b/course/03-death-step/reference/rust/src/engine/world.rs @@ -72,6 +72,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, } impl World { @@ -83,6 +87,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, }; for owner in 0..2 { let id = world.entities.len() as EntityId; @@ -243,14 +249,32 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/03-death-step/reference/rust/src/harness.rs b/course/03-death-step/reference/rust/src/harness.rs index d10d283..30b0547 100644 --- a/course/03-death-step/reference/rust/src/harness.rs +++ b/course/03-death-step/reference/rust/src/harness.rs @@ -51,6 +51,48 @@ impl From<&Event> for GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -138,6 +180,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, |event| Some(GameEvent::from(event))) + } // ---- Module 3 ---- diff --git a/course/03-death-step/reference/ts/src/engine/event.ts b/course/03-death-step/reference/ts/src/engine/event.ts index 7841036..8f92cd6 100644 --- a/course/03-death-step/reference/ts/src/engine/event.ts +++ b/course/03-death-step/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/03-death-step/reference/ts/src/engine/queue.ts b/course/03-death-step/reference/ts/src/engine/queue.ts index 4462889..1980cf0 100644 --- a/course/03-death-step/reference/ts/src/engine/queue.ts +++ b/course/03-death-step/reference/ts/src/engine/queue.ts @@ -134,8 +134,11 @@ function react(world: World, events: Event[]): void { ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); // Depth-first: each action, and every reaction it causes, finishes before the next. for (const action of actions) perform(world, action); + world.closeBlock(); } } diff --git a/course/03-death-step/reference/ts/src/engine/world.ts b/course/03-death-step/reference/ts/src/engine/world.ts index bd06655..ceb5fa1 100644 --- a/course/03-death-step/reference/ts/src/engine/world.ts +++ b/course/03-death-step/reference/ts/src/engine/world.ts @@ -34,6 +34,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** A new game: both heroes are in play from the start, so they are the oldest entities. */ constructor(private readonly library: CardLibrary) { @@ -173,14 +177,31 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/03-death-step/reference/ts/src/harness.ts b/course/03-death-step/reference/ts/src/harness.ts index 140cd00..e4511ca 100644 --- a/course/03-death-step/reference/ts/src/harness.ts +++ b/course/03-death-step/reference/ts/src/harness.ts @@ -28,6 +28,27 @@ function toGameEvent(event: Event): GameEvent { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -47,6 +68,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence } @@ -83,6 +105,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().map(toGameEvent), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), }; diff --git a/course/03-death-step/scenarios.md b/course/03-death-step/scenarios.md index 5bdc69f..c689b82 100644 --- a/course/03-death-step/scenarios.md +++ b/course/03-death-step/scenarios.md @@ -36,6 +36,28 @@ A minion is removed from play only in the Death Creation Step, which runs when t - `zoneOf(U)` and `zoneOf(G)` are graveyard. - `health(R)` is 4. +### test_03_log_tree_death_phase + +- Setup: as in `test_03_chained_death_phases`. Summon `Harvest Golem` (id G), then `Unstable Ghoul` (id U), then `Test Reactor` (id R). `dealDamage(G, 2)`. +- Action: `dealDamage(U, 3)`. +- Assertions: + - D is the first id in `board(0)`. `logTree()` is exactly these ten lines, each ending with a newline: + + ```text + Damaged{target: U, amount: 3} + Destroyed{id: U} + DeathPhase{round: 1} + Triggered{source: U, trigger: "deathrattle"} + Damaged{target: G, amount: 1} + Damaged{target: R, amount: 1} + Destroyed{id: G} + DeathPhase{round: 2} + Triggered{source: G, trigger: "deathrattle"} + Summoned{id: D} + ``` + + The Death Creation Step and the Death Phase run when the outermost Phase has ended, so `Destroyed`, `DeathPhase` and each Deathrattle's `Triggered` are at depth 0, and the Deathrattle's effects at depth 1. + ### test_03_simultaneous_removal - Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Loot Hoarder` (id H1), then another `Loot Hoarder` (id H2). diff --git a/course/03-death-step/tests/rust/test_03.rs b/course/03-death-step/tests/rust/test_03.rs index 4d3f96f..153099d 100644 --- a/course/03-death-step/tests/rust/test_03.rs +++ b/course/03-death-step/tests/rust/test_03.rs @@ -107,6 +107,33 @@ fn test_03_chained_death_phases() { assert_eq!(g.health(r), 4); } +#[test] +fn test_03_log_tree_death_phase() { + let mut g = new_game(GameOpts::default()); + let golem = g.summon("Harvest Golem", 0); + let ghoul = g.summon("Unstable Ghoul", 0); + let r = g.summon("Test Reactor", 0); + g.deal_damage(golem, 2); + g.clear_log(); + + g.deal_damage(ghoul, 3); + + let d = g.board(0)[0]; + let expected = [ + format!("Damaged{{target: {ghoul}, amount: 3}}"), + format!("Destroyed{{id: {ghoul}}}"), + "DeathPhase{round: 1}".to_string(), + format!("Triggered{{source: {ghoul}, trigger: \"deathrattle\"}}"), + format!(" Damaged{{target: {golem}, amount: 1}}"), + format!(" Damaged{{target: {r}, amount: 1}}"), + format!("Destroyed{{id: {golem}}}"), + "DeathPhase{round: 2}".to_string(), + format!("Triggered{{source: {golem}, trigger: \"deathrattle\"}}"), + format!(" Summoned{{id: {d}}}"), + ]; + assert_eq!(g.log_tree(), expected.join("\n") + "\n"); +} + #[test] fn test_03_simultaneous_removal() { let mut g = new_game(GameOpts { diff --git a/course/03-death-step/tests/ts/test_03.test.ts b/course/03-death-step/tests/ts/test_03.test.ts index e469fce..50d9e83 100644 --- a/course/03-death-step/tests/ts/test_03.test.ts +++ b/course/03-death-step/tests/ts/test_03.test.ts @@ -63,6 +63,33 @@ test("test_03_chained_death_phases", () => { expect(g.health(r)).toBe(4); }); +test("test_03_log_tree_death_phase", () => { + const g = newGame(); + const golem = g.summon("Harvest Golem", 0); + const ghoul = g.summon("Unstable Ghoul", 0); + const r = g.summon("Test Reactor", 0); + g.dealDamage(golem, 2); + g.clearLog(); + + g.dealDamage(ghoul, 3); + + const d = g.board(0)[0]; + expect(g.logTree()).toBe( + [ + `Damaged{target: ${ghoul}, amount: 3}`, + `Destroyed{id: ${ghoul}}`, + `DeathPhase{round: 1}`, + `Triggered{source: ${ghoul}, trigger: "deathrattle"}`, + ` Damaged{target: ${golem}, amount: 1}`, + ` Damaged{target: ${r}, amount: 1}`, + `Destroyed{id: ${golem}}`, + `DeathPhase{round: 2}`, + `Triggered{source: ${golem}, trigger: "deathrattle"}`, + ` Summoned{id: ${d}}`, + ].join("\n") + "\n", + ); +}); + test("test_03_simultaneous_removal", () => { const g = newGame({ decks: [fillers(5), []] }); const h1 = g.summon("Loot Hoarder", 0); diff --git a/course/04-interceptors/reference/rust/src/engine/event.rs b/course/04-interceptors/reference/rust/src/engine/event.rs index 0eb180b..8c1b0a8 100644 --- a/course/04-interceptors/reference/rust/src/engine/event.rs +++ b/course/04-interceptors/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/04-interceptors/reference/rust/src/engine/queue.rs b/course/04-interceptors/reference/rust/src/engine/queue.rs index d06e76a..7cf49f0 100644 --- a/course/04-interceptors/reference/rust/src/engine/queue.rs +++ b/course/04-interceptors/reference/rust/src/engine/queue.rs @@ -218,10 +218,13 @@ fn react(world: &mut World, events: &[Event]) { position, }; let actions = (trigger.effect)(&ctx); + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); // Depth-first: each action, and every reaction it causes, finishes before the next. for action in actions { perform(world, action); } + world.close_block(); } } } diff --git a/course/04-interceptors/reference/rust/src/engine/world.rs b/course/04-interceptors/reference/rust/src/engine/world.rs index 15744cc..49f8495 100644 --- a/course/04-interceptors/reference/rust/src/engine/world.rs +++ b/course/04-interceptors/reference/rust/src/engine/world.rs @@ -79,6 +79,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, } @@ -92,6 +96,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), }; for owner in 0..2 { @@ -257,10 +263,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Only an Aura Update calls this. @@ -270,6 +293,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/04-interceptors/reference/rust/src/harness.rs b/course/04-interceptors/reference/rust/src/harness.rs index 4d050a2..c2fc11f 100644 --- a/course/04-interceptors/reference/rust/src/harness.rs +++ b/course/04-interceptors/reference/rust/src/harness.rs @@ -55,6 +55,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -146,6 +188,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- diff --git a/course/04-interceptors/reference/ts/src/engine/event.ts b/course/04-interceptors/reference/ts/src/engine/event.ts index 06c814a..d25fbbc 100644 --- a/course/04-interceptors/reference/ts/src/engine/event.ts +++ b/course/04-interceptors/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/04-interceptors/reference/ts/src/engine/queue.ts b/course/04-interceptors/reference/ts/src/engine/queue.ts index 8a474a0..7569ba0 100644 --- a/course/04-interceptors/reference/ts/src/engine/queue.ts +++ b/course/04-interceptors/reference/ts/src/engine/queue.ts @@ -166,8 +166,11 @@ function react(world: World, events: Event[]): void { ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); // Depth-first: each action, and every reaction it causes, finishes before the next. for (const action of actions) perform(world, action); + world.closeBlock(); } } } diff --git a/course/04-interceptors/reference/ts/src/engine/world.ts b/course/04-interceptors/reference/ts/src/engine/world.ts index 8fd71b5..61c2e2c 100644 --- a/course/04-interceptors/reference/ts/src/engine/world.ts +++ b/course/04-interceptors/reference/ts/src/engine/world.ts @@ -40,6 +40,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; @@ -185,10 +189,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Only an Aura Update calls this. */ @@ -198,6 +218,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/04-interceptors/reference/ts/src/harness.ts b/course/04-interceptors/reference/ts/src/harness.ts index 4f6266d..fbda787 100644 --- a/course/04-interceptors/reference/ts/src/harness.ts +++ b/course/04-interceptors/reference/ts/src/harness.ts @@ -31,6 +31,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -50,6 +71,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence } @@ -84,6 +106,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), }; diff --git a/course/05-assemble/reference/rust/src/engine/event.rs b/course/05-assemble/reference/rust/src/engine/event.rs index 0eb180b..8c1b0a8 100644 --- a/course/05-assemble/reference/rust/src/engine/event.rs +++ b/course/05-assemble/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/05-assemble/reference/rust/src/engine/queue.rs b/course/05-assemble/reference/rust/src/engine/queue.rs index 7779d7f..68c9127 100644 --- a/course/05-assemble/reference/rust/src/engine/queue.rs +++ b/course/05-assemble/reference/rust/src/engine/queue.rs @@ -246,8 +246,11 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); let owner = world.owner_of(source); run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); } } } diff --git a/course/05-assemble/reference/rust/src/engine/world.rs b/course/05-assemble/reference/rust/src/engine/world.rs index e2f22f9..a320da6 100644 --- a/course/05-assemble/reference/rust/src/engine/world.rs +++ b/course/05-assemble/reference/rust/src/engine/world.rs @@ -84,6 +84,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, } @@ -99,6 +103,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), }; for owner in 0..2 { @@ -287,10 +293,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Add a card to the library, or replace the card with the same name. @@ -325,6 +348,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/05-assemble/reference/rust/src/harness.rs b/course/05-assemble/reference/rust/src/harness.rs index cbc82c3..84d47ac 100644 --- a/course/05-assemble/reference/rust/src/harness.rs +++ b/course/05-assemble/reference/rust/src/harness.rs @@ -55,6 +55,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -146,6 +188,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- diff --git a/course/05-assemble/reference/ts/src/engine/event.ts b/course/05-assemble/reference/ts/src/engine/event.ts index 06c814a..d25fbbc 100644 --- a/course/05-assemble/reference/ts/src/engine/event.ts +++ b/course/05-assemble/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/05-assemble/reference/ts/src/engine/queue.ts b/course/05-assemble/reference/ts/src/engine/queue.ts index d8aa76e..c177041 100644 --- a/course/05-assemble/reference/ts/src/engine/queue.ts +++ b/course/05-assemble/reference/ts/src/engine/queue.ts @@ -192,7 +192,10 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); } } } diff --git a/course/05-assemble/reference/ts/src/engine/world.ts b/course/05-assemble/reference/ts/src/engine/world.ts index 4cfb022..76f159a 100644 --- a/course/05-assemble/reference/ts/src/engine/world.ts +++ b/course/05-assemble/reference/ts/src/engine/world.ts @@ -46,6 +46,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; /** Until a chooser is set, take the first option. */ @@ -212,10 +216,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Add a card to the library, or replace the card with the same name. */ @@ -248,6 +268,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/05-assemble/reference/ts/src/harness.ts b/course/05-assemble/reference/ts/src/harness.ts index 9329dab..ab134a1 100644 --- a/course/05-assemble/reference/ts/src/harness.ts +++ b/course/05-assemble/reference/ts/src/harness.ts @@ -45,6 +45,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -64,6 +85,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence // Module 5 @@ -103,6 +125,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), diff --git a/course/06-parser/reference/rust/src/engine/event.rs b/course/06-parser/reference/rust/src/engine/event.rs index 0eb180b..8c1b0a8 100644 --- a/course/06-parser/reference/rust/src/engine/event.rs +++ b/course/06-parser/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/06-parser/reference/rust/src/engine/queue.rs b/course/06-parser/reference/rust/src/engine/queue.rs index 7779d7f..68c9127 100644 --- a/course/06-parser/reference/rust/src/engine/queue.rs +++ b/course/06-parser/reference/rust/src/engine/queue.rs @@ -246,8 +246,11 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); let owner = world.owner_of(source); run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); } } } diff --git a/course/06-parser/reference/rust/src/engine/world.rs b/course/06-parser/reference/rust/src/engine/world.rs index e2f22f9..a320da6 100644 --- a/course/06-parser/reference/rust/src/engine/world.rs +++ b/course/06-parser/reference/rust/src/engine/world.rs @@ -84,6 +84,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, } @@ -99,6 +103,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), }; for owner in 0..2 { @@ -287,10 +293,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Add a card to the library, or replace the card with the same name. @@ -325,6 +348,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/06-parser/reference/rust/src/harness.rs b/course/06-parser/reference/rust/src/harness.rs index 01688bd..0081eba 100644 --- a/course/06-parser/reference/rust/src/harness.rs +++ b/course/06-parser/reference/rust/src/harness.rs @@ -55,6 +55,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -146,6 +188,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- diff --git a/course/06-parser/reference/ts/src/engine/event.ts b/course/06-parser/reference/ts/src/engine/event.ts index 06c814a..d25fbbc 100644 --- a/course/06-parser/reference/ts/src/engine/event.ts +++ b/course/06-parser/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/06-parser/reference/ts/src/engine/queue.ts b/course/06-parser/reference/ts/src/engine/queue.ts index d8aa76e..c177041 100644 --- a/course/06-parser/reference/ts/src/engine/queue.ts +++ b/course/06-parser/reference/ts/src/engine/queue.ts @@ -192,7 +192,10 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); } } } diff --git a/course/06-parser/reference/ts/src/engine/world.ts b/course/06-parser/reference/ts/src/engine/world.ts index 4cfb022..76f159a 100644 --- a/course/06-parser/reference/ts/src/engine/world.ts +++ b/course/06-parser/reference/ts/src/engine/world.ts @@ -46,6 +46,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; /** Until a chooser is set, take the first option. */ @@ -212,10 +216,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Add a card to the library, or replace the card with the same name. */ @@ -248,6 +268,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/06-parser/reference/ts/src/harness.ts b/course/06-parser/reference/ts/src/harness.ts index e987d27..8d68aa6 100644 --- a/course/06-parser/reference/ts/src/harness.ts +++ b/course/06-parser/reference/ts/src/harness.ts @@ -46,6 +46,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -65,6 +86,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence // Module 5 @@ -104,6 +126,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), diff --git a/course/07-rule-resolution/reference/rust/src/engine/event.rs b/course/07-rule-resolution/reference/rust/src/engine/event.rs index 0eb180b..8c1b0a8 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/event.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/07-rule-resolution/reference/rust/src/engine/queue.rs b/course/07-rule-resolution/reference/rust/src/engine/queue.rs index 7779d7f..68c9127 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/queue.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/queue.rs @@ -246,8 +246,11 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); let owner = world.owner_of(source); run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); } } } diff --git a/course/07-rule-resolution/reference/rust/src/engine/world.rs b/course/07-rule-resolution/reference/rust/src/engine/world.rs index e2f22f9..a320da6 100644 --- a/course/07-rule-resolution/reference/rust/src/engine/world.rs +++ b/course/07-rule-resolution/reference/rust/src/engine/world.rs @@ -84,6 +84,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, } @@ -99,6 +103,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), }; for owner in 0..2 { @@ -287,10 +293,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Add a card to the library, or replace the card with the same name. @@ -325,6 +348,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/07-rule-resolution/reference/rust/src/harness.rs b/course/07-rule-resolution/reference/rust/src/harness.rs index 4828ad1..56dae8e 100644 --- a/course/07-rule-resolution/reference/rust/src/harness.rs +++ b/course/07-rule-resolution/reference/rust/src/harness.rs @@ -55,6 +55,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -146,6 +188,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- diff --git a/course/07-rule-resolution/reference/ts/src/engine/event.ts b/course/07-rule-resolution/reference/ts/src/engine/event.ts index 06c814a..d25fbbc 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/event.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/07-rule-resolution/reference/ts/src/engine/queue.ts b/course/07-rule-resolution/reference/ts/src/engine/queue.ts index d8aa76e..c177041 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/queue.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/queue.ts @@ -192,7 +192,10 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); } } } diff --git a/course/07-rule-resolution/reference/ts/src/engine/world.ts b/course/07-rule-resolution/reference/ts/src/engine/world.ts index 4cfb022..76f159a 100644 --- a/course/07-rule-resolution/reference/ts/src/engine/world.ts +++ b/course/07-rule-resolution/reference/ts/src/engine/world.ts @@ -46,6 +46,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; /** Until a chooser is set, take the first option. */ @@ -212,10 +216,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Add a card to the library, or replace the card with the same name. */ @@ -248,6 +268,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/07-rule-resolution/reference/ts/src/harness.ts b/course/07-rule-resolution/reference/ts/src/harness.ts index dec1010..a2808ef 100644 --- a/course/07-rule-resolution/reference/ts/src/harness.ts +++ b/course/07-rule-resolution/reference/ts/src/harness.ts @@ -46,6 +46,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -65,6 +86,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence // Module 5 @@ -104,6 +126,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), diff --git a/course/08-push-chains/reference/rust/src/engine/event.rs b/course/08-push-chains/reference/rust/src/engine/event.rs index 0eb180b..8c1b0a8 100644 --- a/course/08-push-chains/reference/rust/src/engine/event.rs +++ b/course/08-push-chains/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/08-push-chains/reference/rust/src/engine/queue.rs b/course/08-push-chains/reference/rust/src/engine/queue.rs index 7779d7f..68c9127 100644 --- a/course/08-push-chains/reference/rust/src/engine/queue.rs +++ b/course/08-push-chains/reference/rust/src/engine/queue.rs @@ -246,8 +246,11 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); let owner = world.owner_of(source); run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); } } } diff --git a/course/08-push-chains/reference/rust/src/engine/world.rs b/course/08-push-chains/reference/rust/src/engine/world.rs index e2f22f9..a320da6 100644 --- a/course/08-push-chains/reference/rust/src/engine/world.rs +++ b/course/08-push-chains/reference/rust/src/engine/world.rs @@ -84,6 +84,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, } @@ -99,6 +103,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), }; for owner in 0..2 { @@ -287,10 +293,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Add a card to the library, or replace the card with the same name. @@ -325,6 +348,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/08-push-chains/reference/rust/src/harness.rs b/course/08-push-chains/reference/rust/src/harness.rs index 2ccfdeb..3abc8f4 100644 --- a/course/08-push-chains/reference/rust/src/harness.rs +++ b/course/08-push-chains/reference/rust/src/harness.rs @@ -55,6 +55,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -146,6 +188,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- diff --git a/course/08-push-chains/reference/ts/src/engine/event.ts b/course/08-push-chains/reference/ts/src/engine/event.ts index 06c814a..d25fbbc 100644 --- a/course/08-push-chains/reference/ts/src/engine/event.ts +++ b/course/08-push-chains/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/08-push-chains/reference/ts/src/engine/queue.ts b/course/08-push-chains/reference/ts/src/engine/queue.ts index d8aa76e..c177041 100644 --- a/course/08-push-chains/reference/ts/src/engine/queue.ts +++ b/course/08-push-chains/reference/ts/src/engine/queue.ts @@ -192,7 +192,10 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); } } } diff --git a/course/08-push-chains/reference/ts/src/engine/world.ts b/course/08-push-chains/reference/ts/src/engine/world.ts index 4cfb022..76f159a 100644 --- a/course/08-push-chains/reference/ts/src/engine/world.ts +++ b/course/08-push-chains/reference/ts/src/engine/world.ts @@ -46,6 +46,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; /** Until a chooser is set, take the first option. */ @@ -212,10 +216,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Add a card to the library, or replace the card with the same name. */ @@ -248,6 +268,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/08-push-chains/reference/ts/src/harness.ts b/course/08-push-chains/reference/ts/src/harness.ts index dc98aa1..a9b0bf9 100644 --- a/course/08-push-chains/reference/ts/src/harness.ts +++ b/course/08-push-chains/reference/ts/src/harness.ts @@ -46,6 +46,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -65,6 +86,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence // Module 5 @@ -104,6 +126,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), diff --git a/course/09-turn-pipeline/reference/rust/src/engine/event.rs b/course/09-turn-pipeline/reference/rust/src/engine/event.rs index 0eb180b..8c1b0a8 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/event.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs index 7779d7f..68c9127 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs @@ -246,8 +246,11 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); let owner = world.owner_of(source); run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); } } } diff --git a/course/09-turn-pipeline/reference/rust/src/engine/world.rs b/course/09-turn-pipeline/reference/rust/src/engine/world.rs index e2f22f9..a320da6 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/world.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/world.rs @@ -84,6 +84,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, } @@ -99,6 +103,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), }; for owner in 0..2 { @@ -287,10 +293,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Add a card to the library, or replace the card with the same name. @@ -325,6 +348,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/09-turn-pipeline/reference/rust/src/harness.rs b/course/09-turn-pipeline/reference/rust/src/harness.rs index 01d507f..02ab7e9 100644 --- a/course/09-turn-pipeline/reference/rust/src/harness.rs +++ b/course/09-turn-pipeline/reference/rust/src/harness.rs @@ -55,6 +55,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -146,6 +188,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- diff --git a/course/09-turn-pipeline/reference/ts/src/engine/event.ts b/course/09-turn-pipeline/reference/ts/src/engine/event.ts index 06c814a..d25fbbc 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/event.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts index d8aa76e..c177041 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts @@ -192,7 +192,10 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); } } } diff --git a/course/09-turn-pipeline/reference/ts/src/engine/world.ts b/course/09-turn-pipeline/reference/ts/src/engine/world.ts index 4cfb022..76f159a 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/world.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/world.ts @@ -46,6 +46,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; /** Until a chooser is set, take the first option. */ @@ -212,10 +216,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Add a card to the library, or replace the card with the same name. */ @@ -248,6 +268,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/09-turn-pipeline/reference/ts/src/harness.ts b/course/09-turn-pipeline/reference/ts/src/harness.ts index 3faf4b1..1c562e2 100644 --- a/course/09-turn-pipeline/reference/ts/src/harness.ts +++ b/course/09-turn-pipeline/reference/ts/src/harness.ts @@ -46,6 +46,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -65,6 +86,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence // Module 5 @@ -104,6 +126,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), diff --git a/course/10-spellgrid/lesson.md b/course/10-spellgrid/lesson.md index 74469ce..50d0e19 100644 --- a/course/10-spellgrid/lesson.md +++ b/course/10-spellgrid/lesson.md @@ -258,7 +258,7 @@ node tools/play-spellgrid.mjs ts l1 # TypeScript: npx tsx src/cli.ts leve The level is `l1`, `l2` or `l3` (or a path to a level file of your own). `--dir ` stages into a directory you choose and keeps it, and `--stage-only` prints the command without running it. The tool prints where it staged, so you can run the command there again. -`w`, `a`, `s`, `d` play a turn; `c [x y]` casts a spell (at the minion on that tile, if given) or plays a minion (onto that empty tile); `q` quits. Graft asks for its base, donor and half; answer with a number or a name. Writing your own is optional: the course's tests don't call it. The Rust and the TypeScript command lines print the same text for the same input, and `node tools/diff-tracks.mjs` checks that on random levels and scripts. +`w`, `a`, `s`, `d` play a turn; `c [x y]` casts a spell (at the minion on that tile, if given) or plays a minion (onto that empty tile); `q` quits. Graft asks for its base, donor and half; answer with a number or a name. After a command that ran Part I Sequences, it prints their events under `Log:` as an indented tree (`logTree()`, from Module 2). Writing your own is optional: the course's tests don't call it. The Rust and the TypeScript command lines print the same text for the same input, and `node tools/diff-tracks.mjs` checks that on random levels and scripts. ## Name it diff --git a/course/10-spellgrid/reference/rust/src/cli.rs b/course/10-spellgrid/reference/rust/src/cli.rs index 4104e1a..2e0e5b3 100644 --- a/course/10-spellgrid/reference/rust/src/cli.rs +++ b/course/10-spellgrid/reference/rust/src/cli.rs @@ -1,7 +1,8 @@ //! SpellGrid on the command line: `cargo run --bin spellgrid -- levels/l1-double-tap.json`. //! //! A thin loop over the harness: it draws the level, reads a command, and calls -//! `step`, `cast` or `play`. No rules live here. +//! `step`, `cast` or `play`. No rules live here. After a command that ran Part I +//! Sequences, it prints their events under `Log:` as an indented tree. //! //! ```text //! w a s d one turn: up, left, down, right @@ -159,6 +160,8 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc>) -> Outcome { return Outcome::Quit; }; let words: Vec<&str> = line.split_whitespace().collect(); + // The log holds only what this command does. + g.clear_log(); let dir = match words.first() { Some(&"q") => return Outcome::Quit, Some(&"w") => Some(Dir::Up), @@ -189,5 +192,9 @@ fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc>) -> Outcome { if let Err(message) = done { io.borrow_mut().write(&format!("{message}\n")); } + let tree = g.log_tree(); + if !tree.is_empty() { + io.borrow_mut().write(&format!("Log:\n{tree}")); + } } } diff --git a/course/10-spellgrid/reference/rust/src/engine/event.rs b/course/10-spellgrid/reference/rust/src/engine/event.rs index 149737f..115f636 100644 --- a/course/10-spellgrid/reference/rust/src/engine/event.rs +++ b/course/10-spellgrid/reference/rust/src/engine/event.rs @@ -1,4 +1,5 @@ //! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). use super::card::TriggerOn; use super::world::{EntityId, PlayerId}; diff --git a/course/10-spellgrid/reference/rust/src/engine/queue.rs b/course/10-spellgrid/reference/rust/src/engine/queue.rs index f5bdf33..b498f4f 100644 --- a/course/10-spellgrid/reference/rust/src/engine/queue.rs +++ b/course/10-spellgrid/reference/rust/src/engine/queue.rs @@ -275,8 +275,11 @@ fn react(world: &mut World, events: &[Event]) { } _ => world.board_index(source), }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(); let owner = world.owner_of(source); run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); } } } diff --git a/course/10-spellgrid/reference/rust/src/engine/world.rs b/course/10-spellgrid/reference/rust/src/engine/world.rs index 84b66fc..d575e07 100644 --- a/course/10-spellgrid/reference/rust/src/engine/world.rs +++ b/course/10-spellgrid/reference/rust/src/engine/world.rs @@ -85,6 +85,10 @@ pub struct World { sides: [Side; 2], next_order_of_play: u32, log: Vec, + /// The nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// How many triggers are resolving right now, one inside another. + depth: u32, /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). auras: Vec, /// Module 10: auras that no minion carries, for every player's minions. SpellGrid @@ -105,6 +109,8 @@ impl World { sides: Default::default(), next_order_of_play: 0, log: Vec::new(), + depths: Vec::new(), + depth: 0, auras: Vec::new(), rule_auras: Vec::new(), end_of_phase: END_OF_PHASE.to_vec(), @@ -310,10 +316,27 @@ impl World { &self.log } + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + // ---- Changes: each one records its event ---------------------------- pub fn record(&mut self, event: Event) { self.log.push(event); + self.depths.push(self.depth); + } + + /// A trigger starts resolving: the events it causes are recorded one level deeper. + pub fn open_block(&mut self) { + self.depth += 1; + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.depth -= 1; } /// Add a card to the library, or replace the card with the same name. @@ -358,6 +381,7 @@ impl World { pub fn clear_log(&mut self) { self.log.clear(); + self.depths.clear(); } pub fn damage(&mut self, target: EntityId, amount: i32) { diff --git a/course/10-spellgrid/reference/rust/src/harness.rs b/course/10-spellgrid/reference/rust/src/harness.rs index cd362fa..08d4df4 100644 --- a/course/10-spellgrid/reference/rust/src/harness.rs +++ b/course/10-spellgrid/reference/rust/src/harness.rs @@ -60,6 +60,48 @@ impl GameEvent { } } +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + pub struct Game { world: World, } @@ -151,6 +193,10 @@ impl Game { pub fn clear_log(&mut self) { self.world.clear_log(); } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } // ---- Module 3 ---- @@ -396,42 +442,45 @@ impl SpellGrid { .map(|&id| world.card_name(id).to_string()) .collect() } + /// The course's view of an engine event, with every entity id as a grid id. + fn grid_event(&self, event: &Event) -> Option { + let id = |entity: Id| self.grid.id_of(entity); + Some(match GameEvent::from_engine(event)? { + GameEvent::Summoned { id: e } => GameEvent::Summoned { id: id(e) }, + GameEvent::Destroyed { id: e } => GameEvent::Destroyed { id: id(e) }, + GameEvent::Drew { player, id: e } => GameEvent::Drew { player, id: id(e) }, + GameEvent::StatsChanged { + id: e, + attack, + health, + } => GameEvent::StatsChanged { + id: id(e), + attack, + health, + }, + GameEvent::Damaged { target, amount } => GameEvent::Damaged { + target: id(target), + amount, + }, + GameEvent::Triggered { source, trigger } => GameEvent::Triggered { + source: id(source), + trigger, + }, + other => other, + }) + } /// Part I's log, with grid ids. pub fn log(&self) -> Vec { - let id = |entity: Id| self.grid.id_of(entity); - self.grid - .world() - .log() - .iter() - .filter_map(GameEvent::from_engine) - .map(|event| match event { - GameEvent::Summoned { id: e } => GameEvent::Summoned { id: id(e) }, - GameEvent::Destroyed { id: e } => GameEvent::Destroyed { id: id(e) }, - GameEvent::Drew { player, id: e } => GameEvent::Drew { player, id: id(e) }, - GameEvent::StatsChanged { - id: e, - attack, - health, - } => GameEvent::StatsChanged { - id: id(e), - attack, - health, - }, - GameEvent::Damaged { target, amount } => GameEvent::Damaged { - target: id(target), - amount, - }, - GameEvent::Triggered { source, trigger } => GameEvent::Triggered { - source: id(source), - trigger, - }, - other => other, - }) - .collect() + let log = self.grid.world().log(); + log.iter().filter_map(|e| self.grid_event(e)).collect() } pub fn clear_log(&mut self) { self.grid.world_mut().clear_log(); } + /// Part I's log as an indented tree, with grid ids (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(self.grid.world(), |e| self.grid_event(e)) + } /// Who makes Assemble's choices (`"base"`, `"donor"`, `"half"`). pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) { self.grid.world_mut().set_chooser(Box::new(chooser)); diff --git a/course/10-spellgrid/reference/rust/tests/cli.rs b/course/10-spellgrid/reference/rust/tests/cli.rs index 7486a60..885b815 100644 --- a/course/10-spellgrid/reference/rust/tests/cli.rs +++ b/course/10-spellgrid/reference/rust/tests/cli.rs @@ -53,6 +53,30 @@ fn cli_wins_level_2_with_choices_and_play() { assert!(out.contains("Hand: Ember Moth (Assembled)")); } +#[test] +fn cli_prints_the_log_tree_of_each_command() { + let level = include_str!("../levels/l1-double-tap.json"); + let (_, out) = play(level, &["w", "w", "c Whirlwind", "q"]); + // The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested. + assert_eq!(out.matches("Log:\n").count(), 1, "{out}"); + let tree = [ + "Log:", + "SpellCast{card: \"Whirlwind\"}", + "Damaged{target: 32, amount: 1}", + "Damaged{target: 33, amount: 1}", + "Destroyed{id: 32}", + "DeathPhase{round: 1}", + "Triggered{source: 32, trigger: \"deathrattle\"}", + " Damaged{target: 33, amount: 1}", + "Triggered{source: 32, trigger: \"deathrattle\"}", + " Damaged{target: 33, amount: 1}", + "Destroyed{id: 33}", + "DeathPhase{round: 2}", + "", + ]; + assert!(out.contains(&tree.join("\n")), "{out}"); +} + #[test] fn cli_reports_errors_and_quits() { let input = ["c Spark", "x", "q"]; diff --git a/course/10-spellgrid/reference/ts/src/cli.ts b/course/10-spellgrid/reference/ts/src/cli.ts index a5d266a..03b853b 100644 --- a/course/10-spellgrid/reference/ts/src/cli.ts +++ b/course/10-spellgrid/reference/ts/src/cli.ts @@ -1,7 +1,8 @@ // SpellGrid on the command line: `npx tsx src/cli.ts levels/l1-double-tap.json`. // // A thin loop over the harness: it draws the level, reads a command, and calls -// `step`, `cast` or `play`. No rules live here. +// `step`, `cast` or `play`. No rules live here. After a command that ran Part I +// Sequences, it prints their events under `Log:` as an indented tree. // // w a s d one turn: up, left, down, right // c [x y] cast a spell from your hand (at the minion on (x, y), if given), @@ -87,6 +88,8 @@ export function run(levelText: string, readLine: () => string | undefined, write const line = readLine(); if (line === undefined) return "quit"; const [command, ...args] = line.trim().split(/\s+/); + // The log holds only what this command does. + g.clearLog(); try { if (command === "q") return "quit"; if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]); @@ -100,6 +103,8 @@ export function run(levelText: string, readLine: () => string | undefined, write } catch (e) { write(`${e instanceof Error ? e.message : String(e)}\n`); } + const tree = g.logTree(); + if (tree !== "") write(`Log:\n${tree}`); } } diff --git a/course/10-spellgrid/reference/ts/src/engine/event.ts b/course/10-spellgrid/reference/ts/src/engine/event.ts index 0272cab..58a4509 100644 --- a/course/10-spellgrid/reference/ts/src/engine/event.ts +++ b/course/10-spellgrid/reference/ts/src/engine/event.ts @@ -1,4 +1,5 @@ // What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). import type { TriggerOn } from "./card"; import type { EntityId, PlayerId } from "./world"; diff --git a/course/10-spellgrid/reference/ts/src/engine/queue.ts b/course/10-spellgrid/reference/ts/src/engine/queue.ts index 50bfdc0..989e0b9 100644 --- a/course/10-spellgrid/reference/ts/src/engine/queue.ts +++ b/course/10-spellgrid/reference/ts/src/engine/queue.ts @@ -223,7 +223,10 @@ function react(world: World, events: Event[]): void { event.type === "Destroyed" && event.id === source ? positionLeftOf(world, world.ownerOf(source), event.before) : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); } } } diff --git a/course/10-spellgrid/reference/ts/src/engine/world.ts b/course/10-spellgrid/reference/ts/src/engine/world.ts index 8e33878..eef387c 100644 --- a/course/10-spellgrid/reference/ts/src/engine/world.ts +++ b/course/10-spellgrid/reference/ts/src/engine/world.ts @@ -47,6 +47,10 @@ export class World { private readonly sides: [Side, Side]; private nextOrderOfPlay = 0; private events: Event[] = []; + /** The nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** How many triggers are resolving right now, one inside another. */ + private depth = 0; /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ private auras: ActiveAura[] = []; /** Until a chooser is set, take the first option. */ @@ -235,10 +239,26 @@ export class World { return this.events; } + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + // ---- Changes: each one records its event ---- record(event: Event): void { this.events.push(event); + this.depths.push(this.depth); + } + + /** A trigger starts resolving: the events it causes are recorded one level deeper. */ + openBlock(): void { + this.depth++; + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.depth--; } /** Add a card to the library, or replace the card with the same name. */ @@ -281,6 +301,7 @@ export class World { clearLog(): void { this.events = []; + this.depths = []; } damage(target: EntityId, amount: number): void { diff --git a/course/10-spellgrid/reference/ts/src/harness.ts b/course/10-spellgrid/reference/ts/src/harness.ts index 90a4172..853ecaa 100644 --- a/course/10-spellgrid/reference/ts/src/harness.ts +++ b/course/10-spellgrid/reference/ts/src/harness.ts @@ -50,6 +50,27 @@ function toGameEvent(event: Event): GameEvent | undefined { return { ...event }; } +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + export interface Game { summon(card: string, player?: Player): Id; // to right end of board addToHand(card: string, player?: Player): Id; @@ -69,6 +90,7 @@ export interface Game { armor(player: Player): number; log(): GameEvent[]; clearLog(): void; + logTree(): string; // the log as an indented tree // Module 3 destroy(target: Id): void; // marks pending destroy, one Sequence // Module 5 @@ -108,6 +130,7 @@ export function newGame(opts?: { decks?: [string[], string[]] }): Game { armor: (player) => world.armor(player), log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), clearLog: () => world.clearLog(), + logTree: () => logTree(world, toGameEvent), destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), @@ -185,6 +208,7 @@ export interface SpellGrid extends Baba { hand(): string[]; // card names in player 0's hand, in order log(): GameEvent[]; // Part I's log, with grid ids clearLog(): void; + logTree(): string; // Part I's log as an indented tree, with grid ids setChooser(choose: Chooser): void; // makes Assemble's choices withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step // For the command line: why `cast`/`play` would fail, or null if they wouldn't @@ -246,6 +270,11 @@ export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGri .flatMap((event) => toGameEvent(event) ?? []) .map(withIds), clearLog: () => g.world.clearLog(), + logTree: () => + logTree(g.world, (event) => { + const shown = toGameEvent(event); + return shown === undefined ? undefined : withIds(shown); + }), setChooser: (choose) => g.world.setChooser(choose), // The naive engine reads the auras before the dead leave play: the same steps, reversed. withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()), diff --git a/course/10-spellgrid/reference/ts/tests/cli.test.ts b/course/10-spellgrid/reference/ts/tests/cli.test.ts index 87c17f3..5ea03e4 100644 --- a/course/10-spellgrid/reference/ts/tests/cli.test.ts +++ b/course/10-spellgrid/reference/ts/tests/cli.test.ts @@ -24,6 +24,29 @@ test("cli_wins_level_2_with_choices_and_play", () => { expect(out).toContain("Hand: Ember Moth (Assembled)"); }); +test("cli_prints_the_log_tree_of_each_command", () => { + const { out } = play("l1-double-tap", ["w", "w", "c Whirlwind", "q"]); + // The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested. + expect(out.split("Log:\n")).toHaveLength(2); + expect(out).toContain( + [ + "Log:", + 'SpellCast{card: "Whirlwind"}', + "Damaged{target: 32, amount: 1}", + "Damaged{target: 33, amount: 1}", + "Destroyed{id: 32}", + "DeathPhase{round: 1}", + 'Triggered{source: 32, trigger: "deathrattle"}', + " Damaged{target: 33, amount: 1}", + 'Triggered{source: 32, trigger: "deathrattle"}', + " Damaged{target: 33, amount: 1}", + "Destroyed{id: 33}", + "DeathPhase{round: 2}", + "", + ].join("\n"), + ); +}); + test("cli_reports_errors_and_quits", () => { const { outcome, out } = play("l3-simultaneous-paradox", ["c Spark", "x", "q"]); expect(outcome).toBe("quit"); diff --git a/course/harness.md b/course/harness.md index 26f38f2..5fca2a7 100644 --- a/course/harness.md +++ b/course/harness.md @@ -9,7 +9,7 @@ Each module adds functions; `start-module` prints the line for the module you st ## Part I (`Game`): additions per module - M1: `newGame(opts?: { decks?: [string[], string[]] }): Game`; `summon(card: string, player?: 0|1): Id` (to right end of board); `addToHand(card, player?)`, `addToDeck(card, player?)`; `play(handId: Id): void` (Expansion Drop; required from M4); `zoneOf(id): "deck"|"hand"|"board"|"graveyard"`; `board(player): Id[]` (left to right); `hand(player): Id[]`; `orderOfPlay(id): number`; `nameOf(id): string`. -- M2: `hero(player): Id`; `dealDamage(target: Id, amount: number): void` (one Sequence); `cast(card: string, player?, target?: Id): void`; `health(id)`, `attack(id)`, `armor(player)`; `log(): GameEvent[]`; `clearLog()`. +- M2: `hero(player): Id`; `dealDamage(target: Id, amount: number): void` (one Sequence); `cast(card: string, player?, target?: Id): void`; `health(id)`, `attack(id)`, `armor(player)`; `log(): GameEvent[]`; `clearLog()`; `logTree(): string` (the log as an indented tree, see below). - M3: `destroy(target: Id): void` (marks pending-destroy, one Sequence). - M4: no new functions; `play(handId)` (the M1 Expansion Drop) is now required: it runs one Sequence in which the minion enters the right end of its owner's board (`Summoned{id}`), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). The M4 tests also use `Baron Rivendare`, `Novice Engineer` and `Twisting Nether` (the M3 Expansion Drop card). - M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `" (Assembled)"`; throws `AssembleError` if text unchanged); `cost(id): number`; `setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void` (makes Assemble's choices). `CardDef` is the card format of `course/schema/card.schema.json`; `newGame` loads every card file in the workspace's `cards/` directory. @@ -32,6 +32,7 @@ Each module adds functions; `start-module` prints the line for the module you st | M2 | `cast(card, player?, target?)` | `cast(&mut self, card: &str, player: Player, target: Option)` | | M2 | `health(id)`, `attack(id)`, `armor(player)` | `health`, `attack`, `armor` | | M2 | `log()`, `clearLog()` | `log(&self) -> Vec`, `clear_log(&mut self)` | +| M2 | `logTree()` | `log_tree(&self) -> String` | | M3 | `destroy(target)` | `destroy(&mut self, target: Id)` | | M4 | `play(handId)` now required (one Sequence; Battlecry) | `play(&mut self, hand_id: Id)` | | M5 | `registerCards(defs)` | `register_cards(&mut self, defs: Vec)` | @@ -47,6 +48,8 @@ Tagged by `type`: `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, Drawing from an empty deck logs `Fatigue{player}` and does nothing else; the engine must not panic. +`logTree()` is the same events as `log()`, as text: one event per line, each line ending with a newline (an empty log gives `""`). Each event is written as `scenarios.md` writes it, the type then its fields in the order above, strings in double quotes: `Triggered{source: 3, trigger: "deathrattle"}`. Each line is indented two spaces per level of nesting. The outermost action's own events are at depth 0, and so is each trigger in the Queue they fill; the events a resolving trigger causes are one level deeper than its `Triggered` line. The Death Creation Step and the Death Phase (from M3) run after the outermost Phase, so `Destroyed`, `DeathPhase` and each Deathrattle's `Triggered` are at depth 0. + Rust shape (settled in Module 2), defined in `src/harness.rs` with `#[derive(Debug, Clone, PartialEq, Eq)]`: ```rust @@ -131,7 +134,7 @@ From M9, `step(dir)` plays one whole turn, in the course's simplified order: mov ## Part III (`SpellGrid`) -- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast(card, target?)`, `play(card, x, y): Id`, `health(id)`, `hero(player)`, `nameOf(id)`, `minionAt(x, y): Id | null`, `hand(): string[]`, `log()`, `clearLog()`, `setChooser(choose)`, `won()`, and the test hook `withNaiveAuraTiming()`. Minions are grid objects whose noun is their card's `"noun"` field. +- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast(card, target?)`, `play(card, x, y): Id`, `health(id)`, `hero(player)`, `nameOf(id)`, `minionAt(x, y): Id | null`, `hand(): string[]`, `log()`, `clearLog()`, `logTree()`, `setChooser(choose)`, `won()`, and the test hook `withNaiveAuraTiming()`. Minions are grid objects whose noun is their card's `"noun"` field. Rust: `new_spell_grid(level: LevelDef, cards: Vec) -> SpellGrid`. @@ -145,6 +148,7 @@ Rust: `new_spell_grid(level: LevelDef, cards: Vec) -> SpellGrid`. | `minionAt(x, y): Id \| null` | `minion_at(&self, x: i32, y: i32) -> Option` | | `hand(): string[]` | `hand(&self) -> Vec` | | `log()`, `clearLog()` | `log(&self) -> Vec`, `clear_log(&mut self)` | +| `logTree()` | `log_tree(&self) -> String` (Part I's log as a tree, with grid ids) | | `setChooser(choose)` | `set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)` | | `withNaiveAuraTiming()` | `with_naive_aura_timing(&mut self)` |