feat: module 02 action queue
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
78dc09b303
commit
9c50591d3b
36 files changed
+3616
No files matched your search
@@ -0,0 +1 @@
|
||||
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "02" }
|
||||
@@ -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,13 @@
|
||||
# Module 2 reference engine (Rust)
|
||||
|
||||
The course's own engine as of the end of Module 2. It adds heroes, Health, Attack and Armor, triggered abilities, an event log, and the action queue that resolves one player action depth-first. Module 1's reference was the starting point, so a diff between the two shows what this module adds. Module 3's reference starts from this one.
|
||||
|
||||
- `src/engine/`: generic machinery. Names no cards.
|
||||
- `queue.rs`: actions, the context an effect sees, and the resolver. Area damage creates every Damage Event before any trigger is queued; a Queue is filled once, oldest listener first, then locked; each trigger resolves completely before the next.
|
||||
- `world.rs`: entities, zones, order of play, heroes, stats, Armor and the log. Every state change records its event.
|
||||
- `card.rs`: card definitions (stats, triggers, a spell's effect) and the library.
|
||||
- `event.rs`: the engine's events. `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. `play` is still the Module 1 Expansion Drop stub.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 02` from the course repository.
|
||||
@@ -0,0 +1,46 @@
|
||||
//! 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,
|
||||
}]
|
||||
}),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
CardDef::spell("Whirlwind", |c| {
|
||||
vec![Action::Damage {
|
||||
targets: c.world.minions_in_play(),
|
||||
amount: 1,
|
||||
}]
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
/// 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(),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,38 @@
|
||||
//! 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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 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.
|
||||
|
||||
mod card;
|
||||
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};
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 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.
|
||||
|
||||
use super::card::TriggerOn;
|
||||
use super::event::Event;
|
||||
use super::world::{EntityId, PlayerId, World};
|
||||
|
||||
/// 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>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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);
|
||||
// Module 3: when the outermost Phase ends, deaths are processed here.
|
||||
}
|
||||
|
||||
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::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,
|
||||
});
|
||||
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 world.minions_in_play() {
|
||||
for trigger in &world.card(world.card_name(listener)).triggers {
|
||||
if responds(world, trigger.on, listener, event) {
|
||||
queue.push((listener, *trigger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Queue is now locked: nothing that happens below adds to it.
|
||||
for (source, trigger) in queue {
|
||||
world.record(Event::Triggered {
|
||||
source,
|
||||
on: trigger.on,
|
||||
});
|
||||
let ctx = Ctx {
|
||||
world,
|
||||
owner: world.owner_of(source),
|
||||
source: Some(source),
|
||||
target: None,
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`?
|
||||
fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 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<u32>,
|
||||
attack: i32,
|
||||
/// Current Health. It can drop to 0 or below: Module 2 has no death step.
|
||||
health: i32,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
// 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 {
|
||||
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,
|
||||
};
|
||||
let id = self.entities.len() as EntityId;
|
||||
self.entities.push(entity);
|
||||
self.enter(id, zone);
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let owner = self.entity(id).owner;
|
||||
self.sides[owner as usize].zone_mut(zone).push(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
|
||||
}
|
||||
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
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,139 @@
|
||||
//! 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<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 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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<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().map(GameEvent::from).collect()
|
||||
}
|
||||
pub fn clear_log(&mut self) {
|
||||
self.world.clear_log();
|
||||
}
|
||||
}
|
||||
@@ -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": "02" }
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,13 @@
|
||||
# Module 2 reference engine (TypeScript)
|
||||
|
||||
The course's own engine as of the end of Module 2. It adds heroes, Health, Attack and Armor, triggered abilities, an event log, and the action queue that resolves one player action depth-first. Module 1's reference was the starting point, so a diff between the two shows what this module adds. Module 3's reference starts from this one.
|
||||
|
||||
- `src/engine/`: generic machinery. Names no cards.
|
||||
- `queue.ts`: actions, the context an effect sees, and the resolver. Area damage creates every Damage Event before any trigger is queued; a Queue is filled once, oldest listener first, then locked; each trigger resolves completely before the next.
|
||||
- `world.ts`: entities, zones, order of play, heroes, stats, Armor and the log. Every state change records its event.
|
||||
- `card.ts`: card definitions (stats, triggers, a spell's effect) and the library.
|
||||
- `event.ts`: the engine's events. `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. `play` is still the Module 1 Expansion Drop stub.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 02` 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,30 @@
|
||||
// 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 }] },
|
||||
]),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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
|
||||
|
||||
/** 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";
|
||||
}
|
||||
}
|
||||
|
||||
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<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,14 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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.
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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.
|
||||
|
||||
import type { Trigger, TriggerOn } from "./card";
|
||||
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 };
|
||||
|
||||
/** 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 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);
|
||||
// Module 3: when the outermost Phase ends, deaths are processed here.
|
||||
}
|
||||
|
||||
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 "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][] = [];
|
||||
for (const event of events) {
|
||||
for (const listener of world.minionsInPlay()) {
|
||||
for (const trigger of world.card(world.cardName(listener)).triggers) {
|
||||
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Queue is now locked: nothing that happens below adds to it.
|
||||
for (const [source, trigger] of queue) {
|
||||
world.record({ type: "Triggered", source, on: trigger.on });
|
||||
const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source));
|
||||
// Depth-first: each action, and every reaction it causes, finishes before the next.
|
||||
for (const action of actions) perform(world, action);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 !== "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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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. It can drop to 0 or below: Module 2 has no death step. */
|
||||
health: number;
|
||||
}
|
||||
|
||||
/** 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[] = [];
|
||||
|
||||
/** 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,
|
||||
});
|
||||
// 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` at the end of `zone` (the right end for the board). */
|
||||
create(card: string, owner: PlayerId, zone: Zone): 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 });
|
||||
this.enter(id, zone);
|
||||
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): void {
|
||||
const entity = this.entity(id);
|
||||
this.sides[entity.owner][zone].push(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;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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,84 @@
|
||||
// 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) };
|
||||
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;
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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