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}