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}