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