feat: replacement effects in Module 4 (Armor absorption, Bolf Ramshield redirect)
Applying a Damage to one target now runs DAMAGE_STEPS = [redirect, armor]
first. A damageRedirect aura (Bolf Ramshield, fetched from the wiki) sends
damage meant for its owner's hero to the oldest such minion; Armor absorbs
damage to a hero before Health, logged as the new ArmorLost{player, amount}
just before a full-amount Damaged. Four new Module 4 tests; propagated to the
references of Modules 4-10 in both tracks; card schema, catalog, card JSON,
harness.md, Lessons 2, 4 and 5 updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
97c64ebee8
commit
372c99c306
113 files changed
+1925
-231
No files matched your search
@@ -1,4 +1,5 @@
|
||||
//! Auras, and the one routine that applies trigger multipliers.
|
||||
//! Auras, the one routine that applies trigger multipliers, and the lookup for
|
||||
//! damage redirects.
|
||||
//!
|
||||
//! 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
|
||||
@@ -12,7 +13,7 @@
|
||||
//! Summoning a minion also runs an Aura Update.
|
||||
|
||||
use super::card::{Aura, Stacking, TriggerOn};
|
||||
use super::world::{ActiveAura, PlayerId, World};
|
||||
use super::world::{ActiveAura, EntityId, 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) {
|
||||
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
|
||||
for &aura in &world.card(world.card_name(id)).auras {
|
||||
active.push(ActiveAura {
|
||||
owner: world.owner_of(id),
|
||||
source: id,
|
||||
aura,
|
||||
});
|
||||
}
|
||||
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
|
||||
world.set_active_auras(active);
|
||||
}
|
||||
|
||||
/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
|
||||
/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
|
||||
/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
|
||||
pub fn redirect_for(world: &World, player: PlayerId) -> Option<EntityId> {
|
||||
world
|
||||
.active_auras()
|
||||
.iter()
|
||||
.find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
|
||||
.map(|a| a.source)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
|
||||
trigger,
|
||||
times,
|
||||
stacking,
|
||||
} = active.aura;
|
||||
} = active.aura
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if active.owner != owner || trigger != on {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,23 @@ pub enum Aura {
|
||||
times: u32,
|
||||
stacking: Stacking,
|
||||
},
|
||||
/// Damage that would be dealt to the aura owner's hero is dealt to the aura's
|
||||
/// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
|
||||
DamageRedirect { from: RedirectFrom, to: RedirectTo },
|
||||
}
|
||||
|
||||
/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RedirectFrom {
|
||||
FriendlyHero,
|
||||
}
|
||||
|
||||
/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
pub enum RedirectTo {
|
||||
#[serde(rename = "self")]
|
||||
This,
|
||||
}
|
||||
|
||||
/// Who an effect touches. `This` is the schema's `"self"`.
|
||||
|
||||
@@ -28,6 +28,12 @@ pub enum Event {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
/// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
|
||||
/// shows the full amount.
|
||||
ArmorLost {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
StatsChanged {
|
||||
id: EntityId,
|
||||
attack: i32,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! minion from hand, and Battlecries. A second interceptor, replacement: the
|
||||
//! steps that rewrite a Damage before it lands (a redirect, then Armor).
|
||||
//!
|
||||
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
|
||||
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
|
||||
@@ -35,9 +36,9 @@ mod world;
|
||||
|
||||
pub use assemble::{assemble, register_assembled, AssembleError, Half};
|
||||
pub use card::{
|
||||
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
|
||||
Trigger, TriggerOn,
|
||||
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
|
||||
RedirectTo, Stacking, Target, Trigger, TriggerOn,
|
||||
};
|
||||
pub use event::Event;
|
||||
pub use queue::{run_sequence, summon, Action, Ctx};
|
||||
pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
|
||||
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
|
||||
@@ -13,12 +13,16 @@
|
||||
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
|
||||
//! Nothing here knows which cards multiply which triggers.
|
||||
//!
|
||||
//! Module 4, second interceptor: replacement. Before a Damage changes the world,
|
||||
//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
|
||||
//! who takes it, then Armor takes what it can of damage to a hero.
|
||||
//!
|
||||
//! 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::aura::{activations, aura_update_other, redirect_for};
|
||||
use super::card::{Effect, PoolFilter, TriggerOn};
|
||||
use super::death::death_creation_step;
|
||||
use super::effect::actions;
|
||||
@@ -77,6 +81,21 @@ pub enum Action {
|
||||
},
|
||||
}
|
||||
|
||||
/// A step that may rewrite one Damage to one target before the world changes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DamageStep {
|
||||
/// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
|
||||
/// aura's minion instead (the oldest one's, if several).
|
||||
Redirect,
|
||||
/// Damage to a hero lowers its owner's Armor first, then Health.
|
||||
Armor,
|
||||
}
|
||||
|
||||
/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
|
||||
/// never offered to a redirect again. A redirect comes first: Armor protects only
|
||||
/// the hero that actually takes the damage.
|
||||
pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
|
||||
|
||||
/// What an effect may look at when it runs.
|
||||
pub struct Ctx<'a> {
|
||||
pub world: &'a World,
|
||||
@@ -146,8 +165,7 @@ fn perform(world: &mut World, action: Action) {
|
||||
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 });
|
||||
events.push(apply_damage(world, target, amount));
|
||||
}
|
||||
react(world, &events);
|
||||
}
|
||||
@@ -217,6 +235,36 @@ fn run_effects(
|
||||
}
|
||||
}
|
||||
|
||||
/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
|
||||
/// Returns its Damage Event, which names whoever took it.
|
||||
fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
|
||||
let mut taker = target;
|
||||
let mut absorbed = 0;
|
||||
for step in DAMAGE_STEPS {
|
||||
match step {
|
||||
DamageStep::Redirect => {
|
||||
if world.is_hero(taker) {
|
||||
taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
|
||||
}
|
||||
}
|
||||
DamageStep::Armor => {
|
||||
if world.is_hero(taker) {
|
||||
let player = world.owner_of(taker);
|
||||
absorbed = world.armor(player).min(amount);
|
||||
if absorbed > 0 {
|
||||
world.lose_armor(player, absorbed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
world.damage(taker, amount, absorbed);
|
||||
Event::Damaged {
|
||||
target: taker,
|
||||
amount,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -34,10 +34,11 @@ struct Entity {
|
||||
pending_destroy: bool,
|
||||
}
|
||||
|
||||
/// An aura in force: which player's minion has it.
|
||||
/// An aura in force: which minion has it, and that minion's owner.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ActiveAura {
|
||||
pub owner: PlayerId,
|
||||
pub source: EntityId,
|
||||
pub aura: Aura,
|
||||
}
|
||||
|
||||
@@ -351,11 +352,19 @@ impl World {
|
||||
self.depths.clear();
|
||||
}
|
||||
|
||||
pub fn damage(&mut self, target: EntityId, amount: i32) {
|
||||
self.entity_mut(target).health -= amount;
|
||||
/// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
|
||||
/// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
|
||||
pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
|
||||
self.entity_mut(target).health -= amount - absorbed;
|
||||
self.record(Event::Damaged { target, amount });
|
||||
}
|
||||
|
||||
/// `player`'s hero's Armor absorbs `amount` damage.
|
||||
pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
|
||||
self.sides[player as usize].armor -= amount;
|
||||
self.record(Event::ArmorLost { player, 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) {
|
||||
|
||||
@@ -25,6 +25,7 @@ pub enum GameEvent {
|
||||
Drew { player: Player, id: Id },
|
||||
Fatigue { player: Player },
|
||||
ArmorGained { player: Player, amount: i32 },
|
||||
ArmorLost { player: Player, amount: i32 },
|
||||
StatsChanged { id: Id, attack: i32, health: i32 },
|
||||
SpellCast { card: String },
|
||||
}
|
||||
@@ -43,6 +44,7 @@ impl GameEvent {
|
||||
Event::Drew { player, id } => GameEvent::Drew { player, id },
|
||||
Event::Fatigue { player } => GameEvent::Fatigue { player },
|
||||
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
|
||||
Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
|
||||
Event::StatsChanged { id, attack, health } => {
|
||||
GameEvent::StatsChanged { id, attack, health }
|
||||
}
|
||||
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
|
||||
GameEvent::ArmorGained { player, amount } => {
|
||||
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
|
||||
}
|
||||
GameEvent::ArmorLost { player, amount } => {
|
||||
write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
|
||||
}
|
||||
GameEvent::StatsChanged { id, attack, health } => {
|
||||
write!(
|
||||
f,
|
||||
|
||||
Reference in new issue
Block a user