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::{
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::new();
165 let mut statuses: HashMap<String, usize> = HashMap::new();
166 let mut move_costs: HashMap<String, Vec<(usize, u16)>> = HashMap::new();
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::new();
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}