Skip to main content

dotzuki_engine/battle/stack/
driver.rs

1//! The `StackDriver` — the fixed per-turn firing sequence (design §2).
2//!
3//! Replaces `BattleDriver::execute_turn` for stack-based games. Per turn it:
4//! resolves order (with a speed-tie draw), then for each actor fires the move
5//! pipeline as a sequence of [`Event`]s through the mover's registered
6//! [`Effect`](super::event::Effect), and fires **per-mover** residual with the
7//! first-mover-faint short-circuit (the Gen-1 structural choice, design §2 gap
8//! #1). All randomness flows through `ctx.rng` at the points the events fire, so
9//! the byte-stream draw order is auditable and pinnable to a legacy oracle
10//! (design §4).
11
12use crate::battle::rng::BattleRng;
13use crate::battle::{BattleAction, BattleProvider, BattleState, BattlerRef, EnumMap};
14
15use super::ctx::{BattleCtx, EffectProvider, EffectState, MoveContext};
16use super::dispatch::{collect_from_effect, run_event, CollectedHandler};
17use super::event::{EffectId, Event, RelayVar};
18use super::log::{TurnEvent, TurnLog};
19
20/// Which side moved first this turn, surfaced for the caller / parity oracle.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum FirstMover {
23    /// The player (side 0) moved first.
24    Player,
25    /// The opponent (side 1) moved first.
26    Opponent,
27}
28
29/// The outcome of one stack-driven turn.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct StackTurnResult {
32    /// Who moved first.
33    pub first: FirstMover,
34    /// Whether the second mover's action was cancelled (first-mover faint or the
35    /// move KO'd the defender — design §2 step 2d).
36    pub second_cancelled: bool,
37}
38
39/// The stateless stack-based turn driver (design §2).
40pub struct StackDriver;
41
42impl StackDriver {
43    /// Execute exactly one full battle turn.
44    ///
45    /// `actions[0]` is the player's chosen action, `actions[1]` the opponent's.
46    /// `effects` is the live per-effect-state arena (kept sorted by id); the
47    /// driver does not allocate it so per-turn state (toxic counters, …)
48    /// persists across turns.
49    ///
50    /// The firing sequence (design §2):
51    /// 1. resolve order → speed-tie draw (via `turn_order_key`)
52    /// 2. for actor in [first, second]:
53    ///    a. `BeforeMove` gate (may abort — status draws here)
54    ///    b. if it acts: `ModifyCritRatio`(+crit draw) → `Accuracy`(+acc draw)
55    ///       → `ModifyDamage`(+dmg roll) → `Damage`/`DamagingHit`
56    ///    c. fire `Residual` + faint-check FOR THIS ACTOR (per-mover)
57    ///    d. if this actor's residual KO'd it, or the move KO'd the defender →
58    ///       STOP (cancel the second move)
59    pub fn execute_turn<P: EffectProvider>(
60        provider: &P,
61        state: &mut BattleState<P>,
62        effects: &mut Vec<EffectState<P>>,
63        actions: [BattleAction<P>; 2],
64        rng: &mut dyn BattleRng,
65    ) -> StackTurnResult {
66        Self::execute_turn_inner(provider, state, effects, actions, rng, None)
67    }
68
69    /// Like [`execute_turn`](Self::execute_turn) but also returns a generic
70    /// [`TurnLog`] narrating the turn (move used / miss / crit / damage / heal /
71    /// status / stat change / faint, in order) for a frontend to render.
72    ///
73    /// **ADDITIVE + DEFAULTED:** this runs the SAME turn as `execute_turn` — the
74    /// identical `rng` draw order and identical final [`BattleState`] — and merely
75    /// records a structural before/after diff at the driver's existing event sites.
76    /// `execute_turn` is `execute_turn_logged` with the log discarded; the no-log
77    /// path is byte-identical (it allocates nothing and observes nothing).
78    pub fn execute_turn_logged<P: EffectProvider>(
79        provider: &P,
80        state: &mut BattleState<P>,
81        effects: &mut Vec<EffectState<P>>,
82        actions: [BattleAction<P>; 2],
83        rng: &mut dyn BattleRng,
84    ) -> (StackTurnResult, TurnLog<P>) {
85        let mut log = TurnLog::new();
86        let result = Self::execute_turn_inner(provider, state, effects, actions, rng, Some(&mut log));
87        (result, log)
88    }
89
90    /// The shared turn body. `log` is `None` for the plain path (zero observation,
91    /// byte-identical) and `Some` for the logged path (snapshot + diff at each
92    /// event site). All recording is gated behind `log`, so the `None` path runs
93    /// exactly the original sequence.
94    fn execute_turn_inner<P: EffectProvider>(
95        provider: &P,
96        state: &mut BattleState<P>,
97        effects: &mut Vec<EffectState<P>>,
98        actions: [BattleAction<P>; 2],
99        rng: &mut dyn BattleRng,
100        mut log: Option<&mut TurnLog<P>>,
101    ) -> StackTurnResult {
102        // ── 1. Turn order (draws the order/speed-tie byte first, like pokered
103        //       turn_order.rs:41 / move_execution draw order §4). ──
104        let first = Self::resolve_first_mover(provider, state, &actions, rng);
105        let (first_ref, second_ref) = match first {
106            FirstMover::Player => (BattlerRef::PLAYER, BattlerRef::OPPONENT),
107            FirstMover::Opponent => (BattlerRef::OPPONENT, BattlerRef::PLAYER),
108        };
109        let (first_action, second_action) = match first {
110            FirstMover::Player => (&actions[0], &actions[1]),
111            FirstMover::Opponent => (&actions[1], &actions[0]),
112        };
113
114        // ── 2. First mover acts, then per-mover residual + faint check. ──
115        //
116        // CROSS-TURN LOCK-IN (design §3/§9): before reading the per-turn chosen
117        // action, ask the game whether a live volatile (recorded on a PRIOR turn)
118        // forces a different action — Thrash re-issues the lock, Fly strikes on
119        // turn 2, Hyper Beam recharge forces `Nothing`. This is the seam that
120        // proves a per-turn `[Action; 2]` input is insufficient; it is defaulted
121        // to `None` (inert) for every game that registers no forcing volatile.
122        let first_effective =
123            provider.forced_action(effects, first_ref, first_action).unwrap_or_else(|| first_action.clone());
124        // Snapshot before the action (target first, then actor → natural log order:
125        // the hit precedes self-effects like recoil). Only when logging.
126        let act_pre = Self::snap_pair(state, Self::opposing(first_ref), first_ref, &log);
127        let mut mv = MoveContext::default();
128        Self::resolve_action(provider, state, effects, &mut mv, first_ref, &first_effective, rng, log.as_deref_mut());
129        if let Some(l) = log.as_deref_mut() {
130            Self::diff_pair(l, state, [Self::opposing(first_ref), first_ref], act_pre);
131        }
132        // Residual: `residual_and_faint` snapshot-diffs EACH source itself (so each HP
133        // change is cause-tagged for narration — status tick precedes a cross-battler
134        // drain heal in its per-source order).
135        let first_residual_faint = Self::residual_and_faint(
136            provider, state, effects, &mut mv, first_ref, rng, log.as_deref_mut(),
137        );
138
139        // Step 2d: short-circuit. If this actor's residual KO'd it OR the move
140        // KO'd the defender, cancel the second move (design §2 / turn.rs:48-60).
141        let defender_dead = Self::is_fainted(state, second_ref);
142        if first_residual_faint || defender_dead {
143            return StackTurnResult {
144                first,
145                second_cancelled: true,
146            };
147        }
148
149        // ── Second mover acts, then its own per-mover residual. ──
150        let second_effective = provider
151            .forced_action(effects, second_ref, second_action)
152            .unwrap_or_else(|| second_action.clone());
153        let act_pre2 = Self::snap_pair(state, Self::opposing(second_ref), second_ref, &log);
154        let mut mv2 = MoveContext::default();
155        Self::resolve_action(provider, state, effects, &mut mv2, second_ref, &second_effective, rng, log.as_deref_mut());
156        if let Some(l) = log.as_deref_mut() {
157            Self::diff_pair(l, state, [Self::opposing(second_ref), second_ref], act_pre2);
158        }
159        Self::residual_and_faint(
160            provider, state, effects, &mut mv2, second_ref, rng, log.as_deref_mut(),
161        );
162
163        StackTurnResult {
164            first,
165            second_cancelled: false,
166        }
167    }
168
169    /// Resolve a single actor's `Fight` action through the event chain
170    /// (design §2 step 2a/2b). Switch/UseItem/Run/Nothing are no-ops for the
171    /// POC slice (one damaging move per side).
172    fn resolve_action<P: EffectProvider>(
173        provider: &P,
174        state: &mut BattleState<P>,
175        effects: &mut Vec<EffectState<P>>,
176        mv: &mut MoveContext,
177        actor: BattlerRef,
178        action: &BattleAction<P>,
179        rng: &mut dyn BattleRng,
180        mut log: Option<&mut TurnLog<P>>,
181    ) {
182        let move_ = match action {
183            BattleAction::Fight { move_ } => move_.clone(),
184            _ => return,
185        };
186        let Some(eff) = provider.effect_for_move(&move_) else {
187            return;
188        };
189        let target = Self::opposing(actor);
190
191        let mut ctx = BattleCtx {
192            state,
193            effects,
194            mv,
195            rng,
196        };
197
198        // 2a. BeforeMove gate (status draws here — para full-para in the POC).
199        // The gate handler returns `Fail`/`FailSilent` to abort the move; a
200        // `Bool(false)` relay means "cannot act".
201        let gate = Self::fire(&mut ctx, eff, Event::BeforeMove, target, actor, RelayVar::Bool(true));
202        if matches!(gate, RelayVar::Bool(false) | RelayVar::Unit) {
203            // The move was PREVENTED by a BeforeMove gate (asleep / frozen / fully
204            // paralyzed / a confusion self-hit). Record it so a frontend can narrate
205            // "X is fast asleep!" etc.; no MoveUsed is logged for a blocked move.
206            if let Some(l) = log.as_deref_mut() {
207                l.push(TurnEvent::Blocked { actor });
208            }
209            return; // move aborted (e.g. fully paralyzed)
210        }
211
212        // 2a′. RESOURCE COST GATE (doc 13 §4 — the MP/SP/mana cost check). Fires
213        // AFTER the `BeforeMove` status gate has allowed the move, and BEFORE the
214        // crit/accuracy/damage draws, so a move the actor CANNOT pay is prevented
215        // (the existing `BeforeMove`/`Fail` prevention path: an early `return`,
216        // identical in shape to a fully-paralyzed abort) and consumes NONE of the
217        // crit/acc/damage rng either. The check and the deduction are PURE
218        // ARITHMETIC — they touch no `rng`. With the default empty cost and the
219        // empty-by-default `ResourcePool`, this whole block is INERT (an empty
220        // loop), so every existing battle and the stack-parity draw sequence are
221        // byte-identical.
222        let costs = provider.move_cost(&move_);
223        if !costs.is_empty() {
224            let actor_b = ctx.battler(actor);
225            if !costs.iter().all(|(id, amt)| actor_b.can_pay_resource(*id, *amt)) {
226                if let Some(l) = log.as_deref_mut() {
227                    l.push(TurnEvent::Blocked { actor });
228                }
229                return; // cannot pay → move prevented (no rng consumed)
230            }
231            for (id, amt) in costs {
232                ctx.battler_mut(actor).pay_resource(*id, *amt);
233            }
234        }
235
236        // The move passed the BeforeMove gate + the resource cost → it executes.
237        // Record it (the *effective* move; a lock-in override is already resolved
238        // by the driver before resolve_action is called). Gated behind `log`.
239        if let Some(l) = log.as_deref_mut() {
240            l.push(TurnEvent::MoveUsed { actor, move_: move_.clone() });
241        }
242
243        // 2b. crit → accuracy → damage. Crit is drawn BEFORE accuracy
244        // (design §4, bug-critical), guaranteed by the FIRE ORDER here — not by
245        // handler priority.
246        //
247        // INVARIANT (DO NOT SWAP THESE TWO `fire` CALLS): `ModifyCritRatio`
248        // MUST fire before `Accuracy`, so the crit byte is drawn before the
249        // accuracy byte (matching pokered's `MoveRandoms` field order). An audit
250        // agent previously swapped these and broke Gen-1 fidelity silently. This
251        // ordering is pinned by a STANDING DRAW-ORDER GUARD in pokered-core:
252        // `battle::stack_parity::assert_crit_drawn_before_accuracy` (test
253        // `crit_is_drawn_before_accuracy`). Swapping these lines makes that test
254        // FAIL loudly.
255        ctx.mv.is_critical = false;
256        Self::fire(&mut ctx, eff, Event::ModifyCritRatio, target, actor, RelayVar::Unit);
257
258        let acc = Self::fire(&mut ctx, eff, Event::Accuracy, target, actor, RelayVar::Bool(true));
259        if matches!(acc, RelayVar::Bool(false)) {
260            ctx.mv.move_missed = true;
261            if let Some(l) = log.as_deref_mut() {
262                l.push(TurnEvent::Missed { actor });
263            }
264            // ── on:Miss seam (blueprint 15 §3, the one true core touch). Fire the
265            //    move's own `OnMiss` hook on the accuracy-miss branch — Gen-1 Jump
266            //    Kick / Hi Jump Kick crash the user here. ADDITIVE + DEFAULTED: a
267            //    move with no `OnMiss` hook collects ZERO handlers, so the fold is a
268            //    no-op (no rng drawn, no state change) and the byte stream /
269            //    `consumed()` of every existing slice + game stays byte-identical.
270            Self::fire(&mut ctx, eff, Event::OnMiss, target, actor, RelayVar::Unit);
271            return; // missed
272        }
273
274        Self::fire(&mut ctx, eff, Event::ModifyDamage, target, actor, RelayVar::Unit);
275
276        // ── Effectiveness fold (design doc 12 §1.1). Inert at 1× when no handler
277        //    subscribes (the empty-`hs` `run_event` returns the relay unchanged,
278        //    dispatch.rs `for h in hs` never runs), so the write-back is an
279        //    identity no-op and every existing game/test is byte-identical.
280        //    - lift the formula-computed number into the Damage lane,
281        //    - let handlers fold it via `RelayVar::scale` (chart 2×, 1/2×; 0× = immune),
282        //    - write back so the apply at the next line stays the single source of
283        //      truth (the number still lives in `ctx.mv.damage`).
284        //    Fires AFTER `ModifyDamage` (screen/item/weather precede the chart) and
285        //    BEFORE `DamagingHit` (on-hit reactions see the post-effectiveness number).
286        let eff_in = RelayVar::Damage(ctx.mv.damage);
287        let eff_out = Self::fire(&mut ctx, eff, Event::Effectiveness, target, actor, eff_in);
288        ctx.mv.damage = eff_out.as_damage(); // non-Damage relay ⇒ 0 (event.rs as_damage)
289
290        // The hit landed → record a crit if the crit pipeline flagged one. ("Critical
291        // hit!" text shows only on a connecting hit, never on a miss — hence here,
292        // past the miss return, not at ModifyCritRatio.)
293        if ctx.mv.is_critical {
294            if let Some(l) = log.as_deref_mut() {
295                l.push(TurnEvent::Crit { actor });
296            }
297        }
298
299        // ── Damage-application fold (`Event::Damage`, taxonomy Group C). A defender
300        //    effect may INTERCEPT the incoming HP loss before it lands — Substitute
301        //    absorbs it into a proxy HP pool (returning `Set(Damage(0))`), Endure /
302        //    Sturdy floor it to 1, Disguise zeroes the first hit. Fires AFTER the
303        //    effectiveness fold (the number is final) and BEFORE the hp write.
304        //
305        //    The FOLDED number is what lands on hp — but `ctx.mv.{damage,last_damage}`
306        //    keep the MOVE's real (pre-fold) damage, because the DamagingHit riders read
307        //    the number the MOVE dealt, not the post-sink residual: recoil/drain via
308        //    `last_damage`, multi-hit re-hits via `damage`, Counter's recorder. (A sub
309        //    that absorbs 120 still owes 30 recoil / 60 drain / a 120 second hit.)
310        //
311        //    ADDITIVE + DEFAULTED: with NO `Damage` subscriber the fold returns the relay
312        //    unchanged, so `folded == pre` and this is byte-identical to a plain apply
313        //    (`ctx.mv.damage` is never rewritten, no rng drawn, no state change).
314        let pre = ctx.mv.damage;
315        let folded = Self::fire(&mut ctx, eff, Event::Damage, target, actor, RelayVar::Damage(pre))
316            .as_damage();
317        if folded > 0 {
318            ctx.battler_mut(target).take_damage(folded);
319        }
320        // `last_damage` = the damage the MOVE dealt (to the mon OR its sink), for the
321        // recoil/drain `LastDamage` reads — matching the oracle, which bases them on the
322        // real formula number even against a Substitute.
323        if pre > 0 {
324            ctx.mv.last_damage = pre;
325        }
326        Self::fire(
327            &mut ctx,
328            eff,
329            Event::DamagingHit,
330            target,
331            actor,
332            RelayVar::Damage(pre),
333        );
334    }
335
336    /// Fire one event through one effect's hooks and return the folded relay.
337    fn fire<P: EffectProvider>(
338        ctx: &mut BattleCtx<'_, P>,
339        eff: &'static super::event::Effect<P>,
340        ev: Event,
341        target: BattlerRef,
342        source: BattlerRef,
343        relay: RelayVar,
344    ) -> RelayVar {
345        let mut hs: Vec<CollectedHandler<P>> = Vec::new();
346        collect_from_effect(ctx, eff, ev, target, source, &mut hs);
347        run_event(ctx, hs, relay, false)
348    }
349
350    /// Per-mover residual (design §2 step 2c): fire `Residual` on the acting
351    /// side's host, then report whether the acting battler fainted.
352    ///
353    /// Two generic, game-agnostic residual sources fire, in this fixed order:
354    ///   1. the actor's **non-volatile status** effect (`effect_for_status`) — the
355    ///      slice-1 route (Gen-1 burn/poison live in the `status` byte);
356    ///   2. each **live volatile** hosted on the actor (`effect_for_volatile`),
357    ///      walked in the arena's stable `id` order (design §3.4: every live
358    ///      effect contributes its handlers).
359    ///
360    /// The engine fixes only this *source* order (status sources, then volatile
361    /// sources in arena order) and the per-effect hook `order` within each
362    /// `run_event`; it knows NOTHING of which volatile is toxic vs leech, the
363    /// `/16`, or the "status damage then leech" Gen-1 sequencing. The game makes
364    /// the ASM order hold by stamping its volatiles' arena ids (toxic < leech) and
365    /// hook `order` values — so a single `if gen==1` never enters the engine.
366    fn residual_and_faint<P: EffectProvider>(
367        provider: &P,
368        state: &mut BattleState<P>,
369        effects: &mut Vec<EffectState<P>>,
370        mv: &mut MoveContext,
371        actor: BattlerRef,
372        rng: &mut dyn BattleRng,
373        mut log: Option<&mut TurnLog<P>>,
374    ) -> bool {
375        use super::log::HpChangeCause;
376        let opp = Self::opposing(actor);
377        // Each residual SOURCE is snapshot-diffed on its OWN (not once for the whole
378        // phase), so the per-source HP change carries its cause — the game narrates
379        // "hurt by POISON!" (status) vs "sapped by LEECH SEED!" (volatile) as distinct
380        // lines. Byte-identical to the old single-diff for state + consumed (the parity
381        // oracle checks those, not the event list); only MULTI-source residual logs
382        // split into multiple tagged events.
383
384        // 1. Non-volatile status residual (burn/poison) → `Status(s)`.
385        let status = state_status(state, actor);
386        if let Some(s) = status {
387            if let Some(eff) = provider.effect_for_status(&s) {
388                let pre = Self::snap_pair(state, actor, opp, &log);
389                {
390                    let mut ctx = BattleCtx { state, effects, mv, rng };
391                    // Residual fires "on" the acting battler (host == target == source).
392                    Self::fire(&mut ctx, eff, Event::Residual, actor, actor, RelayVar::Unit);
393                }
394                if let Some(l) = log.as_deref_mut() {
395                    let cause = HpChangeCause::Status(s.clone());
396                    if let Some(p) = &pre[0] { Self::emit_diff(l, state, actor, p, Some(cause.clone())); }
397                    if let Some(p) = &pre[1] { Self::emit_diff(l, state, opp, p, Some(cause)); }
398                }
399            }
400        }
401
402        // 2. Volatile residuals, in the arena's stable id order → `Volatile`. Snapshot
403        //    the resolved effects FIRST (sorted by arena id, the deterministic RNG-free
404        //    order) so a handler that mutates the arena (e.g. a leech KO removing a
405        //    volatile) cannot perturb the iteration.
406        // Capture each source's opaque `kind` alongside its effect so the per-source HP
407        // diff can be tagged `Volatile(kind)` — the game maps it back to Toxic / Leech Seed.
408        let mut volatiles: Vec<(EffectId, P::EffectStateKind, &'static super::event::Effect<P>)> =
409            effects
410                .iter()
411                .filter(|e| e.host == actor)
412                .filter_map(|e| {
413                    provider
414                        .effect_for_volatile(&e.kind)
415                        .map(|eff| (e.id, e.kind.clone(), eff))
416                })
417                .collect();
418        volatiles.sort_by_key(|(id, _, _)| *id);
419        for (_id, kind, eff) in volatiles {
420            let pre = Self::snap_pair(state, actor, opp, &log);
421            {
422                // A prior volatile (e.g. Toxic) may have KO'd the actor this tick. The
423                // engine does NOT skip the later volatile (it fires unconditionally);
424                // each game handler is responsible for its own post-faint guard.
425                let mut ctx = BattleCtx { state, effects, mv, rng };
426                Self::fire(&mut ctx, eff, Event::Residual, actor, actor, RelayVar::Unit);
427            }
428            if let Some(l) = log.as_deref_mut() {
429                // Both the actor's Damaged AND the opponent's paired Healed (Leech Seed's
430                // drain-to-source) carry the SAME volatile kind.
431                if let Some(p) = &pre[0] {
432                    Self::emit_diff(l, state, actor, p, Some(HpChangeCause::Volatile(kind.clone())));
433                }
434                if let Some(p) = &pre[1] {
435                    Self::emit_diff(l, state, opp, p, Some(HpChangeCause::Volatile(kind.clone())));
436                }
437            }
438        }
439
440        Self::is_fainted(state, actor)
441    }
442
443    /// Determine the first mover from the provider's RNG-free
444    /// [`turn_order_rank`](EffectProvider::turn_order_rank), drawing **exactly
445    /// one** byte iff the ranks tie (the single turn-order RNG site, mirroring
446    /// pokered's `order_random` coin flip — design §2/§4). On a tie, `byte < 128`
447    /// keeps the player first (matching `turn_order.rs:41`).
448    fn resolve_first_mover<P: EffectProvider>(
449        provider: &P,
450        state: &BattleState<P>,
451        actions: &[BattleAction<P>; 2],
452        rng: &mut dyn BattleRng,
453    ) -> FirstMover {
454        let player_move = move_of(&actions[0]);
455        let enemy_move = move_of(&actions[1]);
456        let player_rank = match player_move {
457            Some(m) => provider.turn_order_rank(state, BattlerRef::PLAYER, &m),
458            None => (0, 0),
459        };
460        let enemy_rank = match enemy_move {
461            Some(m) => provider.turn_order_rank(state, BattlerRef::OPPONENT, &m),
462            None => (0, 0),
463        };
464        match player_rank.cmp(&enemy_rank) {
465            std::cmp::Ordering::Less => FirstMover::Player,
466            std::cmp::Ordering::Greater => FirstMover::Opponent,
467            std::cmp::Ordering::Equal => {
468                // Exact tie → ONE coin-flip byte (the only turn-order draw).
469                if rng.next_u8() < 128 {
470                    FirstMover::Player
471                } else {
472                    FirstMover::Opponent
473                }
474            }
475        }
476    }
477
478    fn opposing(who: BattlerRef) -> BattlerRef {
479        BattlerRef::new(if who.side == 0 { 1 } else { 0 }, who.slot)
480    }
481
482    // ── Turn-event log helpers (P6a). All read-only over `state`; only invoked on
483    //    the logged path (`execute_turn_logged`). The plain `execute_turn` passes
484    //    `None`, so `snap_pair` returns empty snapshots and `diff_pair`/`emit_diff`
485    //    are never reached — the no-log path is byte-identical. ──
486
487    /// Snapshot a battler's loggable state, or `None` if the slot is absent.
488    fn snapshot<P: EffectProvider>(state: &BattleState<P>, who: BattlerRef) -> Option<Snap<P>> {
489        let party = if who.side == 0 {
490            &state.player_battlers
491        } else {
492            &state.opponent_battlers
493        };
494        party.get(who.slot as usize).map(|b| Snap {
495            hp: b.hp,
496            status: b.status.clone(),
497            stages: b.stat_stages.clone(),
498        })
499    }
500
501    /// Snapshot an ordered pair of battlers iff `log` is active; else two `None`s
502    /// (so the plain path allocates/clones nothing).
503    fn snap_pair<P: EffectProvider>(
504        state: &BattleState<P>,
505        a: BattlerRef,
506        b: BattlerRef,
507        log: &Option<&mut TurnLog<P>>,
508    ) -> [Option<Snap<P>>; 2] {
509        if log.is_none() {
510            return [None, None];
511        }
512        [Self::snapshot(state, a), Self::snapshot(state, b)]
513    }
514
515    /// Emit the diff for an ordered pair against their pre-action snapshots.
516    fn diff_pair<P: EffectProvider>(
517        log: &mut TurnLog<P>,
518        state: &BattleState<P>,
519        order: [BattlerRef; 2],
520        pre: [Option<Snap<P>>; 2],
521    ) {
522        for (who, p) in order.into_iter().zip(pre.into_iter()) {
523            if let Some(p) = p {
524                Self::emit_diff(log, state, who, &p, None); // move-phase HP change: no cause
525            }
526        }
527    }
528
529    /// Push the structural [`TurnEvent`]s implied by `who`'s state change since
530    /// `pre`: HP delta (damage/heal), status delta, stat-stage deltas, then faint.
531    fn emit_diff<P: EffectProvider>(
532        log: &mut TurnLog<P>,
533        state: &BattleState<P>,
534        who: BattlerRef,
535        pre: &Snap<P>,
536        cause: Option<super::log::HpChangeCause<P>>,
537    ) {
538        let Some(post) = Self::snapshot(state, who) else {
539            return;
540        };
541        // HP delta — tagged with `cause` (None for move damage/heal, Some for a residual).
542        if post.hp < pre.hp {
543            log.push(TurnEvent::Damaged { target: who, amount: pre.hp - post.hp, cause });
544        } else if post.hp > pre.hp {
545            log.push(TurnEvent::Healed { target: who, amount: post.hp - pre.hp, cause });
546        }
547        // Non-volatile status delta.
548        if pre.status != post.status {
549            match (&pre.status, &post.status) {
550                (None, Some(s)) => log.push(TurnEvent::StatusInflicted { target: who, status: s.clone() }),
551                (Some(s), None) => log.push(TurnEvent::StatusCured { target: who, status: s.clone() }),
552                // A status replaced by a different one: report the new one inflicted.
553                (Some(_), Some(s)) => log.push(TurnEvent::StatusInflicted { target: who, status: s.clone() }),
554                (None, None) => {}
555            }
556        }
557        // Stat-stage deltas: keys present after, then keys that were cleared.
558        for (stat, &after) in post.stages.iter() {
559            let before = pre.stages.get(*stat).copied().unwrap_or(0);
560            if after != before {
561                log.push(TurnEvent::StatChanged { target: who, stat: *stat, delta: after - before });
562            }
563        }
564        for (stat, &before) in pre.stages.iter() {
565            if post.stages.get(*stat).is_none() && before != 0 {
566                log.push(TurnEvent::StatChanged { target: who, stat: *stat, delta: -before });
567            }
568        }
569        // Faint last — after the damage that caused it.
570        if pre.hp > 0 && post.hp == 0 {
571            log.push(TurnEvent::Fainted { who });
572        }
573    }
574
575    fn is_fainted<P: EffectProvider>(state: &BattleState<P>, who: BattlerRef) -> bool {
576        let party = if who.side == 0 {
577            &state.player_battlers
578        } else {
579            &state.opponent_battlers
580        };
581        party
582            .get(who.slot as usize)
583            .map(|b| b.hp == 0)
584            .unwrap_or(true)
585    }
586}
587
588/// A before-action snapshot of one battler's loggable state (P6a). Taken only on
589/// the logged path; diffed against the post-action state to derive [`TurnEvent`]s.
590struct Snap<P: BattleProvider + ?Sized> {
591    hp: u16,
592    status: Option<P::Status>,
593    stages: EnumMap<P::Stat, i8>,
594}
595
596/// Extract the chosen move from a `Fight` action (other actions have no move).
597fn move_of<P: BattleProvider + ?Sized>(action: &BattleAction<P>) -> Option<P::Move> {
598    match action {
599        BattleAction::Fight { move_ } => Some(move_.clone()),
600        _ => None,
601    }
602}
603
604fn state_status<P: EffectProvider>(
605    state: &BattleState<P>,
606    who: BattlerRef,
607) -> Option<P::Status> {
608    let party = if who.side == 0 {
609        &state.player_battlers
610    } else {
611        &state.opponent_battlers
612    };
613    party.get(who.slot as usize).and_then(|b| b.status.clone())
614}