Skip to main content

dotzuki_engine/battle/stack/
ctx.rs

1//! The effect provider, per-effect state arena, per-move scratch, and the
2//! split-borrow [`BattleCtx`] (design §3).
3//!
4//! The borrow-checker crux: never hand a handler `&mut BattleState` *plus*
5//! borrowed battler refs. Instead, hand it a `BattleCtx` of split accessors and
6//! resolve via the relay fold (the dispatch loop in
7//! [`dispatch`](super::dispatch) owns iteration and re-borrows per step).
8
9use crate::battle::rng::BattleRng;
10use crate::battle::{BattleAction, BattleProvider, BattleState, BattlerRef, BattlerState};
11
12use super::event::{Effect, EffectId};
13
14/// The game's effect registry, extending [`BattleProvider`] (design §1.5).
15///
16/// The engine ships the dispatch machinery with **zero game-specific types**; all
17/// specifics (which move maps to which `Effect`, the volatile state enum) live
18/// in the game via this trait. For the POC, `EffectStateKind` is a concrete
19/// game-supplied associated type (design §3.1 — promote to richer generics when
20/// a second game lands).
21pub trait EffectProvider: BattleProvider + 'static {
22    /// The game-supplied typed per-effect-kind state enum (design §3.1). The
23    /// engine treats it opaquely (it only stamps `effect_order` and routes it to
24    /// the host). pokered supplies the Gen-1 enum (Toxic counter, Substitute hp,
25    /// …).
26    type EffectStateKind: Clone;
27
28    /// Resolve the [`Effect`] registered for a given move. Returns `None` if the
29    /// move registers no stack hooks.
30    fn effect_for_move(&self, m: &Self::Move) -> Option<&'static Effect<Self>>
31    where
32        Self: Sized;
33
34    /// Resolve the [`Effect`] registered for a non-volatile status (e.g. the
35    /// poison residual). Returns `None` if the status registers no hooks.
36    fn effect_for_status(&self, s: &Self::Status) -> Option<&'static Effect<Self>>
37    where
38        Self: Sized;
39
40    /// Resolve the [`Effect`] registered for a **live volatile** in the effect
41    /// arena (design §3.4: *every* live effect on a battler contributes its
42    /// handlers, not only the non-volatile status). Returns `None` (the default)
43    /// when the volatile registers no hooks — so a provider that has no
44    /// volatile-borne residuals (every game built on the engine so far) is
45    /// completely unaffected and the driver's arena-residual pass is inert.
46    ///
47    /// This is the generic seam that lets a game host a residual on a *volatile*
48    /// (Gen-1 Leech Seed / badly-poisoned both live in `status2`/`status3` bit
49    /// flags, NOT the non-volatile `status` byte) without the engine knowing any
50    /// game-specific semantics. The `/16`, the toxic counter, and the ASM "status then
51    /// leech" order all live in the **game's** handlers; the engine only fires
52    /// the hooks the game registers, in the order the game's `order` values dictate.
53    fn effect_for_volatile(&self, kind: &Self::EffectStateKind) -> Option<&'static Effect<Self>>
54    where
55        Self: Sized,
56    {
57        let _ = kind;
58        None
59    }
60
61    /// Turn-order rank for `who` — **drawing NO randomness** (design §1.3/§2).
62    ///
63    /// The driver compares the two ranks; on an exact tie it draws **one** byte
64    /// to break it (mirroring pokered's single `order_random` coin flip,
65    /// `turn_order.rs:41`, bug #22) — the only turn-order RNG site. This is why
66    /// the stack does NOT reuse [`BattleProvider::turn_order_key`] (which draws
67    /// per actor): a per-actor draw would consume the wrong number of bytes and
68    /// break draw-order parity with the legacy oracle (design §4.1).
69    ///
70    /// Lower rank acts first; encode "acts earlier" as a *smaller* key
71    /// (e.g. `(-priority, -effective_speed)`).
72    fn turn_order_rank(
73        &self,
74        state: &BattleState<Self>,
75        who: BattlerRef,
76        action: &<Self as BattleProvider>::Move,
77    ) -> (i32, i32)
78    where
79        Self: Sized;
80
81    /// **Cross-turn action override** (design §3 / §9, the multi-turn lock-in
82    /// seam). Before the driver executes `actor`'s chosen action, it asks the
83    /// game whether a *live volatile* forces a different action this turn — Gen-1
84    /// Thrash/Petal Dance and Wrap/Bind re-issue the locked move ignoring the
85    /// player's choice, Fly/Dig/Solar Beam strike on the second turn, and Hyper
86    /// Beam recharge forces inaction ([`BattleAction::Nothing`]). The game reads
87    /// its own `effects` arena (the cross-turn home of the lock counter / charge
88    /// flag / recharge flag) and returns `Some(forced)` to override `chosen`, or
89    /// `None` to let the chosen action stand.
90    ///
91    /// This is the **canonical proof** (design §9) that a per-turn `[Action; 2]`
92    /// input is insufficient: the locked volatile, recorded on a PRIOR turn,
93    /// hijacks this turn's action. The seam is **generic** (the engine names no
94    /// game-specific volatile — it only swaps one `BattleAction` for another) and
95    /// **defaulted to `None`**, so it is completely INERT for every other game
96    /// and for slices 1–5 (which never register a forcing volatile). All Gen-1
97    /// lock-in semantics (which volatile forces which move, the lock counter, the
98    /// recharge skip) live in the game's `forced_action` impl, never in the engine.
99    fn forced_action(
100        &self,
101        effects: &[EffectState<Self>],
102        actor: BattlerRef,
103        chosen: &BattleAction<Self>,
104    ) -> Option<BattleAction<Self>>
105    where
106        Self: Sized,
107    {
108        let _ = (effects, actor, chosen);
109        None
110    }
111
112    // ── Multi-source collection resolvers (design §2.4, P0b) ─────────────────
113    //
114    // These four seams are what turns "abilities/items/weather/side-conditions
115    // are just Effects" into a working collection pass. The broadened collector
116    // (`dispatch::collect_handlers`) calls them so an effect hosted on a
117    // battler's ability/item, on a side, or on the field gets a chance to
118    // subscribe to an event alongside the move/volatile/status effects. **All
119    // four default to `None`/empty**, so a game with no abilities/items/weather
120    // (and every existing Gen-1 slice) sees the broadened collector reduce
121    // *exactly* to today's single-source behavior — zero new handlers, zero
122    // behavioral change, identical `consumed()` draw order.
123    //
124    // The engine never reads an ability/item's *meaning*; it only fetches the
125    // hook table. This is the whole "abilities = effects" mechanism: a resolver
126    // + a collection pass, no new engine enum, no `Ability` dispatcher.
127
128    /// Resolve the [`Effect`] registered for a battler's **ability**, hosted on
129    /// that battler (design §2.4). Returns `None` (the default) when the game
130    /// has no abilities or this one registers no stack hooks.
131    fn effect_for_ability(&self, b: &BattlerState<Self>) -> Option<&'static Effect<Self>>
132    where
133        Self: Sized,
134    {
135        let _ = b;
136        None
137    }
138
139    /// Resolve the [`Effect`] registered for a battler's **held item**, hosted
140    /// on that battler (design §2.4). Returns `None` (the default) when the game
141    /// has no items or this one registers no stack hooks.
142    fn effect_for_item(&self, b: &BattlerState<Self>) -> Option<&'static Effect<Self>>
143    where
144        Self: Sized,
145    {
146        let _ = b;
147        None
148    }
149
150    /// Resolve the **side-hosted** effects for `side` (screens, hazards, Wish;
151    /// design §2.4). Returns `&[]` (the default) when the game has no side
152    /// conditions.
153    ///
154    /// The returned slice borrows from **`self`** (the provider owns the
155    /// registry of `&'static Effect` tables); `ctx` is passed read-only so a
156    /// game can decide *which* side conditions are currently live by consulting
157    /// its arena/field state. The engine only fetches hook tables — it never
158    /// reads a side condition's meaning.
159    fn side_effects(&self, ctx: &BattleCtx<'_, Self>, side: u8) -> &[&'static Effect<Self>]
160    where
161        Self: Sized,
162    {
163        let _ = (ctx, side);
164        &[]
165    }
166
167    /// Resolve the **field-hosted** effects (weather, terrain, Trick Room;
168    /// design §2.4). Returns `&[]` (the default) when the game has no field
169    /// conditions. The returned slice borrows from `self` (see
170    /// [`side_effects`](EffectProvider::side_effects)).
171    fn field_effects(&self, ctx: &BattleCtx<'_, Self>) -> &[&'static Effect<Self>]
172    where
173        Self: Sized,
174    {
175        let _ = ctx;
176        &[]
177    }
178}
179
180/// Where an effect is hosted (design §3.1, the broadened addressing). A
181/// cross-gen battle hosts effects not only on a battler (volatiles, ability,
182/// item) but on a **side** (screens, hazards, Wish) or the **field** (weather,
183/// terrain, Trick Room). `EffectHost` is the 3-way scope the engine routes by.
184///
185/// ## Non-breaking note (design §3.1, §7)
186///
187/// The design's §3.1 sketch proposed *widening the `EffectState.host` field*
188/// from `BattlerRef` to `EffectHost` "via `From<BattlerRef>` so existing
189/// constructors compile unchanged." In Rust that does **not** hold for
190/// struct-literal field initialization (`EffectState { host: who, .. }` and the
191/// field-shorthand `host,`): field init requires the *exact* field type and
192/// never invokes `From`/`Into`. Widening the field would therefore force an edit
193/// to all 40+ Gen-1 slice construction sites — a NO-GO on the design's own
194/// "Non-breaking" axis (§6.2). See the returned findings for the documented
195/// NO-GO.
196///
197/// So `EffectHost` ships as an **additive type**: `EffectState.host` stays
198/// `BattlerRef` (battler-hosted effects, the only kind the engine fires today —
199/// every slice compiles verbatim), and [`EffectState::host_scope`] projects it
200/// to `EffectHost::Battler`. Side- and field-hosted state is addressed through
201/// the `EffectHost::Side`/`Field` cases and the defaulted `side_effects`/
202/// `field_effects` resolvers (the game owns that mutable state). `From` and
203/// `PartialEq` cross-impls let routing code treat a `BattlerRef` and an
204/// `EffectHost::Battler` interchangeably.
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206pub enum EffectHost {
207    /// Hosted on one battler (volatile, ability, item).
208    Battler(BattlerRef),
209    /// Hosted on a side (screens, hazards, Wish). `0` = player, `1` = opponent.
210    Side(u8),
211    /// Hosted on the field (weather, terrain, Trick Room).
212    Field,
213}
214
215impl From<BattlerRef> for EffectHost {
216    fn from(r: BattlerRef) -> Self {
217        EffectHost::Battler(r)
218    }
219}
220
221impl PartialEq<BattlerRef> for EffectHost {
222    fn eq(&self, other: &BattlerRef) -> bool {
223        matches!(self, EffectHost::Battler(r) if r == other)
224    }
225}
226
227impl PartialEq<EffectHost> for BattlerRef {
228    fn eq(&self, other: &EffectHost) -> bool {
229        matches!(other, EffectHost::Battler(r) if r == self)
230    }
231}
232
233/// One live effect's mutable per-instance state, held in an arena keyed by id
234/// (design §3.1). `kind` is the game's typed counter enum, so the compiler
235/// checks every counter (no positional slot bag).
236pub struct EffectState<P: EffectProvider + ?Sized> {
237    /// The effect id (arena key; the arena is kept sorted for binary search).
238    pub id: EffectId,
239    /// The battler this effect is attached to.
240    ///
241    /// Kept `BattlerRef` (not `EffectHost`) so every existing struct-literal
242    /// constructor compiles unchanged (see [`EffectHost`]'s non-breaking note).
243    /// Project to the 3-way scope via [`EffectState::host_scope`].
244    pub host: BattlerRef,
245    /// Monotonic creation counter — the final deterministic tiebreak in the
246    /// comparator (design §1.3), stamped at creation, consumes NO rng.
247    pub effect_order: u64,
248    /// The game's typed counter state.
249    pub kind: P::EffectStateKind,
250}
251
252impl<P: EffectProvider + ?Sized> EffectState<P> {
253    /// The 3-way host scope of this effect (design §3.1). Arena effects are
254    /// always battler-hosted today, so this returns `EffectHost::Battler`; the
255    /// method is the routing seam the driver uses so a future side/field-hosted
256    /// state path can branch on scope without callers caring how `host` is
257    /// stored.
258    pub fn host_scope(&self) -> EffectHost {
259        EffectHost::Battler(self.host)
260    }
261}
262
263impl<P: EffectProvider + ?Sized> Clone for EffectState<P> {
264    fn clone(&self) -> Self {
265        Self {
266            id: self.id,
267            host: self.host,
268            effect_order: self.effect_order,
269            kind: self.kind.clone(),
270        }
271    }
272}
273
274/// Per-move scratch shared across a move's event chain (design §3.2): the crit
275/// flag, the rolled damage, the miss flag, and the last damage dealt (the
276/// canonical home for Counter/Bide reads — design §9 open question).
277#[derive(Clone, Copy, Debug, Default)]
278pub struct MoveContext {
279    /// Whether the in-flight move is a critical hit.
280    pub is_critical: bool,
281    /// The damage computed for the in-flight move.
282    pub damage: u16,
283    /// Whether the in-flight move missed.
284    pub move_missed: bool,
285    /// The last damage actually dealt this turn (Counter/Bide read this).
286    pub last_damage: u16,
287}
288
289/// Split-borrow context handed to every handler (design §3.2). A handler's only
290/// mutable path into the battle is through this struct's accessors.
291pub struct BattleCtx<'a, P: EffectProvider + ?Sized> {
292    /// The shared battle state (the two party `Vec`s live here).
293    pub state: &'a mut BattleState<P>,
294    /// The per-effect-instance arena, kept sorted by `id`.
295    pub effects: &'a mut Vec<EffectState<P>>,
296    /// Per-move scratch.
297    pub mv: &'a mut MoveContext,
298    /// The ONLY randomness source (design §4).
299    pub rng: &'a mut dyn BattleRng,
300}
301
302impl<'a, P: EffectProvider + ?Sized> BattleCtx<'a, P> {
303    /// One battler as `&mut`. Cross-side is trivial (two different `Vec`s).
304    pub fn battler_mut(&mut self, r: BattlerRef) -> &mut BattlerState<P> {
305        match r.side {
306            0 => &mut self.state.player_battlers[r.slot as usize],
307            _ => &mut self.state.opponent_battlers[r.slot as usize],
308        }
309    }
310
311    /// One battler as `&` (read-only).
312    pub fn battler(&self, r: BattlerRef) -> &BattlerState<P> {
313        match r.side {
314            0 => &self.state.player_battlers[r.slot as usize],
315            _ => &self.state.opponent_battlers[r.slot as usize],
316        }
317    }
318
319    /// Two **disjoint** battler refs as two `&mut` (design §3.2). This is the
320    /// borrow-checker crux that lets a Counter-shaped handler mutate `target`
321    /// while reading `source`'s host.
322    ///
323    /// * Cross-side (`a.side != b.side`): the two sides are separate `Vec`s, so
324    ///   the refs are *provably* non-aliasing. This is the **one** localized,
325    ///   documented `unsafe` in the engine hot path (design §3.2).
326    /// * Same-side: a real disjoint split via `split_at_mut` — **fully safe**,
327    ///   no raw pointer. (Once MSRV permits, `<[T]>::get_disjoint_mut`.)
328    ///
329    /// # Panics
330    /// Debug-asserts `a != b` (the two refs must address distinct battlers).
331    pub fn pair_mut(
332        &mut self,
333        a: BattlerRef,
334        b: BattlerRef,
335    ) -> (&mut BattlerState<P>, &mut BattlerState<P>) {
336        debug_assert!(a != b, "pair_mut requires two distinct battlers");
337        if a.side != b.side {
338            // Cross-side: disjoint `Vec`s ⇒ two independent `&mut`.
339            let pa: *mut BattlerState<P> = self.battler_mut(a);
340            let pb: *mut BattlerState<P> = self.battler_mut(b);
341            // SAFETY: `a.side != b.side` ⇒ the two refs index DIFFERENT `Vec`s
342            // (`player_battlers` vs `opponent_battlers`), so the resulting
343            // `&mut`s can never alias. This is the sole engine `unsafe` and its
344            // disjointness is structural, not a runtime invariant.
345            unsafe { (&mut *pa, &mut *pb) }
346        } else {
347            // Same-side: disjoint slots in ONE slice → split_at_mut (safe).
348            let v = if a.side == 0 {
349                &mut self.state.player_battlers
350            } else {
351                &mut self.state.opponent_battlers
352            };
353            let (lo, hi) = (a.slot.min(b.slot) as usize, a.slot.max(b.slot) as usize);
354            let (left, right) = v.split_at_mut(hi);
355            let (first, second) = (&mut left[lo], &mut right[0]);
356            if a.slot < b.slot {
357                (first, second)
358            } else {
359                (second, first)
360            }
361        }
362    }
363
364    /// Mutable access to a live effect's state by id (binary search; the arena
365    /// is kept sorted). Returns `None` if no such effect is live.
366    pub fn effect_mut(&mut self, id: EffectId) -> Option<&mut EffectState<P>> {
367        match self.effects.binary_search_by(|e| e.id.cmp(&id)) {
368            Ok(idx) => Some(&mut self.effects[idx]),
369            Err(_) => None,
370        }
371    }
372
373    /// Read access to a live effect's state by id.
374    pub fn effect(&self, id: EffectId) -> Option<&EffectState<P>> {
375        match self.effects.binary_search_by(|e| e.id.cmp(&id)) {
376            Ok(idx) => Some(&self.effects[idx]),
377            Err(_) => None,
378        }
379    }
380
381    /// Install a new game-defined effect (volatile) on `host`, allocating a
382    /// fresh arena id + creation order and keeping the arena sorted by id.
383    /// Returns the new id. The engine treats `kind` OPAQUELY — this is the
384    /// generic seam a data `InflictVolatile` op uses; the game constructs the
385    /// `P::EffectStateKind` (via its binding) and the engine only stores it.
386    pub fn install_effect(&mut self, host: BattlerRef, kind: P::EffectStateKind) -> EffectId {
387        let id = EffectId(self.effects.iter().map(|e| e.id.0).max().unwrap_or(0) + 1);
388        self.effects.push(EffectState {
389            id,
390            host,
391            effect_order: id.0 as u64,
392            kind,
393        });
394        self.effects.sort_by_key(|e| e.id.0);
395        id
396    }
397}