feat: log tree from Module 2 (logTree / log_tree); SpellGrid CLIs print Log:

Engine events carry the nesting depth they were recorded at; the harness
prints the log as an indented tree. New tests test_02_log_tree_shows_nesting
and test_03_log_tree_death_phase; both SpellGrid CLIs print the tree of the
last command's Sequences under a Log: line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 10:23:59 +01:00
1 parent 06a1b34c5d
commit 97c64ebee8
86 files changed
+1375 -55

No files matched your search

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