feat: module 02 action queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 02:30:26 +01:00
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;