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}