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}