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