Skip to main content

dotzuki_rules/
model.rs

1//! The serde/RON data model + the closed primitive vocabulary (doc 11 §1, §1.1;
2//! doc 12 §2). Every name in the data (`on:`, `kind:`, ops, selectors,
3//! predicates) parses to a **closed** engine concept at LOAD time — an unknown
4//! name is a [`LoadError`], never a battle-time surprise (doc 11 §4.2).
5
6use dotzuki_engine::battle::stack::Event;
7use serde::{Deserialize, Deserializer};
8
9/// An integer rational `[num, den]` deserialized from the doc's bracket-pair RON
10/// syntax (doc 11 `chance:[30,100]`, doc 12 `mult:[2,1]`). RON parses a fixed
11/// `[u32; 2]` as a *tuple* (requiring `(..)`), so we deserialize a 2-element list
12/// and validate the length — keeping the authored `[num, den]` form exact.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct Rational {
15    /// Numerator.
16    pub num: u32,
17    /// Denominator.
18    pub den: u32,
19}
20
21impl<'de> Deserialize<'de> for Rational {
22    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
23        let v: Vec<u32> = Vec::deserialize(d)?;
24        if v.len() != 2 {
25            return Err(serde::de::Error::invalid_length(
26                v.len(),
27                &"a 2-element rational [num, den]",
28            ));
29        }
30        Ok(Rational {
31            num: v[0],
32            den: v[1],
33        })
34    }
35}
36
37/// A statement of which thing in the data layer could not be bound to the closed
38/// vocabulary. Every variant is a **load-time** error (doc 11 §4.2: "a malformed
39/// record fails at load, never mid-battle").
40#[derive(Debug, thiserror::Error, PartialEq, Eq)]
41pub enum LoadError {
42    /// The RON text failed to deserialize into the [`Ruleset`] shape.
43    #[error("RON parse error: {0}")]
44    Ron(String),
45    /// A hook's `on:` named an event outside the closed [`Event`] taxonomy.
46    #[error("unknown event name in `on:`: {0:?}")]
47    UnknownEvent(String),
48    /// A type name referenced in the chart / a `HasType` predicate is not in the
49    /// ruleset's `types:` list.
50    #[error("unknown type name: {0:?}")]
51    UnknownType(String),
52    /// A `chance:` fraction had a zero denominator (would never gate).
53    #[error("invalid chance fraction {0}/{1} (denominator must be > 0)")]
54    BadChance(u32, u32),
55    /// A status name referenced by `InflictStatus` could not be resolved by the
56    /// game's [`RuleBindings`](crate::RuleBindings) at compile time.
57    #[error("unknown status name: {0:?}")]
58    UnknownStatus(String),
59    /// A stat name referenced by `Boost`/`StatIs` could not be resolved.
60    #[error("unknown stat name: {0:?}")]
61    UnknownStat(String),
62    /// A resource name referenced by a `cost:` entry or a `PayResource` op is not
63    /// in the ruleset's `resources:` list (the MP/SP/mana cost gate, doc 13 §4).
64    #[error("unknown resource name: {0:?}")]
65    UnknownResource(String),
66}
67
68/// A whole no-code ruleset (doc 11 §1). Flat table of effect records plus the
69/// shared `types` vocabulary and the optional `type_chart` relation (doc 12 §2).
70#[derive(Debug, Clone, Deserialize)]
71pub struct Ruleset {
72    /// The opaque stat names; the game's [`RuleBindings`](crate::RuleBindings)
73    /// maps these ↔ `P::Stat`. Order is the interned stat index.
74    #[serde(default)]
75    pub stats: Vec<String>,
76    /// The opaque type names; index = interned chart index (doc 12 §3.2).
77    #[serde(default)]
78    pub types: Vec<TypeName>,
79    /// The opaque resource names (MP / SP / mana — doc 13 §4). Order is the
80    /// interned resource index, which the game's
81    /// [`RuleBindings`](crate::RuleBindings) maps ↔ the engine's opaque resource
82    /// id. Empty by default ⇒ no game declares a resource ⇒ the cost gate is inert.
83    #[serde(default)]
84    pub resources: Vec<String>,
85    /// The attacker-type → defender-type → `[num, den]` relation (doc 12 §2).
86    #[serde(default)]
87    pub type_chart: Vec<TypeChartEntry>,
88    /// The effect records (moves / statuses / abilities / items / weather).
89    #[serde(default)]
90    pub effects: Vec<EffectRecord>,
91}
92
93/// A type name string (interned to a chart index by [`Ruleset::type_index`]).
94pub type TypeName = String;
95
96/// One `(atk, def, mult)` chart edge (doc 12 §2). `mult` is an integer rational
97/// `[num, den]`; `[2,1]` = super-effective, `[1,2]` = resisted, `[0,1]` = immune.
98/// Omitted pairs default to `[1,1]` (neutral) at lookup time.
99#[derive(Debug, Clone, Deserialize)]
100pub struct TypeChartEntry {
101    /// Attacking type name (must be in `types:`).
102    pub atk: String,
103    /// Defending type name (must be in `types:`).
104    pub def: String,
105    /// `[num, den]` rational.
106    pub mult: Rational,
107}
108
109/// One effect record (doc 11 §1). `kind` selects which resolver hosts it and the
110/// engine [`EffectType`](dotzuki_engine::battle::stack::EffectType); the optional
111/// `category`/`power`/`type`/`accuracy` are per-move data the provider's damage
112/// formula reads (the engine never sees them).
113#[derive(Debug, Clone, Deserialize)]
114#[serde(rename = "Effect")]
115pub struct EffectRecord {
116    /// Opaque effect id string (e.g. `"move.ember"`). Maps to a synthesized
117    /// [`EffectId`](dotzuki_engine::battle::stack::EffectId) per hook at compile.
118    pub id: String,
119    /// Which resolver hosts this effect + the engine `EffectType`.
120    pub kind: EffectKind,
121    /// Optional per-move category (`Physical`/`Special`) — provider-read data.
122    #[serde(default)]
123    pub category: Option<String>,
124    /// Optional base power — provider-read data.
125    #[serde(default)]
126    pub power: Option<u32>,
127    /// Optional attacking type name — used by `ApplyTypeChart` to recover the
128    /// in-flight move's type from `source_effect` (doc 12 §3.3).
129    #[serde(rename = "type", default)]
130    pub mtype: Option<String>,
131    /// Optional accuracy — provider-read data.
132    #[serde(default)]
133    pub accuracy: Option<u32>,
134    /// Optional resource cost of this move (MP / SP / mana — doc 13 §4). Each
135    /// entry names a resource from the ruleset's `resources:` list and the amount
136    /// to pay. The loader interns the names to resource ids; the engine's cost gate
137    /// (the `move_cost` provider hook) reads them before `BeforeMove`. Empty by
138    /// default ⇒ no cost ⇒ the gate is inert (the move always costs nothing).
139    #[serde(default)]
140    pub cost: Vec<ResourceCost>,
141    /// The event hooks.
142    #[serde(default)]
143    pub hooks: Vec<HookRecord>,
144}
145
146/// One `(resource, amount)` cost entry on a move (doc 13 §4). `resource` names a
147/// resource from the ruleset's `resources:` list; an unknown name is a
148/// [`LoadError::UnknownResource`] at LOAD.
149#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
150#[serde(rename = "Cost")]
151pub struct ResourceCost {
152    /// The resource name (must be in the ruleset's `resources:` list).
153    pub resource: String,
154    /// The amount of the resource to pay.
155    pub amount: u16,
156}
157
158/// One hook = one `(event, ordering, gate, op-list)` (doc 11 §1).
159#[derive(Debug, Clone, Deserialize)]
160#[serde(rename = "Hook")]
161pub struct HookRecord {
162    /// The event name; parsed to the closed [`Event`] at load.
163    pub on: String,
164    /// `on<Event>Order`; LOW first. Default `u32::MAX` (fires last).
165    #[serde(default = "default_order")]
166    pub order: u32,
167    /// `on<Event>Priority`; HIGH first. Default 0.
168    #[serde(default)]
169    pub priority: i32,
170    /// Optional RNG gate `[num, den]`: the op-list runs only if
171    /// `ctx.rng.chance(num, den)` (doc 11 §4.1). The draw is consumed
172    /// unconditionally so draw order is a pure function of the op-list.
173    #[serde(default)]
174    pub chance: Option<Rational>,
175    /// The closed primitive op-list.
176    #[serde(rename = "do", default)]
177    pub ops: Vec<Op>,
178}
179
180fn default_order() -> u32 {
181    u32::MAX
182}
183
184/// The five effect kinds (doc 11 §1). Each maps to an
185/// [`EffectType`](dotzuki_engine::battle::stack::EffectType) AND to which provider
186/// resolver hosts it ([`crate::ResolverKind`]).
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
188pub enum EffectKind {
189    /// A damaging/utility move → `effect_for_move`, `EffectType::Move`.
190    Move,
191    /// A non-volatile status → `effect_for_status`, `EffectType::Status`.
192    Status,
193    /// An ability → `effect_for_ability`, `EffectType::Condition`.
194    Ability,
195    /// A held item → `effect_for_item`, `EffectType::Condition`.
196    Item,
197    /// Weather/field → `field_effects`, `EffectType::Condition`.
198    Weather,
199}
200
201/// A target/host selector (doc 11 §1.1). Resolved against the hook's
202/// `target`/`source` [`BattlerRef`](dotzuki_engine::battle::BattlerRef)s.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
204pub enum Selector {
205    /// The dispatch target.
206    Target,
207    /// The foe of the target (the other side, same slot).
208    Foe,
209    /// The effect host (alias of `Target` for battler-hosted effects).
210    Host,
211    /// The dispatch source.
212    Source,
213}
214
215/// The denominator base for a fraction op (doc 11 §1.1).
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
217pub enum FractionOf {
218    /// Fraction of the selector's `max_hp`.
219    MaxHp,
220    /// Fraction of the selector's current `hp`.
221    CurHp,
222    /// Fraction of the **damage the in-flight move just dealt** (the move
223    /// execution's `ctx.mv.last_damage`; blueprint `15` §2 P2). This is the base
224    /// Gen-1 Drain (`HealFraction` of half the damage dealt) and Recoil
225    /// (`DamageFraction` of a quarter the damage dealt) read. **Floors at 1** like
226    /// the legacy `(dealt / d).max(1)` — a non-zero damage event always drains /
227    /// recoils at least 1 (a 0-damage event yields 0). Pure read of `ctx.mv`; no
228    /// entropy. Selecting `LastDamage` for a non-on-hit hook yields whatever
229    /// `last_damage` holds (0 before any hit), so author it only on `DamagingHit`.
230    LastDamage,
231}
232
233impl Default for FractionOf {
234    fn default() -> Self {
235        FractionOf::MaxHp
236    }
237}
238
239/// A stat reference: a name string, interned to a stat index by the loader and
240/// resolved to `P::Stat` by the game binding.
241pub type StatRef = String;
242
243/// The source of a [`Op::SetDamage`] value (blueprint `15` §2/§3 — the
244/// special/fixed damage moves that **bypass the type chart**: Seismic Toss /
245/// Night Shade = the user's level, Dragon Rage = 40, Sonic Boom = 20, Psywave =
246/// `rng·(num/den)·level`). Every variant is **pure** (no entropy except the
247/// explicit [`RngScaledLevel`](DamageValue::RngScaledLevel) which draws ONE
248/// `ctx.rng` byte at the op's ordinal). Game-agnostic: the only game reach is the
249/// per-battler level, supplied by
250/// [`RuleBindings::battler_level`](crate::RuleBindings::battler_level).
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
252pub enum DamageValue {
253    /// A fixed constant (Dragon Rage = 40, Sonic Boom = 20).
254    Const(u16),
255    /// The `source` selector's level (Seismic Toss / Night Shade).
256    UserLevel,
257    /// `(rng_byte * num / den * level)` then floored at 1 — the Psywave shape
258    /// (`rng · 1.5 · level / 256`, authored as `num=…, den=…`). Draws exactly ONE
259    /// `ctx.rng` byte (the SOLE entropy in this op), at the op's ordinal, so the
260    /// stream stays a pure function of the op-list.
261    RngScaledLevel {
262        /// Numerator (Psywave: the ×1.5 → `num=3`).
263        num: u32,
264        /// Denominator (Psywave: `den=2`, combined with the /256 byte scale).
265        den: u32,
266    },
267}
268
269/// How a status/volatile op computes its **numeric amount** — the sleep or
270/// confusion duration, the Toxic counter seed, etc. Every variant is
271/// **game-agnostic**: the engine resolves the number (drawing exactly ONE
272/// `ctx.rng` byte for the roll variants, at the op's ordinal — the sole
273/// entropy), then hands the resolved `u16` to the game's binding, which alone
274/// decides what it MEANS (sleep turns vs. confusion turns vs. a counter seed).
275/// The game never touches the RNG; the engine never learns the meaning.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
277pub enum AmountSpec {
278    /// A fixed constant (Rest = `2` sleep turns; a Toxic counter seed = `0`).
279    Const(u16),
280    /// `(rng_byte & mask) + plus` — one byte (Gen-1 confusion = `mask:3, plus:2`
281    /// ⇒ 2–5 turns; a `mask:7` sleep-style roll ⇒ 0–7).
282    RngMask {
283        /// Bit mask applied to the drawn byte.
284        mask: u8,
285        /// Constant added after masking.
286        plus: u8,
287    },
288    /// Uniformly random in `[lo, hi]`, drawn from the engine's byte stream by
289    /// REJECTION sampling (bytes in the skewed tail are re-drawn) so every value
290    /// in the span is equiprobable — the Gen-1 sleep counter (1–7) rejects a 0
291    /// roll rather than taking `byte % 7` (which oversamples low values).
292    RngRange {
293        /// Inclusive lower bound.
294        lo: u16,
295        /// Inclusive upper bound.
296        hi: u16,
297    },
298}
299
300impl Default for AmountSpec {
301    /// `Const(0)` — the inert default so an `InflictStatus` authored without an
302    /// `amount:` keeps its pre-existing (duration-less) behaviour.
303    fn default() -> Self {
304        AmountSpec::Const(0)
305    }
306}
307
308/// A closed predicate (doc 11 §1.1). Used by `unless`/`when`/`cond` guards.
309#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
310pub enum Predicate {
311    /// The selector has the named type (chart membership).
312    HasType(String),
313    /// The in-flight folded stat equals the named stat (the Sandstorm
314    /// `WeatherModifyStat` SpD case, doc 11 §1). Requires the driver to stash the
315    /// in-flight stat index in scratch via the binding; see [`crate::run_ops`].
316    StatIs(String),
317    /// The current relay-as-int is strictly less than `n` (the Clear-Body
318    /// `VetoIf(RelayIntLt(0))` pattern, doc 11 §1).
319    RelayIntLt(i64),
320    /// **The `target` selector has the named volatile live** (blueprint `15` §2 /
321    /// §3, the new predicate). The Gen-1 Substitute block on side-status:
322    /// `VetoIf(HasVolatile("Substitute"))` (status fails through a Substitute,
323    /// `status_effects.rs`). The volatile name is the game's vocabulary; the binding
324    /// resolves it against the live `ctx.effects` arena
325    /// ([`RuleBindings::has_volatile`](crate::RuleBindings::has_volatile)). Pure
326    /// read; no entropy. Game-agnostic: the rules crate names no concrete volatile.
327    HasVolatile(String),
328    /// **The in-flight move's type equals one of the `target` selector's types**
329    /// (blueprint `15` §2 / §3). The Gen-1 burn/freeze/paralyze self-type-immunity
330    /// quirk #23: `VetoIf(MoveTypeIsDefenderType)` — a Fire-type can't be burned by
331    /// a Fire move, etc. (`status_effects.rs:85/110/135`). The move type is
332    /// recovered from the compiled hook's `move_type_index` (the record's `type:`);
333    /// the binding answers membership
334    /// ([`RuleBindings::move_type_is_defender_type`](crate::RuleBindings::move_type_is_defender_type)).
335    /// Pure read; no entropy.
336    MoveTypeIsDefenderType,
337    /// **The `target` selector currently has the named non-volatile status**
338    /// (blueprint `15` §2, the Dream Eater sleep gate
339    /// `VetoIf(!TargetHasStatus("sleep"))`). The status name is the game's
340    /// vocabulary, resolved by the binding's `status_index_of` (compiled into the
341    /// status map) + [`RuleBindings::has_status`](crate::RuleBindings::has_status).
342    /// Pure read; no entropy.
343    TargetHasStatus(String),
344    /// **Logical negation** of a closed predicate — `VetoIf(Not(TargetHasStatus(
345    /// "sleep")))` fires when the target is NOT asleep (the Dream Eater gate:
346    /// drain only a sleeping target). Generic over any inner predicate; pure.
347    Not(Box<Predicate>),
348    /// **The `target` selector has ANY non-volatile status.** Lets a compound
349    /// status move (`VetoIf(TargetHasAnyStatus)` then `InflictStatus` +
350    /// `InflictVolatile`) apply atomically or not at all — the Gen-1 Toxic
351    /// "already-statused ⇒ nothing happens" rule. The engine knows no concrete
352    /// status; the binding answers via
353    /// [`RuleBindings::has_any_status`](crate::RuleBindings::has_any_status).
354    /// Pure read; no entropy.
355    TargetHasAnyStatus,
356    /// **The `source` selector's level is ≥ the `target` selector's level**
357    /// (blueprint `15` §2/§3, the OHKO gate). The Gen-1 one-hit-KO connects only
358    /// when the user's level is at least the foe's (bug #19: a foe of strictly
359    /// higher level is immune). The level is the game's per-battler quantity —
360    /// answered by [`RuleBindings::battler_level`](crate::RuleBindings::battler_level)
361    /// (the rules crate stays game-agnostic: it reads level only through the binding,
362    /// never off `BattlerState` directly), which **defaults to `0`** so a game that
363    /// never authors `LevelGE` is unaffected.
364    /// Pure read; no entropy. Game-agnostic: the rules crate names no
365    /// game-specific level concept — only "a number the binding supplies per battler".
366    LevelGE,
367    /// **The `source` selector's HP fraction is strictly below `num/den`** (the
368    /// wuxia 「血越低攻越高」 self-HP-threshold gate, affinity.md §四 吕醉仙/苏夜).
369    /// True iff `source_hp * den < source_max_hp * num` (so `< num/den`); `den`
370    /// clamps to ≥1. Reads the SOURCE (the acting battler) directly off `ctx`
371    /// (`hp`/`max_hp` are engine fields, no binding needed), so a `ModifyDamage`
372    /// hook can scale outgoing damage only when the actor's own HP is low. Pure
373    /// read; no entropy. Game-agnostic: a fraction of an engine HP field — no
374    /// game-specific/wuxia concept named here.
375    SelfHpBelow {
376        /// Numerator of the threshold fraction (e.g. `1` for `< 1/2`).
377        num: u32,
378        /// Denominator of the threshold fraction (clamped to ≥1).
379        den: u32,
380    },
381    /// **The `source` selector currently has the named non-volatile status** —
382    /// exactly [`TargetHasStatus`](Predicate::TargetHasStatus) but on the SOURCE
383    /// (the acting battler) rather than the dispatch target. Lets a `BeforeMove`
384    /// `VetoIf(SourceHasStatus("..."))` skip the HOLDER's OWN move (the wuxia
385    /// 眩晕/控制 gate, affinity.md §四). The status name is the game's vocabulary,
386    /// resolved by the same `status_index_of` map + the EXISTING
387    /// [`RuleBindings::has_status`](crate::RuleBindings::has_status) binding (no new
388    /// binding). Pure read; no entropy.
389    SourceHasStatus(String),
390}
391
392/// The closed primitive op vocabulary (doc 11 §1.1 + doc 12 §3.1). Each variant
393/// maps 1:1 to an existing `ctx`/`RelayVar` op. **This closed set is the entire
394/// expressiveness budget** (doc 11 §5).
395#[derive(Debug, Clone, PartialEq, Deserialize)]
396pub enum Op {
397    /// Writes `ctx.mv.damage` from the provider formula. The provider isn't in
398    /// `BattleCtx`, so (exactly like minimon's `move_damage_hook`) the number is
399    /// precomputed by the driver into `ctx.mv.damage` before the fold; this op is
400    /// the `ModifyDamage` subscription marker and resolves `Unchanged`.
401    DealMoveDamage,
402    /// `battler_mut(t).take_damage(of * num/den)`. `unless` skips when the
403    /// predicate holds (the Sandstorm "non-Rock chip" case).
404    DamageFraction {
405        /// Numerator.
406        num: u32,
407        /// Denominator (clamped to ≥1).
408        den: u32,
409        /// What the fraction is of.
410        #[serde(default)]
411        of: FractionOf,
412        /// The selector to damage.
413        target: Selector,
414        /// Skip when this predicate holds.
415        #[serde(default)]
416        unless: Option<Predicate>,
417    },
418    /// `battler_mut(t).heal(of * num/den)`.
419    HealFraction {
420        /// Numerator.
421        num: u32,
422        /// Denominator (clamped to ≥1).
423        den: u32,
424        /// What the fraction is of.
425        #[serde(default)]
426        of: FractionOf,
427        /// The selector to heal.
428        target: Selector,
429        /// Skip when this predicate holds.
430        #[serde(default)]
431        unless: Option<Predicate>,
432    },
433    /// Set the selector's non-volatile status (the on-hit secondary). For Phase 1
434    /// this directly sets `BattlerState.status` via the game binding; the nested
435    /// `TrySetStatus` veto cascade is driver orchestration (doc 11 §3, Phase 2).
436    InflictStatus {
437        /// Status name (resolved by the binding).
438        status: String,
439        /// The selector to afflict.
440        target: Selector,
441        /// Optional numeric parameter handed to the binding (e.g. sleep turns).
442        /// Defaults to `Const(0)`, so a plain `InflictStatus` is unchanged and
443        /// the binding's `set_status_with_amount` default ignores it.
444        #[serde(default)]
445        amount: AmountSpec,
446    },
447    /// **Install a game-defined live volatile on the selector** — the generic
448    /// counterpart to [`InflictStatus`] for effects that live in the effect
449    /// arena rather than the non-volatile status slot (Gen-1 confusion, Leech
450    /// Seed, Toxic-counter, flinch). The engine resolves `amount` (one
451    /// `ctx.rng` byte for the roll variants), then asks the game's binding to
452    /// build the OPAQUE `P::EffectStateKind` for `kind` + `amount`; if the
453    /// binding returns one, the engine installs it generically (fresh id, kept
454    /// sorted). The engine never learns what the volatile means — only the game
455    /// (via `make_volatile`) does. A game with no volatiles returns `None` and
456    /// the op is inert.
457    InflictVolatile {
458        /// The volatile's game-vocabulary name (e.g. `"confusion"`), passed to
459        /// the binding — NOT interned (like [`Predicate::HasVolatile`]).
460        kind: String,
461        /// The selector to afflict.
462        target: Selector,
463        /// The numeric parameter (turns / counter seed) handed to the binding.
464        #[serde(default)]
465        amount: AmountSpec,
466    },
467    /// Apply a stat-stage delta to the selector (the Intimidate request). For
468    /// Phase 1 this applies directly via the binding; the nested `TryBoost` veto
469    /// (Clear Body) is driver orchestration (doc 11 §3, Phase 2).
470    Boost {
471        /// Stat name (resolved by the binding).
472        stat: String,
473        /// Signed stage delta.
474        stages: i8,
475        /// The selector to boost.
476        target: Selector,
477    },
478    /// `Set(relay.scale(num, den))` (doc 11 §1.1; minimon Sandstorm). `when`
479    /// gates the scale on a predicate (else `Unchanged`).
480    ScaleRelay {
481        /// Numerator.
482        num: u32,
483        /// Denominator (clamped to ≥1 by `RelayVar::scale`).
484        den: u32,
485        /// Apply the scale only when ALL these predicates hold.
486        #[serde(default)]
487        when: Vec<Predicate>,
488    },
489    /// `Set(Int(v))` — overwrite the relay with a constant int.
490    SetRelay(i64),
491    /// `Set(Int(relay.as_int() + k))`.
492    AddRelay(i64),
493    /// `Set(Int(relay.as_int().clamp(lo, hi)))`.
494    ClampRelay {
495        /// Lower bound.
496        lo: i64,
497        /// Upper bound.
498        hi: i64,
499    },
500    /// `Fail` when the predicate holds (the Clear-Body veto). `silent` ⇒
501    /// `FailSilent` (no "but it failed!" message).
502    VetoIf {
503        /// The veto condition.
504        cond: Predicate,
505        /// Suppress the failure message.
506        #[serde(default)]
507        silent: bool,
508    },
509    /// `Set(relay.scale(num, den))` from the type chart (doc 12 §3.1). Folds the
510    /// dual-type PRODUCT into ONE rational, then a single `scale` (doc 12 §5.3).
511    /// The in-flight move's type is recovered from `source_effect`.
512    ApplyTypeChart,
513    /// **Pay a resource cost (MP / SP / mana — doc 13 §4).** If the selector cannot
514    /// pay `amount` of the named resource, the op `Fail`s (the move is prevented via
515    /// the existing veto path, exactly like `VetoIf`); otherwise it deducts the
516    /// amount and passes the relay through. This expresses the cost gate **in data**
517    /// for a hook on `BeforeMove`. The deduction is pure arithmetic — no rng. The
518    /// resource name is interned to an id at LOAD (unknown ⇒ [`LoadError`]).
519    PayResource {
520        /// The resource name (must be in the ruleset's `resources:` list).
521        resource: String,
522        /// The amount to pay.
523        amount: u16,
524        /// Who pays (typically `Source` — the acting battler).
525        target: Selector,
526    },
527    /// **Set the selector's HP to a constant** (blueprint `15` §2/§3, the new op).
528    /// OHKO writes `SetHp(Foe, 0)`; Explode writes `SetHp(Source, 0)`. The
529    /// `when` guard (ALL predicates hold) gates the write — OHKO authors
530    /// `when: [LevelGE]` so the KO lands only when the user's level ≥ the foe's
531    /// (bug #19); an empty `when` always applies. Pure write of `ctx.battler.hp`;
532    /// no entropy. (Unlike `DamageFraction`, this does NOT route through
533    /// `take_damage` — it is an absolute set, the faithful Gen-1 OHKO/Explode.)
534    SetHp {
535        /// The selector whose HP to set.
536        target: Selector,
537        /// The HP value to set (typically `0`).
538        value: u16,
539        /// Apply only when ALL these predicates hold (empty ⇒ always).
540        #[serde(default)]
541        when: Vec<Predicate>,
542    },
543    /// **Set `ctx.mv.damage` from a [`DamageValue`] source, bypassing the type
544    /// chart** (blueprint `15` §2/§3, the new op). The special/fixed damage moves
545    /// (Seismic Toss = user level, Dragon Rage = 40, Sonic Boom = 20, Psywave =
546    /// `rng·num/den·level`) write the move's damage directly so it rides the same
547    /// driver apply path as `DealMoveDamage` (the driver applies `ctx.mv.damage`
548    /// to the target after the `ModifyDamage`/`Effectiveness` fold). Authored on
549    /// `ModifyDamage` at a HIGH order so it overwrites the formula number. Pure
550    /// except [`DamageValue::RngScaledLevel`], which draws ONE `ctx.rng` byte.
551    SetDamage {
552        /// The damage value source.
553        value: DamageValue,
554        /// The selector whose level the level-based variants read (the user —
555        /// typically `Source`).
556        #[serde(default = "default_source_selector")]
557        of: Selector,
558    },
559    /// **Damage the selector by a fraction of its CURRENT HP** (blueprint `15`
560    /// §2/§3, the new op). Super Fang = `curHP/2` (floored at 1). Differs from
561    /// `DamageFraction { of: CurHp }` in that it ALSO writes `ctx.mv.damage` (so a
562    /// Substitute redirect / Counter read sees the real number) and floors a
563    /// non-zero result at 1 like the legacy `(curHP/2).max(1)`. Pure read +
564    /// `take_damage`; no entropy.
565    DamageCurrentHpFraction {
566        /// Numerator (Super Fang: `num=1`).
567        num: u32,
568        /// Denominator (Super Fang: `den=2`, clamped to ≥1).
569        den: u32,
570        /// The selector to damage.
571        target: Selector,
572    },
573    /// **Re-apply the in-flight move's damage N times — the Gen-1 multi-hit loop,
574    /// driven GAME-SIDE with NO engine change** (blueprint `15` §2/§3 "RepeatHits",
575    /// P4). Authored on `DamagingHit`, which the StackDriver fires AFTER the FIRST
576    /// hit's `take_damage` (driver.rs `resolve_action`). So `ctx.mv.damage` is the
577    /// per-hit number the driver already applied ONCE; this op re-applies the SAME
578    /// number to `target` `(N-1)` MORE times — the faithful Gen-1 "compute damage
579    /// once, deal it N times" (no per-hit recompute). N comes from [`count`]:
580    ///   * `Fixed(k)` ⇒ exactly k hits, NO byte (Double Kick / Bonemerang / the
581    ///     Twineedle double-hit);
582    ///   * `TwoToFive` ⇒ ONE byte folded by the legacy `determine_hit_count`
583    ///     distribution (the `multi_hit_roll`).
584    /// [`final_hit`] runs Twineedle's final-hit-only secondary (one `chance` byte +
585    /// guarded `InflictStatus`) AFTER the last hit, at the legacy `side_effect`
586    /// ordinal. This op needs NO engine seam: it loops `take_damage` on the existing
587    /// `BattleCtx`, drawing only `ctx.rng` (one count byte + the optional final-hit
588    /// chance byte) at its ordinal — so a `ScriptedRng` replays it identically and
589    /// `consumed()` is a pure function of the op-list.
590    RepeatHits {
591        /// Where N comes from (fixed, or the 2-5 distribution draw).
592        count: HitCount,
593        /// The selector to deal the repeated hits to (the defender — `Target` on a
594        /// `DamagingHit` hook).
595        target: Selector,
596        /// The final-hit secondary (Twineedle poison); `None` for a plain multi-hit.
597        #[serde(default)]
598        final_hit: FinalHitRider,
599    },
600    /// **Clear the selector's non-volatile status** (the wuxia cleanse / 驱散
601    /// 静心咒·天玑回春 op, affinity.md §六). Resolves the selector, then writes
602    /// `ctx.battler_mut(who).status = None` — a generic engine-field write, exactly
603    /// like [`SetHp`](Op::SetHp) writes `.hp = value`; no binding, no entropy. The
604    /// inverse of [`InflictStatus`](Op::InflictStatus): where that sets a status,
605    /// this removes whatever non-volatile status is held. Resolves `Unchanged` (the
606    /// relay threads through). A game that never authors `RemoveStatus` is unaffected.
607    RemoveStatus {
608        /// The selector whose non-volatile status to clear.
609        target: Selector,
610    },
611}
612
613fn default_source_selector() -> Selector {
614    Selector::Source
615}
616
617/// The number of times a [`Op::RepeatHits`] re-applies the in-flight move's damage
618/// (blueprint `15` §2/§3, the new game-side multi-hit construct). Gen-1 multi-hit
619/// checks accuracy ONCE then deals the SAME computed damage N times; this enum is
620/// the **source of N**. Every variant is game-agnostic — the rules crate names no
621/// game-specific concept, only "a count, optionally drawn from one byte".
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
623pub enum HitCount {
624    /// A fixed number of hits (Double Kick / Bonemerang / Twineedle = `Fixed(2)`).
625    /// Draws NO rng.
626    Fixed(u8),
627    /// The Gen-1 two-to-five distribution (`TwoToFiveAttacksEffect`): draws ONE
628    /// byte and folds it `3/8·3/8·1/8·1/8` over `{2,3,4,5}` — bit-identical to the
629    /// legacy `determine_hit_count` (`roll<96⇒2, <192⇒3, <224⇒4, else⇒5`). The byte
630    /// is the legacy `multi_hit_roll`; it is the SOLE entropy of this variant, drawn
631    /// at the op's ordinal so the stream stays a pure function of the op-list.
632    TwoToFive,
633}
634
635/// What [`Op::RepeatHits`] does AFTER the final hit lands (Twineedle's
636/// final-hit-only poison, blueprint `15` §2). `None` ⇒ a plain multi-hit (Double
637/// Kick / Fury Attack). `InflictOnFinal` ⇒ on the LAST hit only, draw ONE
638/// `chance` byte and (if it passes the gate) apply the named status to the target —
639/// the Twineedle 20%+1 (52/256) poison at the legacy `side_effect` ordinal. The
640/// guards (poison-type immunity, Substitute block) are authored as the SAME
641/// `VetoIf` ops a side-status move uses, evaluated game-side by the interpreter.
642#[derive(Debug, Clone, PartialEq, Deserialize)]
643pub enum FinalHitRider {
644    /// No final-hit secondary.
645    None,
646    /// On the final hit only: draw the `chance` byte, then (if it passes) run the
647    /// rider ops (`VetoIf` guards + `InflictStatus`) exactly like a side-status
648    /// hook. `chance` is `[num, den]`; the byte is drawn UNCONDITIONALLY of whether
649    /// the secondary fires (the `consumed()` invariant).
650    OnFinal {
651        /// The `[num, den]` chance gate (Twineedle = `[52, 256]`).
652        chance: Rational,
653        /// The rider op-list (guards + `InflictStatus`), run on the final hit if the
654        /// gate passes.
655        ops: Vec<Op>,
656    },
657}
658
659impl Default for FinalHitRider {
660    fn default() -> Self {
661        FinalHitRider::None
662    }
663}
664
665/// Parse a hook's `on:` string to the closed [`Event`] enum (doc 11 §3). An
666/// unknown name is a **load** error. `Custom(N)` is the open tail.
667pub fn parse_event(name: &str) -> Result<Event, LoadError> {
668    let ev = match name {
669        // Group A
670        "BeforeTurn" => Event::BeforeTurn,
671        "ResidualOrder" => Event::ResidualOrder,
672        "AfterTurn" => Event::AfterTurn,
673        // Group B
674        "BeforeMove" => Event::BeforeMove,
675        "ModifyMove" => Event::ModifyMove,
676        "ModifyType" => Event::ModifyType,
677        "ModifyCritRatio" => Event::ModifyCritRatio,
678        "Accuracy" => Event::Accuracy,
679        "Invulnerability" => Event::Invulnerability,
680        "ModifyDamage" => Event::ModifyDamage,
681        "Effectiveness" => Event::Effectiveness,
682        "AfterMove" => Event::AfterMove,
683        // Group C
684        "TryHit" => Event::TryHit,
685        "Damage" => Event::Damage,
686        "DamagingHit" => Event::DamagingHit,
687        "Heal" => Event::Heal,
688        "AfterFaint" => Event::AfterFaint,
689        // Group D
690        "TrySetStatus" => Event::TrySetStatus,
691        "AfterSetStatus" => Event::AfterSetStatus,
692        "TryBoost" => Event::TryBoost,
693        "AfterBoost" => Event::AfterBoost,
694        "ModifyStat" => Event::ModifyStat,
695        "WeatherModifyStat" => Event::WeatherModifyStat,
696        // Group E
697        "Start" => Event::Start,
698        "End" => Event::End,
699        "Faint" => Event::Faint,
700        "SwitchIn" => Event::SwitchIn,
701        "SwitchOut" => Event::SwitchOut,
702        // Group F
703        "SetWeather" => Event::SetWeather,
704        "FieldResidual" => Event::FieldResidual,
705        "SideResidual" => Event::SideResidual,
706        // Legacy
707        "Residual" => Event::Residual,
708        // The open tail: `Custom(N)`.
709        other => {
710            if let Some(rest) = other
711                .strip_prefix("Custom(")
712                .and_then(|s| s.strip_suffix(')'))
713            {
714                rest.trim()
715                    .parse::<u16>()
716                    .map(Event::Custom)
717                    .map_err(|_| LoadError::UnknownEvent(name.to_string()))?
718            } else {
719                return Err(LoadError::UnknownEvent(name.to_string()));
720            }
721        }
722    };
723    Ok(ev)
724}
725
726/// Parse an [`EffectKind`] to the engine [`EffectType`] (doc 11 §1). The
727/// resolver-host mapping is in [`crate::ResolverKind::from_kind`].
728pub fn parse_kind(kind: EffectKind) -> dotzuki_engine::battle::stack::EffectType {
729    use dotzuki_engine::battle::stack::EffectType;
730    match kind {
731        EffectKind::Move => EffectType::Move,
732        EffectKind::Status => EffectType::Status,
733        // Abilities/items/weather are conditions in the comparator's sub_order.
734        EffectKind::Ability | EffectKind::Item | EffectKind::Weather => EffectType::Condition,
735    }
736}
737
738impl Ruleset {
739    /// Parse a `rules.ron` text into a [`Ruleset`]. Pure deserialization; the
740    /// closed-vocabulary binding (events, types, stats) happens in
741    /// [`crate::CompiledRuleset::compile`].
742    ///
743    /// The `implicit_some` RON extension is enabled so authors write the
744    /// ergonomic `chance:[30,100]` / `unless:HasType("Rock")` (doc 11 §1) rather
745    /// than the verbose `Some(...)`. This affects parsing only; it adds no
746    /// entropy and no nondeterminism.
747    pub fn from_ron(text: &str) -> Result<Self, LoadError> {
748        let opts = ron::Options::default()
749            .with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
750        opts.from_str(text)
751            .map_err(|e| LoadError::Ron(e.to_string()))
752    }
753
754    /// Intern a type name to its chart index, or `None` if not in `types:`.
755    pub fn type_index(&self, name: &str) -> Option<usize> {
756        self.types.iter().position(|t| t == name)
757    }
758
759    /// Intern a stat name to its index, or `None` if not in `stats:`.
760    pub fn stat_index(&self, name: &str) -> Option<usize> {
761        self.stats.iter().position(|s| s == name)
762    }
763
764    /// Intern a resource name to its index, or `None` if not in `resources:`
765    /// (the MP/SP/mana cost gate, doc 13 §4).
766    pub fn resource_index(&self, name: &str) -> Option<usize> {
767        self.resources.iter().position(|r| r == name)
768    }
769}