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}