From 8a4eba194859980cc698340c61e1242325d058d0 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 02:42:12 +0100 Subject: [PATCH] feat: module 03 death step Deferred death: damage and destroy only mark minions; the Death Creation Step removes them when the outermost Phase ends, and Death Phases resolve Deathrattles in order of play until no new deaths occur. Deathrattle summons use the board position the Death Event remembered. Harness adds destroy(target). Expansion Drop: Twisting Nether as content only. test_02_mortally_wounded_still_triggers no longer asserts the Acolyte is still on the board: from Module 3 on it leaves at the end of the Sequence, and verify-references runs Module 2's tests against the Module 3 reference. Co-Authored-By: Claude Opus 5.5 --- course/02-action-queue/lesson.md | 4 +- course/02-action-queue/scenarios.md | 2 +- course/02-action-queue/tests/rust/test_02.rs | 1 - .../02-action-queue/tests/ts/test_02.test.ts | 1 - course/03-death-step/expansion/README.md | 21 + .../solution/rust/src/content/mod.rs | 73 + .../solution/ts/src/content/index.ts | 50 + .../expansion/tests/rust/test_03x.rs | 52 + .../expansion/tests/ts/test_03x.test.ts | 37 + course/03-death-step/lesson.md | 355 ++++ .../03-death-step/reference/rust/.course.json | 1 + .../03-death-step/reference/rust/.gitignore | 1 + .../03-death-step/reference/rust/Cargo.toml | 7 + course/03-death-step/reference/rust/README.md | 14 + .../reference/rust/src/content/mod.rs | 67 + .../reference/rust/src/engine/card.rs | 99 ++ .../reference/rust/src/engine/death.rs | 30 + .../reference/rust/src/engine/event.rs | 47 + .../reference/rust/src/engine/mod.rs | 23 + .../reference/rust/src/engine/queue.rs | 217 +++ .../reference/rust/src/engine/world.rs | 296 ++++ .../reference/rust/src/harness.rs | 149 ++ .../03-death-step/reference/rust/src/lib.rs | 3 + .../03-death-step/reference/ts/.course.json | 1 + course/03-death-step/reference/ts/.gitignore | 1 + course/03-death-step/reference/ts/README.md | 14 + .../reference/ts/package-lock.json | 1496 +++++++++++++++++ .../03-death-step/reference/ts/package.json | 14 + .../reference/ts/src/content/index.ts | 48 + .../reference/ts/src/engine/card.ts | 69 + .../reference/ts/src/engine/death.ts | 21 + .../reference/ts/src/engine/event.ts | 20 + .../reference/ts/src/engine/index.ts | 26 + .../reference/ts/src/engine/queue.ts | 166 ++ .../reference/ts/src/engine/world.ts | 227 +++ .../03-death-step/reference/ts/src/harness.ts | 89 + .../03-death-step/reference/ts/tsconfig.json | 12 + .../reference/ts/vitest.config.ts | 5 + course/03-death-step/scenarios.md | 56 + course/03-death-step/tests/rust/test_03.rs | 157 ++ course/03-death-step/tests/ts/test_03.test.ts | 113 ++ 41 files changed, 4080 insertions(+), 5 deletions(-) create mode 100644 course/03-death-step/expansion/README.md create mode 100644 course/03-death-step/expansion/solution/rust/src/content/mod.rs create mode 100644 course/03-death-step/expansion/solution/ts/src/content/index.ts create mode 100644 course/03-death-step/expansion/tests/rust/test_03x.rs create mode 100644 course/03-death-step/expansion/tests/ts/test_03x.test.ts create mode 100644 course/03-death-step/lesson.md create mode 100644 course/03-death-step/reference/rust/.course.json create mode 100644 course/03-death-step/reference/rust/.gitignore create mode 100644 course/03-death-step/reference/rust/Cargo.toml create mode 100644 course/03-death-step/reference/rust/README.md create mode 100644 course/03-death-step/reference/rust/src/content/mod.rs create mode 100644 course/03-death-step/reference/rust/src/engine/card.rs create mode 100644 course/03-death-step/reference/rust/src/engine/death.rs create mode 100644 course/03-death-step/reference/rust/src/engine/event.rs create mode 100644 course/03-death-step/reference/rust/src/engine/mod.rs create mode 100644 course/03-death-step/reference/rust/src/engine/queue.rs create mode 100644 course/03-death-step/reference/rust/src/engine/world.rs create mode 100644 course/03-death-step/reference/rust/src/harness.rs create mode 100644 course/03-death-step/reference/rust/src/lib.rs create mode 100644 course/03-death-step/reference/ts/.course.json create mode 100644 course/03-death-step/reference/ts/.gitignore create mode 100644 course/03-death-step/reference/ts/README.md create mode 100644 course/03-death-step/reference/ts/package-lock.json create mode 100644 course/03-death-step/reference/ts/package.json create mode 100644 course/03-death-step/reference/ts/src/content/index.ts create mode 100644 course/03-death-step/reference/ts/src/engine/card.ts create mode 100644 course/03-death-step/reference/ts/src/engine/death.ts create mode 100644 course/03-death-step/reference/ts/src/engine/event.ts create mode 100644 course/03-death-step/reference/ts/src/engine/index.ts create mode 100644 course/03-death-step/reference/ts/src/engine/queue.ts create mode 100644 course/03-death-step/reference/ts/src/engine/world.ts create mode 100644 course/03-death-step/reference/ts/src/harness.ts create mode 100644 course/03-death-step/reference/ts/tsconfig.json create mode 100644 course/03-death-step/reference/ts/vitest.config.ts create mode 100644 course/03-death-step/scenarios.md create mode 100644 course/03-death-step/tests/rust/test_03.rs create mode 100644 course/03-death-step/tests/ts/test_03.test.ts diff --git a/course/02-action-queue/lesson.md b/course/02-action-queue/lesson.md index 51ca314..5906cdc 100644 --- a/course/02-action-queue/lesson.md +++ b/course/02-action-queue/lesson.md @@ -70,7 +70,7 @@ Each player has a hero with 30 Health, in play from the start. `hero(player)` re The four 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, ends at 0 Health, and is still on the board. +- `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_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. @@ -140,7 +140,7 @@ Make the four tests pass without breaking Module 1's. The rules for where code g 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. -Don't build deaths. A minion at 0 Health stays on the board, and `test_02_mortally_wounded_still_triggers` checks exactly that. Module 3 adds the step that removes it. +Don't build deaths. A minion at 0 Health stays on the board and keeps reacting, which is what `test_02_mortally_wounded_still_triggers` relies on. Module 3 adds the step that removes it, at the end of the Sequence.
Hint 1 — nudge diff --git a/course/02-action-queue/scenarios.md b/course/02-action-queue/scenarios.md index b01a329..eb3aa52 100644 --- a/course/02-action-queue/scenarios.md +++ b/course/02-action-queue/scenarios.md @@ -32,7 +32,7 @@ Heroes have 30 Health and 0 Armor at the start of a game. - The log contains `Triggered{source: A, trigger: "damaged"}` and a `Drew` event for player 0. - `hand(0)` has N + 1 cards. - `health(A)` is 0. - - `zoneOf(A)` is still board. Module 2 has no death step; Module 3 adds one. + - The test does not check `zoneOf(A)`. Module 2 has no death step, so A stays on the board at 0 Health. Module 3 adds the step that removes A once the Sequence's reactions have resolved, and this test must keep passing then. ### test_02_depth_first_nesting diff --git a/course/02-action-queue/tests/rust/test_02.rs b/course/02-action-queue/tests/rust/test_02.rs index ba28d9a..157abdf 100644 --- a/course/02-action-queue/tests/rust/test_02.rs +++ b/course/02-action-queue/tests/rust/test_02.rs @@ -85,7 +85,6 @@ fn test_02_mortally_wounded_still_triggers() { .any(|e| matches!(e, GameEvent::Drew { player: 0, .. }))); assert_eq!(g.hand(0).len(), n + 1); assert_eq!(g.health(a), 0); - assert_eq!(g.zone_of(a), Zone::Board); } #[test] diff --git a/course/02-action-queue/tests/ts/test_02.test.ts b/course/02-action-queue/tests/ts/test_02.test.ts index 38df22a..07e28fd 100644 --- a/course/02-action-queue/tests/ts/test_02.test.ts +++ b/course/02-action-queue/tests/ts/test_02.test.ts @@ -49,7 +49,6 @@ test("test_02_mortally_wounded_still_triggers", () => { expect(log.some((e) => e.type === "Drew" && e.player === 0)).toBe(true); expect(g.hand(0)).toHaveLength(n + 1); expect(g.health(a)).toBe(0); - expect(g.zoneOf(a)).toBe("board"); }); test("test_02_depth_first_nesting", () => { diff --git a/course/03-death-step/expansion/README.md b/course/03-death-step/expansion/README.md new file mode 100644 index 0000000..bd1bc4b --- /dev/null +++ b/course/03-death-step/expansion/README.md @@ -0,0 +1,21 @@ +# Module 3 Expansion Drop: Twisting Nether + +The lesson's [Expansion Drop](../lesson.md#expansion-drop) beat explains the task. This file holds the scenario, the test locations and the check. + +- Card: Twisting Nether, 8-mana spell, "Destroy all minions and locations." ([Twisting Nether, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Twisting_Nether)). The course has no locations, so in this course it destroys all minions. +- Tests: `tests/rust/test_03x.rs` and `tests/ts/test_03x.test.ts`. Copy the one for your track into your workspace's `tests/`. +- Reference answer: `solution/rust/src/content/mod.rs` and `solution/ts/src/content/index.ts`. Each is the Module 3 reference content with one card added. Nothing under `src/engine/` changes, and neither does the harness. +- Check: commit your workspace before you start, then run `node tools/expansion-check.mjs ` from the course repository. It fails if any file under `src/engine/` changed. + +## Scenario + +### test_03x_twisting_nether_destroys_all + +- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Loot Hoarder` for player 0 (id H), then `Harvest Golem` for player 0 (id G), then `Test Filler` for player 1 (id F). Call `clearLog()`. +- Action: player 0 casts `Twisting Nether`. +- Assertions: + - `board(0)` has one minion; call it D. `hand(0)` has one card; call it C. + - The log is exactly, in this order: `SpellCast{card: "Twisting Nether"}`, `Destroyed{id: H}`, `Destroyed{id: G}`, `Destroyed{id: F}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C}`, `Triggered{source: G, trigger: "deathrattle"}`, `Summoned{id: D}`. All three minions, on both sides, leave play together before any Deathrattle resolves. D is summoned after the Death Creation Step, so the destroy never touches it, and there is no second Death Phase. + - `nameOf(D)` is `"Damaged Golem"`, and `board(1)` is empty. + - `zoneOf` is graveyard for H, G and F. + - `health(H)` is 1, `health(G)` is 3 and `health(F)` is 1: nothing was damaged. diff --git a/course/03-death-step/expansion/solution/rust/src/content/mod.rs b/course/03-death-step/expansion/solution/rust/src/content/mod.rs new file mode 100644 index 0000000..bc50447 --- /dev/null +++ b/course/03-death-step/expansion/solution/rust/src/content/mod.rs @@ -0,0 +1,73 @@ +//! Content: card definitions. Only this directory may name specific cards. +//! +//! Each effect is a small Rust function that returns engine actions. Module 5 +//! replaces these functions with card data the engine interprets. + +use crate::engine::{Action, CardDef, CardLibrary, TriggerOn}; + +/// Every card this game knows. +pub fn library() -> CardLibrary { + CardLibrary::new(vec![ + // Fictional test minion with no text. + CardDef::minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + CardDef::minion("Armorsmith", 1, 4).on(TriggerOn::FriendlyMinionDamaged, |c| { + vec![Action::GainArmor { + player: c.owner, + amount: 1, + }] + }), + // "Whenever this minion takes damage, draw a card." + CardDef::minion("Acolyte of Pain", 1, 4).on(TriggerOn::SelfDamaged, |c| { + vec![Action::Draw { player: c.owner }] + }), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + CardDef::minion("Test Echo", 1, 5).on(TriggerOn::AnyMinionDamaged, |c| { + vec![Action::Damage { + targets: vec![c.enemy_hero()], + amount: 1, + }] + }), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + CardDef::minion("Test Reactor", 1, 5).on(TriggerOn::HeroDamaged, |c| { + vec![Action::GainAttack { + id: c.minion(), + amount: 1, + }] + }), + // "Deathrattle: Draw a card." + CardDef::minion("Loot Hoarder", 2, 1).on(TriggerOn::Deathrattle, |c| { + vec![Action::Draw { player: c.owner }] + }), + // "Deathrattle: Summon a 2/1 Damaged Golem." + CardDef::minion("Harvest Golem", 2, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Summon { + card: "Damaged Golem".to_string(), + player: c.owner, + at: c.position, + }] + }), + // Summoned by Harvest Golem; no text. + CardDef::minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + CardDef::minion("Unstable Ghoul", 1, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // "Deal 1 damage to ALL minions." + CardDef::spell("Whirlwind", |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // Expansion Drop: "Destroy all minions and locations." The course has no locations. + CardDef::spell("Twisting Nether", |c| { + vec![Action::Destroy { + targets: c.world.minions_in_play(), + }] + }), + ]) +} diff --git a/course/03-death-step/expansion/solution/ts/src/content/index.ts b/course/03-death-step/expansion/solution/ts/src/content/index.ts new file mode 100644 index 0000000..563cd2f --- /dev/null +++ b/course/03-death-step/expansion/solution/ts/src/content/index.ts @@ -0,0 +1,50 @@ +// Content: card definitions. Only this directory may name specific cards. +// +// Each effect is a small function that returns engine actions. Module 5 +// replaces these functions with card data the engine interprets. + +import { CardLibrary, minion, spell } from "../engine"; + +/** Every card this game knows. */ +export function library(): CardLibrary { + return new CardLibrary([ + // Fictional test minion with no text. + minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + minion("Armorsmith", 1, 4, [ + { on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] }, + ]), + // "Whenever this minion takes damage, draw a card." + minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + minion("Test Echo", 1, 5, [ + { on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] }, + ]), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + minion("Test Reactor", 1, 5, [ + { on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] }, + ]), + // "Deathrattle: Draw a card." + minion("Loot Hoarder", 2, 1, [{ on: "deathrattle", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // "Deathrattle: Summon a 2/1 Damaged Golem." + minion("Harvest Golem", 2, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "summon", card: "Damaged Golem", player: c.owner, at: c.position }], + }, + ]), + // Summoned by Harvest Golem; no text. + minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + minion("Unstable Ghoul", 1, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }], + }, + ]), + // "Deal 1 damage to ALL minions." + spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]), + // Expansion Drop: "Destroy all minions and locations." The course has no locations. + spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]), + ]); +} diff --git a/course/03-death-step/expansion/tests/rust/test_03x.rs b/course/03-death-step/expansion/tests/rust/test_03x.rs new file mode 100644 index 0000000..b1f2216 --- /dev/null +++ b/course/03-death-step/expansion/tests/rust/test_03x.rs @@ -0,0 +1,52 @@ +// Module 3 Expansion Drop: Twisting Nether. Matches expansion/README.md. +use engine::harness::*; + +#[test] +fn test_03x_twisting_nether_destroys_all() { + let mut g = new_game(GameOpts { + decks: Some([vec!["Test Filler".to_string(); 5], vec![]]), + }); + let h = g.summon("Loot Hoarder", 0); + let golem = g.summon("Harvest Golem", 0); + let f = g.summon("Test Filler", 1); + g.clear_log(); + + g.cast("Twisting Nether", 0, None); + + let board = g.board(0); + assert_eq!(board.len(), 1); + let d = board[0]; + let hand = g.hand(0); + assert_eq!(hand.len(), 1); + let c = hand[0]; + assert_eq!( + g.log(), + vec![ + GameEvent::SpellCast { + card: "Twisting Nether".to_string() + }, + GameEvent::Destroyed { id: h }, + GameEvent::Destroyed { id: golem }, + GameEvent::Destroyed { id: f }, + GameEvent::DeathPhase { round: 1 }, + GameEvent::Triggered { + source: h, + trigger: "deathrattle".to_string() + }, + GameEvent::Drew { player: 0, id: c }, + GameEvent::Triggered { + source: golem, + trigger: "deathrattle".to_string() + }, + GameEvent::Summoned { id: d }, + ] + ); + assert_eq!(g.name_of(d), "Damaged Golem"); + assert_eq!(g.board(1), Vec::::new()); + for id in [h, golem, f] { + assert_eq!(g.zone_of(id), Zone::Graveyard); + } + assert_eq!(g.health(h), 1); + assert_eq!(g.health(golem), 3); + assert_eq!(g.health(f), 1); +} diff --git a/course/03-death-step/expansion/tests/ts/test_03x.test.ts b/course/03-death-step/expansion/tests/ts/test_03x.test.ts new file mode 100644 index 0000000..7abac54 --- /dev/null +++ b/course/03-death-step/expansion/tests/ts/test_03x.test.ts @@ -0,0 +1,37 @@ +// Module 3 Expansion Drop: Twisting Nether. Matches expansion/README.md. +import { expect, test } from "vitest"; +import { newGame } from "../src/harness"; + +test("test_03x_twisting_nether_destroys_all", () => { + const g = newGame({ decks: [Array(5).fill("Test Filler"), []] }); + const h = g.summon("Loot Hoarder", 0); + const golem = g.summon("Harvest Golem", 0); + const f = g.summon("Test Filler", 1); + g.clearLog(); + + g.cast("Twisting Nether", 0); + + const board = g.board(0); + expect(board).toHaveLength(1); + const d = board[0]; + const hand = g.hand(0); + expect(hand).toHaveLength(1); + const c = hand[0]; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Twisting Nether" }, + { type: "Destroyed", id: h }, + { type: "Destroyed", id: golem }, + { type: "Destroyed", id: f }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d }, + ]); + expect(g.nameOf(d)).toBe("Damaged Golem"); + expect(g.board(1)).toEqual([]); + for (const id of [h, golem, f]) expect(g.zoneOf(id)).toBe("graveyard"); + expect(g.health(h)).toBe(1); + expect(g.health(golem)).toBe(3); + expect(g.health(f)).toBe(1); +}); diff --git a/course/03-death-step/lesson.md b/course/03-death-step/lesson.md new file mode 100644 index 0000000..1e5bf9c --- /dev/null +++ b/course/03-death-step/lesson.md @@ -0,0 +1,355 @@ +# Module 3: Deferred death + +At the end of Module 2, a minion at 0 Health stays on the board forever. That was deliberate: it still has to react to the damage that put it there. In this module your engine decides *when* the dead leave play and what happens next. It adds one checkpoint at the end of every Sequence that removes all the dead at once, then resolves their Deathrattles, then checks again. + +You need your Module 2 workspace with all Module 1 and Module 2 tests passing, under git. + +## Observe + +Three cards ([Loot Hoarder](https://hearthstone.wiki.gg/wiki/Loot_Hoarder), [Harvest Golem](https://hearthstone.wiki.gg/wiki/Harvest_Golem), [Unstable Ghoul](https://hearthstone.wiki.gg/wiki/Unstable_Ghoul)): + +- Loot Hoarder, 2-mana 2/1: "Deathrattle: Draw a card." +- Harvest Golem, 3-mana 2/3: "Deathrattle: Summon a 2/1 Damaged Golem." +- Unstable Ghoul, 2-mana 1/3: "Taunt. Deathrattle: Deal 1 damage to all minions." + +**Damage does not kill on the spot.** Module 2 already used this: a minion at 0 or less Health is *mortally wounded*, and it stays in play and keeps triggering. The rulebook says when that ends: "mortally wounded and pending destroy are ONLY converted into dead once the outermost Phase ends!" Phases nest (every reaction opens one inside the Phase that caused it), "but only the outermost Phase ending initiates the Death Creation Step." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). + +**The Death Creation Step removes everyone at once.** It "Looks for all mortally wounded (0 or less Health)/pending destroy (hit with a destroy effect) Entities", then "sends them from play to the graveyard and creates Death Events for each." (same source). *Pending destroy* is the second way to die: a destroy effect such as Twisting Nether doesn't touch Health, it marks the minion, and the step collects it with the rest. + +**Then a Death Phase.** "Then if any Deaths occurred, a Death Phase starts, resolving Death Events in order of play." Deathrattles are queued there, oldest first, and resolve depth-first like any Module 2 trigger. A Death Phase is itself an outermost Phase, so when it ends the step runs again: "This continues until there are no new Deaths." (same source). + +**Summons remember where the dead stood.** A Deathrattle summon appears "in the board position the Death Event remembered from when it was created." (same source). + +The rulebook's own example uses Unstable Ghoul. With three Ghouls dying in one Phase and an Acolyte of Pain on the board, the Acolyte "is hit, triggered and draws three cards" before it dies (same source): every Ghoul's damage lands, every Acolyte trigger resolves, and only then does the next Death Creation Step take the Acolyte. + +Now a smaller chain. You played Harvest Golem, then Unstable Ghoul. The Golem has taken 2 damage and has 1 Health left. You deal 3 damage to the Ghoul. + +## Predict + +Write your answers down before you open the discussion, for example in `notes/03.md` in your workspace. + +1. Walk through the Golem and Ghoul case event by event. How many Death Phases are there? Where on the board does the Damaged Golem appear? Does the Ghoul's Deathrattle damage the Ghoul? +2. You played Loot Hoarder, then Armorsmith ("Whenever a friendly minion takes damage, gain 1 Armor."), and cast Whirlwind. How much Armor do you gain? When does the Hoarder leave the board: before or after the Armor? +3. Two Loot Hoarders die to the same Whirlwind. Does the first Hoarder's card get drawn before the second Hoarder has left the board? Name a card for which the answer would change the outcome. +4. Your Module 2 engine has one entry point per player action. Where should "remove the dead" live, and why not inside `damage`? + +
Discussion + +1. Two. Damage the Ghoul (0 Health). The outermost Phase ends; the Death Creation Step removes the Ghoul: Death Phase 1. Its Deathrattle damages all minions, which by now means only the Golem (and anything else still in play). The Ghoul has already left play, so its own Deathrattle doesn't hit it. The Golem drops to 0 but stays: it is only mortally wounded, and this Death Phase is still running. When Death Phase 1 ends, the step runs again, removes the Golem, and Death Phase 2 resolves its Deathrattle. The Damaged Golem appears where the Golem stood when its Death Event was created, not at the right end. + +2. 2 Armor. Whirlwind creates both Damage Events (Hoarder, then Armorsmith), Armorsmith reacts to each, and only after that whole Phase does the step remove the Hoarder. The rulebook's Flamestrike example is the same shape: "the Armorsmiths will be mortally wounded but remain in play, triggering on each Damage Event" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). + +3. No. The step removes both Hoarders and creates both Death Events before the Death Phase resolves either Deathrattle. It matters for any Deathrattle that looks at the board. Unstable Ghoul is one: two Ghouls dying together never hit each other, because both have left play before either Deathrattle resolves. If your engine removed and resolved them one at a time, the second Ghoul would take the first one's damage while still on the board. + +4. In the Sequence's entry point, after the action and all its reactions have resolved. `damage` runs inside nested Phases: if it removed the minion, the minion would miss the reactions it is still owed (Module 2's mortally wounded Acolyte), and a removal there would happen mid-Phase, in the middle of somebody else's Queue. One checkpoint at the end of the outermost Phase means one place where minions leave play. + +
+ +## Spec + +From the course repository, copy the module's tests into your workspace: + +```sh +node tools/start-module.mjs 03 ~/my-engine +``` + +This module adds one harness function (the M3 line in `course/harness.md`): + +| TypeScript | Rust | +|---|---| +| `destroy(target)` | `destroy(&mut self, target: Id)` | + +`destroy` runs one Sequence whose only action marks `target` pending destroy. It deals no damage and does not move the minion itself; the Death Creation Step at the end of that Sequence does. + +Two `GameEvent`s, already in your `GameEvent` type since Module 2, now get recorded: + +- `Destroyed{id}`: the Death Creation Step moved `id` from play to the graveyard. One per minion, in order of play. +- `DeathPhase{round}`: a Death Phase begins. `round` counts from 1 within one Sequence. + +A Deathrattle reports `Triggered{source, trigger: "deathrattle"}`, with the dead minion as `source`. + +The four 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_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 Reactor is the fictional "Whenever a hero takes damage, gain +1 Attack." minion from Module 2. No hero takes damage here, so it only stands on the board and gives the Damaged Golem something to land left of. + +The chain test is the heart of the module: + +```rust +#[test] +fn test_03_chained_death_phases() { + 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]; + assert_eq!( + g.log(), + vec![ + damaged(ghoul, 3), + GameEvent::Destroyed { id: ghoul }, + GameEvent::DeathPhase { round: 1 }, + triggered(ghoul, "deathrattle"), + damaged(golem, 1), + damaged(r, 1), + GameEvent::Destroyed { id: golem }, + GameEvent::DeathPhase { round: 2 }, + triggered(golem, "deathrattle"), + GameEvent::Summoned { id: d }, + ] + ); + assert_eq!(g.name_of(d), "Damaged Golem"); + assert_eq!(g.board(0), vec![d, r]); + assert_eq!(g.zone_of(ghoul), Zone::Graveyard); + assert_eq!(g.zone_of(golem), Zone::Graveyard); + assert_eq!(g.health(r), 4); +} +``` + +```ts +test("test_03_chained_death_phases", () => { + 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.log()).toEqual([ + { type: "Damaged", target: ghoul, amount: 3 }, + { type: "Destroyed", id: ghoul }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: ghoul, trigger: "deathrattle" }, + { type: "Damaged", target: golem, amount: 1 }, + { type: "Damaged", target: r, amount: 1 }, + { type: "Destroyed", id: golem }, + { type: "DeathPhase", round: 2 }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d }, + ]); + expect(g.nameOf(d)).toBe("Damaged Golem"); + expect(g.board(0)).toEqual([d, r]); + expect(g.zoneOf(ghoul)).toBe("graveyard"); + expect(g.zoneOf(golem)).toBe("graveyard"); + expect(g.health(r)).toBe(4); +}); +``` + +`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 four Module 3 tests fail; Modules 1 and 2 still pass. That is RED. + +## Build + +Make the four 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. +- `src/harness` stays thin. `destroy` hands one action to the engine, exactly as `dealDamage` does. + +`test_02_mortally_wounded_still_triggers` must keep passing. It now ends with the Acolyte in the graveyard, which that test doesn't check: what it checks, that a mortally wounded minion still draws, is still true. + +
Hint 1 — nudge + +Your Module 2 `runSequence` has a comment where deaths go. After `perform` returns, every Queue of the Sequence has been resolved, so that line is the end of the outermost Phase. What should happen there? And what must happen *again* after the Deathrattles you resolve there have finished? + +
+ +
Hint 2 — concept + +- **Marks, not removals.** Damage lowers Health. A destroy action sets a `pendingDestroy` flag. Neither moves anything. +- **Death Creation Step:** one function. Collect every minion in play, in order of play, that has Health ≤ 0 or `pendingDestroy`. Read each one's board index *first*, then move them all to the graveyard and record `Destroyed` for each. Return their Death Events, each holding the id and the remembered position. +- **Death Phase:** if the step returned anything, record `DeathPhase{round}` and hand the Death Events to the same `react` you wrote in Module 2. A Deathrattle is a trigger whose condition is "the Death Event is mine". The dead minion is no longer in play, so `react` must also consider the dead minion itself as a listener for its own Death Event. +- **Loop:** after the Death Phase, run the step again. Stop when it returns nothing. +- **Position:** an effect needs to know where to summon. Pass the remembered position along with the trigger (in the reference it's a field of the effect's context), and give the summon action an optional board index. + +
+ +
Hint 3 — pseudo-code + +```text +runSequence(action): + perform(action) + deaths = endOfOutermostPhase() + round = 0 + while deaths not empty: + round += 1 + log DeathPhase(round) + react(deaths) // the Module 2 Phase, unchanged in shape + deaths = endOfOutermostPhase() + +endOfOutermostPhase(): // Module 4 adds aura updates around this + return deathCreationStep() + +deathCreationStep(): + dying = [m for m in minionsInPlay (order of play) if m.health <= 0 or m.pendingDestroy] + events = [Destroyed(m, boardIndex(m)) for m in dying] // positions first + for e in events: + move e.id to graveyard; log Destroyed(e.id) + return events + +perform(action): + ... + Destroy(targets): for t in targets: t.pendingDestroy = true + Summon(card, p, at): create card on p's board at index min(at, len), or the right end + +react(events): + for e in events: + listeners = minionsInPlay, plus e.id if e is Destroyed // sorted by order of play + for m in listeners, for trig in m.triggers: + if responds(trig.on, m, e): queue.push((m, trig, e)) + for (m, trig, e) in queue: + log Triggered(m, label(trig.on)) + position = e.position if e is m's Death Event else boardIndex(m) + for a in trig.effect(ctx(owner = m.owner, source = m, position)): + perform(a) + +responds(deathrattle, m, Destroyed(id)) -> id == m +``` + +
+ +When your tests pass, compare with the reference engine in `course/03-death-step/reference/rust/src/engine/` or `course/03-death-step/reference/ts/src/engine/`. The step is `death.rs` / `death.ts`; the loop is `run_sequence` / `runSequence` in `queue.rs` / `queue.ts`. + +Two decisions in the reference are worth naming, because the rulebook raises them and this module's cards don't force them: + +- **A queued trigger whose source has left play.** In this engine that cannot happen to anything but a Deathrattle. Nothing leaves play mid-Phase, and the Death Creation Step runs only after the outermost Phase, when every Queue has been emptied. Deathrattles are the deliberate exception: their source has always left play by the time they resolve, and they resolve anyway. A minion that is only mortally wounded is still in play and still triggers (Module 2's Acolyte). The course's sources don't say what happens to a queued trigger whose source is removed some other way mid-Phase. No card in this course does that, so the engine doesn't re-check a source before resolving its trigger. That is a course simplification. +- **"A trigger cannot be nested inside itself"** ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The reference has no guard for this yet. No card in this module can nest a trigger inside itself: a Ghoul whose Deathrattle kills another Ghoul doesn't nest anything, because the second Ghoul's Deathrattle waits for the next Death Phase. Two Module 2-style cards that deal damage whenever a minion takes damage *would* recurse forever, and the rule is the cure. Add it when a card needs it. + +> **How the real game differs** +> - The Death Creation Step is one of several steps at the end of the outermost Phase: "After the outermost Phase ends, Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and an Aura Update (Other) follows it ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course has no auras until Module 4, which adds both updates around the step. +> - The rulebook says "negative (damaging/destroying/hindering/misdirecting) triggers ignore mortally wounded and pending destroy characters", while "AoE effects count mortally wounded" (same source). The course's area effects count them, which matches; nothing in the course is a targeted negative trigger, so the first rule is not modelled. +> - A Death Phase queues "Deathrattles, on-Death Secrets and on-Death triggered effects" (same source). The course has only Deathrattles. +> - "Feign Death and similar effects summon minions at the right side of the board, not the minion's position" (same source). The course has no such effect; every Deathrattle summon uses the remembered position. +> - Unstable Ghoul has Taunt. The course has no combat, so Taunt does nothing here. Heroes never die in the course either: no game ends. + +## Name it + +The pattern is a **deferred state-based check**. Effects only change state (Health, a pending-destroy flag). Whether that state means "dead" is decided at one defined checkpoint, for everything at once, and the check repeats until it finds nothing. + +Magic: The Gathering has the same rule under that name. The [Comprehensive Rules](https://magic.wizards.com/en/rules), rule 704: "State-based actions are game actions that happen automatically whenever certain conditions (listed below) are met." The game "performs all applicable state-based actions simultaneously as a single event", and "If any state-based actions are performed as a result of a check, the check is repeated". One of the conditions (704.5f): "If a creature has toughness 0 or less, it's put into its owner's graveyard." That is a Death Creation Step and its loop, in a different game. + +In the reference engine, the end of a Sequence: + +```rust +/// Run one Sequence: a player action and everything it causes. +pub fn run_sequence(world: &mut World, action: Action) { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let mut deaths = end_of_outermost_phase(world); + let mut round = 0; + while !deaths.is_empty() { + round += 1; + world.record(Event::DeathPhase { round }); + react(world, &deaths); + deaths = end_of_outermost_phase(world); + } +} + +/// The steps that run only when the outermost Phase ends; returns the Death Events. +/// Module 4 adds an Aura Update (Health/Attack) before the Death Creation Step +/// and an Aura Update (Other) after it, here. +fn end_of_outermost_phase(world: &mut World) -> Vec { + death_creation_step(world) +} +``` + +```ts +/** Run one Sequence: a player action and everything it causes. */ +export function runSequence(world: World, action: Action): void { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let deaths = endOfOutermostPhase(world); + for (let round = 1; deaths.length > 0; round++) { + world.record({ type: "DeathPhase", round }); + react(world, deaths); + deaths = endOfOutermostPhase(world); + } +} + +/** + * The steps that run only when the outermost Phase ends; returns the Death Events. + * Module 4 adds an Aura Update (Health/Attack) before the Death Creation Step + * and an Aura Update (Other) after it, here. + */ +function endOfOutermostPhase(world: World): Event[] { + return deathCreationStep(world); +} +``` + +Notice what didn't change: a Death Phase is the Module 2 `react`, fed Death Events instead of Damage Events. A Deathrattle is one more trigger condition. Depth-first resolution, order of play and the locked Queue all carry over for free. + +Fireplace, a Hearthstone simulator in Python, has the same checkpoint. In [`fireplace/game.py`](https://github.com/jleclanche/fireplace/blob/47a2572/fireplace/game.py) (commit `47a2572`), `action_end` checks `if not self._action_stack:`, meaning the outermost action block has just closed, and only then refreshes auras and calls `process_deaths`. + +**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows "Deathrattle", "summon at a position" and "destroy". Those are rules vocabulary. "Harvest Golem" is not. + +## Expansion Drop + +A new card arrives: Twisting Nether, 8-mana spell, "Destroy all minions and locations." ([Twisting Nether](https://hearthstone.wiki.gg/wiki/Twisting_Nether)). + +> **How the real game differs** +> Locations are a card type this course doesn't have. Here, Twisting Nether destroys all minions. + +The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't need to change either. Only `src/content/` should. + +1. Commit your workspace and note the commit: `git add -A && git commit -m "module 3 done"`. +2. Copy the expansion test for your track from `course/03-death-step/expansion/tests/rust/test_03x.rs` or `course/03-death-step/expansion/tests/ts/test_03x.test.ts` into your workspace's `tests/`. Run it and watch it fail. +3. Add Twisting Nether to your content. +4. When the tests pass, run the check from the course repository: + +```sh +node tools/expansion-check.mjs ~/my-engine HEAD +``` + +The test, `test_03x_twisting_nether_destroys_all`, summons Loot Hoarder and Harvest Golem for player 0 and a `Test Filler` for player 1, then casts Twisting Nether. All three are destroyed in one Death Creation Step, before either Deathrattle resolves, with their Health untouched. The Damaged Golem arrives afterwards and survives: the destroy marked only the minions that were in play when it resolved. The full scenario is in `course/03-death-step/expansion/README.md`. + +
Discussion: what this drop measures + +If `destroy` is an action your effects can return, Twisting Nether is one entry in your content library: + +```rust +// Expansion Drop: "Destroy all minions and locations." The course has no locations. +CardDef::spell("Twisting Nether", |c| { + vec![Action::Destroy { + targets: c.world.minions_in_play(), + }] +}), +``` + +```ts +// Expansion Drop: "Destroy all minions and locations." The course has no locations. +spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]), +``` + +That is the reference answer in `course/03-death-step/expansion/solution/`. The drop is fair: the harness's `destroy` already needed a destroy action, and Whirlwind already showed how a spell targets every minion. Twisting Nether combines them. + +Shapes that fail this drop: + +- **`destroy` lives in the harness only.** If the harness marks the minion itself instead of handing the engine an action, a card can't return "destroy", and Twisting Nether needs a new engine action. +- **Destroy removes immediately.** If the destroy action moves each minion to the graveyard and resolves its Deathrattle on the spot, the Hoarder's draw happens before the Golem is destroyed, and the log order is wrong. +- **The death step only looks at Health.** Pending-destroy minions at full Health are never collected. + +If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1. + +
+ +## Further reading + +- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): the glossary entries for Sequence, Phase and Death, and the passages on what runs when the outermost Phase ends (the Death Creation Step and the two Aura Updates). Read the Unstable Ghoul / Acolyte of Pain example in full. +- Wizards of the Coast, [Magic: The Gathering Comprehensive Rules](https://magic.wizards.com/en/rules), rule 704 (state-based actions). Short, precise, and written for a game with far more ways to die. Read 704.3 for the loop and skim 704.5 for the list of conditions. +- Fireplace, [`fireplace/game.py`](https://github.com/jleclanche/fireplace/blob/47a2572/fireplace/game.py) at commit `47a2572`: read `action_end` and `process_deaths`, around lines 127–192. Compare where it calls `refresh_auras()` with the Module 4 seam in your `endOfOutermostPhase`. +- Jon Parham, [*Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/) (The Liquid Fire, 2017). The "death reaper" loop that runs "to take care of any mortally wounded cards" after the root action, previewed in Module 2, is this module's loop. diff --git a/course/03-death-step/reference/rust/.course.json b/course/03-death-step/reference/rust/.course.json new file mode 100644 index 0000000..0c28f91 --- /dev/null +++ b/course/03-death-step/reference/rust/.course.json @@ -0,0 +1 @@ +{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "03" } diff --git a/course/03-death-step/reference/rust/.gitignore b/course/03-death-step/reference/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/course/03-death-step/reference/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/course/03-death-step/reference/rust/Cargo.toml b/course/03-death-step/reference/rust/Cargo.toml new file mode 100644 index 0000000..e992026 --- /dev/null +++ b/course/03-death-step/reference/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "engine" +version = "0.1.0" +edition = "2021" + +[dependencies] +# serde_json arrives at Module 5; nothing needed yet. diff --git a/course/03-death-step/reference/rust/README.md b/course/03-death-step/reference/rust/README.md new file mode 100644 index 0000000..c2e2505 --- /dev/null +++ b/course/03-death-step/reference/rust/README.md @@ -0,0 +1,14 @@ +# Module 3 reference engine (Rust) + +The course's own engine as of the end of Module 3. It adds deferred death: damage and destroy effects only mark minions, and the Death Creation Step removes them when the outermost Phase ends, followed by Death Phases that resolve Deathrattles until no new deaths occur. Module 2's reference was the starting point, so a diff between the two shows what this module adds. Module 4's reference starts from this one. + +- `src/engine/`: generic machinery. Names no cards. + - `queue.rs`: actions, the context an effect sees, and the resolver. A Sequence performs its action, then runs the end-of-outermost-Phase steps and the Death Phase loop. Module 4's aura updates go in the marked seam next to the Death Creation Step. A Death Phase is the same Phase function as a damage reaction, fed Death Events; the dead minion answers its own Death Event with its Deathrattle. + - `death.rs`: the Death Creation Step. It collects every mortally wounded or pending-destroy minion, reads all their board positions first, then removes them together and returns their Death Events in order of play. + - `world.rs`: entities, zones, order of play, heroes, stats, Armor, the pending-destroy flag and the log. Every state change records its event, except marking a minion pending destroy, which the course log does not show. + - `card.rs`: card definitions (stats, triggers, a spell's effect) and the library. + - `event.rs`: the engine's events. A Death Event also carries the remembered board position. `src/harness.rs` translates them into the course's `GameEvent`. +- `src/content/mod.rs`: card definitions. Each effect is a small function that returns actions; Module 5 turns them into data. +- `src/harness.rs`: the adapter the course tests call. `destroy` runs one Sequence that marks its target. `play` is still the Module 1 Expansion Drop stub. + +Run it with the module's tests: `node tools/verify-references.mjs 03` from the course repository. diff --git a/course/03-death-step/reference/rust/src/content/mod.rs b/course/03-death-step/reference/rust/src/content/mod.rs new file mode 100644 index 0000000..ee9b7ec --- /dev/null +++ b/course/03-death-step/reference/rust/src/content/mod.rs @@ -0,0 +1,67 @@ +//! Content: card definitions. Only this directory may name specific cards. +//! +//! Each effect is a small Rust function that returns engine actions. Module 5 +//! replaces these functions with card data the engine interprets. + +use crate::engine::{Action, CardDef, CardLibrary, TriggerOn}; + +/// Every card this game knows. +pub fn library() -> CardLibrary { + CardLibrary::new(vec![ + // Fictional test minion with no text. + CardDef::minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + CardDef::minion("Armorsmith", 1, 4).on(TriggerOn::FriendlyMinionDamaged, |c| { + vec![Action::GainArmor { + player: c.owner, + amount: 1, + }] + }), + // "Whenever this minion takes damage, draw a card." + CardDef::minion("Acolyte of Pain", 1, 4).on(TriggerOn::SelfDamaged, |c| { + vec![Action::Draw { player: c.owner }] + }), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + CardDef::minion("Test Echo", 1, 5).on(TriggerOn::AnyMinionDamaged, |c| { + vec![Action::Damage { + targets: vec![c.enemy_hero()], + amount: 1, + }] + }), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + CardDef::minion("Test Reactor", 1, 5).on(TriggerOn::HeroDamaged, |c| { + vec![Action::GainAttack { + id: c.minion(), + amount: 1, + }] + }), + // "Deathrattle: Draw a card." + CardDef::minion("Loot Hoarder", 2, 1).on(TriggerOn::Deathrattle, |c| { + vec![Action::Draw { player: c.owner }] + }), + // "Deathrattle: Summon a 2/1 Damaged Golem." + CardDef::minion("Harvest Golem", 2, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Summon { + card: "Damaged Golem".to_string(), + player: c.owner, + at: c.position, + }] + }), + // Summoned by Harvest Golem; no text. + CardDef::minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + CardDef::minion("Unstable Ghoul", 1, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // "Deal 1 damage to ALL minions." + CardDef::spell("Whirlwind", |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + ]) +} diff --git a/course/03-death-step/reference/rust/src/engine/card.rs b/course/03-death-step/reference/rust/src/engine/card.rs new file mode 100644 index 0000000..407bfea --- /dev/null +++ b/course/03-death-step/reference/rust/src/engine/card.rs @@ -0,0 +1,99 @@ +//! Card definitions as the engine sees them. The definitions themselves live in `content`. + +use std::collections::HashMap; + +use super::queue::{Action, Ctx}; + +/// A card's behaviour: given where and for whom it runs, the actions to perform. +/// Module 2 writes these as Rust functions in `content`; Module 5 turns them into data. +pub type EffectFn = fn(&Ctx) -> Vec; + +/// What a triggered ability listens for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerOn { + /// This minion takes damage. + SelfDamaged, + /// A minion with the same owner (this one included) takes damage. + FriendlyMinionDamaged, + /// Any minion takes damage. + AnyMinionDamaged, + /// Either hero takes damage. + HeroDamaged, + /// This minion dies. It resolves after the minion has left play. + Deathrattle, +} + +impl TriggerOn { + /// The trigger's family name, as reported in `Triggered` events. + pub fn label(self) -> &'static str { + match self { + TriggerOn::SelfDamaged + | TriggerOn::FriendlyMinionDamaged + | TriggerOn::AnyMinionDamaged + | TriggerOn::HeroDamaged => "damaged", + TriggerOn::Deathrattle => "deathrattle", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Trigger { + pub on: TriggerOn, + pub effect: EffectFn, +} + +/// What the engine needs to know about a card. +#[derive(Debug, Clone)] +pub struct CardDef { + pub name: String, + pub attack: i32, + pub health: i32, + /// Triggered abilities, in printed order. + pub triggers: Vec, + /// A spell's effect when cast; `None` for minions. + pub on_cast: Option, +} + +impl CardDef { + pub fn minion(name: &str, attack: i32, health: i32) -> CardDef { + CardDef { + name: name.to_string(), + attack, + health, + triggers: Vec::new(), + on_cast: None, + } + } + + pub fn spell(name: &str, effect: EffectFn) -> CardDef { + CardDef { + on_cast: Some(effect), + ..CardDef::minion(name, 0, 0) + } + } + + /// Add a triggered ability: "Whenever , ". + pub fn on(mut self, on: TriggerOn, effect: EffectFn) -> CardDef { + self.triggers.push(Trigger { on, effect }); + self + } +} + +/// Every card the game can create, looked up by name. +#[derive(Debug, Default)] +pub struct CardLibrary { + cards: HashMap, +} + +impl CardLibrary { + pub fn new(defs: Vec) -> CardLibrary { + let cards = defs.into_iter().map(|d| (d.name.clone(), d)).collect(); + CardLibrary { cards } + } + + pub fn get(&self, name: &str) -> &CardDef { + self.cards + .get(name) + .unwrap_or_else(|| panic!("unknown card {name:?}: define it in src/content")) + } +} diff --git a/course/03-death-step/reference/rust/src/engine/death.rs b/course/03-death-step/reference/rust/src/engine/death.rs new file mode 100644 index 0000000..69a00e3 --- /dev/null +++ b/course/03-death-step/reference/rust/src/engine/death.rs @@ -0,0 +1,30 @@ +//! The Death Creation Step: the one place minions leave play. +//! +//! Damage and destroy effects only mark a minion (0 or less Health, or pending +//! destroy). It stays in play, and keeps triggering, until the outermost Phase +//! ends. Then this step removes every marked minion at once. + +use super::event::Event; +use super::world::World; + +/// Remove every mortally wounded or pending-destroy minion from play, simultaneously. +/// Returns their Death Events in order of play; empty if nothing died. +pub fn death_creation_step(world: &mut World) -> Vec { + // Every position is read before anything moves: removal is simultaneous, and each + // Death Event remembers where its minion stood when the step began. + let mut deaths = Vec::new(); + for id in world.minions_in_play() { + if world.is_doomed(id) { + let position = world + .board_index(id) + .expect("a minion in play is on the board"); + deaths.push(Event::Destroyed { id, position }); + } + } + for death in &deaths { + if let Event::Destroyed { id, position } = *death { + world.kill(id, position); + } + } + deaths +} diff --git a/course/03-death-step/reference/rust/src/engine/event.rs b/course/03-death-step/reference/rust/src/engine/event.rs new file mode 100644 index 0000000..9c8ccd2 --- /dev/null +++ b/course/03-death-step/reference/rust/src/engine/event.rs @@ -0,0 +1,47 @@ +//! What happened, in order. The engine records an event each time it changes the game. + +use super::card::TriggerOn; +use super::world::{EntityId, PlayerId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Summoned { + id: EntityId, + }, + Damaged { + target: EntityId, + amount: i32, + }, + Triggered { + source: EntityId, + on: TriggerOn, + }, + Drew { + player: PlayerId, + id: EntityId, + }, + Fatigue { + player: PlayerId, + }, + ArmorGained { + player: PlayerId, + amount: i32, + }, + StatsChanged { + id: EntityId, + attack: i32, + health: i32, + }, + SpellCast { + card: String, + }, + /// A Death Event: the Death Creation Step moved `id` from play to the graveyard. + /// `position` is the board index it had when the step began; its Deathrattle summons there. + Destroyed { + id: EntityId, + position: usize, + }, + DeathPhase { + round: u32, + }, +} diff --git a/course/03-death-step/reference/rust/src/engine/mod.rs b/course/03-death-step/reference/rust/src/engine/mod.rs new file mode 100644 index 0000000..08a27c4 --- /dev/null +++ b/course/03-death-step/reference/rust/src/engine/mod.rs @@ -0,0 +1,23 @@ +//! Engine core: generic rules machinery. Never name a specific card here. +//! +//! Module 1: every card in the game is an entity addressed by an id handle. +//! The engine knows which zone each entity is in, who owns it, and when it +//! entered play. +//! +//! Module 2: heroes, Health, Attack and Armor; triggered abilities; and the +//! action queue that resolves a Sequence depth-first, recording an event log. +//! +//! Module 3: deferred death. Damage and destroy effects only mark minions; the +//! Death Creation Step removes them when the outermost Phase ends, and Death +//! Phases resolve their Deathrattles until no new deaths occur. + +mod card; +mod death; +mod event; +mod queue; +mod world; + +pub use card::{CardDef, CardLibrary, EffectFn, Trigger, TriggerOn}; +pub use event::Event; +pub use queue::{run_sequence, Action, Ctx}; +pub use world::{EntityId, PlayerId, World, Zone, HERO_HEALTH}; diff --git a/course/03-death-step/reference/rust/src/engine/queue.rs b/course/03-death-step/reference/rust/src/engine/queue.rs new file mode 100644 index 0000000..356520b --- /dev/null +++ b/course/03-death-step/reference/rust/src/engine/queue.rs @@ -0,0 +1,217 @@ +//! The action queue: how one player action (a Sequence) resolves. +//! +//! Performing an action changes the world and produces events. Every trigger that +//! responds to those events goes into one Queue, oldest listener first, and the Queue +//! is locked. Each queued trigger then resolves completely, including everything +//! its own actions cause, before the next one starts: depth-first. +//! +//! Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the +//! Death Creation Step removes the dead, and a Death Phase resolves their +//! Deathrattles. That repeats until a step finds no new deaths. + +use super::card::TriggerOn; +use super::death::death_creation_step; +use super::event::Event; +use super::world::{EntityId, PlayerId, World, Zone}; + +/// Something the engine can do. Card effects return these instead of changing the world. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Deal `amount` to each target at once: all damage first, then the reactions. + Damage { + targets: Vec, + amount: i32, + }, + Draw { + player: PlayerId, + }, + GainArmor { + player: PlayerId, + amount: i32, + }, + GainAttack { + id: EntityId, + amount: i32, + }, + Cast { + card: String, + player: PlayerId, + target: Option, + }, + /// Mark each target pending destroy. They leave play in the next Death Creation Step. + Destroy { + targets: Vec, + }, + /// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`. + Summon { + card: String, + player: PlayerId, + at: Option, + }, +} + +/// What an effect may look at when it runs. +pub struct Ctx<'a> { + pub world: &'a World, + /// The player the effect works for. + pub owner: PlayerId, + /// The minion whose trigger is resolving; `None` for a spell. + pub source: Option, + /// The chosen target, if any. + pub target: Option, + /// The source's board position. For a Deathrattle, the source has left play, + /// and this is the position its Death Event remembered. + pub position: Option, +} + +impl Ctx<'_> { + /// The minion whose trigger is resolving. + pub fn minion(&self) -> EntityId { + self.source.expect("only a triggered ability has a source") + } + + pub fn enemy_hero(&self) -> EntityId { + self.world.hero(1 - self.owner) + } +} + +/// Run one Sequence: a player action and everything it causes. +pub fn run_sequence(world: &mut World, action: Action) { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let mut deaths = end_of_outermost_phase(world); + let mut round = 0; + while !deaths.is_empty() { + round += 1; + world.record(Event::DeathPhase { round }); + react(world, &deaths); + deaths = end_of_outermost_phase(world); + } +} + +/// The steps that run only when the outermost Phase ends; returns the Death Events. +/// Module 4 adds an Aura Update (Health/Attack) before the Death Creation Step +/// and an Aura Update (Other) after it, here. +fn end_of_outermost_phase(world: &mut World) -> Vec { + death_creation_step(world) +} + +fn perform(world: &mut World, action: Action) { + match action { + Action::Damage { + mut targets, + amount, + } => { + // Area damage: every Damage Event is created, in order of play, before any reaction. + targets.sort_by_key(|&id| world.order_of_play(id)); + let mut events = Vec::new(); + for target in targets { + world.damage(target, amount); + events.push(Event::Damaged { target, amount }); + } + react(world, &events); + } + Action::Draw { player } => world.draw(player), + Action::GainArmor { player, amount } => world.gain_armor(player, amount), + Action::GainAttack { id, amount } => world.gain_attack(id, amount), + Action::Destroy { targets } => { + for target in targets { + world.mark_destroyed(target); + } + } + Action::Summon { card, player, at } => { + world.create_at(&card, player, Zone::Board, at); + } + Action::Cast { + card, + player, + target, + } => { + let effect = world + .card(&card) + .on_cast + .unwrap_or_else(|| panic!("{card:?} is not a spell")); + world.record(Event::SpellCast { card }); + let actions = effect(&Ctx { + world, + owner: player, + source: None, + target, + position: None, + }); + for action in actions { + perform(world, action); + } + } + } +} + +/// A Phase: queue every trigger that responds to `events`, then resolve them one by one. +fn react(world: &mut World, events: &[Event]) { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + let mut queue = Vec::new(); + for event in events { + for listener in listeners(world, event) { + for trigger in &world.card(world.card_name(listener)).triggers { + if responds(world, trigger.on, listener, event) { + queue.push((listener, *trigger, event)); + } + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (source, trigger, event) in queue { + world.record(Event::Triggered { + source, + on: trigger.on, + }); + let position = match *event { + Event::Destroyed { id, position } if id == source => Some(position), + _ => world.board_index(source), + }; + let ctx = Ctx { + world, + owner: world.owner_of(source), + source: Some(source), + target: None, + position, + }; + let actions = (trigger.effect)(&ctx); + // Depth-first: each action, and every reaction it causes, finishes before the next. + for action in actions { + perform(world, action); + } + } +} + +/// Who may respond to `event`, in order of play: every minion in play and, for a +/// Death Event, the minion that died. It has left play, but its Deathrattle still answers. +fn listeners(world: &World, event: &Event) -> Vec { + let mut ids = world.minions_in_play(); + if let Event::Destroyed { id, .. } = *event { + ids.push(id); + ids.sort_by_key(|&id| world.order_of_play(id)); + } + ids +} + +/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`? +fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool { + if let Event::Destroyed { id, .. } = *event { + return on == TriggerOn::Deathrattle && id == listener; + } + let Event::Damaged { target, .. } = *event else { + return false; + }; + let is_minion = !world.is_hero(target); + match on { + TriggerOn::SelfDamaged => target == listener, + TriggerOn::FriendlyMinionDamaged => { + is_minion && world.owner_of(target) == world.owner_of(listener) + } + TriggerOn::AnyMinionDamaged => is_minion, + TriggerOn::HeroDamaged => !is_minion, + TriggerOn::Deathrattle => false, + } +} diff --git a/course/03-death-step/reference/rust/src/engine/world.rs b/course/03-death-step/reference/rust/src/engine/world.rs new file mode 100644 index 0000000..cd72c4c --- /dev/null +++ b/course/03-death-step/reference/rust/src/engine/world.rs @@ -0,0 +1,296 @@ +//! Entities, zones, order of play, stats and the event log: the game's state. +//! The rules for resolving actions live in `queue.rs`. + +use super::card::{CardDef, CardLibrary}; +use super::event::Event; + +/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone. +pub type EntityId = u32; +pub type PlayerId = u8; + +/// Starting Health of each hero. +pub const HERO_HEALTH: i32 = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Zone { + Deck, + Hand, + Board, + Graveyard, +} + +#[derive(Debug)] +struct Entity { + card: String, + owner: PlayerId, + zone: Zone, + /// Stamped each time the entity enters the board; `None` until it first does. + order_of_play: Option, + attack: i32, + /// Current Health. At 0 or below the entity is mortally wounded: still in play until the + /// Death Creation Step. + health: i32, + /// Hit by a destroy effect: still in play until the Death Creation Step. + pending_destroy: bool, +} + +/// One player's zones. Each is an ordered list of ids (the board is left to right). +#[derive(Debug, Default)] +struct Side { + deck: Vec, + hand: Vec, + board: Vec, + graveyard: Vec, + hero: EntityId, + armor: i32, +} + +impl Side { + fn zone(&self, zone: Zone) -> &Vec { + match zone { + Zone::Deck => &self.deck, + Zone::Hand => &self.hand, + Zone::Board => &self.board, + Zone::Graveyard => &self.graveyard, + } + } + + fn zone_mut(&mut self, zone: Zone) -> &mut Vec { + match zone { + Zone::Deck => &mut self.deck, + Zone::Hand => &mut self.hand, + Zone::Board => &mut self.board, + Zone::Graveyard => &mut self.graveyard, + } + } +} + +pub struct World { + library: CardLibrary, + /// Indexed by `EntityId`. + entities: Vec, + sides: [Side; 2], + next_order_of_play: u32, + log: Vec, +} + +impl World { + /// A new game: both heroes are in play from the start, so they are the oldest entities. + pub fn new(library: CardLibrary) -> World { + let mut world = World { + library, + entities: Vec::new(), + sides: Default::default(), + next_order_of_play: 0, + log: Vec::new(), + }; + for owner in 0..2 { + let id = world.entities.len() as EntityId; + world.next_order_of_play += 1; + world.entities.push(Entity { + card: "Hero".to_string(), + owner, + zone: Zone::Board, + order_of_play: Some(world.next_order_of_play), + attack: 0, + health: HERO_HEALTH, + pending_destroy: false, + }); + // In play, but not on the board list: the board holds minions only. + world.sides[owner as usize].hero = id; + } + world + } + + /// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board). + pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId { + self.create_at(card, owner, zone, None) + } + + /// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the + /// zone's length), or at the end if `at` is `None`. + pub fn create_at( + &mut self, + card: &str, + owner: PlayerId, + zone: Zone, + at: Option, + ) -> EntityId { + assert!(owner < 2, "no player {owner}"); + let def = self.library.get(card); + let entity = Entity { + card: def.name.clone(), + owner, + zone, + order_of_play: None, + attack: def.attack, + health: def.health, + pending_destroy: false, + }; + let id = self.entities.len() as EntityId; + self.entities.push(entity); + self.enter(id, zone, at); + id + } + + /// Move an entity to the end of `zone` on its owner's side, keeping its id. + pub fn move_to(&mut self, id: EntityId, zone: Zone) { + let (owner, from) = (self.entity(id).owner, self.entity(id).zone); + self.sides[owner as usize] + .zone_mut(from) + .retain(|&e| e != id); + self.enter(id, zone, None); + } + + /// The single place an entity arrives in a zone. Entering the board starts a new order of play. + fn enter(&mut self, id: EntityId, zone: Zone, at: Option) { + let owner = self.entity(id).owner; + let list = self.sides[owner as usize].zone_mut(zone); + list.insert(at.map_or(list.len(), |i| i.min(list.len())), id); + let entity = &mut self.entities[id as usize]; + entity.zone = zone; + if zone == Zone::Board { + self.next_order_of_play += 1; + entity.order_of_play = Some(self.next_order_of_play); + self.record(Event::Summoned { id }); + } + } + + fn entity(&self, id: EntityId) -> &Entity { + self.entities + .get(id as usize) + .unwrap_or_else(|| panic!("unknown entity id {id}")) + } + + fn entity_mut(&mut self, id: EntityId) -> &mut Entity { + self.entities + .get_mut(id as usize) + .unwrap_or_else(|| panic!("unknown entity id {id}")) + } + + // ---- Queries ------------------------------------------------------- + + pub fn card(&self, name: &str) -> &CardDef { + self.library.get(name) + } + + pub fn zone_of(&self, id: EntityId) -> Zone { + self.entity(id).zone + } + + pub fn card_name(&self, id: EntityId) -> &str { + &self.entity(id).card + } + + pub fn owner_of(&self, id: EntityId) -> PlayerId { + self.entity(id).owner + } + + /// When the entity last entered the board (larger is later); `None` if it never has. + pub fn order_of_play(&self, id: EntityId) -> Option { + self.entity(id).order_of_play + } + + /// The ids in one player's zone, in zone order (left to right for the board). + pub fn zone(&self, owner: PlayerId, zone: Zone) -> &[EntityId] { + assert!(owner < 2, "no player {owner}"); + self.sides[owner as usize].zone(zone) + } + + pub fn hero(&self, owner: PlayerId) -> EntityId { + assert!(owner < 2, "no player {owner}"); + self.sides[owner as usize].hero + } + + pub fn is_hero(&self, id: EntityId) -> bool { + self.sides.iter().any(|s| s.hero == id) + } + + /// Every minion on either board, oldest first (order of play). + pub fn minions_in_play(&self) -> Vec { + let mut ids: Vec = self.sides.iter().flat_map(|s| s.board.clone()).collect(); + ids.sort_by_key(|&id| self.order_of_play(id)); + ids + } + + pub fn attack(&self, id: EntityId) -> i32 { + self.entity(id).attack + } + + pub fn health(&self, id: EntityId) -> i32 { + self.entity(id).health + } + + /// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next + /// Death Creation Step. + pub fn is_doomed(&self, id: EntityId) -> bool { + let entity = self.entity(id); + entity.health <= 0 || entity.pending_destroy + } + + /// Index of a minion on its owner's board, left to right; `None` if it is not on the board. + pub fn board_index(&self, id: EntityId) -> Option { + self.zone(self.owner_of(id), Zone::Board) + .iter() + .position(|&e| e == id) + } + + pub fn armor(&self, owner: PlayerId) -> i32 { + self.sides[owner as usize].armor + } + + pub fn log(&self) -> &[Event] { + &self.log + } + + // ---- Changes: each one records its event ---------------------------- + + pub fn record(&mut self, event: Event) { + self.log.push(event); + } + + pub fn clear_log(&mut self) { + self.log.clear(); + } + + pub fn damage(&mut self, target: EntityId, amount: i32) { + self.entity_mut(target).health -= amount; + self.record(Event::Damaged { target, amount }); + } + + /// Draw the top card of `player`'s deck (the first in the list). An empty deck + /// records `Fatigue` and changes nothing else. + pub fn draw(&mut self, player: PlayerId) { + match self.zone(player, Zone::Deck).first() { + Some(&id) => { + self.move_to(id, Zone::Hand); + self.record(Event::Drew { player, id }); + } + None => self.record(Event::Fatigue { player }), + } + } + + /// Mark for destruction. Nothing leaves play until the Death Creation Step. + pub fn mark_destroyed(&mut self, id: EntityId) { + self.entity_mut(id).pending_destroy = true; + } + + /// Move a minion from play to its owner's graveyard, recording its Death Event. + pub fn kill(&mut self, id: EntityId, position: usize) { + self.entity_mut(id).pending_destroy = false; + self.move_to(id, Zone::Graveyard); + self.record(Event::Destroyed { id, position }); + } + + pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { + self.sides[player as usize].armor += amount; + self.record(Event::ArmorGained { player, amount }); + } + + pub fn gain_attack(&mut self, id: EntityId, amount: i32) { + let entity = self.entity_mut(id); + entity.attack += amount; + let (attack, health) = (entity.attack, entity.health); + self.record(Event::StatsChanged { id, attack, health }); + } +} diff --git a/course/03-death-step/reference/rust/src/harness.rs b/course/03-death-step/reference/rust/src/harness.rs new file mode 100644 index 0000000..d10d283 --- /dev/null +++ b/course/03-death-step/reference/rust/src/harness.rs @@ -0,0 +1,149 @@ +//! Test harness: the thin adapter the course tests call. It translates each +//! call into engine operations and holds no rules of its own. + +use crate::content; +use crate::engine::{run_sequence, Action, Event, World}; + +pub use crate::engine::Zone; + +pub type Id = u32; +pub type Player = u8; + +#[derive(Debug, Default, Clone)] +pub struct GameOpts { + pub decks: Option<[Vec; 2]>, +} + +/// The events the tests read from `log()` (see course/harness.md). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameEvent { + Summoned { id: Id }, + Damaged { target: Id, amount: i32 }, + Triggered { source: Id, trigger: String }, + Destroyed { id: Id }, + DeathPhase { round: u32 }, + Drew { player: Player, id: Id }, + Fatigue { player: Player }, + ArmorGained { player: Player, amount: i32 }, + StatsChanged { id: Id, attack: i32, health: i32 }, + SpellCast { card: String }, +} + +impl From<&Event> for GameEvent { + fn from(event: &Event) -> GameEvent { + match event.clone() { + Event::Summoned { id } => GameEvent::Summoned { id }, + Event::Damaged { target, amount } => GameEvent::Damaged { target, amount }, + Event::Triggered { source, on } => GameEvent::Triggered { + source, + trigger: on.label().to_string(), + }, + Event::Drew { player, id } => GameEvent::Drew { player, id }, + Event::Fatigue { player } => GameEvent::Fatigue { player }, + Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount }, + Event::StatsChanged { id, attack, health } => { + GameEvent::StatsChanged { id, attack, health } + } + Event::SpellCast { card } => GameEvent::SpellCast { card }, + Event::Destroyed { id, .. } => GameEvent::Destroyed { id }, + Event::DeathPhase { round } => GameEvent::DeathPhase { round }, + } + } +} + +pub struct Game { + world: World, +} + +pub fn new_game(opts: GameOpts) -> Game { + let mut world = World::new(content::library()); + if let Some(decks) = opts.decks { + for (player, deck) in decks.iter().enumerate() { + for card in deck { + world.create(card, player as Player, Zone::Deck); + } + } + } + Game { world } +} + +impl Game { + /// Put `card` at the right end of `player`'s board. + pub fn summon(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Board) + } + pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Hand) + } + pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Deck) + } + /// Play a card from hand. This is the Module 1 Expansion Drop: see + /// course/01-handles/expansion/README.md. + pub fn play(&mut self, _hand_id: Id) { + todo!("Module 1 Expansion Drop") + } + pub fn zone_of(&self, id: Id) -> Zone { + self.world.zone_of(id) + } + /// Board ids, left to right. + pub fn board(&self, player: Player) -> Vec { + self.world.zone(player, Zone::Board).to_vec() + } + pub fn hand(&self, player: Player) -> Vec { + self.world.zone(player, Zone::Hand).to_vec() + } + pub fn order_of_play(&self, id: Id) -> u32 { + self.world + .order_of_play(id) + .unwrap_or_else(|| panic!("entity {id} has not entered play")) + } + pub fn name_of(&self, id: Id) -> String { + self.world.card_name(id).to_string() + } + + // ---- Module 2 ---- + + pub fn hero(&self, player: Player) -> Id { + self.world.hero(player) + } + /// Deal damage to one character, as one Sequence. + pub fn deal_damage(&mut self, target: Id, amount: i32) { + let targets = vec![target]; + run_sequence(&mut self.world, Action::Damage { targets, amount }); + } + pub fn cast(&mut self, card: &str, player: Player, target: Option) { + let card = card.to_string(); + run_sequence( + &mut self.world, + Action::Cast { + card, + player, + target, + }, + ); + } + pub fn health(&self, id: Id) -> i32 { + self.world.health(id) + } + pub fn attack(&self, id: Id) -> i32 { + self.world.attack(id) + } + pub fn armor(&self, player: Player) -> i32 { + self.world.armor(player) + } + pub fn log(&self) -> Vec { + self.world.log().iter().map(GameEvent::from).collect() + } + pub fn clear_log(&mut self) { + self.world.clear_log(); + } + + // ---- Module 3 ---- + + /// Mark `target` pending destroy, as one Sequence. It leaves play in the death step. + pub fn destroy(&mut self, target: Id) { + let targets = vec![target]; + run_sequence(&mut self.world, Action::Destroy { targets }); + } +} diff --git a/course/03-death-step/reference/rust/src/lib.rs b/course/03-death-step/reference/rust/src/lib.rs new file mode 100644 index 0000000..f5868fb --- /dev/null +++ b/course/03-death-step/reference/rust/src/lib.rs @@ -0,0 +1,3 @@ +pub mod content; +pub mod engine; +pub mod harness; diff --git a/course/03-death-step/reference/ts/.course.json b/course/03-death-step/reference/ts/.course.json new file mode 100644 index 0000000..1972e15 --- /dev/null +++ b/course/03-death-step/reference/ts/.course.json @@ -0,0 +1 @@ +{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "03" } diff --git a/course/03-death-step/reference/ts/.gitignore b/course/03-death-step/reference/ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/course/03-death-step/reference/ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/course/03-death-step/reference/ts/README.md b/course/03-death-step/reference/ts/README.md new file mode 100644 index 0000000..4a3c990 --- /dev/null +++ b/course/03-death-step/reference/ts/README.md @@ -0,0 +1,14 @@ +# Module 3 reference engine (TypeScript) + +The course's own engine as of the end of Module 3. It adds deferred death: damage and destroy effects only mark minions, and the Death Creation Step removes them when the outermost Phase ends, followed by Death Phases that resolve Deathrattles until no new deaths occur. Module 2's reference was the starting point, so a diff between the two shows what this module adds. Module 4's reference starts from this one. + +- `src/engine/`: generic machinery. Names no cards. + - `queue.ts`: actions, the context an effect sees, and the resolver. A Sequence performs its action, then runs the end-of-outermost-Phase steps and the Death Phase loop. Module 4's aura updates go in the marked seam next to the Death Creation Step. A Death Phase is the same Phase function as a damage reaction, fed Death Events; the dead minion answers its own Death Event with its Deathrattle. + - `death.ts`: the Death Creation Step. It collects every mortally wounded or pending-destroy minion, reads all their board positions first, then removes them together and returns their Death Events in order of play. + - `world.ts`: entities, zones, order of play, heroes, stats, Armor, the pending-destroy flag and the log. Every state change records its event, except marking a minion pending destroy, which the course log does not show. + - `card.ts`: card definitions (stats, triggers, a spell's effect) and the library. + - `event.ts`: the engine's events. A Death Event also carries the remembered board position. `src/harness.ts` translates them into the course's `GameEvent`. +- `src/content/index.ts`: card definitions. Each effect is a small function that returns actions; Module 5 turns them into data. +- `src/harness.ts`: the adapter the course tests call. `destroy` runs one Sequence that marks its target. `play` is still the Module 1 Expansion Drop stub. + +Run it with the module's tests: `node tools/verify-references.mjs 03` from the course repository. diff --git a/course/03-death-step/reference/ts/package-lock.json b/course/03-death-step/reference/ts/package-lock.json new file mode 100644 index 0000000..8fa242d --- /dev/null +++ b/course/03-death-step/reference/ts/package-lock.json @@ -0,0 +1,1496 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "systemic-design-workspace", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/course/03-death-step/reference/ts/package.json b/course/03-death-step/reference/ts/package.json new file mode 100644 index 0000000..8d2da89 --- /dev/null +++ b/course/03-death-step/reference/ts/package.json @@ -0,0 +1,14 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/course/03-death-step/reference/ts/src/content/index.ts b/course/03-death-step/reference/ts/src/content/index.ts new file mode 100644 index 0000000..b01b0e9 --- /dev/null +++ b/course/03-death-step/reference/ts/src/content/index.ts @@ -0,0 +1,48 @@ +// Content: card definitions. Only this directory may name specific cards. +// +// Each effect is a small function that returns engine actions. Module 5 +// replaces these functions with card data the engine interprets. + +import { CardLibrary, minion, spell } from "../engine"; + +/** Every card this game knows. */ +export function library(): CardLibrary { + return new CardLibrary([ + // Fictional test minion with no text. + minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + minion("Armorsmith", 1, 4, [ + { on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] }, + ]), + // "Whenever this minion takes damage, draw a card." + minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + minion("Test Echo", 1, 5, [ + { on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] }, + ]), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + minion("Test Reactor", 1, 5, [ + { on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] }, + ]), + // "Deathrattle: Draw a card." + minion("Loot Hoarder", 2, 1, [{ on: "deathrattle", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // "Deathrattle: Summon a 2/1 Damaged Golem." + minion("Harvest Golem", 2, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "summon", card: "Damaged Golem", player: c.owner, at: c.position }], + }, + ]), + // Summoned by Harvest Golem; no text. + minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + minion("Unstable Ghoul", 1, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }], + }, + ]), + // "Deal 1 damage to ALL minions." + spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]), + ]); +} diff --git a/course/03-death-step/reference/ts/src/engine/card.ts b/course/03-death-step/reference/ts/src/engine/card.ts new file mode 100644 index 0000000..ab9b321 --- /dev/null +++ b/course/03-death-step/reference/ts/src/engine/card.ts @@ -0,0 +1,69 @@ +// Card definitions as the engine sees them. The definitions themselves live in `content`. + +import type { Action, Ctx } from "./queue"; + +/** + * A card's behaviour: given where and for whom it runs, the actions to perform. + * Module 2 writes these as functions in `content`; Module 5 turns them into data. + */ +export type EffectFn = (ctx: Ctx) => Action[]; + +/** What a triggered ability listens for. */ +export type TriggerOn = + | "selfDamaged" // this minion takes damage + | "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage + | "anyMinionDamaged" // any minion takes damage + | "heroDamaged" // either hero takes damage + | "deathrattle"; // this minion dies (it resolves after the minion has left play) + +/** The trigger's family name, as reported in `Triggered` events. */ +export function triggerLabel(on: TriggerOn): string { + switch (on) { + case "selfDamaged": + case "friendlyMinionDamaged": + case "anyMinionDamaged": + case "heroDamaged": + return "damaged"; + case "deathrattle": + return "deathrattle"; + } +} + +export interface Trigger { + on: TriggerOn; + effect: EffectFn; +} + +/** What the engine needs to know about a card. */ +export interface CardDef { + name: string; + attack: number; + health: number; + /** Triggered abilities, in printed order. */ + triggers: Trigger[]; + /** A spell's effect when cast; absent for minions. */ + onCast?: EffectFn; +} + +export function minion(name: string, attack: number, health: number, triggers: Trigger[] = []): CardDef { + return { name, attack, health, triggers }; +} + +export function spell(name: string, onCast: EffectFn): CardDef { + return { name, attack: 0, health: 0, triggers: [], onCast }; +} + +/** Every card the game can create, looked up by name. */ +export class CardLibrary { + private readonly cards = new Map(); + + constructor(defs: CardDef[]) { + for (const def of defs) this.cards.set(def.name, def); + } + + get(name: string): CardDef { + const def = this.cards.get(name); + if (!def) throw new Error(`unknown card "${name}": define it in src/content`); + return def; + } +} diff --git a/course/03-death-step/reference/ts/src/engine/death.ts b/course/03-death-step/reference/ts/src/engine/death.ts new file mode 100644 index 0000000..6dc715d --- /dev/null +++ b/course/03-death-step/reference/ts/src/engine/death.ts @@ -0,0 +1,21 @@ +// The Death Creation Step: the one place minions leave play. +// +// Damage and destroy effects only mark a minion (0 or less Health, or pending +// destroy). It stays in play, and keeps triggering, until the outermost Phase +// ends. Then this step removes every marked minion at once. + +import type { Event } from "./event"; +import type { World } from "./world"; + +/** + * Remove every mortally wounded or pending-destroy minion from play, simultaneously. + * Returns their Death Events in order of play; empty if nothing died. + */ +export function deathCreationStep(world: World): Event[] { + const dying = world.minionsInPlay().filter((id) => world.isDoomed(id)); + // Every position is read before anything moves: removal is simultaneous, and each + // Death Event remembers where its minion stood when the step began. + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, position: world.boardIndex(id)! })); + for (const { id, position } of deaths) world.kill(id, position); + return deaths; +} diff --git a/course/03-death-step/reference/ts/src/engine/event.ts b/course/03-death-step/reference/ts/src/engine/event.ts new file mode 100644 index 0000000..ce049e4 --- /dev/null +++ b/course/03-death-step/reference/ts/src/engine/event.ts @@ -0,0 +1,20 @@ +// What happened, in order. The engine records an event each time it changes the game. + +import type { TriggerOn } from "./card"; +import type { EntityId, PlayerId } from "./world"; + +export type Event = + | { type: "Summoned"; id: EntityId } + | { type: "Damaged"; target: EntityId; amount: number } + | { type: "Triggered"; source: EntityId; on: TriggerOn } + | { type: "Drew"; player: PlayerId; id: EntityId } + | { type: "Fatigue"; player: PlayerId } + | { type: "ArmorGained"; player: PlayerId; amount: number } + | { type: "StatsChanged"; id: EntityId; attack: number; health: number } + | { type: "SpellCast"; card: string } + /** + * A Death Event: the Death Creation Step moved `id` from play to the graveyard. + * `position` is the board index it had when the step began; its Deathrattle summons there. + */ + | { type: "Destroyed"; id: EntityId; position: number } + | { type: "DeathPhase"; round: number }; diff --git a/course/03-death-step/reference/ts/src/engine/index.ts b/course/03-death-step/reference/ts/src/engine/index.ts new file mode 100644 index 0000000..9198c1e --- /dev/null +++ b/course/03-death-step/reference/ts/src/engine/index.ts @@ -0,0 +1,26 @@ +// Engine core: generic rules machinery. Never name a specific card here. +// +// Module 1: every card in the game is an entity addressed by an id handle. +// The engine knows which zone each entity is in, who owns it, and when it +// entered play. +// +// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the +// action queue that resolves a Sequence depth-first, recording an event log. +// +// Module 3: deferred death. Damage and destroy effects only mark minions; the +// Death Creation Step removes them when the outermost Phase ends, and Death +// Phases resolve their Deathrattles until no new deaths occur. + +export { + CardLibrary, + minion, + spell, + triggerLabel, + type CardDef, + type EffectFn, + type Trigger, + type TriggerOn, +} from "./card"; +export type { Event } from "./event"; +export { Ctx, runSequence, type Action } from "./queue"; +export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world"; diff --git a/course/03-death-step/reference/ts/src/engine/queue.ts b/course/03-death-step/reference/ts/src/engine/queue.ts new file mode 100644 index 0000000..6452003 --- /dev/null +++ b/course/03-death-step/reference/ts/src/engine/queue.ts @@ -0,0 +1,166 @@ +// The action queue: how one player action (a Sequence) resolves. +// +// Performing an action changes the world and produces events. Every trigger that +// responds to those events goes into one Queue, oldest listener first, and the Queue +// is locked. Each queued trigger then resolves completely, including everything +// its own actions cause, before the next one starts: depth-first. +// +// Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the +// Death Creation Step removes the dead, and a Death Phase resolves their +// Deathrattles. That repeats until a step finds no new deaths. + +import type { Trigger, TriggerOn } from "./card"; +import { deathCreationStep } from "./death"; +import type { Event } from "./event"; +import type { EntityId, PlayerId, World } from "./world"; + +/** Something the engine can do. Card effects return these instead of changing the world. */ +export type Action = + /** Deal `amount` to each target at once: all damage first, then the reactions. */ + | { type: "damage"; targets: EntityId[]; amount: number } + | { type: "draw"; player: PlayerId } + | { type: "gainArmor"; player: PlayerId; amount: number } + | { type: "gainAttack"; id: EntityId; amount: number } + | { type: "cast"; card: string; player: PlayerId; target?: EntityId } + /** Mark each target pending destroy. They leave play in the next Death Creation Step. */ + | { type: "destroy"; targets: EntityId[] } + /** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */ + | { type: "summon"; card: string; player: PlayerId; at?: number }; + +/** What an effect may look at when it runs. */ +export class Ctx { + constructor( + readonly world: World, + /** The player the effect works for. */ + readonly owner: PlayerId, + /** The minion whose trigger is resolving; undefined for a spell. */ + readonly source?: EntityId, + /** The chosen target, if any. */ + readonly target?: EntityId, + /** + * The source's board position. For a Deathrattle, the source has left play, + * and this is the position its Death Event remembered. + */ + readonly position?: number, + ) {} + + /** The minion whose trigger is resolving. */ + minion(): EntityId { + if (this.source === undefined) throw new Error("only a triggered ability has a source"); + return this.source; + } + + enemyHero(): EntityId { + return this.world.hero(this.owner === 0 ? 1 : 0); + } +} + +/** Run one Sequence: a player action and everything it causes. */ +export function runSequence(world: World, action: Action): void { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let deaths = endOfOutermostPhase(world); + for (let round = 1; deaths.length > 0; round++) { + world.record({ type: "DeathPhase", round }); + react(world, deaths); + deaths = endOfOutermostPhase(world); + } +} + +/** + * The steps that run only when the outermost Phase ends; returns the Death Events. + * Module 4 adds an Aura Update (Health/Attack) before the Death Creation Step + * and an Aura Update (Other) after it, here. + */ +function endOfOutermostPhase(world: World): Event[] { + return deathCreationStep(world); +} + +function perform(world: World, action: Action): void { + switch (action.type) { + case "damage": { + // Area damage: every Damage Event is created, in order of play, before any reaction. + const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); + const events: Event[] = []; + for (const target of targets) { + world.damage(target, action.amount); + events.push({ type: "Damaged", target, amount: action.amount }); + } + react(world, events); + break; + } + case "draw": + world.draw(action.player); + break; + case "gainArmor": + world.gainArmor(action.player, action.amount); + break; + case "gainAttack": + world.gainAttack(action.id, action.amount); + break; + case "destroy": + for (const target of action.targets) world.markDestroyed(target); + break; + case "summon": + world.create(action.card, action.player, "board", action.at); + break; + case "cast": { + const effect = world.card(action.card).onCast; + if (!effect) throw new Error(`"${action.card}" is not a spell`); + world.record({ type: "SpellCast", card: action.card }); + for (const next of effect(new Ctx(world, action.player, undefined, action.target))) perform(world, next); + break; + } + } +} + +/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */ +function react(world: World, events: Event[]): void { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + const queue: [EntityId, Trigger, Event][] = []; + for (const event of events) { + for (const listener of listeners(world, event)) { + for (const trigger of world.card(world.cardName(listener)).triggers) { + if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]); + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (const [source, trigger, event] of queue) { + world.record({ type: "Triggered", source, on: trigger.on }); + const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source); + const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } +} + +/** + * Who may respond to `event`, in order of play: every minion in play and, for a + * Death Event, the minion that died. It has left play, but its Deathrattle still answers. + */ +function listeners(world: World, event: Event): EntityId[] { + const inPlay = world.minionsInPlay(); + if (event.type !== "Destroyed") return inPlay; + return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); +} + +/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */ +function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean { + if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener; + if (event.type !== "Damaged") return false; + const isMinion = !world.isHero(event.target); + switch (on) { + case "selfDamaged": + return event.target === listener; + case "friendlyMinionDamaged": + return isMinion && world.ownerOf(event.target) === world.ownerOf(listener); + case "anyMinionDamaged": + return isMinion; + case "heroDamaged": + return !isMinion; + case "deathrattle": + return false; + } +} diff --git a/course/03-death-step/reference/ts/src/engine/world.ts b/course/03-death-step/reference/ts/src/engine/world.ts new file mode 100644 index 0000000..af8fdae --- /dev/null +++ b/course/03-death-step/reference/ts/src/engine/world.ts @@ -0,0 +1,227 @@ +// Entities, zones, order of play, stats and the event log: the game's state. +// The rules for resolving actions live in `queue.ts`. + +import type { CardDef, CardLibrary } from "./card"; +import type { Event } from "./event"; + +/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */ +export type EntityId = number; +export type PlayerId = 0 | 1; +export type Zone = "deck" | "hand" | "board" | "graveyard"; + +/** Starting Health of each hero. */ +export const HERO_HEALTH = 30; + +interface Entity { + card: string; + owner: PlayerId; + zone: Zone; + /** Stamped each time the entity enters the board; undefined until it first does. */ + orderOfPlay?: number; + attack: number; + /** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */ + health: number; + /** Hit by a destroy effect: still in play until the Death Creation Step. */ + pendingDestroy: boolean; +} + +/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */ +type Side = Record & { hero: EntityId; armor: number }; + +export class World { + /** Indexed by EntityId. */ + private readonly entities: Entity[] = []; + private readonly sides: [Side, Side]; + private nextOrderOfPlay = 0; + private events: Event[] = []; + + /** A new game: both heroes are in play from the start, so they are the oldest entities. */ + constructor(private readonly library: CardLibrary) { + const side = (owner: PlayerId): Side => { + const hero = this.entities.length; + this.entities.push({ + card: "Hero", + owner, + zone: "board", + orderOfPlay: ++this.nextOrderOfPlay, + attack: 0, + health: HERO_HEALTH, + pendingDestroy: false, + }); + // In play, but not on the board list: the board holds minions only. + return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 }; + }; + this.sides = [side(0), side(1)]; + } + + /** + * Create an entity of `card` for `owner` in `zone`: at the end (the right end for the board), + * or at index `at` when given (clamped to the zone's length). + */ + create(card: string, owner: PlayerId, zone: Zone, at?: number): EntityId { + if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`); + const def = this.library.get(card); + const id = this.entities.length; + this.entities.push({ + card: def.name, + owner, + zone, + attack: def.attack, + health: def.health, + pendingDestroy: false, + }); + this.enter(id, zone, at); + return id; + } + + /** Move an entity to the end of `zone` on its owner's side, keeping its id. */ + moveTo(id: EntityId, zone: Zone): void { + const { owner, zone: from } = this.entity(id); + const list = this.sides[owner][from]; + list.splice(list.indexOf(id), 1); + this.enter(id, zone); + } + + /** The single place an entity arrives in a zone. Entering the board starts a new order of play. */ + private enter(id: EntityId, zone: Zone, at?: number): void { + const entity = this.entity(id); + const list = this.sides[entity.owner][zone]; + list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id); + entity.zone = zone; + if (zone === "board") { + entity.orderOfPlay = ++this.nextOrderOfPlay; + this.record({ type: "Summoned", id }); + } + } + + private entity(id: EntityId): Entity { + const entity = this.entities[id]; + if (!entity) throw new Error(`unknown entity id ${id}`); + return entity; + } + + // ---- Queries ---- + + card(name: string): CardDef { + return this.library.get(name); + } + + zoneOf(id: EntityId): Zone { + return this.entity(id).zone; + } + + cardName(id: EntityId): string { + return this.entity(id).card; + } + + ownerOf(id: EntityId): PlayerId { + return this.entity(id).owner; + } + + /** When the entity last entered the board (larger is later); undefined if it never has. */ + orderOfPlay(id: EntityId): number | undefined { + return this.entity(id).orderOfPlay; + } + + /** The ids in one player's zone, in zone order (left to right for the board). */ + zone(owner: PlayerId, zone: Zone): readonly EntityId[] { + if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`); + return this.sides[owner][zone]; + } + + hero(owner: PlayerId): EntityId { + if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`); + return this.sides[owner].hero; + } + + isHero(id: EntityId): boolean { + return this.sides.some((s) => s.hero === id); + } + + /** Every minion on either board, oldest first (order of play). */ + minionsInPlay(): EntityId[] { + return [...this.sides[0].board, ...this.sides[1].board].sort( + (a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!, + ); + } + + attack(id: EntityId): number { + return this.entity(id).attack; + } + + health(id: EntityId): number { + return this.entity(id).health; + } + + /** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */ + isDoomed(id: EntityId): boolean { + const entity = this.entity(id); + return entity.health <= 0 || entity.pendingDestroy; + } + + /** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */ + boardIndex(id: EntityId): number | undefined { + const index = this.sides[this.ownerOf(id)].board.indexOf(id); + return index < 0 ? undefined : index; + } + + armor(owner: PlayerId): number { + return this.sides[owner].armor; + } + + log(): readonly Event[] { + return this.events; + } + + // ---- Changes: each one records its event ---- + + record(event: Event): void { + this.events.push(event); + } + + clearLog(): void { + this.events = []; + } + + damage(target: EntityId, amount: number): void { + this.entity(target).health -= amount; + this.record({ type: "Damaged", target, amount }); + } + + /** + * Draw the top card of `player`'s deck (the first in the list). An empty deck + * records `Fatigue` and changes nothing else. + */ + draw(player: PlayerId): void { + const id = this.zone(player, "deck")[0]; + if (id === undefined) { + this.record({ type: "Fatigue", player }); + return; + } + this.moveTo(id, "hand"); + this.record({ type: "Drew", player, id }); + } + + /** Mark for destruction. Nothing leaves play until the Death Creation Step. */ + markDestroyed(id: EntityId): void { + this.entity(id).pendingDestroy = true; + } + + /** Move a minion from play to its owner's graveyard, recording its Death Event. */ + kill(id: EntityId, position: number): void { + this.entity(id).pendingDestroy = false; + this.moveTo(id, "graveyard"); + this.record({ type: "Destroyed", id, position }); + } + + gainArmor(player: PlayerId, amount: number): void { + this.sides[player].armor += amount; + this.record({ type: "ArmorGained", player, amount }); + } + + gainAttack(id: EntityId, amount: number): void { + const entity = this.entity(id); + entity.attack += amount; + this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health }); + } +} diff --git a/course/03-death-step/reference/ts/src/harness.ts b/course/03-death-step/reference/ts/src/harness.ts new file mode 100644 index 0000000..140cd00 --- /dev/null +++ b/course/03-death-step/reference/ts/src/harness.ts @@ -0,0 +1,89 @@ +// Test harness: the thin adapter the course tests call. It translates each +// call into engine operations and holds no rules of its own. + +import { library } from "./content"; +import { runSequence, triggerLabel, World, type Event, type Zone } from "./engine"; + +export type { Zone }; + +export type Id = number; +export type Player = 0 | 1; + +/** The events the tests read from `log()` (see course/harness.md). */ +export type GameEvent = + | { type: "Summoned"; id: Id } + | { type: "Damaged"; target: Id; amount: number } + | { type: "Triggered"; source: Id; trigger: string } + | { type: "Destroyed"; id: Id } + | { type: "DeathPhase"; round: number } + | { type: "Drew"; player: Player; id: Id } + | { type: "Fatigue"; player: Player } + | { type: "ArmorGained"; player: Player; amount: number } + | { type: "StatsChanged"; id: Id; attack: number; health: number } + | { type: "SpellCast"; card: string }; + +function toGameEvent(event: Event): GameEvent { + if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) }; + if (event.type === "Destroyed") return { type: "Destroyed", id: event.id }; + return { ...event }; +} + +export interface Game { + summon(card: string, player?: Player): Id; // to right end of board + addToHand(card: string, player?: Player): Id; + addToDeck(card: string, player?: Player): Id; + play(handId: Id): void; // Expansion Drop + zoneOf(id: Id): Zone; + board(player: Player): Id[]; // left to right + hand(player: Player): Id[]; + orderOfPlay(id: Id): number; + nameOf(id: Id): string; + // Module 2 + hero(player: Player): Id; + dealDamage(target: Id, amount: number): void; // one Sequence + cast(card: string, player?: Player, target?: Id): void; + health(id: Id): number; + attack(id: Id): number; + armor(player: Player): number; + log(): GameEvent[]; + clearLog(): void; + // Module 3 + destroy(target: Id): void; // marks pending destroy, one Sequence +} + +export function newGame(opts?: { decks?: [string[], string[]] }): Game { + const world = new World(library()); + opts?.decks?.forEach((deck, player) => { + for (const card of deck) world.create(card, player as Player, "deck"); + }); + + return { + summon: (card, player = 0) => world.create(card, player, "board"), + addToHand: (card, player = 0) => world.create(card, player, "hand"), + addToDeck: (card, player = 0) => world.create(card, player, "deck"), + // Module 1 Expansion Drop: see course/01-handles/expansion/README.md. + play: () => { + throw new Error("not implemented: Module 1 Expansion Drop"); + }, + zoneOf: (id) => world.zoneOf(id), + board: (player) => [...world.zone(player, "board")], + hand: (player) => [...world.zone(player, "hand")], + orderOfPlay: (id) => { + const order = world.orderOfPlay(id); + if (order === undefined) throw new Error(`entity ${id} has not entered play`); + return order; + }, + nameOf: (id) => world.cardName(id), + + hero: (player) => world.hero(player), + dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }), + cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }), + health: (id) => world.health(id), + attack: (id) => world.attack(id), + armor: (player) => world.armor(player), + log: () => world.log().map(toGameEvent), + clearLog: () => world.clearLog(), + + destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), + }; +} diff --git a/course/03-death-step/reference/ts/tsconfig.json b/course/03-death-step/reference/ts/tsconfig.json new file mode 100644 index 0000000..2099df7 --- /dev/null +++ b/course/03-death-step/reference/ts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src", "tests"] +} diff --git a/course/03-death-step/reference/ts/vitest.config.ts b/course/03-death-step/reference/ts/vitest.config.ts new file mode 100644 index 0000000..a6f65eb --- /dev/null +++ b/course/03-death-step/reference/ts/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { include: ["tests/**/*.test.ts"], passWithNoTests: true }, +}); diff --git a/course/03-death-step/scenarios.md b/course/03-death-step/scenarios.md new file mode 100644 index 0000000..b1ef8b8 --- /dev/null +++ b/course/03-death-step/scenarios.md @@ -0,0 +1,56 @@ +# Module 3 scenarios: Deferred death + +Each heading is a test name. The Rust test lives in `tests/rust/test_03.rs` and the TypeScript test in `tests/ts/test_03.test.ts`; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests is summoned for player 0, in the order listed, so the first one listed has the lowest order of play and stands furthest left. Each test calls `clearLog()` after its setup, so `log()` holds only the events of the action under test. + +Cards used (text from `course/catalog.json`): + +- `Loot Hoarder`, 2/1: "Deathrattle: Draw a card." +- `Harvest Golem`, 2/3: "Deathrattle: Summon a 2/1 Damaged Golem." +- `Damaged Golem`, 2/1, no text. Only Harvest Golem creates it. +- `Unstable Ghoul`, 1/3: "Taunt. Deathrattle: Deal 1 damage to all minions." Taunt has no effect in these tests. +- `Armorsmith`, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor." +- `Test Reactor` (fictional), 1/5: "Whenever a hero takes damage, gain +1 Attack." No hero takes damage in these tests, so it never triggers; it is here to stand on the board. +- `Whirlwind`, spell: "Deal 1 damage to ALL minions." +- `Test Filler` (fictional), 1/1, no text. Used to fill decks. + +A minion is removed from play only in the Death Creation Step, which runs when the Sequence's outermost Phase ends. The step removes every minion at 0 or less Health and every minion marked by a destroy effect, all at once, and records `Destroyed{id}` for each in order of play. If it removed anything, a Death Phase follows: it records `DeathPhase{round}` (1 for the first Death Phase of the Sequence, 2 for the next, and so on) and resolves the Deathrattles of the removed minions in order of play. When the Death Phase ends, the step runs again, until it removes nothing. + +### test_03_removed_only_after_sequence + +- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Loot Hoarder` (id H), then `Armorsmith` (id S). +- Action: player 0 casts `Whirlwind`. +- Assertions: + - The log is exactly, in this order: `SpellCast{card: "Whirlwind"}`, `Damaged{target: H, amount: 1}`, `Damaged{target: S, amount: 1}`, `Triggered{source: S, trigger: "damaged"}`, `ArmorGained{player: 0, amount: 1}`, `Triggered{source: S, trigger: "damaged"}`, `ArmorGained{player: 0, amount: 1}`, `Destroyed{id: H}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C}`, where C is the only card in `hand(0)`. H is at 0 Health from the first `Damaged` event, but it is removed only after every reaction to the Whirlwind has resolved. + - `zoneOf(H)` is graveyard. + - `board(0)` is `[S]`. + - `armor(0)` is 2. + +### test_03_chained_death_phases + +- Setup: an empty game. Summon `Harvest Golem` (id G), then `Unstable Ghoul` (id U), then `Test Reactor` (id R). `dealDamage(G, 2)`, which leaves G at 1 Health. +- Action: `dealDamage(U, 3)`. +- Assertions: + - The log is exactly, in this order: `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}`, where D is the first id in `board(0)`. The Ghoul's Deathrattle does not hit the Ghoul: it has already left play. Its damage leaves G at 0 Health, and G is removed by the second Death Creation Step, after the first Death Phase has ended. + - `nameOf(D)` is `"Damaged Golem"`. + - `board(0)` is `[D, R]`: the Damaged Golem takes the board position G had when it died, left of R, not the right end. + - `zoneOf(U)` and `zoneOf(G)` are graveyard. + - `health(R)` is 4. + +### 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). +- Action: player 0 casts `Whirlwind`. +- Assertions: + - `hand(0)` has 2 cards; call them C1 and C2, in hand order. + - The log is exactly, in this order: `SpellCast{card: "Whirlwind"}`, `Damaged{target: H1, amount: 1}`, `Damaged{target: H2, amount: 1}`, `Destroyed{id: H1}`, `Destroyed{id: H2}`, `DeathPhase{round: 1}`, `Triggered{source: H1, trigger: "deathrattle"}`, `Drew{player: 0, id: C1}`, `Triggered{source: H2, trigger: "deathrattle"}`, `Drew{player: 0, id: C2}`. Both minions leave play before either Deathrattle resolves, and the Deathrattles resolve in order of play, in one Death Phase. + - `zoneOf(H1)` and `zoneOf(H2)` are graveyard, and `board(0)` is empty. + +### test_03_destroy_marks_pending + +- Setup: an empty game. Summon `Harvest Golem` (id G). +- Action: `destroy(G)`. +- Assertions: + - The log is exactly, in this order: `Destroyed{id: G}`, `DeathPhase{round: 1}`, `Triggered{source: G, trigger: "deathrattle"}`, `Summoned{id: D}`, where D is the only id in `board(0)`. A destroy effect deals no damage and does not remove the minion itself: it marks G, and the Death Creation Step removes it like any other death. + - `health(G)` is 3: G was at full Health when it was removed. + - `zoneOf(G)` is graveyard. + - `nameOf(D)` is `"Damaged Golem"`. diff --git a/course/03-death-step/tests/rust/test_03.rs b/course/03-death-step/tests/rust/test_03.rs new file mode 100644 index 0000000..eca2f6a --- /dev/null +++ b/course/03-death-step/tests/rust/test_03.rs @@ -0,0 +1,157 @@ +// Module 3: Deferred death. Each test matches a heading in scenarios.md. +use engine::harness::*; + +fn fillers(n: usize) -> Vec { + vec!["Test Filler".to_string(); n] +} + +fn damaged(target: Id, amount: i32) -> GameEvent { + GameEvent::Damaged { target, amount } +} + +fn triggered(source: Id, trigger: &str) -> GameEvent { + GameEvent::Triggered { + source, + trigger: trigger.to_string(), + } +} + +fn whirlwind() -> GameEvent { + GameEvent::SpellCast { + card: "Whirlwind".to_string(), + } +} + +#[test] +fn test_03_removed_only_after_sequence() { + let mut g = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + let h = g.summon("Loot Hoarder", 0); + let s = g.summon("Armorsmith", 0); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + + let hand = g.hand(0); + assert_eq!(hand.len(), 1); + let c = hand[0]; + assert_eq!( + g.log(), + vec![ + whirlwind(), + damaged(h, 1), + damaged(s, 1), + triggered(s, "damaged"), + GameEvent::ArmorGained { + player: 0, + amount: 1 + }, + triggered(s, "damaged"), + GameEvent::ArmorGained { + player: 0, + amount: 1 + }, + GameEvent::Destroyed { id: h }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c }, + ] + ); + assert_eq!(g.zone_of(h), Zone::Graveyard); + assert_eq!(g.board(0), vec![s]); + assert_eq!(g.armor(0), 2); +} + +#[test] +fn test_03_chained_death_phases() { + 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]; + assert_eq!( + g.log(), + vec![ + damaged(ghoul, 3), + GameEvent::Destroyed { id: ghoul }, + GameEvent::DeathPhase { round: 1 }, + triggered(ghoul, "deathrattle"), + damaged(golem, 1), + damaged(r, 1), + GameEvent::Destroyed { id: golem }, + GameEvent::DeathPhase { round: 2 }, + triggered(golem, "deathrattle"), + GameEvent::Summoned { id: d }, + ] + ); + assert_eq!(g.name_of(d), "Damaged Golem"); + assert_eq!(g.board(0), vec![d, r]); + assert_eq!(g.zone_of(ghoul), Zone::Graveyard); + assert_eq!(g.zone_of(golem), Zone::Graveyard); + assert_eq!(g.health(r), 4); +} + +#[test] +fn test_03_simultaneous_removal() { + let mut g = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + let h1 = g.summon("Loot Hoarder", 0); + let h2 = g.summon("Loot Hoarder", 0); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + + let hand = g.hand(0); + assert_eq!(hand.len(), 2); + let (c1, c2) = (hand[0], hand[1]); + assert_eq!( + g.log(), + vec![ + whirlwind(), + damaged(h1, 1), + damaged(h2, 1), + GameEvent::Destroyed { id: h1 }, + GameEvent::Destroyed { id: h2 }, + GameEvent::DeathPhase { round: 1 }, + triggered(h1, "deathrattle"), + GameEvent::Drew { player: 0, id: c1 }, + triggered(h2, "deathrattle"), + GameEvent::Drew { player: 0, id: c2 }, + ] + ); + assert_eq!(g.zone_of(h1), Zone::Graveyard); + assert_eq!(g.zone_of(h2), Zone::Graveyard); + assert_eq!(g.board(0), Vec::::new()); +} + +#[test] +fn test_03_destroy_marks_pending() { + let mut g = new_game(GameOpts::default()); + let golem = g.summon("Harvest Golem", 0); + g.clear_log(); + + g.destroy(golem); + + let board = g.board(0); + assert_eq!(board.len(), 1); + let d = board[0]; + assert_eq!( + g.log(), + vec![ + GameEvent::Destroyed { id: golem }, + GameEvent::DeathPhase { round: 1 }, + triggered(golem, "deathrattle"), + GameEvent::Summoned { id: d }, + ] + ); + assert_eq!(g.health(golem), 3); + assert_eq!(g.zone_of(golem), Zone::Graveyard); + assert_eq!(g.name_of(d), "Damaged Golem"); +} diff --git a/course/03-death-step/tests/ts/test_03.test.ts b/course/03-death-step/tests/ts/test_03.test.ts new file mode 100644 index 0000000..eda5ea5 --- /dev/null +++ b/course/03-death-step/tests/ts/test_03.test.ts @@ -0,0 +1,113 @@ +// Module 3: Deferred death. Each test matches a heading in scenarios.md. +import { expect, test } from "vitest"; +import { newGame } from "../src/harness"; + +const fillers = (n: number): string[] => Array(n).fill("Test Filler"); + +test("test_03_removed_only_after_sequence", () => { + const g = newGame({ decks: [fillers(5), []] }); + const h = g.summon("Loot Hoarder", 0); + const s = g.summon("Armorsmith", 0); + g.clearLog(); + + g.cast("Whirlwind", 0); + + const hand = g.hand(0); + expect(hand).toHaveLength(1); + const c = hand[0]; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Whirlwind" }, + { type: "Damaged", target: h, amount: 1 }, + { type: "Damaged", target: s, amount: 1 }, + { type: "Triggered", source: s, trigger: "damaged" }, + { type: "ArmorGained", player: 0, amount: 1 }, + { type: "Triggered", source: s, trigger: "damaged" }, + { type: "ArmorGained", player: 0, amount: 1 }, + { type: "Destroyed", id: h }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c }, + ]); + expect(g.zoneOf(h)).toBe("graveyard"); + expect(g.board(0)).toEqual([s]); + expect(g.armor(0)).toBe(2); +}); + +test("test_03_chained_death_phases", () => { + 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.log()).toEqual([ + { type: "Damaged", target: ghoul, amount: 3 }, + { type: "Destroyed", id: ghoul }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: ghoul, trigger: "deathrattle" }, + { type: "Damaged", target: golem, amount: 1 }, + { type: "Damaged", target: r, amount: 1 }, + { type: "Destroyed", id: golem }, + { type: "DeathPhase", round: 2 }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d }, + ]); + expect(g.nameOf(d)).toBe("Damaged Golem"); + expect(g.board(0)).toEqual([d, r]); + expect(g.zoneOf(ghoul)).toBe("graveyard"); + expect(g.zoneOf(golem)).toBe("graveyard"); + expect(g.health(r)).toBe(4); +}); + +test("test_03_simultaneous_removal", () => { + const g = newGame({ decks: [fillers(5), []] }); + const h1 = g.summon("Loot Hoarder", 0); + const h2 = g.summon("Loot Hoarder", 0); + g.clearLog(); + + g.cast("Whirlwind", 0); + + const hand = g.hand(0); + expect(hand).toHaveLength(2); + const [c1, c2] = hand; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Whirlwind" }, + { type: "Damaged", target: h1, amount: 1 }, + { type: "Damaged", target: h2, amount: 1 }, + { type: "Destroyed", id: h1 }, + { type: "Destroyed", id: h2 }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h1, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c1 }, + { type: "Triggered", source: h2, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c2 }, + ]); + expect(g.zoneOf(h1)).toBe("graveyard"); + expect(g.zoneOf(h2)).toBe("graveyard"); + expect(g.board(0)).toEqual([]); +}); + +test("test_03_destroy_marks_pending", () => { + const g = newGame(); + const golem = g.summon("Harvest Golem", 0); + g.clearLog(); + + g.destroy(golem); + + const board = g.board(0); + expect(board).toHaveLength(1); + const d = board[0]; + expect(g.log()).toEqual([ + { type: "Destroyed", id: golem }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d }, + ]); + expect(g.health(golem)).toBe(3); + expect(g.zoneOf(golem)).toBe("graveyard"); + expect(g.nameOf(d)).toBe("Damaged Golem"); +});