Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
286 lines
10 KiB
Rust
286 lines
10 KiB
Rust
//! 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.
|
|
//!
|
|
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
|
|
//! its actions performed before the next effect of the same ability is read, so
|
|
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
|
|
|
|
use super::assemble::assemble_from_pool;
|
|
use super::aura::{activations, aura_update_other};
|
|
use super::card::{Effect, PoolFilter, TriggerOn};
|
|
use super::death::death_creation_step;
|
|
use super::effect::actions;
|
|
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,
|
|
},
|
|
/// Put an Assembled minion into `player`'s hand (see `assemble.rs`).
|
|
Assemble {
|
|
player: PlayerId,
|
|
filter: PoolFilter,
|
|
},
|
|
/// Give one entity +attack/+health.
|
|
Buff {
|
|
id: EntityId,
|
|
attack: i32,
|
|
health: i32,
|
|
},
|
|
}
|
|
|
|
/// 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 spell = world.card(&card);
|
|
assert!(spell.is_spell(), "{card:?} is not a spell");
|
|
let effects = spell.on_cast.clone();
|
|
world.record(Event::SpellCast { card });
|
|
run_effects(world, &effects, player, None, target, None);
|
|
}
|
|
Action::Assemble { player, filter } => {
|
|
assemble_from_pool(world, player, &filter);
|
|
}
|
|
Action::Buff { id, attack, health } => world.buff(id, attack, health),
|
|
}
|
|
}
|
|
|
|
/// Interpret and perform an ability's effects in printed order. Each effect sees the
|
|
/// world as the effects before it left it. Depth-first: each action, and every
|
|
/// reaction it causes, finishes before the next.
|
|
fn run_effects(
|
|
world: &mut World,
|
|
effects: &[Effect],
|
|
owner: PlayerId,
|
|
source: Option<EntityId>,
|
|
target: Option<EntityId>,
|
|
position: Option<usize>,
|
|
) {
|
|
for effect in effects {
|
|
let ctx = Ctx {
|
|
world,
|
|
owner,
|
|
source,
|
|
target,
|
|
position,
|
|
};
|
|
for action in actions(effect, &ctx) {
|
|
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.clone(), 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 owner = world.owner_of(source);
|
|
run_effects(world, &trigger.effects, owner, Some(source), None, position);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 | TriggerOn::EndOfTurn => false,
|
|
}
|
|
}
|