feat: module 04 interceptors
Trigger multipliers as aura data on cards, snapshotted by an Aura Update (Other) after each Death Creation Step and on summon, and applied by one generic routine (activations) when each queued trigger starts. Baron Rivendare is content only (stacking "max"); Brann Bronzebeard is the content-only Expansion Drop. Adds play (now required) and Battlecry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a4eba1948
commit
bcdaca0cd2
40 files changed
+4421
-1
No files matched your search
@@ -0,0 +1 @@
|
||||
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "04" }
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# serde_json arrives at Module 5; nothing needed yet.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Module 4 reference engine (Rust)
|
||||
|
||||
The course's own engine as of the end of Module 4. It adds auras as card data and one routine that decides how many times a queued trigger activates, so "Your minions trigger their Deathrattles twice." is a card, not a change to Deathrattle. It also adds playing a minion from hand, and Battlecries. Module 3's reference was the starting point, so a diff between the two shows what this module adds. Module 5's reference starts from this one.
|
||||
|
||||
- `src/engine/`: generic machinery. Names no cards.
|
||||
- `aura.rs`: the Aura Update (Other), which snapshots the auras of every minion in play, and `activations`, the one routine that reads the snapshot and returns how many times a trigger of a given kind activates for a given player. Stacking policy (`Max` or `Additive`) is a field of each multiplier.
|
||||
- `queue.rs`: actions, the context an effect sees, and the resolver. The end of the outermost Phase runs the Death Creation Step, then the Aura Update (Other). Summoning a minion also runs the Aura Update; playing one does not. Each queued trigger asks `activations` once, as it starts, and then activates that many times back to back. A `Play` action moves a minion from hand to board and raises a `Played` event, which the minion's Battlecry answers.
|
||||
- `death.rs`: the Death Creation Step, unchanged from Module 3.
|
||||
- `world.rs`: entities, zones, order of play, heroes, stats, Armor, the pending-destroy flag, the aura snapshot and the log.
|
||||
- `card.rs`: card definitions (stats, triggers, auras, a spell's effect) and the library.
|
||||
- `event.rs`: the engine's events. `Played` is engine-only: `src/harness.rs` leaves it out of the course's `GameEvent` log, where the played minion's `Summoned` already shows it.
|
||||
- `src/content/mod.rs`: card definitions. Baron Rivendare is one aura entry: `Aura::TriggerMultiplier` on `TriggerOn::Deathrattle`, `times` 2, `Stacking::Max`. Twisting Nether (the Module 3 Expansion Drop) is here because the Module 4 tests use it.
|
||||
- `src/harness.rs`: the adapter the course tests call. `summon` goes through the engine's summon, so it runs an Aura Update. `play` (the Module 1 Expansion Drop) runs one Sequence with a `Play` action.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 04` from the course repository.
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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. Auras are
|
||||
//! already data: the engine reads them, and no engine code knows these cards.
|
||||
|
||||
use crate::engine::{Action, Aura, CardDef, CardLibrary, Stacking, 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,
|
||||
}]
|
||||
}),
|
||||
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
|
||||
CardDef::minion("Baron Rivendare", 1, 7).aura(Aura::TriggerMultiplier {
|
||||
trigger: TriggerOn::Deathrattle,
|
||||
times: 2,
|
||||
stacking: Stacking::Max,
|
||||
}),
|
||||
// "Battlecry: Draw a card."
|
||||
CardDef::minion("Novice Engineer", 1, 1).on(TriggerOn::Battlecry, |c| {
|
||||
vec![Action::Draw { player: c.owner }]
|
||||
}),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
CardDef::spell("Whirlwind", |c| {
|
||||
vec![Action::Damage {
|
||||
targets: c.world.minions_in_play(),
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
// "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.)
|
||||
CardDef::spell("Twisting Nether", |c| {
|
||||
vec![Action::Destroy {
|
||||
targets: c.world.minions_in_play(),
|
||||
}]
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Auras, and the one routine that applies trigger multipliers.
|
||||
//!
|
||||
//! An aura is data on a card: a continuous effect its minion has while in play.
|
||||
//! The engine never asks "is that card on the board?" in the middle of resolving
|
||||
//! something. It takes a snapshot of the auras in force at defined points (an Aura
|
||||
//! Update) and every rule reads the snapshot.
|
||||
//!
|
||||
//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
|
||||
//! Health/Attack auras before the Death Creation Step, and one for every other aura
|
||||
//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
|
||||
//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
|
||||
//! Summoning a minion also runs an Aura Update.
|
||||
|
||||
use super::card::{Aura, Stacking, TriggerOn};
|
||||
use super::world::{ActiveAura, PlayerId, World};
|
||||
|
||||
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
|
||||
pub fn aura_update_other(world: &mut World) {
|
||||
let mut active = Vec::new();
|
||||
for id in world.minions_in_play() {
|
||||
for &aura in &world.card(world.card_name(id)).auras {
|
||||
active.push(ActiveAura {
|
||||
owner: world.owner_of(id),
|
||||
aura,
|
||||
});
|
||||
}
|
||||
}
|
||||
world.set_active_auras(active);
|
||||
}
|
||||
|
||||
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
|
||||
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
|
||||
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
|
||||
/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations.
|
||||
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
|
||||
let mut max = 1;
|
||||
let mut extra = 0;
|
||||
for active in world.active_auras() {
|
||||
let Aura::TriggerMultiplier {
|
||||
trigger,
|
||||
times,
|
||||
stacking,
|
||||
} = active.aura;
|
||||
if active.owner != owner || trigger != on {
|
||||
continue;
|
||||
}
|
||||
match stacking {
|
||||
Stacking::Max => max = max.max(times),
|
||||
Stacking::Additive => extra += times - 1,
|
||||
}
|
||||
}
|
||||
max + extra
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! 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<Action>;
|
||||
|
||||
/// 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,
|
||||
/// This minion is played from hand.
|
||||
Battlecry,
|
||||
}
|
||||
|
||||
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",
|
||||
TriggerOn::Battlecry => "battlecry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How several trigger multipliers for the same trigger combine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stacking {
|
||||
/// The largest multiplier wins: two copies of a doubler still double.
|
||||
Max,
|
||||
/// Each copy adds its extra activations.
|
||||
Additive,
|
||||
}
|
||||
|
||||
/// An aura: a continuous effect a minion has while it is in play. The engine reads
|
||||
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Aura {
|
||||
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
|
||||
/// owner, activate `times` times each.
|
||||
TriggerMultiplier {
|
||||
trigger: TriggerOn,
|
||||
times: u32,
|
||||
stacking: Stacking,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<Trigger>,
|
||||
/// Auras this minion has while in play.
|
||||
pub auras: Vec<Aura>,
|
||||
/// A spell's effect when cast; `None` for minions.
|
||||
pub on_cast: Option<EffectFn>,
|
||||
}
|
||||
|
||||
impl CardDef {
|
||||
pub fn minion(name: &str, attack: i32, health: i32) -> CardDef {
|
||||
CardDef {
|
||||
name: name.to_string(),
|
||||
attack,
|
||||
health,
|
||||
triggers: Vec::new(),
|
||||
auras: 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 <on>, <effect>".
|
||||
pub fn on(mut self, on: TriggerOn, effect: EffectFn) -> CardDef {
|
||||
self.triggers.push(Trigger { on, effect });
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an aura.
|
||||
pub fn aura(mut self, aura: Aura) -> CardDef {
|
||||
self.auras.push(aura);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Every card the game can create, looked up by name.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CardLibrary {
|
||||
cards: HashMap<String, CardDef>,
|
||||
}
|
||||
|
||||
impl CardLibrary {
|
||||
pub fn new(defs: Vec<CardDef>) -> 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"))
|
||||
}
|
||||
}
|
||||
@@ -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<Event> {
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 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,
|
||||
},
|
||||
/// A minion was played from hand (it has just entered the board). Its Battlecry answers this.
|
||||
Played {
|
||||
id: EntityId,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Module 4: auras as card data, Aura Updates at defined points, and one
|
||||
//! routine that decides how many times a queued trigger activates. Playing a
|
||||
//! minion from hand, and Battlecries.
|
||||
|
||||
mod aura;
|
||||
mod card;
|
||||
mod death;
|
||||
mod event;
|
||||
mod queue;
|
||||
mod world;
|
||||
|
||||
pub use card::{Aura, CardDef, CardLibrary, EffectFn, Stacking, Trigger, TriggerOn};
|
||||
pub use event::Event;
|
||||
pub use queue::{run_sequence, summon, Action, Ctx};
|
||||
pub use world::{EntityId, PlayerId, World, Zone, HERO_HEALTH};
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Module 4: a queued trigger may activate more than once. How many times is
|
||||
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
|
||||
//! Nothing here knows which cards multiply which triggers.
|
||||
|
||||
use super::aura::{activations, aura_update_other};
|
||||
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<EntityId>,
|
||||
amount: i32,
|
||||
},
|
||||
Draw {
|
||||
player: PlayerId,
|
||||
},
|
||||
GainArmor {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
GainAttack {
|
||||
id: EntityId,
|
||||
amount: i32,
|
||||
},
|
||||
Cast {
|
||||
card: String,
|
||||
player: PlayerId,
|
||||
target: Option<EntityId>,
|
||||
},
|
||||
/// Mark each target pending destroy. They leave play in the next Death Creation Step.
|
||||
Destroy {
|
||||
targets: Vec<EntityId>,
|
||||
},
|
||||
/// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`.
|
||||
Summon {
|
||||
card: String,
|
||||
player: PlayerId,
|
||||
at: Option<usize>,
|
||||
},
|
||||
/// Play a minion from its owner's hand: it enters the right end of the board, then its
|
||||
/// Battlecry resolves.
|
||||
Play {
|
||||
id: EntityId,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<EntityId>,
|
||||
/// The chosen target, if any.
|
||||
pub target: Option<EntityId>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
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.
|
||||
/// Hearthstone runs an Aura Update (Health/Attack) first. The course has no
|
||||
/// Health/Attack auras, so that step has nothing to do and is left out.
|
||||
fn end_of_outermost_phase(world: &mut World) -> Vec<Event> {
|
||||
let deaths = death_creation_step(world);
|
||||
// After the step: a minion that just died no longer contributes its auras
|
||||
// to the Death Phase that resolves these deaths.
|
||||
aura_update_other(world);
|
||||
deaths
|
||||
}
|
||||
|
||||
/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an
|
||||
/// Aura Update.
|
||||
pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option<usize>) -> EntityId {
|
||||
let id = world.create_at(card, player, Zone::Board, at);
|
||||
aura_update_other(world);
|
||||
id
|
||||
}
|
||||
|
||||
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 } => {
|
||||
summon(world, &card, player, at);
|
||||
}
|
||||
Action::Play { id } => {
|
||||
assert!(
|
||||
world.zone_of(id) == Zone::Hand,
|
||||
"play: entity {id} is not in a hand"
|
||||
);
|
||||
// Playing is not summoning: no Aura Update here. The played minion's own
|
||||
// auras join at the end of the outermost Phase.
|
||||
world.move_to(id, Zone::Board);
|
||||
let played = Event::Played { id };
|
||||
world.record(played.clone());
|
||||
react(world, &[played]);
|
||||
}
|
||||
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 {
|
||||
// The interceptor: the entry was queued once; the auras in force when it
|
||||
// starts decide how many times it activates, back to back.
|
||||
let times = activations(world, world.owner_of(source), trigger.on);
|
||||
for _ in 0..times {
|
||||
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<EntityId> {
|
||||
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;
|
||||
}
|
||||
if let Event::Played { id } = *event {
|
||||
return on == TriggerOn::Battlecry && 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 | TriggerOn::Battlecry => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 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::{Aura, 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<u32>,
|
||||
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,
|
||||
}
|
||||
|
||||
/// An aura in force: which player's minion has it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ActiveAura {
|
||||
pub owner: PlayerId,
|
||||
pub aura: Aura,
|
||||
}
|
||||
|
||||
/// One player's zones. Each is an ordered list of ids (the board is left to right).
|
||||
#[derive(Debug, Default)]
|
||||
struct Side {
|
||||
deck: Vec<EntityId>,
|
||||
hand: Vec<EntityId>,
|
||||
board: Vec<EntityId>,
|
||||
graveyard: Vec<EntityId>,
|
||||
hero: EntityId,
|
||||
armor: i32,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn zone(&self, zone: Zone) -> &Vec<EntityId> {
|
||||
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<EntityId> {
|
||||
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<Entity>,
|
||||
sides: [Side; 2],
|
||||
next_order_of_play: u32,
|
||||
log: Vec<Event>,
|
||||
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
|
||||
auras: Vec<ActiveAura>,
|
||||
}
|
||||
|
||||
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(),
|
||||
auras: 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<usize>,
|
||||
) -> 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<usize>) {
|
||||
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<u32> {
|
||||
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<EntityId> {
|
||||
let mut ids: Vec<EntityId> = 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<usize> {
|
||||
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 active_auras(&self) -> &[ActiveAura] {
|
||||
&self.auras
|
||||
}
|
||||
|
||||
pub fn log(&self) -> &[Event] {
|
||||
&self.log
|
||||
}
|
||||
|
||||
// ---- Changes: each one records its event ----------------------------
|
||||
|
||||
pub fn record(&mut self, event: Event) {
|
||||
self.log.push(event);
|
||||
}
|
||||
|
||||
/// Only an Aura Update calls this.
|
||||
pub fn set_active_auras(&mut self, auras: Vec<ActiveAura>) {
|
||||
self.auras = auras;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! 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, summon, 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<String>; 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 GameEvent {
|
||||
/// The course's view of an engine event; `None` for engine events the course log
|
||||
/// doesn't show.
|
||||
fn from_engine(event: &Event) -> Option<GameEvent> {
|
||||
Some(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 },
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
Event::Played { .. } => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
summon(&mut self.world, card, player, None)
|
||||
}
|
||||
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, as one Sequence: it enters the right end of the board, then
|
||||
/// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.)
|
||||
pub fn play(&mut self, hand_id: Id) {
|
||||
run_sequence(&mut self.world, Action::Play { id: hand_id });
|
||||
}
|
||||
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<Id> {
|
||||
self.world.zone(player, Zone::Board).to_vec()
|
||||
}
|
||||
pub fn hand(&self, player: Player) -> Vec<Id> {
|
||||
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<Id>) {
|
||||
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<GameEvent> {
|
||||
self.world
|
||||
.log()
|
||||
.iter()
|
||||
.filter_map(GameEvent::from_engine)
|
||||
.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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod content;
|
||||
pub mod engine;
|
||||
pub mod harness;
|
||||
@@ -0,0 +1 @@
|
||||
{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "04" }
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,15 @@
|
||||
# Module 4 reference engine (TypeScript)
|
||||
|
||||
The course's own engine as of the end of Module 4. It adds auras as card data and one routine that decides how many times a queued trigger activates, so "Your minions trigger their Deathrattles twice." is a card, not a change to Deathrattle. It also adds playing a minion from hand, and Battlecries. Module 3's reference was the starting point, so a diff between the two shows what this module adds. Module 5's reference starts from this one.
|
||||
|
||||
- `src/engine/`: generic machinery. Names no cards.
|
||||
- `aura.ts`: the Aura Update (Other), which snapshots the auras of every minion in play, and `activations`, the one routine that reads the snapshot and returns how many times a trigger of a given kind activates for a given player. Stacking policy (`max` or `additive`) is a field of each multiplier.
|
||||
- `queue.ts`: actions, the context an effect sees, and the resolver. The end of the outermost Phase runs the Death Creation Step, then the Aura Update (Other). Summoning a minion also runs the Aura Update; playing one does not. Each queued trigger asks `activations` once, as it starts, and then activates that many times back to back. A `Play` action moves a minion from hand to board and raises a `Played` event, which the minion's Battlecry answers.
|
||||
- `death.ts`: the Death Creation Step, unchanged from Module 3.
|
||||
- `world.ts`: entities, zones, order of play, heroes, stats, Armor, the pending-destroy flag, the aura snapshot and the log.
|
||||
- `card.ts`: card definitions (stats, triggers, auras, a spell's effect) and the library.
|
||||
- `event.ts`: the engine's events. `Played` is engine-only: `src/harness.ts` leaves it out of the course's `GameEvent` log, where the played minion's `Summoned` already shows it.
|
||||
- `src/content/index.ts`: card definitions. Baron Rivendare is one aura entry: `triggerMultiplier` on `deathrattle`, `times` 2, stacking `max`. Twisting Nether (the Module 3 Expansion Drop) is here because the Module 4 tests use it.
|
||||
- `src/harness.ts`: the adapter the course tests call. `summon` goes through the engine's summon, so it runs an Aura Update. `play` (the Module 1 Expansion Drop) runs one Sequence with a `Play` action.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 04` from the course repository.
|
||||
+1496
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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. Auras are
|
||||
// already data: the engine reads them, and no engine code knows these cards.
|
||||
|
||||
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 }],
|
||||
},
|
||||
]),
|
||||
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
|
||||
minion("Baron Rivendare", 1, 7, [], [
|
||||
{ type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" },
|
||||
]),
|
||||
// "Battlecry: Draw a card."
|
||||
minion("Novice Engineer", 1, 1, [{ on: "battlecry", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]),
|
||||
// "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.)
|
||||
spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Auras, and the one routine that applies trigger multipliers.
|
||||
//
|
||||
// An aura is data on a card: a continuous effect its minion has while in play.
|
||||
// The engine never asks "is that card on the board?" in the middle of resolving
|
||||
// something. It takes a snapshot of the auras in force at defined points (an Aura
|
||||
// Update) and every rule reads the snapshot.
|
||||
//
|
||||
// Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
|
||||
// Health/Attack auras before the Death Creation Step, and one for every other aura
|
||||
// after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
|
||||
// Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
|
||||
// Summoning a minion also runs an Aura Update.
|
||||
|
||||
import type { TriggerOn } from "./card";
|
||||
import type { PlayerId, World } from "./world";
|
||||
|
||||
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
|
||||
export function auraUpdateOther(world: World): void {
|
||||
world.setActiveAuras(
|
||||
world.minionsInPlay().flatMap((id) =>
|
||||
world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
|
||||
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
|
||||
* force the answer is 1. Multipliers with `stacking: "max"` contribute their largest
|
||||
* `times`; each `"additive"` one adds `times - 1` extra activations.
|
||||
*/
|
||||
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
|
||||
let max = 1;
|
||||
let extra = 0;
|
||||
for (const active of world.activeAuras()) {
|
||||
const { trigger, times, stacking } = active.aura;
|
||||
if (active.owner !== owner || trigger !== on) continue;
|
||||
if (stacking === "max") max = Math.max(max, times);
|
||||
else extra += times - 1;
|
||||
}
|
||||
return max + extra;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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)
|
||||
| "battlecry"; // this minion is played from hand
|
||||
|
||||
/** 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";
|
||||
case "battlecry":
|
||||
return "battlecry";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An aura: a continuous effect a minion has while it is in play. The engine reads
|
||||
* auras only at the Aura Update points (see `aura.ts`), never mid-Phase.
|
||||
*
|
||||
* `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with
|
||||
* the aura owner's owner, activate `times` times each. `stacking` says how several
|
||||
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
|
||||
* a doubler still double); `"additive"` adds each copy's extra activations.
|
||||
*/
|
||||
export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
|
||||
|
||||
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[];
|
||||
/** Auras this minion has while in play. */
|
||||
auras: Aura[];
|
||||
/** A spell's effect when cast; absent for minions. */
|
||||
onCast?: EffectFn;
|
||||
}
|
||||
|
||||
export function minion(
|
||||
name: string,
|
||||
attack: number,
|
||||
health: number,
|
||||
triggers: Trigger[] = [],
|
||||
auras: Aura[] = [],
|
||||
): CardDef {
|
||||
return { name, attack, health, triggers, auras };
|
||||
}
|
||||
|
||||
export function spell(name: string, onCast: EffectFn): CardDef {
|
||||
return { name, attack: 0, health: 0, triggers: [], auras: [], onCast };
|
||||
}
|
||||
|
||||
/** Every card the game can create, looked up by name. */
|
||||
export class CardLibrary {
|
||||
private readonly cards = new Map<string, CardDef>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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 }
|
||||
/** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */
|
||||
| { type: "Played"; id: EntityId };
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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.
|
||||
//
|
||||
// Module 4: auras as card data, Aura Updates at defined points, and one
|
||||
// routine that decides how many times a queued trigger activates. Playing a
|
||||
// minion from hand, and Battlecries.
|
||||
|
||||
export {
|
||||
CardLibrary,
|
||||
minion,
|
||||
spell,
|
||||
triggerLabel,
|
||||
type Aura,
|
||||
type CardDef,
|
||||
type EffectFn,
|
||||
type Trigger,
|
||||
type TriggerOn,
|
||||
} from "./card";
|
||||
export type { Event } from "./event";
|
||||
export { Ctx, runSequence, summon, type Action } from "./queue";
|
||||
export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world";
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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.
|
||||
//
|
||||
// Module 4: a queued trigger may activate more than once. How many times is
|
||||
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
|
||||
// Nothing here knows which cards multiply which triggers.
|
||||
|
||||
import { activations, auraUpdateOther } from "./aura";
|
||||
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 }
|
||||
/** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */
|
||||
| { type: "play"; id: EntityId };
|
||||
|
||||
/** 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.
|
||||
* Hearthstone runs an Aura Update (Health/Attack) first. The course has no
|
||||
* Health/Attack auras, so that step has nothing to do and is left out.
|
||||
*/
|
||||
function endOfOutermostPhase(world: World): Event[] {
|
||||
const deaths = deathCreationStep(world);
|
||||
// After the step: a minion that just died no longer contributes its auras
|
||||
// to the Death Phase that resolves these deaths.
|
||||
auraUpdateOther(world);
|
||||
return deaths;
|
||||
}
|
||||
|
||||
/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */
|
||||
export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId {
|
||||
const id = world.create(card, player, "board", at);
|
||||
auraUpdateOther(world);
|
||||
return id;
|
||||
}
|
||||
|
||||
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":
|
||||
summon(world, action.card, action.player, action.at);
|
||||
break;
|
||||
case "play": {
|
||||
if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.id} is not in a hand`);
|
||||
// Playing is not summoning: no Aura Update here. The played minion's own
|
||||
// auras join at the end of the outermost Phase.
|
||||
world.moveTo(action.id, "board");
|
||||
const played: Event = { type: "Played", id: action.id };
|
||||
world.record(played);
|
||||
react(world, [played]);
|
||||
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) {
|
||||
// The interceptor: the entry was queued once; the auras in force when it
|
||||
// starts decide how many times it activates, back to back.
|
||||
const times = activations(world, world.ownerOf(source), trigger.on);
|
||||
for (let i = 0; i < times; i++) {
|
||||
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 === "Played") return on === "battlecry" && 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":
|
||||
case "battlecry":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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 { Aura, 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;
|
||||
}
|
||||
|
||||
/** An aura in force: which player's minion has it. */
|
||||
export interface ActiveAura {
|
||||
owner: PlayerId;
|
||||
aura: Aura;
|
||||
}
|
||||
|
||||
/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */
|
||||
type Side = Record<Zone, EntityId[]> & { 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[] = [];
|
||||
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
|
||||
private auras: ActiveAura[] = [];
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
activeAuras(): readonly ActiveAura[] {
|
||||
return this.auras;
|
||||
}
|
||||
|
||||
log(): readonly Event[] {
|
||||
return this.events;
|
||||
}
|
||||
|
||||
// ---- Changes: each one records its event ----
|
||||
|
||||
record(event: Event): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
/** Only an Aura Update calls this. */
|
||||
setActiveAuras(auras: ActiveAura[]): void {
|
||||
this.auras = auras;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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, summon, 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 };
|
||||
|
||||
/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */
|
||||
function toGameEvent(event: Event): GameEvent | undefined {
|
||||
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
|
||||
if (event.type === "Destroyed") return { type: "Destroyed", id: event.id };
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
if (event.type === "Played") return undefined;
|
||||
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; // Module 1 Expansion Drop; required from Module 4 (Battlecry)
|
||||
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) => summon(world, card, player),
|
||||
addToHand: (card, player = 0) => world.create(card, player, "hand"),
|
||||
addToDeck: (card, player = 0) => world.create(card, player, "deck"),
|
||||
// Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves.
|
||||
play: (handId) => runSequence(world, { type: "play", id: handId }),
|
||||
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().flatMap((event) => toGameEvent(event) ?? []),
|
||||
clearLog: () => world.clearLog(),
|
||||
|
||||
destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ["tests/**/*.test.ts"], passWithNoTests: true },
|
||||
});
|
||||
Reference in new issue
Block a user