Skip to main content

dotzuki_rules/
registry.rs

1//! The side registry (doc 11 §2): compile a [`Ruleset`] into runtime
2//! [`Effect`](dotzuki_engine::battle::stack::Effect)s + a map from the synthesized
3//! [`EffectId`] to the compiled op-list the [`interpret`](crate::interpret)
4//! bridge reads.
5//!
6//! ## Option A — zero engine change (doc 11 §2.2)
7//!
8//! The loader mints one distinct [`EffectId`] per `(effect, event)` hook and
9//! registers each as its own tiny runtime `Effect` **through the existing
10//! defaulted resolvers** (`effect_for_move` / `_status` / `_ability` / `_item` /
11//! `field_effects`). Each such `Effect` has hooks whose `call` is `interpret::<P>`
12//! and whose **owning effect id** keys the op-list. The engine never learns "data
13//! exists"; it sees an ordinary `Effect` with a `fn`-pointer hook and threads its
14//! `source_effect` to the handler (`dispatch.rs:128`) — which is exactly the key
15//! the interpreter reads. **No engine edit, no new engine-trait method.**
16//!
17//! The game reaches the compiled registry through the **game-side**
18//! [`RulesProvider`] trait (which *extends* `EffectProvider`); the engine is
19//! untouched.
20
21use std::collections::HashMap;
22
23use dotzuki_engine::battle::stack::{Effect, EffectId, EffectProvider, EffectType, Event, EventHook};
24
25use crate::bindings::RuleBindings;
26use crate::interp::interpret;
27use crate::model::{EffectKind, LoadError, Op, Ruleset};
28
29/// Which defaulted resolver hosts a compiled effect (doc 11 §1, §2.2 Option A).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ResolverKind {
32    /// `effect_for_move`.
33    Move,
34    /// `effect_for_status`.
35    Status,
36    /// `effect_for_ability`.
37    Ability,
38    /// `effect_for_item`.
39    Item,
40    /// `field_effects`.
41    Weather,
42}
43
44impl ResolverKind {
45    /// The resolver host for an [`EffectKind`].
46    pub fn from_kind(kind: EffectKind) -> Self {
47        match kind {
48            EffectKind::Move => ResolverKind::Move,
49            EffectKind::Status => ResolverKind::Status,
50            EffectKind::Ability => ResolverKind::Ability,
51            EffectKind::Item => ResolverKind::Item,
52            EffectKind::Weather => ResolverKind::Weather,
53        }
54    }
55}
56
57/// One compiled hook program (doc 11 §2): the closed op-list + its ordering +
58/// the optional RNG gate. The driver / interpreter reads this by `EffectId`.
59#[derive(Debug, Clone)]
60pub struct CompiledHook {
61    /// The synthesized id keying this hook (the engine threads it as
62    /// `source_effect`).
63    pub id: EffectId,
64    /// Which closed event this hook fires on.
65    pub event: Event,
66    /// `on<Event>Order`; LOW first.
67    pub order: u32,
68    /// `on<Event>Priority`; HIGH first.
69    pub priority: i32,
70    /// Optional `(num, den)` RNG gate — drawn unconditionally so draw order is a
71    /// pure function of the op-list (doc 11 §4.1).
72    pub chance: Option<(u32, u32)>,
73    /// The interned, bound op-list (chart/stat/status names already validated to
74    /// indices at compile).
75    pub ops: Vec<Op>,
76    /// The in-flight move type chart index for this effect (for `ApplyTypeChart`,
77    /// recovered from `source_effect`), if the record carried a `type:`.
78    pub move_type_index: Option<usize>,
79    /// The engine effect category (sub_order feed).
80    pub effect_type: EffectType,
81    /// The original effect id string (for tracing/diagnostics).
82    pub source_id: String,
83    /// Which resolver hosts the owning effect.
84    pub resolver: ResolverKind,
85}
86
87/// A compiled ruleset: the synthesized-id → [`CompiledHook`] map plus the
88/// interned `types`/`stats` vocabularies (doc 11 §2 side registry). Built once at
89/// load, addressed by `EffectId`, so a hot-reload swaps the map between turns
90/// without invalidating in-flight engine `EffectState` (doc 11 §4.2).
91#[derive(Debug, Clone)]
92pub struct CompiledRuleset {
93    /// `EffectId` → compiled hook (the interpreter's lookup; doc 11 §2).
94    pub hooks: HashMap<EffectId, CompiledHook>,
95    /// Interned type names (index = chart index).
96    pub types: Vec<String>,
97    /// Interned stat names (index = stat index).
98    pub stats: Vec<String>,
99    /// Interned resource names (index = resource index; the MP/SP/mana cost gate,
100    /// doc 13 §4). The game binding maps each ↔ the engine's opaque resource id.
101    pub resources: Vec<String>,
102    /// Per-move resource cost: the move record's `source_id` → the list of
103    /// `(resource_index, amount)` it costs (doc 13 §4). The game reads this to
104    /// implement [`BattleProvider::move_cost`](dotzuki_engine::battle::BattleProvider::move_cost)
105    /// so the engine's cost gate fires. Empty for a move with no `cost:`.
106    pub move_costs: HashMap<String, Vec<(usize, u16)>>,
107    /// Status-name → the GAME's status index (resolved at compile via the
108    /// game-supplied `status_index_of`). The RON `stats:` list does NOT carry
109    /// statuses; this map is the closed status vocabulary the interpreter reads.
110    pub statuses: HashMap<String, usize>,
111    /// The interned type chart (doc 12 §2): `(atk_type_index, def_type_index)` →
112    /// `(num, den)`, keyed by the ruleset's `types:` intern indices. Omitted pairs
113    /// default to `(1, 1)` at lookup via [`chart_mult`](CompiledRuleset::chart_mult).
114    /// The DATA layer OWNS the chart here — a hot-reload of the RON `type_chart`
115    /// rebuilds this map and the binding reads it, so the relation is genuinely
116    /// data-driven (not a hardcoded native const). Built once at compile; an
117    /// unknown type name in an edge is a [`LoadError`](crate::LoadError::UnknownType).
118    pub type_chart: HashMap<(usize, usize), (u32, u32)>,
119}
120
121impl CompiledRuleset {
122    /// The game status index for a status name (the interpreter's `InflictStatus`
123    /// lookup), validated at compile.
124    pub fn status_index(&self, name: &str) -> Option<usize> {
125        self.statuses.get(name).copied()
126    }
127
128    /// The interned chart multiplier `(num, den)` for an attacker-type index vs a
129    /// defender-type index (the indices are the ruleset's `types:` positions).
130    /// Omitted pairs default to `(1, 1)` (neutral) — doc 12 §2. Pure; no RNG. The
131    /// game binding folds the product over a defender's type(s) and applies ONE
132    /// `scale` (doc 12 §5.3), so the data path owns the relation end to end.
133    pub fn chart_mult(&self, atk_type_index: usize, def_type_index: usize) -> (u32, u32) {
134        self.type_chart
135            .get(&(atk_type_index, def_type_index))
136            .copied()
137            .unwrap_or((1, 1))
138    }
139}
140
141impl CompiledRuleset {
142    /// Compile a [`Ruleset`], minting one [`EffectId`] per hook starting at
143    /// `id_base` (doc 11 §2.2 Option A). **All closed-vocabulary binding happens
144    /// here**: every `on:` event, every `HasType`/`StatIs`/chart type name, every
145    /// `chance` fraction is validated NOW — an unknown name/op is a [`LoadError`]
146    /// at LOAD, never at battle time (doc 11 §4.2).
147    ///
148    /// `bindings` resolves status/stat names to indices (defense-in-depth: the
149    /// loader also pre-validates names against the ruleset's `stats:` list, but
150    /// status names are the game's vocabulary, so the binding is the authority).
151    pub fn compile<P, B>(
152        ruleset: &Ruleset,
153        id_base: u32,
154        bindings: &B,
155        status_index_of: impl Fn(&str) -> Option<usize>,
156    ) -> Result<Self, LoadError>
157    where
158        P: EffectProvider,
159        B: RuleBindings<P>,
160    {
161        let _ = bindings; // bindings authority is exercised via status_index_of/stat list
162        let mut hooks = HashMap::new();
163        let mut statuses: HashMap<String, usize> = HashMap::new();
164        let mut move_costs: HashMap<String, Vec<(usize, u16)>> = HashMap::new();
165        let mut next_id = id_base;
166
167        // Intern the type chart NOW (doc 12 §2): every edge's atk/def names must
168        // be in `types:` — an unknown name is a LOAD error, never a battle-time
169        // surprise. The DATA layer owns the chart; a hot-reload rebuilds this map.
170        let mut type_chart: HashMap<(usize, usize), (u32, u32)> = HashMap::new();
171        for edge in &ruleset.type_chart {
172            let a = ruleset
173                .type_index(&edge.atk)
174                .ok_or_else(|| LoadError::UnknownType(edge.atk.clone()))?;
175            let d = ruleset
176                .type_index(&edge.def)
177                .ok_or_else(|| LoadError::UnknownType(edge.def.clone()))?;
178            type_chart.insert((a, d), (edge.mult.num, edge.mult.den));
179        }
180
181        for rec in &ruleset.effects {
182            let effect_type = crate::model::parse_kind(rec.kind);
183            let resolver = ResolverKind::from_kind(rec.kind);
184            let move_type_index = match &rec.mtype {
185                Some(name) => Some(
186                    ruleset
187                        .type_index(name)
188                        .ok_or_else(|| LoadError::UnknownType(name.clone()))?,
189                ),
190                None => None,
191            };
192
193            // Intern the move's resource cost NOW (doc 13 §4): each `cost:` entry's
194            // resource name must be in `resources:` — an unknown name is a LOAD
195            // error, never a battle-time surprise. Stored by the record's id so the
196            // game's `move_cost` hook can read it.
197            if !rec.cost.is_empty() {
198                let mut costs = Vec::with_capacity(rec.cost.len());
199                for c in &rec.cost {
200                    let idx = ruleset
201                        .resource_index(&c.resource)
202                        .ok_or_else(|| LoadError::UnknownResource(c.resource.clone()))?;
203                    costs.push((idx, c.amount));
204                }
205                move_costs.insert(rec.id.clone(), costs);
206            }
207
208            for hook in &rec.hooks {
209                // Parse the event to the closed enum NOW (unknown ⇒ load error).
210                let event = crate::model::parse_event(&hook.on)?;
211
212                // Validate the chance fraction NOW.
213                let chance = match hook.chance {
214                    Some(r) => {
215                        if r.den == 0 {
216                            return Err(LoadError::BadChance(r.num, r.den));
217                        }
218                        Some((r.num, r.den))
219                    }
220                    None => None,
221                };
222
223                // Validate every name referenced by an op NOW, and intern any
224                // status names into the closed status vocabulary — both
225                // `InflictStatus` ops AND `TargetHasStatus` predicates (the
226                // interpreter resolves both through the same `statuses` map).
227                for op in &hook.ops {
228                    validate_op::<P, B>(op, ruleset, &status_index_of)?;
229                    if let Op::InflictStatus { status, .. } = op {
230                        let idx = status_index_of(status)
231                            .ok_or_else(|| LoadError::UnknownStatus(status.clone()))?;
232                        statuses.insert(status.clone(), idx);
233                    }
234                    for pred in op_predicates(op) {
235                        // Both `TargetHasStatus` and `SourceHasStatus` name the game's
236                        // status vocabulary; intern each into the closed status map so
237                        // the interpreter's `status_index` lookup resolves at runtime.
238                        let status_name = match pred {
239                            crate::model::Predicate::TargetHasStatus(s)
240                            | crate::model::Predicate::SourceHasStatus(s) => Some(s),
241                            _ => None,
242                        };
243                        if let Some(s) = status_name {
244                            let idx = status_index_of(s)
245                                .ok_or_else(|| LoadError::UnknownStatus(s.clone()))?;
246                            statuses.insert(s.clone(), idx);
247                        }
248                    }
249                }
250
251                let id = EffectId(next_id);
252                next_id += 1;
253                hooks.insert(
254                    id,
255                    CompiledHook {
256                        id,
257                        event,
258                        order: hook.order,
259                        priority: hook.priority,
260                        chance,
261                        ops: hook.ops.clone(),
262                        move_type_index,
263                        effect_type,
264                        source_id: rec.id.clone(),
265                        resolver,
266                    },
267                );
268            }
269        }
270
271        Ok(CompiledRuleset {
272            hooks,
273            types: ruleset.types.clone(),
274            stats: ruleset.stats.clone(),
275            resources: ruleset.resources.clone(),
276            move_costs,
277            statuses,
278            type_chart,
279        })
280    }
281
282    /// The compiled hook for an `EffectId` (the interpreter's lookup).
283    pub fn hook(&self, id: EffectId) -> Option<&CompiledHook> {
284        self.hooks.get(&id)
285    }
286
287    /// The resource cost of the move with record id `source_id`, as interned
288    /// `(resource_index, amount)` pairs (doc 13 §4). Empty for a move with no
289    /// `cost:`. The game maps each `resource_index` to its engine resource id (via
290    /// the binding) to build [`BattleProvider::move_cost`](dotzuki_engine::battle::BattleProvider::move_cost).
291    pub fn move_cost(&self, source_id: &str) -> &[(usize, u16)] {
292        self.move_costs
293            .get(source_id)
294            .map(|v| v.as_slice())
295            .unwrap_or(&[])
296    }
297
298    /// Build the `&'static`-shaped per-hook [`Effect`] registry that the game's
299    /// defaulted resolvers hand back to the engine. Because the engine requires
300    /// `&'static [EventHook]`, the caller leaks these once at load (a deliberate
301    /// one-time leak — the registry lives for the whole battle; doc 11 §4.2 notes
302    /// a reload swaps the map, not in-flight state). Each `Effect` has ONE hook
303    /// whose `call` is `interpret::<P>` and whose id keys the op-list.
304    pub fn build_effects<P>(&self) -> Vec<&'static Effect<P>>
305    where
306        P: RulesProvider,
307    {
308        let mut out = Vec::with_capacity(self.hooks.len());
309        for h in self.hooks.values() {
310            let hook: EventHook<P> = EventHook {
311                event: h.event,
312                call: interpret::<P>,
313                order: h.order,
314                priority: h.priority,
315                sub_order: None,
316            };
317            let leaked_hooks: &'static [EventHook<P>] = Box::leak(vec![hook].into_boxed_slice());
318            let eff: &'static Effect<P> = Box::leak(Box::new(Effect {
319                id: h.id,
320                kind: h.effect_type,
321                hooks: leaked_hooks,
322            }));
323            out.push(eff);
324        }
325        out
326    }
327}
328
329/// Collect every [`Predicate`](crate::model::Predicate) an op carries (its
330/// `unless`/`when`/`cond` guards), so the compiler can intern any status names a
331/// `TargetHasStatus` predicate references. Pure; allocation-light.
332fn op_predicates(op: &Op) -> Vec<&crate::model::Predicate> {
333    match op {
334        Op::DamageFraction { unless, .. } | Op::HealFraction { unless, .. } => {
335            unless.iter().collect()
336        }
337        Op::ScaleRelay { when, .. } | Op::SetHp { when, .. } => when.iter().collect(),
338        Op::VetoIf { cond, .. } => vec![cond],
339        // RepeatHits' final-hit rider carries its own ops (VetoIf guards +
340        // InflictStatus); recurse so a `TargetHasStatus`/`HasType` guard's name is
341        // interned and an `InflictStatus` status is validated (done in validate_op).
342        Op::RepeatHits {
343            final_hit: crate::model::FinalHitRider::OnFinal { ops, .. },
344            ..
345        } => ops.iter().flat_map(op_predicates).collect(),
346        _ => Vec::new(),
347    }
348}
349
350/// Validate every name an op references against the closed vocabulary, NOW (load
351/// time). Returns the first [`LoadError`].
352fn validate_op<P, B>(
353    op: &Op,
354    ruleset: &Ruleset,
355    status_index_of: &impl Fn(&str) -> Option<usize>,
356) -> Result<(), LoadError>
357where
358    P: EffectProvider,
359    B: RuleBindings<P>,
360{
361    use crate::model::Predicate;
362    let check_type = |name: &str| -> Result<(), LoadError> {
363        ruleset
364            .type_index(name)
365            .map(|_| ())
366            .ok_or_else(|| LoadError::UnknownType(name.to_string()))
367    };
368    let check_stat = |name: &str| -> Result<(), LoadError> {
369        ruleset
370            .stat_index(name)
371            .map(|_| ())
372            .ok_or_else(|| LoadError::UnknownStat(name.to_string()))
373    };
374    let check_resource = |name: &str| -> Result<(), LoadError> {
375        ruleset
376            .resource_index(name)
377            .map(|_| ())
378            .ok_or_else(|| LoadError::UnknownResource(name.to_string()))
379    };
380    let check_status = |name: &str| -> Result<(), LoadError> {
381        status_index_of(name)
382            .map(|_| ())
383            .ok_or_else(|| LoadError::UnknownStatus(name.to_string()))
384    };
385    let check_pred = |p: &Predicate| -> Result<(), LoadError> {
386        match p {
387            Predicate::HasType(t) => check_type(t),
388            Predicate::StatIs(s) => check_stat(s),
389            Predicate::RelayIntLt(_) => Ok(()),
390            // `HasVolatile`'s name is the game's volatile vocabulary (resolved by
391            // the binding's `has_volatile` against the live arena), not the closed
392            // `types`/`stats`/`resources` lists — so it is NOT load-validated here
393            // (the binding owns it). `MoveTypeIsDefenderType` references no name.
394            Predicate::HasVolatile(_) | Predicate::MoveTypeIsDefenderType => Ok(()),
395            // `TargetHasStatus`'s name IS the game's status vocabulary — validate it
396            // NOW against `status_index_of`, exactly like `InflictStatus`.
397            Predicate::TargetHasStatus(s) => check_status(s),
398            // `LevelGE` references no name (the binding supplies both levels).
399            Predicate::LevelGE => Ok(()),
400            // `SelfHpBelow` references no name (it reads engine HP fields).
401            Predicate::SelfHpBelow { .. } => Ok(()),
402            // `SourceHasStatus`'s name IS the game's status vocabulary — validate it
403            // NOW, exactly like `TargetHasStatus`.
404            Predicate::SourceHasStatus(s) => check_status(s),
405            // `Not` validates its inner's name (one level; nested Not isn't
406            // authored). Name-free / binding-resolved inners need no validation.
407            Predicate::Not(inner) => match inner.as_ref() {
408                Predicate::HasType(t) => check_type(t),
409                Predicate::StatIs(s) => check_stat(s),
410                Predicate::TargetHasStatus(s) | Predicate::SourceHasStatus(s) => check_status(s),
411                _ => Ok(()),
412            },
413            // `TargetHasAnyStatus` references no name (the binding answers it).
414            Predicate::TargetHasAnyStatus => Ok(()),
415        }
416    };
417    match op {
418        Op::DealMoveDamage | Op::ApplyTypeChart | Op::SetRelay(_) | Op::AddRelay(_) => Ok(()),
419        Op::ClampRelay { .. } => Ok(()),
420        Op::DamageFraction { unless, .. } | Op::HealFraction { unless, .. } => {
421            if let Some(p) = unless {
422                check_pred(p)?;
423            }
424            Ok(())
425        }
426        Op::InflictStatus { status, .. } => status_index_of(status)
427            .map(|_| ())
428            .ok_or_else(|| LoadError::UnknownStatus(status.clone())),
429        // The volatile `kind` is the game's RUNTIME vocabulary (resolved by the
430        // binding's `make_volatile`, exactly like `HasVolatile` is resolved by
431        // `has_volatile`) — not an interned name, so nothing to validate at load.
432        Op::InflictVolatile { .. } => Ok(()),
433        Op::Boost { stat, .. } => check_stat(stat),
434        Op::ScaleRelay { when, .. } => {
435            for p in when {
436                check_pred(p)?;
437            }
438            Ok(())
439        }
440        Op::VetoIf { cond, .. } => check_pred(cond),
441        Op::PayResource { resource, .. } => check_resource(resource),
442        // SetHp's `when` guards may carry predicates (OHKO's `LevelGE`).
443        Op::SetHp { when, .. } => {
444            for p in when {
445                check_pred(p)?;
446            }
447            Ok(())
448        }
449        // SetDamage / DamageCurrentHpFraction / RemoveStatus reference no
450        // closed-vocabulary name (the level reach is the binding's `battler_level`,
451        // validated by type; RemoveStatus clears whatever status is held by name).
452        Op::SetDamage { .. } | Op::DamageCurrentHpFraction { .. } | Op::RemoveStatus { .. } => {
453            Ok(())
454        }
455        // RepeatHits: the count source is a plain number (no name). Validate the
456        // final-hit rider's nested ops (its InflictStatus status + guard names) NOW,
457        // recursively, so an unknown name in Twineedle's poison rider is a LOAD error.
458        Op::RepeatHits { final_hit, .. } => {
459            if let crate::model::FinalHitRider::OnFinal { ops, .. } = final_hit {
460                for op in ops {
461                    validate_op::<P, B>(op, ruleset, status_index_of)?;
462                }
463            }
464            Ok(())
465        }
466    }
467}
468
469/// The **game-side** bridge trait (doc 11 §2.2 Option A) — extends
470/// [`EffectProvider`] with the two things the zero-capture
471/// [`interpret`](crate::interpret) `fn` needs but cannot capture: the compiled
472/// op-list registry and the game binding. **This adds NO method to any engine
473/// trait** — it is an additive game-side super-trait, so the engine is untouched.
474pub trait RulesProvider: EffectProvider {
475    /// The game binding resolving interned indices ↔ concrete `P::Stat`/`P::Status`
476    /// and supplying the chart fold.
477    type Bindings: RuleBindings<Self>;
478
479    /// The compiled ruleset (the side registry the interpreter reads by
480    /// `source_effect`). Typically a `&'static` built once via `OnceLock`.
481    fn compiled(&self) -> &CompiledRuleset;
482
483    /// The game binding instance.
484    fn bindings(&self) -> &Self::Bindings;
485
486    /// Read the provider from `ctx` for the interpreter. The interpreter only has
487    /// `&mut BattleCtx`, which does NOT carry `&P` (the engine's borrow
488    /// discipline). A game whose chart/registry is a `&'static` (the common case)
489    /// returns it here without touching `ctx`; the default points at that static.
490    /// Returns `None` if no static is installed.
491    fn rules_host() -> Option<&'static RulesHost<Self>>
492    where
493        Self: Sized;
494}
495
496/// A `&'static` bundle of the compiled registry + binding the zero-capture
497/// [`interpret`](crate::interpret) `fn` reaches without capturing (doc 11 §2.2).
498/// A game installs one once (e.g. in a `OnceLock`) and returns it from
499/// [`RulesProvider::rules_host`].
500pub struct RulesHost<P: RulesProvider> {
501    /// The compiled side registry.
502    pub compiled: CompiledRuleset,
503    /// The game binding.
504    pub bindings: P::Bindings,
505}
506
507impl<P: RulesProvider> RulesHost<P> {
508    /// Bundle a compiled registry + binding.
509    pub fn new(compiled: CompiledRuleset, bindings: P::Bindings) -> Self {
510        Self { compiled, bindings }
511    }
512
513    /// The compiled hook for an `EffectId`.
514    pub fn hook(&self, id: EffectId) -> Option<&CompiledHook> {
515        self.compiled.hook(id)
516    }
517}