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