Skip to main content

dotzuki_engine/battle/stack/
mod.rs

1//! Showdown-style **effect-stack** battle engine (Pattern C) — vertical-slice
2//! POC (design doc `06-battle-engine-effect-stack-design.md`, §8).
3//!
4//! This is an **additive sibling** to [`crate::battle::driver`]; the existing
5//! `BattleDriver` and every other game's code keep compiling unchanged. The
6//! module is **100% game-agnostic**: no game-specific concrete types, no
7//! `rand` (randomness only via the existing [`BattleRng`](crate::battle::rng)).
8//!
9//! ## Pieces
10//!
11//! * [`event`] — the closed [`Event`] taxonomy, [`RelayVar`]/[`HandlerResult`],
12//!   the `fn`-pointer [`HandlerFn`] signature, and the [`Effect`]/[`EventHook`]
13//!   registration shape.
14//! * [`ctx`] — the [`EffectProvider`] trait, the typed [`EffectState`] arena,
15//!   per-move scratch, and the split-borrow [`BattleCtx`] (`battler_mut` /
16//!   `pair_mut` (both branches) / `effect_mut`).
17//! * [`dispatch`] — the Showdown `comparePriority` comparator, the speed-tie
18//!   draw, and the [`run_event`](dispatch::run_event) fold.
19//! * [`driver`] — the [`StackDriver`](driver::StackDriver) firing the fixed §2
20//!   per-turn sequence with the per-mover residual + first-mover-faint
21//!   short-circuit.
22
23#[macro_use]
24pub mod authoring;
25pub mod ctx;
26pub mod dispatch;
27pub mod driver;
28pub mod event;
29pub mod log;
30
31pub use ctx::{BattleCtx, EffectHost, EffectProvider, EffectState, MoveContext};
32pub use dispatch::{collect_handlers, compare, run_event, run_event_checked, CollectedHandler};
33pub use driver::{FirstMover, StackDriver, StackTurnResult};
34pub use log::{HpChangeCause, TurnEvent, TurnLog};
35pub use event::{
36    Effect, EffectId, EffectType, Event, EventHook, HandlerFn, HandlerResult, RelayVar,
37};
38
39#[cfg(test)]
40pub(crate) mod tests_support;
41
42// ─── Engine unit tests (design §8: prove the three structural risks) ─────────
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use crate::battle::rng::ScriptedRng;
48    use crate::battle::{
49        BattleProvider, BattleState, BattlerRef, BattlerState, DamageResult, EnumMap, MoveEffect,
50        EffectResult,
51    };
52
53    // ── A tiny game provider for the engine-side compile/run proofs ──────
54
55    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
56    enum TStat {
57        Hp,
58    }
59    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
60    #[allow(dead_code)] // variants exist to satisfy the trait's assoc-type shape
61    enum TStatus {
62        Poisoned,
63    }
64    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65    #[allow(dead_code)]
66    enum TType {
67        N,
68    }
69    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70    enum TSpecies {
71        Mon,
72    }
73    #[derive(Debug, Clone, PartialEq)]
74    struct TMove {
75        power: u8,
76    }
77
78    /// The game-supplied typed effect-state enum (design §3.1).
79    #[derive(Clone)]
80    #[allow(dead_code)] // `None` is the inert variant of the typed-state shape
81    enum TKind {
82        None,
83        Toxic { counter: u8 },
84    }
85
86    struct TProvider;
87
88    impl BattleProvider for TProvider {
89        type Monster = ();
90        type Move = TMove;
91        type Ability = ();
92        type Status = TStatus;
93        type Stat = TStat;
94        type Species = TSpecies;
95        type Type = TType;
96        type Item = ();
97
98        fn calculate_damage(
99            &self,
100            move_: &Self::Move,
101            _a: &BattlerState<Self>,
102            _d: &BattlerState<Self>,
103            _r: u8,
104            _c: bool,
105        ) -> DamageResult {
106            DamageResult {
107                damage: move_.power as u16,
108                effectiveness: 1.0,
109                is_miss: false,
110            }
111        }
112        fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
113            b.moves.first().cloned().unwrap()
114        }
115        fn apply_move_effect(
116            &self,
117            _e: MoveEffect,
118            _u: &mut BattlerState<Self>,
119            _t: &mut BattlerState<Self>,
120        ) -> EffectResult {
121            EffectResult::NoEffect
122        }
123        fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
124            BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
125        }
126    }
127
128    impl EffectProvider for TProvider {
129        type EffectStateKind = TKind;
130        fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
131            None
132        }
133        fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
134            None
135        }
136        fn turn_order_rank(
137            &self,
138            _state: &BattleState<Self>,
139            _who: BattlerRef,
140            _action: &Self::Move,
141        ) -> (i32, i32) {
142            (0, 0) // engine unit tests do not exercise turn order
143        }
144    }
145
146    fn mon(hp: u16) -> BattlerState<TProvider> {
147        let mut stats = EnumMap::new();
148        stats.set(TStat::Hp, hp);
149        BattlerState::new(TSpecies::Mon, hp, hp, stats, vec![TMove { power: 10 }])
150    }
151
152    // ── Handlers used by the proofs (zero-capture fn pointers) ──────────
153
154    /// A `ModifyDamage`-shaped handler: deals `relay` int as damage to target.
155    fn deal_5<P: EffectProvider<EffectStateKind = TKind> + ?Sized>(
156        ctx: &mut BattleCtx<'_, P>,
157        _relay: RelayVar,
158        target: BattlerRef,
159        _source: BattlerRef,
160        _eff: EffectId,
161    ) -> HandlerResult {
162        ctx.battler_mut(target).take_damage(5);
163        HandlerResult::Unchanged
164    }
165
166    /// A second handler in the same fold — proves the fold chains multiple
167    /// handlers and threads the relay.
168    fn add_int<P: EffectProvider + ?Sized>(
169        _ctx: &mut BattleCtx<'_, P>,
170        relay: RelayVar,
171        _t: BattlerRef,
172        _s: BattlerRef,
173        _e: EffectId,
174    ) -> HandlerResult {
175        let n = match relay {
176            RelayVar::Int(n) => n,
177            _ => 0,
178        };
179        HandlerResult::Set(RelayVar::Int(n + 1))
180    }
181
182    /// A **Counter-shaped** handler (design §8): mutate `target` while READING
183    /// `source`'s host — the proof that `pair_mut` lets a handler touch both
184    /// battlers with NO `RefCell`/`Rc`. It deals damage to `target` equal to
185    /// `source`'s current hp / 10.
186    fn counter_shaped<P: EffectProvider + ?Sized>(
187        ctx: &mut BattleCtx<'_, P>,
188        _relay: RelayVar,
189        target: BattlerRef,
190        source: BattlerRef,
191        _eff: EffectId,
192    ) -> HandlerResult {
193        let (tgt, src) = ctx.pair_mut(target, source);
194        let reflected = src.hp / 10; // READ source's host
195        tgt.take_damage(reflected); // WRITE target
196        HandlerResult::Unchanged
197    }
198
199    fn make_ctx_parts(
200        hp_a: u16,
201        hp_b: u16,
202    ) -> (BattleState<TProvider>, Vec<EffectState<TProvider>>, MoveContext) {
203        let state = BattleState::new(vec![mon(hp_a)], vec![mon(hp_b)]);
204        (state, Vec::new(), MoveContext::default())
205    }
206
207    // ── BORROW proof 1: run_event fold compiles & runs, threads relay ──
208
209    #[test]
210    fn fold_dispatch_runs_and_threads_relay() {
211        let (mut state, mut effects, mut mv) = make_ctx_parts(100, 100);
212        let mut rng = ScriptedRng::new(vec![0]);
213        let mut ctx = BattleCtx {
214            state: &mut state,
215            effects: &mut effects,
216            mv: &mut mv,
217            rng: &mut rng,
218        };
219        // Two handlers, both targeting the opponent; the second reads & bumps
220        // the int relay set by nothing (starts at Int(0)).
221        let hs = vec![
222            CollectedHandler {
223                order: 1,
224                priority: 0,
225                speed: 0,
226                sub_order: 6,
227                effect_order: 0,
228                target: BattlerRef::OPPONENT,
229                source: BattlerRef::PLAYER,
230                source_effect: EffectId(1),
231                call: add_int::<TProvider>,
232            },
233            CollectedHandler {
234                order: 2,
235                priority: 0,
236                speed: 0,
237                sub_order: 6,
238                effect_order: 1,
239                target: BattlerRef::OPPONENT,
240                source: BattlerRef::PLAYER,
241                source_effect: EffectId(2),
242                call: deal_5::<TProvider>,
243            },
244        ];
245        let out = run_event(&mut ctx, hs, RelayVar::Int(0), false);
246        assert_eq!(out, RelayVar::Int(1), "relay threaded through add_int");
247        assert_eq!(state.opponent_battlers[0].hp, 95, "deal_5 mutated target");
248    }
249
250    // ── BORROW proof 2: pair_mut CROSS-side branch (the one unsafe) ──
251
252    #[test]
253    fn pair_mut_cross_side_branch_works() {
254        let (mut state, mut effects, mut mv) = make_ctx_parts(100, 50);
255        let mut rng = ScriptedRng::new(vec![0]);
256        let mut ctx = BattleCtx {
257            state: &mut state,
258            effects: &mut effects,
259            mv: &mut mv,
260            rng: &mut rng,
261        };
262        let (player, opp) = ctx.pair_mut(BattlerRef::PLAYER, BattlerRef::OPPONENT);
263        assert_eq!(player.hp, 100);
264        assert_eq!(opp.hp, 50);
265        // Mutate one while reading the other — proves true disjoint &mut.
266        let opp_hp = opp.hp;
267        player.take_damage(opp_hp);
268        assert_eq!(state.player_battlers[0].hp, 50);
269        assert_eq!(state.opponent_battlers[0].hp, 50);
270    }
271
272    // ── BORROW proof 3: pair_mut SAME-side branch (split_at_mut, safe) ──
273
274    #[test]
275    fn pair_mut_same_side_branch_works() {
276        // Two slots on the player side (doubles-shaped) to exercise split_at_mut.
277        let mut state = BattleState::new(vec![mon(100), mon(40)], vec![mon(100)]);
278        let mut effects: Vec<EffectState<TProvider>> = Vec::new();
279        let mut mv = MoveContext::default();
280        let mut rng = ScriptedRng::new(vec![0]);
281        let mut ctx = BattleCtx {
282            state: &mut state,
283            effects: &mut effects,
284            mv: &mut mv,
285            rng: &mut rng,
286        };
287        // Higher slot first, to also prove the order-swap path.
288        let a = BattlerRef::new(0, 1);
289        let b = BattlerRef::new(0, 0);
290        let (slot1, slot0) = ctx.pair_mut(a, b);
291        assert_eq!(slot1.hp, 40);
292        assert_eq!(slot0.hp, 100);
293        let read = slot0.hp;
294        slot1.take_damage(read / 4); // 100/4 = 25 → 40-25 = 15
295        assert_eq!(state.player_battlers[1].hp, 15);
296        assert_eq!(state.player_battlers[0].hp, 100);
297    }
298
299    // ── BORROW proof 4: Counter-shaped handler, NO RefCell ──
300
301    #[test]
302    fn counter_shaped_handler_compiles_no_refcell() {
303        let (mut state, mut effects, mut mv) = make_ctx_parts(100, 70);
304        let mut rng = ScriptedRng::new(vec![0]);
305        let mut ctx = BattleCtx {
306            state: &mut state,
307            effects: &mut effects,
308            mv: &mut mv,
309            rng: &mut rng,
310        };
311        // Player's handler reflects opponent's hp/10 onto opponent? No: it
312        // mutates `target` reading `source`. Here target=OPPONENT, source=PLAYER.
313        let hs = vec![CollectedHandler {
314            order: 1,
315            priority: 0,
316            speed: 0,
317            sub_order: 6,
318            effect_order: 0,
319            target: BattlerRef::OPPONENT,
320            source: BattlerRef::PLAYER,
321            source_effect: EffectId(1),
322            call: counter_shaped::<TProvider>,
323        }];
324        run_event(&mut ctx, hs, RelayVar::Unit, false);
325        // opponent took player.hp/10 = 100/10 = 10 → 70-10 = 60
326        assert_eq!(state.opponent_battlers[0].hp, 60);
327    }
328
329    // ── Typed EffectState proof: Toxic counter via effect_mut (design §3.3) ──
330
331    #[test]
332    fn typed_effect_state_toxic_counter() {
333        let (mut state, mut effects, mut mv) = make_ctx_parts(100, 100);
334        effects.push(EffectState {
335            id: EffectId(7),
336            host: BattlerRef::PLAYER,
337            effect_order: 0,
338            kind: TKind::Toxic { counter: 0 },
339        });
340        let mut rng = ScriptedRng::new(vec![0]);
341        let mut ctx = BattleCtx {
342            state: &mut state,
343            effects: &mut effects,
344            mv: &mut mv,
345            rng: &mut rng,
346        };
347        let n = match &mut ctx.effect_mut(EffectId(7)).unwrap().kind {
348            TKind::Toxic { counter } => {
349                *counter = counter.saturating_add(1);
350                *counter
351            }
352            TKind::None => 0,
353        };
354        assert_eq!(n, 1);
355        // binary-search miss returns None.
356        assert!(ctx.effect_mut(EffectId(99)).is_none());
357    }
358
359    // ── forced_action seam: a live volatile overrides the chosen action ──
360    //
361    // Proves the generic, defaulted cross-turn lock-in seam (design §3/§9): the
362    // driver consults `EffectProvider::forced_action` BEFORE reading the per-turn
363    // chosen action, so a volatile recorded earlier hijacks this turn. This is the
364    // engine-side proof that a per-turn `[Action; 2]` input is insufficient; it is
365    // game-agnostic (only swaps one `BattleAction` for another, names no game-specific
366    // volatile) and inert by default (every other test/game gets `None`).
367
368    use crate::battle::BattleAction;
369    use crate::battle::rng::ScriptedRng as EngineScriptedRng;
370
371    /// A damaging move effect for the forced-action proof: `ModifyDamage` deals
372    /// `move.power` to the defender (the driver applies `ctx.mv.damage`).
373    fn force_dmg<P: EffectProvider + ?Sized>(
374        ctx: &mut BattleCtx<'_, P>,
375        _relay: RelayVar,
376        _target: BattlerRef,
377        _source: BattlerRef,
378        _eff: EffectId,
379    ) -> HandlerResult {
380        ctx.mv.damage = 10;
381        HandlerResult::Unchanged
382    }
383
384    static FORCE_DMG_EFFECT: Effect<TForce> = Effect {
385        id: EffectId(1),
386        kind: EffectType::Move,
387        hooks: &[EventHook {
388            event: Event::ModifyDamage,
389            call: force_dmg::<TForce>,
390            order: 1000,
391            priority: 0,
392            sub_order: None,
393        }],
394    };
395
396    /// A provider whose `forced_action` forces `Nothing` whenever the actor hosts
397    /// a `TKind::Toxic` volatile (used here purely as a generic "is-locked" marker
398    /// — the engine attaches NO game-specific meaning to it).
399    struct TForce;
400
401    impl BattleProvider for TForce {
402        type Monster = ();
403        type Move = TMove;
404        type Ability = ();
405        type Status = TStatus;
406        type Stat = TStat;
407        type Species = TSpecies;
408        type Type = TType;
409        type Item = ();
410        fn calculate_damage(
411            &self,
412            _m: &Self::Move,
413            _a: &BattlerState<Self>,
414            _d: &BattlerState<Self>,
415            _r: u8,
416            _c: bool,
417        ) -> DamageResult {
418            DamageResult { damage: 0, effectiveness: 1.0, is_miss: false }
419        }
420        fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
421            b.moves.first().cloned().unwrap()
422        }
423        fn apply_move_effect(
424            &self,
425            _e: MoveEffect,
426            _u: &mut BattlerState<Self>,
427            _t: &mut BattlerState<Self>,
428        ) -> EffectResult {
429            EffectResult::NoEffect
430        }
431        fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
432            BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
433        }
434    }
435
436    impl EffectProvider for TForce {
437        type EffectStateKind = TKind;
438        fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
439            Some(&FORCE_DMG_EFFECT)
440        }
441        fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
442            None
443        }
444        fn turn_order_rank(
445            &self,
446            _state: &BattleState<Self>,
447            who: BattlerRef,
448            _action: &Self::Move,
449        ) -> (i32, i32) {
450            // Player always first (no tie → no order byte drawn).
451            if who.side == 0 { (0, 0) } else { (1, 0) }
452        }
453        fn forced_action(
454            &self,
455            effects: &[EffectState<Self>],
456            actor: BattlerRef,
457            chosen: &BattleAction<Self>,
458        ) -> Option<BattleAction<Self>> {
459            // A `Toxic` volatile on the actor forces inaction (the recharge shape).
460            let locked = effects
461                .iter()
462                .any(|e| e.host == actor && matches!(e.kind, TKind::Toxic { .. }));
463            if locked {
464                Some(BattleAction::Nothing)
465            } else {
466                let _ = chosen;
467                None
468            }
469        }
470    }
471
472    fn force_mon(hp: u16) -> BattlerState<TForce> {
473        let mut stats = EnumMap::new();
474        stats.set(TStat::Hp, hp);
475        BattlerState::new(TSpecies::Mon, hp, hp, stats, vec![TMove { power: 10 }])
476    }
477
478    #[test]
479    fn forced_action_overrides_chosen_action() {
480        // Player has a "lock" volatile → forced_action returns `Nothing` → its
481        // chosen `Fight` is IGNORED (deals no damage). The opponent has no lock →
482        // its `Fight` runs normally (deals 10). Proves the seam hijacks the
483        // per-turn chosen action using cross-turn arena state.
484        let mut state = BattleState::new(vec![force_mon(100)], vec![force_mon(100)]);
485        let mut effects: Vec<EffectState<TForce>> = vec![EffectState {
486            id: EffectId(50),
487            host: BattlerRef::PLAYER,
488            effect_order: 0,
489            kind: TKind::Toxic { counter: 0 },
490        }];
491        let actions = [
492            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
493            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
494        ];
495        let mut rng = EngineScriptedRng::new(vec![]);
496        let provider = TForce;
497        StackDriver::execute_turn(&provider, &mut state, &mut effects, actions, &mut rng);
498        // Player's chosen Fight was overridden to Nothing → opponent UNHARMED.
499        assert_eq!(state.opponent_battlers[0].hp, 100, "locked player forced to Nothing");
500        // Opponent acted normally → player took 10.
501        assert_eq!(state.player_battlers[0].hp, 90, "unlocked opponent's Fight ran");
502    }
503
504    #[test]
505    fn forced_action_default_is_inert() {
506        // No lock volatile → forced_action returns None → both Fights run.
507        let mut state = BattleState::new(vec![force_mon(100)], vec![force_mon(100)]);
508        let mut effects: Vec<EffectState<TForce>> = Vec::new();
509        let actions = [
510            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
511            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
512        ];
513        let mut rng = EngineScriptedRng::new(vec![]);
514        let provider = TForce;
515        StackDriver::execute_turn(&provider, &mut state, &mut effects, actions, &mut rng);
516        assert_eq!(state.opponent_battlers[0].hp, 90, "player Fight ran (no lock)");
517        assert_eq!(state.player_battlers[0].hp, 90, "opponent Fight ran (no lock)");
518    }
519
520    // ── P6a: the generic turn-event log (execute_turn_logged) ────────────────
521
522    use super::log::TurnEvent;
523
524    /// The ADDITIVE/DEFAULTED guarantee: `execute_turn_logged` runs the SAME turn
525    /// as `execute_turn` — identical final state, identical `StackTurnResult`,
526    /// identical `rng.consumed()`. The log is pure observation.
527    #[test]
528    fn logged_turn_is_behaviorally_identical_to_plain() {
529        let actions = || {
530            [
531                BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
532                BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
533            ]
534        };
535        // Plain.
536        let mut s_plain = BattleState::new(vec![force_mon(100)], vec![force_mon(100)]);
537        let mut e_plain: Vec<EffectState<TForce>> = Vec::new();
538        let mut rng_plain = EngineScriptedRng::new(vec![]);
539        let r_plain =
540            StackDriver::execute_turn(&TForce, &mut s_plain, &mut e_plain, actions(), &mut rng_plain);
541        // Logged.
542        let mut s_log = BattleState::new(vec![force_mon(100)], vec![force_mon(100)]);
543        let mut e_log: Vec<EffectState<TForce>> = Vec::new();
544        let mut rng_log = EngineScriptedRng::new(vec![]);
545        let (r_log, log) =
546            StackDriver::execute_turn_logged(&TForce, &mut s_log, &mut e_log, actions(), &mut rng_log);
547
548        assert_eq!(r_plain, r_log, "same StackTurnResult");
549        assert_eq!(rng_plain.consumed(), rng_log.consumed(), "same rng draw count");
550        assert_eq!(s_plain.player_battlers[0].hp, s_log.player_battlers[0].hp, "same player hp");
551        assert_eq!(
552            s_plain.opponent_battlers[0].hp, s_log.opponent_battlers[0].hp,
553            "same opponent hp"
554        );
555        assert!(!log.is_empty(), "the logged path recorded events");
556    }
557
558    /// The log captures `MoveUsed` + `Damaged` for both movers, in order. With
559    /// `TForce`: player first (deals 10 → opp 90), then opponent (deals 10 → player
560    /// 90). No crit/miss handlers ⇒ no `Crit`/`Missed`.
561    #[test]
562    fn log_records_move_used_and_damage_both_movers() {
563        let mut state = BattleState::new(vec![force_mon(100)], vec![force_mon(100)]);
564        let mut effects: Vec<EffectState<TForce>> = Vec::new();
565        let actions = [
566            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
567            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
568        ];
569        let mut rng = EngineScriptedRng::new(vec![]);
570        let (_r, log) =
571            StackDriver::execute_turn_logged(&TForce, &mut state, &mut effects, actions, &mut rng);
572
573        let p = BattlerRef::PLAYER;
574        let o = BattlerRef::OPPONENT;
575        let ev = &log.events;
576        assert!(matches!(ev[0], TurnEvent::MoveUsed { actor, .. } if actor == p), "1: player MoveUsed");
577        assert!(matches!(ev[1], TurnEvent::Damaged { target, amount: 10, .. } if target == o), "2: opponent took 10");
578        assert!(matches!(ev[2], TurnEvent::MoveUsed { actor, .. } if actor == o), "3: opponent MoveUsed");
579        assert!(matches!(ev[3], TurnEvent::Damaged { target, amount: 10, .. } if target == p), "4: player took 10");
580        assert_eq!(ev.len(), 4, "exactly MoveUsed+Damaged ×2 (no crit/miss/status)");
581    }
582
583    /// A KO logs `Damaged` then `Fainted`, and the second mover is cancelled (so it
584    /// never logs a `MoveUsed`). Opponent at 10 HP dies to the player's 10 damage.
585    #[test]
586    fn log_records_faint_and_cancels_second() {
587        let mut state = BattleState::new(vec![force_mon(100)], vec![force_mon(10)]);
588        let mut effects: Vec<EffectState<TForce>> = Vec::new();
589        let actions = [
590            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
591            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
592        ];
593        let mut rng = EngineScriptedRng::new(vec![]);
594        let (r, log) =
595            StackDriver::execute_turn_logged(&TForce, &mut state, &mut effects, actions, &mut rng);
596
597        assert!(r.second_cancelled, "defender KO'd → second move cancelled");
598        let o = BattlerRef::OPPONENT;
599        let ev = &log.events;
600        assert!(matches!(ev[0], TurnEvent::MoveUsed { .. }), "player MoveUsed");
601        assert!(matches!(ev[1], TurnEvent::Damaged { target, amount: 10, .. } if target == o), "opponent took 10");
602        assert!(matches!(ev[2], TurnEvent::Fainted { who } if who == o), "opponent fainted");
603        assert!(
604            !ev.iter().any(|e| matches!(e, TurnEvent::MoveUsed { actor, .. } if *actor == o)),
605            "cancelled opponent never logs a MoveUsed"
606        );
607    }
608
609    /// A rich move effect exercises the `StatChanged` / `StatusInflicted` / `Healed`
610    /// diff paths: it deals 10 to the target, boosts the ACTOR's stat stage +1,
611    /// poisons the target, and heals the actor (who starts below max).
612    fn log_rich(
613        ctx: &mut BattleCtx<'_, TRich>,
614        _relay: RelayVar,
615        target: BattlerRef,
616        source: BattlerRef,
617        _eff: EffectId,
618    ) -> HandlerResult {
619        ctx.mv.damage = 10; // driver applies → Damaged{target,10}
620        ctx.battler_mut(source).stat_stages.set(TStat::Hp, 1); // StatChanged{source,+1}
621        ctx.battler_mut(target).status = Some(TStatus::Poisoned); // StatusInflicted{target}
622        let b = ctx.battler_mut(source);
623        b.hp = (b.hp + 20).min(b.max_hp); // Healed{source,20}
624        HandlerResult::Unchanged
625    }
626
627    static LOG_RICH_EFFECT: Effect<TRich> = Effect {
628        id: EffectId(1),
629        kind: EffectType::Move,
630        hooks: &[EventHook {
631            event: Event::ModifyDamage,
632            call: log_rich,
633            order: 1000,
634            priority: 0,
635            sub_order: None,
636        }],
637    };
638
639    /// A provider whose move both damages the foe and buffs/heals/poisons — only
640    /// the player acts (opponent does `Nothing`) so the assertions are unambiguous.
641    struct TRich;
642    impl BattleProvider for TRich {
643        type Monster = ();
644        type Move = TMove;
645        type Ability = ();
646        type Status = TStatus;
647        type Stat = TStat;
648        type Species = TSpecies;
649        type Type = TType;
650        type Item = ();
651        fn calculate_damage(&self, _m: &Self::Move, _a: &BattlerState<Self>, _d: &BattlerState<Self>, _r: u8, _c: bool) -> DamageResult {
652            DamageResult { damage: 0, effectiveness: 1.0, is_miss: false }
653        }
654        fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
655            b.moves.first().cloned().unwrap()
656        }
657        fn apply_move_effect(&self, _e: MoveEffect, _u: &mut BattlerState<Self>, _t: &mut BattlerState<Self>) -> EffectResult {
658            EffectResult::NoEffect
659        }
660        fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
661            BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
662        }
663    }
664    impl EffectProvider for TRich {
665        type EffectStateKind = TKind;
666        fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
667            Some(&LOG_RICH_EFFECT)
668        }
669        fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
670            None
671        }
672        fn turn_order_rank(&self, _state: &BattleState<Self>, who: BattlerRef, _action: &Self::Move) -> (i32, i32) {
673            if who.side == 0 { (0, 0) } else { (1, 0) }
674        }
675    }
676
677    #[test]
678    fn log_records_stat_status_and_heal_diffs() {
679        // Player at 50/100 so the +20 self-heal is observable; opponent does Nothing.
680        let mut player = BattlerState::<TRich>::new(TSpecies::Mon, 50, 100, EnumMap::new(), vec![TMove { power: 10 }]);
681        player.max_hp = 100;
682        let opponent = BattlerState::<TRich>::new(TSpecies::Mon, 100, 100, EnumMap::new(), vec![TMove { power: 10 }]);
683        let mut state = BattleState::new(vec![player], vec![opponent]);
684        let mut effects: Vec<EffectState<TRich>> = Vec::new();
685        let actions = [
686            BattleAction::<TRich>::Fight { move_: TMove { power: 10 } },
687            BattleAction::<TRich>::Nothing,
688        ];
689        let mut rng = EngineScriptedRng::new(vec![]);
690        let (_r, log) =
691            StackDriver::execute_turn_logged(&TRich, &mut state, &mut effects, actions, &mut rng);
692
693        let p = BattlerRef::PLAYER;
694        let o = BattlerRef::OPPONENT;
695        let ev = &log.events;
696        assert!(ev.iter().any(|e| matches!(e, TurnEvent::MoveUsed { actor, .. } if *actor == p)), "MoveUsed");
697        assert!(ev.iter().any(|e| matches!(e, TurnEvent::Damaged { target, amount: 10, .. } if *target == o)), "Damaged target");
698        assert!(ev.iter().any(|e| matches!(e, TurnEvent::StatusInflicted { target, .. } if *target == o)), "StatusInflicted target");
699        assert!(ev.iter().any(|e| matches!(e, TurnEvent::StatChanged { target, delta: 1, .. } if *target == p)), "StatChanged actor +1");
700        assert!(ev.iter().any(|e| matches!(e, TurnEvent::Healed { target, amount: 20, .. } if *target == p)), "Healed actor +20");
701    }
702
703    // ── RESOURCE COST GATE (doc 13 §4): the generic MP/SP/mana cost check ──
704    //
705    // A `TCost` provider declares a `move_cost` of 4 on resource id 0 (the engine
706    // assigns this resource NO meaning — it is "MP" only to the game). The driver,
707    // at `resolve_action`, checks the actor can pay it against its `ResourcePool`
708    // (else prevents the move via the `Fail`/early-return path) and deducts on
709    // success — PURE ARITHMETIC, consuming NO rng. The default empty cost + empty
710    // pool is inert (the forced_action tests above, which declare no cost, prove
711    // the no-op case end to end).
712
713    const MP: u16 = 0; // the game's opaque resource id for "MP" — the engine never names it.
714
715    /// A move-effect that deals 10 to the defender (so a paid move's effect is
716    /// observable, and a prevented move's absence is observable).
717    static COST_DMG_EFFECT: Effect<TCost> = Effect {
718        id: EffectId(1),
719        kind: EffectType::Move,
720        hooks: &[EventHook {
721            event: Event::ModifyDamage,
722            call: force_dmg::<TCost>,
723            order: 1000,
724            priority: 0,
725            sub_order: None,
726        }],
727    };
728
729    /// A provider whose every move costs 4 of resource [`MP`].
730    struct TCost;
731
732    impl BattleProvider for TCost {
733        type Monster = ();
734        type Move = TMove;
735        type Ability = ();
736        type Status = TStatus;
737        type Stat = TStat;
738        type Species = TSpecies;
739        type Type = TType;
740        type Item = ();
741        fn calculate_damage(
742            &self,
743            _m: &Self::Move,
744            _a: &BattlerState<Self>,
745            _d: &BattlerState<Self>,
746            _r: u8,
747            _c: bool,
748        ) -> DamageResult {
749            DamageResult { damage: 0, effectiveness: 1.0, is_miss: false }
750        }
751        fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
752            b.moves.first().cloned().unwrap()
753        }
754        fn apply_move_effect(
755            &self,
756            _e: MoveEffect,
757            _u: &mut BattlerState<Self>,
758            _t: &mut BattlerState<Self>,
759        ) -> EffectResult {
760            EffectResult::NoEffect
761        }
762        fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
763            BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
764        }
765        /// The whole point: every move costs 4 MP. Defaulted-hook OVERRIDE.
766        fn move_cost(&self, _move_: &Self::Move) -> &[(u16, u16)] {
767            &[(MP, 4)]
768        }
769    }
770
771    impl EffectProvider for TCost {
772        type EffectStateKind = TKind;
773        fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
774            Some(&COST_DMG_EFFECT)
775        }
776        fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
777            None
778        }
779        fn turn_order_rank(
780            &self,
781            _state: &BattleState<Self>,
782            who: BattlerRef,
783            _action: &Self::Move,
784        ) -> (i32, i32) {
785            // Player always first (no tie → no order byte drawn).
786            if who.side == 0 { (0, 0) } else { (1, 0) }
787        }
788    }
789
790    /// A `TCost` battler with `mp` MP (resource id 0) and `mp` max.
791    fn cost_mon(hp: u16, mp: u16) -> BattlerState<TCost> {
792        let mut stats = EnumMap::new();
793        stats.set(TStat::Hp, hp);
794        BattlerState::new(TSpecies::Mon, hp, hp, stats, vec![TMove { power: 10 }])
795            .with_resource(MP, mp)
796    }
797
798    #[test]
799    fn cost_gate_pays_and_deducts_when_affordable() {
800        // Player has 10 MP; its move costs 4 → it acts (deals 10) and ends with 6.
801        // Opponent has 0 MP (no resource declared at all) so its 4-MP move is
802        // PREVENTED — but it moves SECOND, after the player KO check passes, and is
803        // a clean control for the insufficient case in one turn.
804        let mut state = BattleState::new(vec![cost_mon(100, 10)], vec![cost_mon(100, 0)]);
805        let mut effects: Vec<EffectState<TCost>> = Vec::new();
806        let actions = [
807            BattleAction::<TCost>::Fight { move_: TMove { power: 10 } },
808            BattleAction::<TCost>::Fight { move_: TMove { power: 10 } },
809        ];
810        let mut rng = EngineScriptedRng::new(vec![]);
811        StackDriver::execute_turn(&TCost, &mut state, &mut effects, actions, &mut rng);
812
813        // Player paid: 10 - 4 = 6 MP left, and its move connected (opp took 10).
814        assert_eq!(state.player_battlers[0].resources.current(MP), Some(6),
815            "affordable: 10 MP - 4 cost = 6 left");
816        assert_eq!(state.opponent_battlers[0].hp, 90, "paid move dealt its 10 damage");
817
818        // Opponent could NOT pay (0 MP, undeclared/zero) → move prevented → player
819        // unharmed, opponent MP unchanged (still absent → current() None... it WAS
820        // declared with max 0, so current is Some(0), and stays Some(0)).
821        assert_eq!(state.player_battlers[0].hp, 100, "insufficient opp move prevented → no damage");
822        assert_eq!(state.opponent_battlers[0].resources.current(MP), Some(0),
823            "insufficient: MP unchanged (no deduction on a prevented move)");
824    }
825
826    #[test]
827    fn cost_gate_prevents_when_unaffordable_and_leaves_mp_unchanged() {
828        // Player has 3 MP, move costs 4 → CANNOT pay → move PREVENTED. The opponent
829        // (here also short on MP) is irrelevant; assert the prevention + no deduction.
830        let mut state = BattleState::new(vec![cost_mon(100, 3)], vec![cost_mon(100, 0)]);
831        let mut effects: Vec<EffectState<TCost>> = Vec::new();
832        let actions = [
833            BattleAction::<TCost>::Fight { move_: TMove { power: 10 } },
834            BattleAction::<TCost>::Fight { move_: TMove { power: 10 } },
835        ];
836        let mut rng = EngineScriptedRng::new(vec![]);
837        StackDriver::execute_turn(&TCost, &mut state, &mut effects, actions, &mut rng);
838
839        assert_eq!(state.opponent_battlers[0].hp, 100,
840            "player could not pay → its move was prevented → opp unharmed");
841        assert_eq!(state.player_battlers[0].resources.current(MP), Some(3),
842            "prevented move deducts NOTHING → MP unchanged at 3");
843    }
844
845    #[test]
846    fn cost_gate_consumes_no_rng() {
847        // The cost path is pure arithmetic. Drive a turn where the player pays and
848        // the opponent is prevented (the most code-covering case) with an EMPTY
849        // scripted rng and assert `consumed() == 0`. (The driver draws an order
850        // byte only on a tie; here turn_order_rank gives the player a strictly
851        // lower rank, so even turn-order draws nothing — isolating the cost path.)
852        let mut state = BattleState::new(vec![cost_mon(100, 10)], vec![cost_mon(100, 1)]);
853        let mut effects: Vec<EffectState<TCost>> = Vec::new();
854        let actions = [
855            BattleAction::<TCost>::Fight { move_: TMove { power: 10 } },
856            BattleAction::<TCost>::Fight { move_: TMove { power: 10 } },
857        ];
858        let mut rng = EngineScriptedRng::new(vec![]);
859        StackDriver::execute_turn(&TCost, &mut state, &mut effects, actions, &mut rng);
860        assert_eq!(rng.consumed(), 0,
861            "the resource cost check + deduction consume NO randomness");
862    }
863
864    #[test]
865    fn no_cost_no_resources_is_inert() {
866        // The `TForce` provider declares NO `move_cost` (defaulted `&[]`) and its
867        // battlers declare NO resources (empty `ResourcePool`). Driving a turn is
868        // byte-identical to the pre-resource engine: both Fights run, dealing 10.
869        // This is the additivity witness — the empty/default path is a pure no-op.
870        let mut state = BattleState::new(vec![force_mon(100)], vec![force_mon(100)]);
871        let mut effects: Vec<EffectState<TForce>> = Vec::new();
872        let actions = [
873            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
874            BattleAction::<TForce>::Fight { move_: TMove { power: 10 } },
875        ];
876        let mut rng = EngineScriptedRng::new(vec![]);
877        StackDriver::execute_turn(&TForce, &mut state, &mut effects, actions, &mut rng);
878        assert_eq!(state.opponent_battlers[0].hp, 90, "no-cost move ran (inert gate)");
879        assert_eq!(state.player_battlers[0].hp, 90, "no-cost move ran (inert gate)");
880        assert!(state.player_battlers[0].resources.is_empty(), "pool defaulted EMPTY");
881    }
882
883    // ── speed_sort_tiebreak: draws a byte ONLY on a tie, permutes the run ──
884
885    #[test]
886    fn speed_tiebreak_draws_only_on_tie() {
887        // Two fully-equal handlers → one tie comparison → one byte drawn.
888        let mk = |eo: u64| CollectedHandler::<TProvider> {
889            order: 1,
890            priority: 0,
891            speed: 0,
892            sub_order: 6,
893            effect_order: eo,
894            target: BattlerRef::OPPONENT,
895            source: BattlerRef::PLAYER,
896            source_effect: EffectId(eo as u32),
897            call: add_int::<TProvider>,
898        };
899        // effect_order differs → NOT equal → no draw.
900        let mut distinct = vec![mk(0), mk(1)];
901        let mut rng = ScriptedRng::new(vec![200, 200]);
902        dispatch::speed_sort_tiebreak(&mut distinct, &mut rng);
903        assert_eq!(rng.consumed(), 0, "distinct effect_order ⇒ no tie ⇒ no draw");
904
905        // identical effect_order → equal → one byte drawn for the pair.
906        let mut tied = vec![mk(0), mk(0)];
907        let mut rng2 = ScriptedRng::new(vec![200]);
908        dispatch::speed_sort_tiebreak(&mut tied, &mut rng2);
909        assert_eq!(rng2.consumed(), 1, "one tied pair ⇒ exactly one draw");
910    }
911
912    // ── comparator: order asc, priority desc, effect_order asc tiebreak ──
913
914    #[test]
915    fn comparator_lexical_order() {
916        use std::cmp::Ordering;
917        let h = |order, priority, eo| CollectedHandler::<TProvider> {
918            order,
919            priority,
920            speed: 0,
921            sub_order: 6,
922            effect_order: eo,
923            target: BattlerRef::OPPONENT,
924            source: BattlerRef::PLAYER,
925            source_effect: EffectId(0),
926            call: add_int::<TProvider>,
927        };
928        // lower order fires first
929        assert_eq!(compare(&h(1, 0, 0), &h(2, 0, 0)), Ordering::Less);
930        // equal order: higher priority fires first
931        assert_eq!(compare(&h(1, 5, 0), &h(1, 1, 0)), Ordering::Less);
932        // equal order+priority: lower effect_order fires first
933        assert_eq!(compare(&h(1, 0, 3), &h(1, 0, 9)), Ordering::Less);
934        // fully equal
935        assert_eq!(compare(&h(1, 0, 0), &h(1, 0, 0)), Ordering::Equal);
936    }
937}
938
939// ─── P0b: broadened multi-source collection + EffectHost engine proofs ────────
940//
941// Game-agnostic, mock-game style (design §6). Each test uses the shared
942// `tests_support::TProvider` (6-stat Gen-4 shape, opaque ability/item/field
943// markers). The handlers stamp `stat_stages[Atk]` so a test can read which
944// sources fired and in what order.
945
946#[cfg(test)]
947mod multi_source_tests {
948    use super::dispatch::{collect_handlers, run_event, run_event_checked};
949    use super::tests_support::{
950        marker, mon, parts, TKind, TProvider, TSpecies, MOCK_VOLATILE,
951    };
952    use super::*;
953    use crate::battle::stack::ctx::EffectHost;
954    use crate::battle::{BattlerRef, EffectResult};
955
956    fn ctx_from<'a>(
957        state: &'a mut crate::battle::BattleState<TProvider>,
958        effects: &'a mut Vec<EffectState<TProvider>>,
959        mv: &'a mut MoveContext,
960        rng: &'a mut crate::battle::rng::ScriptedRng,
961    ) -> BattleCtx<'a, TProvider> {
962        BattleCtx { state, effects, mv, rng }
963    }
964
965    // ── Proof 1: multi-source collection gathers from ability + item + field,
966    //    firing them in comparator (`order`) order. ──
967    #[test]
968    fn collects_ability_item_field_in_order() {
969        // Player has BOTH ability+item; field is ON. Event targets the OPPONENT
970        // (source = player). The opponent's own ability/item are absent (Plain).
971        let provider = TProvider { field_on: true };
972        let (mut state, mut effects, mut mv, mut rng) =
973            parts(mon(100, TSpecies::HasBoth), mon(100, TSpecies::Plain));
974        let mut ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
975
976        let mut hs = Vec::new();
977        collect_handlers(
978            &ctx,
979            &provider,
980            None, // no source move effect
981            Event::DamagingHit,
982            BattlerRef::OPPONENT, // target
983            BattlerRef::PLAYER,   // source
984            &mut hs,
985        );
986        // Player's ability (order 10) + item (order 20) + field (order 30) = 3.
987        assert_eq!(hs.len(), 3, "ability + item + field collected");
988
989        run_event(&mut ctx, hs, RelayVar::Unit, false);
990        // ability marks player (target of the ability hook is the host = player),
991        // item marks player, field marks OPPONENT (target). Read both:
992        // ability(1) + item(10) on player → 11; field(100) on opponent → 100.
993        assert_eq!(marker(&state, BattlerRef::PLAYER), 11, "ability+item fired on host");
994        assert_eq!(marker(&state, BattlerRef::OPPONENT), 100, "field fired on target");
995    }
996
997    // ── Proof 1b: multi-source ordering is by the comparator. Manually verify
998    //    the collected order is ability(10) < volatile(15) < item(20) < field(30).
999    #[test]
1000    fn multi_source_comparator_order() {
1001        let provider = TProvider { field_on: true };
1002        let (mut state, mut effects, mut mv, mut rng) =
1003            parts(mon(100, TSpecies::HasBoth), mon(100, TSpecies::Plain));
1004        // Add a live volatile on the source (player) → MOCK_VOLATILE (order 15).
1005        effects.push(EffectState {
1006            id: MOCK_VOLATILE.id,
1007            host: BattlerRef::PLAYER,
1008            effect_order: 0,
1009            kind: TKind::Vol,
1010        });
1011        let ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1012
1013        let mut hs = Vec::new();
1014        collect_handlers(
1015            &ctx,
1016            &provider,
1017            None,
1018            Event::DamagingHit,
1019            BattlerRef::OPPONENT,
1020            BattlerRef::PLAYER,
1021            &mut hs,
1022        );
1023        hs.sort_by(super::dispatch::compare);
1024        let orders: Vec<u32> = hs.iter().map(|h| h.order).collect();
1025        assert_eq!(orders, vec![10, 15, 20, 30], "ability<volatile<item<field");
1026    }
1027
1028    // ── Proof 2: snapshot + per-step re-check handles a handler that KOs the
1029    //    target another handler was about to act on (mid-fold mutation). ──
1030    fn ko_target<P: EffectProvider + ?Sized>(
1031        ctx: &mut BattleCtx<'_, P>,
1032        _r: RelayVar,
1033        target: BattlerRef,
1034        _s: BattlerRef,
1035        _e: EffectId,
1036    ) -> HandlerResult {
1037        ctx.battler_mut(target).hp = 0; // KO the target
1038        HandlerResult::Unchanged
1039    }
1040    fn touch_target<P: EffectProvider<Stat = super::tests_support::TStat> + ?Sized>(
1041        ctx: &mut BattleCtx<'_, P>,
1042        _r: RelayVar,
1043        target: BattlerRef,
1044        _s: BattlerRef,
1045        _e: EffectId,
1046    ) -> HandlerResult {
1047        super::tests_support::mark(ctx, target, 7); // would mark — but target is dead
1048        HandlerResult::Unchanged
1049    }
1050
1051    #[test]
1052    fn re_check_skips_handler_whose_target_was_koed_midfold() {
1053        let (mut state, mut effects, mut mv, mut rng) =
1054            parts(mon(100, TSpecies::Plain), mon(100, TSpecies::Plain));
1055        let mut ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1056        // First handler (order 1) KOs OPPONENT; second (order 2) would mark it.
1057        let hs = vec![
1058            CollectedHandler {
1059                order: 1, priority: 0, speed: 0, sub_order: 6, effect_order: 0,
1060                target: BattlerRef::OPPONENT, source: BattlerRef::PLAYER,
1061                source_effect: EffectId(1), call: ko_target::<TProvider>,
1062            },
1063            CollectedHandler {
1064                order: 2, priority: 0, speed: 0, sub_order: 6, effect_order: 1,
1065                target: BattlerRef::OPPONENT, source: BattlerRef::PLAYER,
1066                source_effect: EffectId(2), call: touch_target::<TProvider>,
1067            },
1068        ];
1069        run_event_checked(&mut ctx, hs, RelayVar::Unit, false);
1070        assert_eq!(state.opponent_battlers[0].hp, 0, "first handler KO'd target");
1071        assert_eq!(
1072            marker(&state, BattlerRef::OPPONENT), 0,
1073            "re-check SKIPPED the second handler (dead target)"
1074        );
1075    }
1076
1077    // The plain `run_event` (slice path) does NOT re-check → the second handler
1078    // would still fire. This documents the behavioral difference precisely.
1079    #[test]
1080    fn plain_run_event_does_not_re_check() {
1081        let provider = TProvider::default();
1082        let _ = &provider; // unused: hs built directly
1083        let (mut state, mut effects, mut mv, mut rng) =
1084            parts(mon(100, TSpecies::Plain), mon(100, TSpecies::Plain));
1085        let mut ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1086        let hs = vec![
1087            CollectedHandler {
1088                order: 1, priority: 0, speed: 0, sub_order: 6, effect_order: 0,
1089                target: BattlerRef::OPPONENT, source: BattlerRef::PLAYER,
1090                source_effect: EffectId(1), call: ko_target::<TProvider>,
1091            },
1092            CollectedHandler {
1093                order: 2, priority: 0, speed: 0, sub_order: 6, effect_order: 1,
1094                target: BattlerRef::OPPONENT, source: BattlerRef::PLAYER,
1095                source_effect: EffectId(2), call: touch_target::<TProvider>,
1096            },
1097        ];
1098        run_event(&mut ctx, hs, RelayVar::Unit, false);
1099        assert_eq!(
1100            marker(&state, BattlerRef::OPPONENT), 7,
1101            "plain run_event fired the second handler (no re-check) — slice contract"
1102        );
1103    }
1104
1105    // ── Proof 2b: a handler that REMOVES another live effect mid-fold does not
1106    //    corrupt the owned snapshot (no iterator invalidation, no RefCell). ──
1107    fn remove_other_effect<P: EffectProvider + ?Sized>(
1108        ctx: &mut BattleCtx<'_, P>,
1109        _r: RelayVar,
1110        _t: BattlerRef,
1111        _s: BattlerRef,
1112        _e: EffectId,
1113    ) -> HandlerResult {
1114        // Remove the volatile (id == MOCK_VOLATILE.id) from the arena.
1115        ctx.effects.retain(|e| e.id != MOCK_VOLATILE.id);
1116        HandlerResult::Unchanged
1117    }
1118
1119    #[test]
1120    fn handler_removing_effect_midfold_is_snapshot_safe() {
1121        let provider = TProvider::default();
1122        let _ = &provider; // unused: hs built directly
1123        let (mut state, mut effects, mut mv, mut rng) =
1124            parts(mon(100, TSpecies::Plain), mon(100, TSpecies::Plain));
1125        effects.push(EffectState {
1126            id: MOCK_VOLATILE.id,
1127            host: BattlerRef::PLAYER,
1128            effect_order: 0,
1129            kind: TKind::Vol,
1130        });
1131        let mut ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1132        // First handler removes the volatile; second still runs from the snapshot.
1133        let hs = vec![
1134            CollectedHandler {
1135                order: 1, priority: 0, speed: 0, sub_order: 6, effect_order: 0,
1136                target: BattlerRef::PLAYER, source: BattlerRef::PLAYER,
1137                source_effect: EffectId(1), call: remove_other_effect::<TProvider>,
1138            },
1139            CollectedHandler {
1140                order: 2, priority: 0, speed: 0, sub_order: 6, effect_order: 1,
1141                target: BattlerRef::PLAYER, source: BattlerRef::PLAYER,
1142                source_effect: EffectId(2), call: touch_target::<TProvider>,
1143            },
1144        ];
1145        run_event_checked(&mut ctx, hs, RelayVar::Unit, false);
1146        assert!(effects.is_empty(), "volatile removed mid-fold");
1147        assert_eq!(
1148            marker(&state, BattlerRef::PLAYER), 7,
1149            "second handler still fired from the owned snapshot (no invalidation)"
1150        );
1151    }
1152
1153    // ── Proof 3: EffectHost routes a Field-hosted residual / scope projection. ──
1154    #[test]
1155    fn effect_host_routes_field_and_battler() {
1156        // Battler-hosted arena entry projects to EffectHost::Battler.
1157        let es = EffectState::<TProvider> {
1158            id: EffectId(1),
1159            host: BattlerRef::PLAYER,
1160            effect_order: 0,
1161            kind: TKind::None,
1162        };
1163        assert_eq!(es.host_scope(), EffectHost::Battler(BattlerRef::PLAYER));
1164
1165        // From / PartialEq cross-impls (non-breaking widening proof).
1166        let h: EffectHost = BattlerRef::OPPONENT.into();
1167        assert_eq!(h, EffectHost::Battler(BattlerRef::OPPONENT));
1168        assert!(h == BattlerRef::OPPONENT, "EffectHost == BattlerRef");
1169        assert!(BattlerRef::OPPONENT == h, "BattlerRef == EffectHost");
1170        assert_ne!(EffectHost::Side(1), EffectHost::Field);
1171
1172        // A Field-hosted effect fires via the field_effects resolver path:
1173        // collect_handlers with field_on=true yields the field hook, and running
1174        // it marks the target — i.e. a Field-hosted residual is routed.
1175        let provider = TProvider { field_on: true };
1176        let (mut state, mut effects, mut mv, mut rng) =
1177            parts(mon(100, TSpecies::Plain), mon(100, TSpecies::Plain));
1178        let mut ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1179        let mut hs = Vec::new();
1180        collect_handlers(
1181            &ctx, &provider, None, Event::DamagingHit,
1182            BattlerRef::PLAYER, BattlerRef::PLAYER, &mut hs,
1183        );
1184        assert_eq!(hs.len(), 1, "only the field effect (no ability/item)");
1185        run_event_checked(&mut ctx, hs, RelayVar::Unit, false);
1186        assert_eq!(marker(&state, BattlerRef::PLAYER), 100, "field residual routed");
1187    }
1188
1189    // ── Proof 4: an event with no subscribers is a no-op. ──
1190    #[test]
1191    fn event_with_no_subscribers_is_noop() {
1192        let provider = TProvider::default(); // field off, both Plain
1193        let (mut state, mut effects, mut mv, mut rng) =
1194            parts(mon(100, TSpecies::Plain), mon(100, TSpecies::Plain));
1195        let ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1196        let mut hs = Vec::new();
1197        collect_handlers(
1198            &ctx, &provider, None, Event::DamagingHit,
1199            BattlerRef::OPPONENT, BattlerRef::PLAYER, &mut hs,
1200        );
1201        assert!(hs.is_empty(), "no live source ⇒ no handlers ⇒ no-op");
1202        drop(ctx);
1203        assert_eq!(marker(&state, BattlerRef::PLAYER), 0);
1204        assert_eq!(marker(&state, BattlerRef::OPPONENT), 0);
1205        let _ = EffectResult::NoEffect; // keep the import meaningful
1206    }
1207
1208    // ── Proof 5: default resolvers ⇒ collect_handlers reduces to identity with
1209    //    the single-source collect_from_effect (byte-identical CollectedHandlers).
1210    #[test]
1211    fn default_resolvers_reduce_to_single_source_identity() {
1212        // A move effect with one DamagingHit hook; both battlers Plain; field off;
1213        // empty arena ⇒ every resolver default. collect_handlers(src) MUST equal
1214        // collect_from_effect(src).
1215        use super::dispatch::collect_from_effect;
1216        static SRC: Effect<TProvider> = Effect {
1217            id: EffectId(0x99),
1218            kind: EffectType::Move,
1219            hooks: &[EventHook {
1220                event: Event::DamagingHit,
1221                call: super::tests_support::mock_src_hit::<TProvider>,
1222                order: 5,
1223                priority: 0,
1224                sub_order: None,
1225            }],
1226        };
1227        let provider = TProvider::default();
1228        let (mut state, mut effects, mut mv, mut rng) =
1229            parts(mon(100, TSpecies::Plain), mon(100, TSpecies::Plain));
1230        let ctx = ctx_from(&mut state, &mut effects, &mut mv, &mut rng);
1231
1232        let mut single = Vec::new();
1233        collect_from_effect(
1234            &ctx, &SRC, Event::DamagingHit,
1235            BattlerRef::OPPONENT, BattlerRef::PLAYER, &mut single,
1236        );
1237        let mut multi = Vec::new();
1238        collect_handlers(
1239            &ctx, &provider, Some(&SRC), Event::DamagingHit,
1240            BattlerRef::OPPONENT, BattlerRef::PLAYER, &mut multi,
1241        );
1242        assert_eq!(single.len(), 1);
1243        assert_eq!(multi.len(), single.len(), "identity: same count");
1244        for (a, b) in single.iter().zip(multi.iter()) {
1245            assert_eq!(a.order, b.order);
1246            assert_eq!(a.priority, b.priority);
1247            assert_eq!(a.speed, b.speed);
1248            assert_eq!(a.sub_order, b.sub_order);
1249            assert_eq!(a.effect_order, b.effect_order);
1250            assert_eq!(a.target, b.target);
1251            assert_eq!(a.source, b.source);
1252            assert_eq!(a.source_effect, b.source_effect);
1253        }
1254    }
1255}
1256
1257// ─── Effectiveness fold: the engine fires Event::Effectiveness in resolve_action
1258//     between ModifyDamage and the damage apply, folding RelayVar::Damage via
1259//     scale(num,den). Game-agnostic, mock-game style (design doc 12 §1.1/§1.3).
1260//     Proves: (a) no Effectiveness subscriber ⇒ damage unchanged (inert);
1261//     (b) a subscriber that scale(2,1) doubles, scale(1,2) halves, scale(0,1)
1262//     zeroes the damage. No game-specific type, no element name, no `rand`. ──────────
1263#[cfg(test)]
1264mod effectiveness_fold_tests {
1265    use super::*;
1266    use crate::battle::rng::ScriptedRng as EngineScriptedRng;
1267    use crate::battle::stack::ctx::{BattleCtx, EffectProvider, EffectState};
1268    use crate::battle::stack::event::{
1269        Effect, EffectId, EffectType, Event, EventHook, HandlerResult, RelayVar,
1270    };
1271    use crate::battle::{
1272        BattleAction, BattleProvider, BattleState, BattlerRef, BattlerState, DamageResult,
1273        EffectResult, EnumMap, MoveEffect,
1274    };
1275
1276    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1277    enum EStat {
1278        Hp,
1279    }
1280    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1281    #[allow(dead_code)]
1282    enum EStatus {
1283        Poisoned,
1284    }
1285    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286    #[allow(dead_code)]
1287    enum EType {
1288        N,
1289    }
1290    /// The attacker's species encodes WHICH Effectiveness handler the move effect
1291    /// registers (the engine learns nothing of its meaning — it is an opaque
1292    /// marker the provider maps to an `&'static Effect`).
1293    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1294    enum ESpecies {
1295        /// Move effect has NO Effectiveness hook ⇒ the fold is inert (1×).
1296        NoSub,
1297        /// Move effect's Effectiveness hook does `scale(2,1)` (super-effective).
1298        Double,
1299        /// `scale(1,2)` (resisted).
1300        Half,
1301        /// `scale(0,1)` (immune).
1302        Zero,
1303    }
1304    #[derive(Debug, Clone, PartialEq)]
1305    struct EMove {
1306        power: u8,
1307    }
1308    #[derive(Clone)]
1309    #[allow(dead_code)]
1310    enum EKind {
1311        None,
1312    }
1313
1314    struct EProvider;
1315
1316    impl BattleProvider for EProvider {
1317        type Monster = ();
1318        type Move = EMove;
1319        type Ability = ();
1320        type Status = EStatus;
1321        type Stat = EStat;
1322        type Species = ESpecies;
1323        type Type = EType;
1324        type Item = ();
1325        fn calculate_damage(
1326            &self,
1327            _m: &Self::Move,
1328            _a: &BattlerState<Self>,
1329            _d: &BattlerState<Self>,
1330            _r: u8,
1331            _c: bool,
1332        ) -> DamageResult {
1333            DamageResult { damage: 0, effectiveness: 1.0, is_miss: false }
1334        }
1335        fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
1336            b.moves.first().cloned().unwrap()
1337        }
1338        fn apply_move_effect(
1339            &self,
1340            _e: MoveEffect,
1341            _u: &mut BattlerState<Self>,
1342            _t: &mut BattlerState<Self>,
1343        ) -> EffectResult {
1344            EffectResult::NoEffect
1345        }
1346        fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
1347            BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
1348        }
1349    }
1350
1351    impl EffectProvider for EProvider {
1352        type EffectStateKind = EKind;
1353        /// Route the attacker's species marker to the matching move effect. Only
1354        /// the player (side 0) attacks in these tests, so the player's species
1355        /// selects the effect; the opponent is the defender (its move is inert
1356        /// `NoSub` and never resolves because the player KO-or-acts first).
1357        fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
1358            // The driver does not pass the actor's species here; the effect is the
1359            // SAME static for every move, and its Effectiveness handler reads the
1360            // SOURCE battler's species off `ctx` to pick the fold (below).
1361            Some(&EFF_MOVE_EFFECT)
1362        }
1363        fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
1364            None
1365        }
1366        fn turn_order_rank(
1367            &self,
1368            _state: &BattleState<Self>,
1369            who: BattlerRef,
1370            _action: &Self::Move,
1371        ) -> (i32, i32) {
1372            // Player always first (no tie ⇒ no order byte drawn).
1373            if who.side == 0 { (0, 0) } else { (1, 0) }
1374        }
1375    }
1376
1377    /// `ModifyDamage`: set the formula-computed damage to 80 (the base the chart
1378    /// folds on top of, mirroring doc 12 §4's `atk==def ⇒ base==power` setup).
1379    fn eff_modify_damage(
1380        ctx: &mut BattleCtx<'_, EProvider>,
1381        _r: RelayVar,
1382        _t: BattlerRef,
1383        _s: BattlerRef,
1384        _e: EffectId,
1385    ) -> HandlerResult {
1386        ctx.mv.damage = 80;
1387        HandlerResult::Unchanged
1388    }
1389
1390    /// `Effectiveness`: fold the `RelayVar::Damage` relay by the rational the
1391    /// SOURCE (attacker) battler's species selects. `NoSub` registers no hook so
1392    /// it never reaches here (proving inertness through a separate effect).
1393    fn eff_effectiveness(
1394        ctx: &mut BattleCtx<'_, EProvider>,
1395        relay: RelayVar,
1396        _target: BattlerRef,
1397        source: BattlerRef,
1398        _e: EffectId,
1399    ) -> HandlerResult {
1400        let sp = ctx.battler(source).species;
1401        let (num, den) = match sp {
1402            ESpecies::Double => (2u32, 1u32),
1403            ESpecies::Half => (1, 2),
1404            ESpecies::Zero => (0, 1),
1405            ESpecies::NoSub => (1, 1), // never reached (no hook), defensive identity
1406        };
1407        HandlerResult::Set(relay.scale(num, den))
1408    }
1409
1410    /// The move effect WITHOUT an Effectiveness hook ⇒ the fold is provably inert.
1411    static EFF_MOVE_EFFECT: Effect<EProvider> = Effect {
1412        id: EffectId(1),
1413        kind: EffectType::Move,
1414        hooks: &[
1415            EventHook {
1416                event: Event::ModifyDamage,
1417                call: eff_modify_damage,
1418                order: 1000,
1419                priority: 0,
1420                sub_order: None,
1421            },
1422            EventHook {
1423                event: Event::Effectiveness,
1424                call: eff_effectiveness,
1425                order: 1000,
1426                priority: 0,
1427                sub_order: None,
1428            },
1429        ],
1430    };
1431
1432    /// A provider variant whose move effect has NO Effectiveness subscriber (the
1433    /// inert case: the Effectiveness fire collects zero handlers).
1434    struct EProviderNoSub;
1435    impl BattleProvider for EProviderNoSub {
1436        type Monster = ();
1437        type Move = EMove;
1438        type Ability = ();
1439        type Status = EStatus;
1440        type Stat = EStat;
1441        type Species = ESpecies;
1442        type Type = EType;
1443        type Item = ();
1444        fn calculate_damage(
1445            &self,
1446            _m: &Self::Move,
1447            _a: &BattlerState<Self>,
1448            _d: &BattlerState<Self>,
1449            _r: u8,
1450            _c: bool,
1451        ) -> DamageResult {
1452            DamageResult { damage: 0, effectiveness: 1.0, is_miss: false }
1453        }
1454        fn select_move(&self, b: &BattlerState<Self>, _s: &BattleState<Self>) -> Self::Move {
1455            b.moves.first().cloned().unwrap()
1456        }
1457        fn apply_move_effect(
1458            &self,
1459            _e: MoveEffect,
1460            _u: &mut BattlerState<Self>,
1461            _t: &mut BattlerState<Self>,
1462        ) -> EffectResult {
1463            EffectResult::NoEffect
1464        }
1465        fn create_monster(&self, s: Self::Species, _l: u8) -> BattlerState<Self> {
1466            BattlerState::new(s, 100, 100, EnumMap::new(), vec![])
1467        }
1468    }
1469    impl EffectProvider for EProviderNoSub {
1470        type EffectStateKind = EKind;
1471        fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
1472            Some(&EFF_MOVE_NO_SUB_NS)
1473        }
1474        fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
1475            None
1476        }
1477        fn turn_order_rank(
1478            &self,
1479            _state: &BattleState<Self>,
1480            who: BattlerRef,
1481            _action: &Self::Move,
1482        ) -> (i32, i32) {
1483            if who.side == 0 { (0, 0) } else { (1, 0) }
1484        }
1485    }
1486    fn eff_modify_damage_ns(
1487        ctx: &mut BattleCtx<'_, EProviderNoSub>,
1488        _r: RelayVar,
1489        _t: BattlerRef,
1490        _s: BattlerRef,
1491        _e: EffectId,
1492    ) -> HandlerResult {
1493        ctx.mv.damage = 80;
1494        HandlerResult::Unchanged
1495    }
1496    static EFF_MOVE_NO_SUB_NS: Effect<EProviderNoSub> = Effect {
1497        id: EffectId(2),
1498        kind: EffectType::Move,
1499        hooks: &[EventHook {
1500            event: Event::ModifyDamage,
1501            call: eff_modify_damage_ns,
1502            order: 1000,
1503            priority: 0,
1504            sub_order: None,
1505        }],
1506    };
1507
1508    fn mon_eff(species: ESpecies) -> BattlerState<EProvider> {
1509        let mut stats = EnumMap::new();
1510        stats.set(EStat::Hp, 200);
1511        BattlerState::new(species, 200, 200, stats, vec![EMove { power: 80 }])
1512    }
1513    fn mon_eff_ns(species: ESpecies) -> BattlerState<EProviderNoSub> {
1514        let mut stats = EnumMap::new();
1515        stats.set(EStat::Hp, 200);
1516        BattlerState::new(species, 200, 200, stats, vec![EMove { power: 80 }])
1517    }
1518
1519    /// Run one player turn and return the damage the opponent took (200 - hp).
1520    fn run_player_turn(attacker: ESpecies) -> u16 {
1521        let mut state = BattleState::new(vec![mon_eff(attacker)], vec![mon_eff(ESpecies::NoSub)]);
1522        let mut effects: Vec<EffectState<EProvider>> = Vec::new();
1523        let actions = [
1524            BattleAction::<EProvider>::Fight { move_: EMove { power: 80 } },
1525            BattleAction::<EProvider>::Fight { move_: EMove { power: 80 } },
1526        ];
1527        let mut rng = EngineScriptedRng::new(vec![]);
1528        StackDriver::execute_turn(&EProvider, &mut state, &mut effects, actions, &mut rng);
1529        200 - state.opponent_battlers[0].hp
1530    }
1531
1532    #[test]
1533    fn effectiveness_fold_is_inert_with_no_subscriber() {
1534        // The move effect has ONLY a ModifyDamage hook (no Effectiveness hook), so
1535        // the engine's Effectiveness fire collects zero handlers and run_event
1536        // returns RelayVar::Damage(80) unchanged ⇒ identity write-back ⇒ 80.
1537        let mut state = BattleState::new(
1538            vec![mon_eff_ns(ESpecies::NoSub)],
1539            vec![mon_eff_ns(ESpecies::NoSub)],
1540        );
1541        let mut effects: Vec<EffectState<EProviderNoSub>> = Vec::new();
1542        let actions = [
1543            BattleAction::<EProviderNoSub>::Fight { move_: EMove { power: 80 } },
1544            BattleAction::<EProviderNoSub>::Fight { move_: EMove { power: 80 } },
1545        ];
1546        let mut rng = EngineScriptedRng::new(vec![]);
1547        StackDriver::execute_turn(&EProviderNoSub, &mut state, &mut effects, actions, &mut rng);
1548        let dmg = 200 - state.opponent_battlers[0].hp;
1549        assert_eq!(dmg, 80, "no Effectiveness subscriber ⇒ damage unchanged (inert 1×)");
1550    }
1551
1552    #[test]
1553    fn effectiveness_subscriber_scale_2_1_doubles() {
1554        assert_eq!(run_player_turn(ESpecies::Double), 160, "scale(2,1): 80 → 160");
1555    }
1556
1557    #[test]
1558    fn effectiveness_subscriber_scale_1_2_halves() {
1559        assert_eq!(run_player_turn(ESpecies::Half), 40, "scale(1,2): 80 → 40");
1560    }
1561
1562    #[test]
1563    fn effectiveness_subscriber_scale_0_1_zeroes() {
1564        assert_eq!(run_player_turn(ESpecies::Zero), 0, "scale(0,1): 80 → 0 (immune)");
1565    }
1566}