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

@@ -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),
};
}