dotzuki_rules/bindings.rs
1//! The game binding seam (doc 11 §1, doc 12 §3.2).
2//!
3//! `dotzuki-rules` stays game-agnostic: it never names a concrete `P::Stat` /
4//! `P::Status` / `P::Type`. The data uses **names**, interned to `usize` indices
5//! at load. To actually apply a `Boost`/`InflictStatus`/`HasType`/`ApplyTypeChart`
6//! against a [`BattlerState<P>`](dotzuki_engine::battle::BattlerState) the interpreter
7//! asks the **game** to resolve those indices via this trait. This mirrors doc 12
8//! §3.2's two defaulted provider seams (`defender_types`, `type_chart_mult`), but
9//! lives game-side (no engine edit), keyed by the interned index the loader owns.
10//!
11//! All methods are **pure / RNG-free** (determinism, doc 11 §4.1): a binding may
12//! read battler state and the interned chart but must never draw randomness. The
13//! interpreter's only entropy is `ctx.rng`.
14
15use dotzuki_engine::battle::stack::{BattleCtx, EffectProvider};
16use dotzuki_engine::battle::{BattlerRef, BattlerState};
17
18/// Resolves interned data-layer indices to concrete `P::Stat`/`P::Status` and
19/// supplies the type-chart fold + defender-type membership, all pure.
20///
21/// A game implements this once for its provider; the loader carries it so the
22/// zero-capture [`interpret`](crate::interpret) bridge can reach it. The trait is
23/// generic over `P: EffectProvider`, so the engine learns nothing.
24pub trait RuleBindings<P: EffectProvider + ?Sized>: 'static {
25 /// Apply a signed stat-stage delta to `who` for the interned `stat_index`.
26 /// Returns `false` if the index is unknown (a no-op; the loader validates
27 /// names at compile, so this is defense-in-depth). Phase 1 applies directly;
28 /// the nested-`TryBoost` veto is driver orchestration (doc 11 §3).
29 fn apply_boost(&self, b: &mut BattlerState<P>, stat_index: usize, stages: i8) -> bool;
30
31 /// Set `who`'s non-volatile status for the interned `status_index`. Returns
32 /// `false` if the index is unknown.
33 fn set_status(&self, b: &mut BattlerState<P>, status_index: usize) -> bool;
34
35 /// Set `who`'s non-volatile status carrying a game-interpreted numeric
36 /// `amount` (e.g. Gen-1 sleep turns). The engine resolves `amount` from the
37 /// op's [`AmountSpec`](crate::AmountSpec) — drawing its OWN rng — and hands
38 /// the pure number here. **Defaulted** to delegate to
39 /// [`set_status`](Self::set_status) and ignore the amount, so a game whose
40 /// statuses carry no duration is unaffected. Pure; no entropy.
41 fn set_status_with_amount(
42 &self,
43 b: &mut BattlerState<P>,
44 status_index: usize,
45 _amount: u16,
46 ) -> bool {
47 self.set_status(b, status_index)
48 }
49
50 /// Build the game's OPAQUE `P::EffectStateKind` volatile for the vocabulary
51 /// `name` + already-resolved `amount` (the [`InflictVolatile`](crate::Op::InflictVolatile)
52 /// op). The engine installs whatever is returned generically (fresh arena
53 /// id) and never learns what the volatile means — only the game does.
54 /// **Defaulted to `None`** ⇒ a game with no volatiles (or that doesn't
55 /// recognise `name`) makes the op inert. Pure — the engine already drew any
56 /// rng needed for `amount`.
57 fn make_volatile(&self, _name: &str, _amount: u16) -> Option<P::EffectStateKind> {
58 None
59 }
60
61 /// Whether `who` has the type with interned chart `type_index` (the `HasType`
62 /// predicate, doc 11 §1.1). Pure read.
63 fn has_type(&self, b: &BattlerState<P>, type_index: usize) -> bool;
64
65 /// The chart fold for the in-flight `move_type_index` against `defender`'s
66 /// type(s), as ONE pre-combined integer rational `(num, den)` (doc 12 §3.2,
67 /// §5.3 — one rational ⇒ exactly one `scale`, avoiding per-step truncation).
68 /// Default `(1, 1)` ⇒ inert (no chart). Pure / RNG-free.
69 fn type_chart_mult(
70 &self,
71 _ctx: &BattleCtx<'_, P>,
72 _move_type_index: usize,
73 _defender: BattlerRef,
74 ) -> (u32, u32) {
75 (1, 1)
76 }
77
78 /// The in-flight folded stat index, if the driver stashed one for a
79 /// `StatIs` predicate (the Sandstorm `WeatherModifyStat` case, doc 11 §1).
80 /// Default `None` ⇒ `StatIs` never matches. Pure.
81 fn current_stat_index(&self, _ctx: &BattleCtx<'_, P>) -> Option<usize> {
82 None
83 }
84
85 /// Whether `who` currently has the live volatile named by `name` (the
86 /// `HasVolatile` predicate, blueprint `15` §2/§3 — the Substitute block on
87 /// side-status). The game inspects its own `ctx.effects` arena (the engine
88 /// treats `EffectStateKind` opaquely, so only the game can tell which arena
89 /// entry IS "Substitute"). **Defaulted to `false`** so a game with no volatiles
90 /// (every game built so far) is unaffected and the predicate never matches.
91 /// Pure read; no entropy.
92 fn has_volatile(&self, _ctx: &BattleCtx<'_, P>, _who: BattlerRef, _name: &str) -> bool {
93 false
94 }
95
96 /// **Damage-redirection seam** for the DIRECT-MUTATE ops (`SetHp` / `DamageFraction`
97 /// / `DamageCurrentHpFraction` / `RepeatHits`) that apply HP OUTSIDE the driver's
98 /// `Event::Damage` fold. Before such an op subtracts `amount` HP from `who`
99 /// (attributed to `source`), the interpreter asks the game whether a **damage sink**
100 /// on `who` should swallow it instead — a monster Substitute doll, a cross-game
101 /// shield/ward/decoy. Returning `true` means the game HANDLED the loss (it mutated
102 /// its own sink via `ctx`); the interpreter then SKIPS the direct HP write. Returning
103 /// `false` (the default) leaves the op to apply HP exactly as before.
104 ///
105 /// This is the ONLY binding permitted to MUTATE through `ctx` (every other is a pure
106 /// read) — it is the redirect analogue of the `TryBoost`/`Event::Damage` interception
107 /// the driver already fires for formula damage, extended to the ops the driver never
108 /// routes. **Defaulted to `false`** so every existing game (and every op) is
109 /// byte-identical: the loss applies unredirected, no `ctx` mutation, no entropy.
110 /// `source` lets a game exempt self-inflicted loss (recoil / self-KO) from its own
111 /// sink. Draws NO randomness.
112 fn redirect_hp_loss(
113 &self,
114 _ctx: &mut BattleCtx<'_, P>,
115 _who: BattlerRef,
116 _source: BattlerRef,
117 _amount: u16,
118 ) -> bool {
119 false
120 }
121
122 /// Whether the in-flight move's type (`move_type_index`, recovered from the
123 /// record's `type:`) equals one of `who`'s types (the `MoveTypeIsDefenderType`
124 /// predicate — Gen-1 burn/freeze/paralyze self-type-immunity quirk #23,
125 /// blueprint `15` §2/§3). **Defaulted to `false`** ⇒ the quirk never fires for a
126 /// game that does not implement it. Pure read; no entropy. The default body
127 /// delegates to [`has_type`](Self::has_type) so a game whose `has_type` already
128 /// answers chart membership gets the quirk for free by overriding nothing — but
129 /// the engine has no `move_type_index` for a generic predicate, so the loader
130 /// passes it through and the binding decides.
131 fn move_type_is_defender_type(
132 &self,
133 ctx: &BattleCtx<'_, P>,
134 move_type_index: usize,
135 who: BattlerRef,
136 ) -> bool {
137 self.has_type(ctx.battler(who), move_type_index)
138 }
139
140 /// Whether `who` currently has the non-volatile status at interned
141 /// `status_index` (the `TargetHasStatus` predicate — the Dream Eater sleep
142 /// gate, blueprint `15` §2). The status index is the game's vocabulary (the same
143 /// indices `set_status` consumes). **Defaulted to `false`** ⇒ a game that does
144 /// not implement it never matches. Pure read; no entropy.
145 fn has_status(&self, _b: &BattlerState<P>, _status_index: usize) -> bool {
146 false
147 }
148
149 /// Whether `b` has ANY non-volatile status (the `TargetHasAnyStatus`
150 /// predicate — the Toxic "already-statused ⇒ fail" guard). The engine knows
151 /// no concrete status, so the game answers. **Defaulted to `false`** ⇒ a game
152 /// that doesn't implement it never matches. Pure read; no entropy.
153 fn has_any_status(&self, _b: &BattlerState<P>) -> bool {
154 false
155 }
156
157 /// The level of battler `b` (the `LevelGE` predicate's gate + the level-based
158 /// [`SetDamage`](crate::Op::SetDamage) sources — Seismic Toss / Night Shade /
159 /// Psywave; blueprint `15` §2/§3). [`BattlerState<P>`](dotzuki_engine::battle::BattlerState)
160 /// carries no `level` field (the engine is level-agnostic), so the game answers
161 /// it here. **Defaulted to `0`** ⇒ a game that authors no level-gated op is
162 /// unaffected (it never calls this) and `LevelGE` is `0 >= 0 == true`. Pure
163 /// read; no entropy. Game-agnostic: "a number the binding supplies per battler".
164 fn battler_level(&self, _b: &BattlerState<P>) -> u16 {
165 0
166 }
167
168 /// Map an interned resource index (the ruleset's `resources:` order) to the
169 /// engine's opaque resource id used in [`ResourcePool`](dotzuki_engine::battle::ResourcePool)
170 /// / [`BattleProvider::move_cost`](dotzuki_engine::battle::BattleProvider::move_cost)
171 /// (the MP/SP/mana cost gate, doc 13 §4). **Defaulted** so games with no
172 /// resources need not implement it — they never reference a resource, so it is
173 /// never called. Default: identity (`index as u16`). Pure.
174 fn resource_id(&self, resource_index: usize) -> u16 {
175 resource_index as u16
176 }
177
178 /// Whether `b` can pay `amount` of the resource at interned `resource_index`
179 /// (the `PayResource` op's gate, doc 13 §4). Pure read. The default delegates
180 /// to the engine's [`ResourcePool`](dotzuki_engine::battle::ResourcePool) via
181 /// [`resource_id`](Self::resource_id) — a game that stores its pool on
182 /// `BattlerState.resources` (the engine default) gets correct behavior for free.
183 fn can_pay_resource(&self, b: &BattlerState<P>, resource_index: usize, amount: u16) -> bool {
184 b.can_pay_resource(self.resource_id(resource_index), amount)
185 }
186
187 /// Deduct `amount` of the resource at interned `resource_index` from `b` (the
188 /// `PayResource` op's deduction). **Pure arithmetic — no rng.** Default
189 /// delegates to the engine `ResourcePool`.
190 fn pay_resource(&self, b: &mut BattlerState<P>, resource_index: usize, amount: u16) {
191 b.pay_resource(self.resource_id(resource_index), amount);
192 }
193}