dotzuki_rules/interp.rs
1//! The runtime bridge + the closed primitive interpreter (doc 11 §2, §1.1).
2//!
3//! [`interpret`] is the **single** zero-capture `fn` every data hook's `call`
4//! field points at. It looks up its op-list by the [`EffectId`] the engine
5//! threads as `source_effect` (`dispatch.rs:128`), then folds it via [`run_ops`].
6//!
7//! [`run_ops`] is a **pure interpreter over `ctx` + the closed op enum**: it
8//! mutates only through `ctx` (`battler_mut` / `effect_mut` / the binding), and
9//! its **only entropy is `ctx.rng`** (the `chance` gate; doc 11 §4.1). No clock,
10//! no pointer hashing, no `HashMap` iteration affecting draw order. A
11//! [`ScriptedRng`](dotzuki_engine::battle::rng::ScriptedRng) therefore replays a
12//! data ruleset identically.
13
14use dotzuki_engine::battle::stack::{BattleCtx, EffectId, HandlerResult, RelayVar};
15use dotzuki_engine::battle::BattlerRef;
16
17use crate::bindings::RuleBindings;
18use crate::model::{AmountSpec, DamageValue, FractionOf, Op, Predicate, Selector};
19use crate::registry::{CompiledHook, RulesProvider};
20use crate::trace;
21
22/// **THE bridge** (doc 11 §2): the single zero-capture `fn` every data hook's
23/// `call` points at. Keyed entirely off `source_effect` — the engine already
24/// threads it to every handler (`dispatch.rs:128`). Looks the compiled hook up in
25/// the game's `&'static` [`RulesHost`](crate::registry::RulesHost), applies the
26/// `chance` gate (the only RNG), then folds the op-list.
27///
28/// Signature matches [`HandlerFn<P>`](dotzuki_engine::battle::stack::HandlerFn)
29/// exactly so it is a valid `call` field.
30pub fn interpret<P: RulesProvider>(
31 ctx: &mut BattleCtx<'_, P>,
32 relay: RelayVar,
33 target: BattlerRef,
34 source: BattlerRef,
35 source_effect: EffectId,
36) -> HandlerResult {
37 let Some(host) = P::rules_host() else {
38 // No registry installed ⇒ inert (relay passes through), identical to a
39 // game with no data hooks. (Defensive; a wired game always installs one.)
40 return HandlerResult::Unchanged;
41 };
42 let Some(hook) = host.hook(source_effect) else {
43 return HandlerResult::Unchanged;
44 };
45
46 // The chance gate — the SOLE entropy (doc 11 §4.1). Drawn UNCONDITIONALLY so
47 // draw count/order is a pure function of the op-list, not of the outcome.
48 if let Some((num, den)) = hook.chance {
49 let pass = ctx.rng.chance(num, den);
50 if !pass {
51 return HandlerResult::Unchanged;
52 }
53 }
54
55 run_ops(ctx, relay, target, source, &host.bindings, hook)
56}
57
58/// The closed primitive interpreter (doc 11 §1.1). Folds the hook's op-list over
59/// `relay`, returning the engine [`HandlerResult`]. Short-circuits on the first
60/// `Fail`/`FailSilent` exactly like the native fold (`dispatch.rs:285`); a
61/// numeric op produces `Set`; a side-effecting op produces `Unchanged` (the relay
62/// is threaded through).
63///
64/// **Determinism**: this fn touches `ctx.rng` ONLY via the caller's `chance` gate
65/// (it is not re-drawn here); every op below is a pure `ctx`/`RelayVar`/binding
66/// operation. No entropy, no clock, no draw-order-affecting iteration.
67pub fn run_ops<P: RulesProvider>(
68 ctx: &mut BattleCtx<'_, P>,
69 mut relay: RelayVar,
70 target: BattlerRef,
71 source: BattlerRef,
72 bindings: &P::Bindings,
73 hook: &CompiledHook,
74) -> HandlerResult {
75 let mut result = HandlerResult::Unchanged;
76 for op in &hook.ops {
77 let before = relay;
78 match apply_op(ctx, relay, target, source, bindings, hook, op) {
79 OpOutcome::Unchanged => {
80 trace::record(hook.id, hook.event, op, before, before);
81 }
82 OpOutcome::Set(v) => {
83 relay = v;
84 result = HandlerResult::Set(v);
85 trace::record(hook.id, hook.event, op, before, v);
86 }
87 OpOutcome::Fail => {
88 trace::record(hook.id, hook.event, op, before, RelayVar::Bool(false));
89 return HandlerResult::Fail;
90 }
91 OpOutcome::FailSilent => {
92 trace::record(hook.id, hook.event, op, before, RelayVar::Unit);
93 return HandlerResult::FailSilent;
94 }
95 }
96 }
97 result
98}
99
100/// The per-op verdict, before it is folded into the running [`HandlerResult`].
101enum OpOutcome {
102 Unchanged,
103 Set(RelayVar),
104 Fail,
105 FailSilent,
106}
107
108/// Apply ONE op. Pure over `ctx` + binding; no entropy.
109fn apply_op<P: RulesProvider>(
110 ctx: &mut BattleCtx<'_, P>,
111 relay: RelayVar,
112 target: BattlerRef,
113 source: BattlerRef,
114 bindings: &P::Bindings,
115 hook: &CompiledHook,
116 op: &Op,
117) -> OpOutcome {
118 match op {
119 // Damage was precomputed into `ctx.mv.damage` by the driver (the provider
120 // isn't in BattleCtx); this is the ModifyDamage subscription marker.
121 Op::DealMoveDamage => OpOutcome::Unchanged,
122
123 Op::DamageFraction {
124 num,
125 den,
126 of,
127 target: sel,
128 unless,
129 } => {
130 if pred_holds(ctx, bindings, relay, target, source, hook, unless.as_ref()) {
131 return OpOutcome::Unchanged; // skipped (e.g. non-Rock chip's `unless: HasType(Rock)`)
132 }
133 let who = resolve(*sel, target, source);
134 let amt = fraction_amount(ctx, who, *of, *num, *den);
135 // A damage sink on `who` (Substitute / shield) may swallow the loss;
136 // otherwise apply it. Defaulted-false ⇒ unchanged for every existing game.
137 if !bindings.redirect_hp_loss(ctx, who, source, amt) {
138 ctx.battler_mut(who).take_damage(amt);
139 }
140 OpOutcome::Unchanged
141 }
142
143 Op::HealFraction {
144 num,
145 den,
146 of,
147 target: sel,
148 unless,
149 } => {
150 if pred_holds(ctx, bindings, relay, target, source, hook, unless.as_ref()) {
151 return OpOutcome::Unchanged;
152 }
153 let who = resolve(*sel, target, source);
154 let amt = fraction_amount(ctx, who, *of, *num, *den);
155 ctx.battler_mut(who).heal(amt);
156 OpOutcome::Unchanged
157 }
158
159 Op::InflictStatus {
160 status,
161 target: sel,
162 amount,
163 } => {
164 let who = resolve(*sel, target, source);
165 // Draw the amount first (unconditionally, at this op's ordinal) so
166 // the rng stream is a pure function of the op-list. `Const` (the
167 // default) draws nothing, so a plain InflictStatus is unchanged.
168 let amt = resolve_amount(ctx, *amount);
169 // Resolve the status name to an index via the binding. The loader
170 // already validated this name at compile, so the index exists.
171 if let Some(idx) = status_index::<P>(status) {
172 let b = ctx.battler_mut(who);
173 bindings.set_status_with_amount(b, idx, amt);
174 }
175 OpOutcome::Unchanged
176 }
177
178 Op::InflictVolatile {
179 kind,
180 target: sel,
181 amount,
182 } => {
183 let who = resolve(*sel, target, source);
184 let amt = resolve_amount(ctx, *amount);
185 // The game builds its OPAQUE volatile kind for (name, amount); the
186 // engine installs it generically. Unknown name ⇒ `None` ⇒ inert.
187 if let Some(kind) = bindings.make_volatile(kind, amt) {
188 ctx.install_effect(who, kind);
189 }
190 OpOutcome::Unchanged
191 }
192
193 Op::Boost {
194 stat,
195 stages,
196 target: sel,
197 } => {
198 let who = resolve(*sel, target, source);
199 if let Some(idx) = host_stat_index::<P>(stat) {
200 let b = ctx.battler_mut(who);
201 bindings.apply_boost(b, idx, *stages);
202 }
203 OpOutcome::Unchanged
204 }
205
206 Op::ScaleRelay { num, den, when } => {
207 if when
208 .iter()
209 .all(|p| pred_holds(ctx, bindings, relay, target, source, hook, Some(p)))
210 {
211 OpOutcome::Set(relay.scale(*num, *den))
212 } else {
213 OpOutcome::Unchanged
214 }
215 }
216
217 Op::SetRelay(v) => OpOutcome::Set(RelayVar::Int(*v)),
218 Op::AddRelay(k) => OpOutcome::Set(RelayVar::Int(relay.as_int() + *k)),
219 Op::ClampRelay { lo, hi } => {
220 let v = relay.as_int().clamp(*lo, *hi);
221 OpOutcome::Set(RelayVar::Int(v))
222 }
223
224 Op::VetoIf { cond, silent } => {
225 if pred_holds(ctx, bindings, relay, target, source, hook, Some(cond)) {
226 if *silent {
227 OpOutcome::FailSilent
228 } else {
229 OpOutcome::Fail
230 }
231 } else {
232 OpOutcome::Unchanged
233 }
234 }
235
236 Op::ApplyTypeChart => {
237 let Some(mti) = hook.move_type_index else {
238 return OpOutcome::Unchanged; // untyped move ⇒ neutral 1×
239 };
240 // Fold the dual-type PRODUCT into ONE rational, then a single scale
241 // (doc 12 §5.3). The binding owns the chart; pure, no RNG.
242 let (num, den) = bindings.type_chart_mult(ctx, mti, target);
243 OpOutcome::Set(relay.scale(num, den))
244 }
245
246 Op::PayResource {
247 resource,
248 amount,
249 target: sel,
250 } => {
251 // The MP/SP/mana cost gate expressed in DATA (doc 13 §4). If the payer
252 // cannot afford the cost, `Fail` (the move is prevented via the existing
253 // veto path); otherwise deduct it. PURE ARITHMETIC — touches no rng.
254 let who = resolve(*sel, target, source);
255 let Some(idx) = host_resource_index::<P>(resource) else {
256 // The loader validated the name at compile, so this is defensive.
257 return OpOutcome::Unchanged;
258 };
259 if !bindings.can_pay_resource(ctx.battler(who), idx, *amount) {
260 return OpOutcome::Fail; // insufficient ⇒ prevent the move
261 }
262 bindings.pay_resource(ctx.battler_mut(who), idx, *amount);
263 OpOutcome::Unchanged
264 }
265
266 // OHKO (SetHp(Foe, 0, when:[LevelGE])) / Explode (SetHp(Source, 0)). An
267 // ABSOLUTE set — not routed through `take_damage` — the faithful Gen-1
268 // one-hit-KO / self-detonate. The `when` guard gates the write (ALL hold).
269 // Pure write; no entropy.
270 Op::SetHp {
271 target: sel,
272 value,
273 when,
274 } => {
275 if !when
276 .iter()
277 .all(|p| pred_holds(ctx, bindings, relay, target, source, hook, Some(p)))
278 {
279 return OpOutcome::Unchanged; // gate failed (e.g. OHKO immune, bug #19)
280 }
281 let who = resolve(*sel, target, source);
282 let cur = ctx.battler(who).hp;
283 // Only a genuine LOSS (cur > value) may be routed to a damage sink. A KO
284 // (value == 0) breaks the sink UNCONDITIONALLY — the oracle zeroes the
285 // Substitute regardless of its HP vs the mon's — so pass a break-guaranteeing
286 // amount; a partial set (unused in Gen-1) passes its real loss.
287 if cur > *value {
288 let sink_amount = if *value == 0 { u16::MAX } else { cur - *value };
289 if bindings.redirect_hp_loss(ctx, who, source, sink_amount) {
290 return OpOutcome::Unchanged; // the sink took it; skip the absolute set
291 }
292 }
293 ctx.battler_mut(who).hp = *value;
294 OpOutcome::Unchanged
295 }
296
297 // Special/fixed damage that BYPASSES the type chart: write `ctx.mv.damage`
298 // directly so it rides the SAME driver apply path as `DealMoveDamage`. The
299 // level-based variants read the user's level via the binding; `RngScaledLevel`
300 // draws exactly ONE `ctx.rng` byte (Psywave) — the SOLE entropy here.
301 Op::SetDamage { value, of } => {
302 let who = resolve(*of, target, source);
303 let dmg = match *value {
304 DamageValue::Const(c) => c,
305 DamageValue::UserLevel => bindings.battler_level(ctx.battler(who)),
306 DamageValue::RngScaledLevel { num, den } => {
307 let byte = ctx.rng.next_u8() as u32;
308 let level = bindings.battler_level(ctx.battler(who)) as u32;
309 let den = den.max(1);
310 let raw = (byte * num / den * level) / 256;
311 raw.min(u16::MAX as u32) as u16
312 }
313 };
314 ctx.mv.damage = dmg;
315 OpOutcome::Unchanged
316 }
317
318 // Super Fang: damage the selector by a fraction of its CURRENT HP, floored
319 // at 1 for a non-zero base (the legacy `(curHP/2).max(1)`), AND write
320 // `ctx.mv.damage` so a redirect / Counter read sees the real number. Pure.
321 Op::DamageCurrentHpFraction {
322 num,
323 den,
324 target: sel,
325 } => {
326 let who = resolve(*sel, target, source);
327 let cur = ctx.battler(who).hp as u64;
328 let den = (*den).max(1) as u64;
329 let amt = ((cur * *num as u64) / den).min(u16::MAX as u64) as u16;
330 let amt = if cur > 0 { amt.max(1) } else { 0 };
331 // Super Fang deals a fraction of the MON's current HP (the oracle reads the
332 // mon, not the doll), but a Substitute swallows the resulting number.
333 if !bindings.redirect_hp_loss(ctx, who, source, amt) {
334 ctx.battler_mut(who).take_damage(amt);
335 }
336 ctx.mv.damage = amt; // keep the real number for a Counter / informational read
337 OpOutcome::Unchanged
338 }
339
340 // The Gen-1 multi-hit loop, GAME-SIDE (no engine seam). On `DamagingHit`
341 // the driver has already dealt the FIRST hit (`take_damage(ctx.mv.damage)`),
342 // so re-apply the SAME per-hit number `(N-1)` more times. N is drawn from
343 // `count`; `TwoToFive` consumes ONE byte via `determine_hit_count` (the
344 // legacy `multi_hit_roll`). The final-hit rider (Twineedle poison) draws its
345 // `chance` byte UNCONDITIONALLY after the last hit (the consumed() invariant),
346 // then runs its guard/inflict ops if the gate passes. Only `ctx.rng` is
347 // touched (count byte + optional final-hit byte), at this op's ordinal.
348 Op::RepeatHits {
349 count,
350 target: sel,
351 final_hit,
352 } => {
353 let who = resolve(*sel, target, source);
354 let per_hit = ctx.mv.damage;
355 let n = match count {
356 crate::model::HitCount::Fixed(k) => *k,
357 crate::model::HitCount::TwoToFive => determine_hit_count(ctx.rng.next_u8()),
358 };
359 // The driver already dealt hit #1 (through the Event::Damage fold, so a
360 // Substitute absorbed it); deal the remaining (N-1), each likewise routed
361 // to the sink until it breaks, then to the mon.
362 for _ in 1..n {
363 if !bindings.redirect_hp_loss(ctx, who, source, per_hit) {
364 ctx.battler_mut(who).take_damage(per_hit);
365 }
366 }
367 // Final-hit-only secondary (Twineedle 52/256 poison). The chance byte is
368 // drawn UNCONDITIONALLY (consumed() invariant), then — if it passes — the
369 // rider ops (VetoIf guards + InflictStatus) run exactly like a side-status
370 // hook. Resolved against the same `target` (the defender).
371 if let crate::model::FinalHitRider::OnFinal { chance, ops } = final_hit {
372 let pass = ctx.rng.chance(chance.num, chance.den);
373 if pass {
374 for op in ops {
375 match apply_op(ctx, relay, target, source, bindings, hook, op) {
376 // A VetoIf that fires aborts the rider (poison-type /
377 // Substitute immunity), exactly like the side-status
378 // VetoIf short-circuit — but it does NOT fail the whole
379 // multi-hit (the hits already landed). Stop running the
380 // rider; the op itself is otherwise side-effecting.
381 OpOutcome::Fail | OpOutcome::FailSilent => break,
382 OpOutcome::Unchanged | OpOutcome::Set(_) => {}
383 }
384 }
385 }
386 }
387 OpOutcome::Unchanged
388 }
389
390 // The cleanse: clear the selector's non-volatile status (the wuxia 驱散
391 // op). A generic engine-field write — `.status = None` — mirroring how
392 // `SetHp` writes `.hp = value`; no binding, no entropy. The inverse of
393 // `InflictStatus`.
394 Op::RemoveStatus { target: sel } => {
395 let who = resolve(*sel, target, source);
396 ctx.battler_mut(who).status = None;
397 OpOutcome::Unchanged
398 }
399 }
400}
401
402/// Gen-1 two-to-five hit distribution (`3/8` each for 2/3, `1/8` each for 4/5),
403/// bit-identical to the legacy pokered `determine_hit_count` (multi_hit_effects.rs):
404/// `roll<96⇒2, <192⇒3, <224⇒4, else⇒5`. Pure; the SOLE caller draws the byte once.
405fn determine_hit_count(roll: u8) -> u8 {
406 if roll < 96 {
407 2
408 } else if roll < 192 {
409 3
410 } else if roll < 224 {
411 4
412 } else {
413 5
414 }
415}
416
417/// Resolve an [`AmountSpec`] to a number, drawing from the `ctx.rng` byte stream
418/// (at the op's ordinal — the sole entropy). `Const` draws nothing, so a
419/// duration-less op preserves the pre-existing rng stream. `RngMask` draws
420/// exactly ONE byte; `RngRange` REJECTION-samples (redrawing the skewed tail) so
421/// the span is uniform — see below.
422fn resolve_amount<P: RulesProvider>(ctx: &mut BattleCtx<'_, P>, spec: AmountSpec) -> u16 {
423 match spec {
424 AmountSpec::Const(c) => c,
425 AmountSpec::RngMask { mask, plus } => {
426 let byte = ctx.rng.next_u8();
427 (byte & mask) as u16 + plus as u16
428 }
429 AmountSpec::RngRange { lo, hi } => {
430 let span = hi.saturating_sub(lo).saturating_add(1).max(1);
431 if span >= 256 {
432 // One byte can't cover the span; degrade to a single modulo draw.
433 let byte = ctx.rng.next_u8() as u16;
434 return lo + (byte % span);
435 }
436 // Rejection sampling (Gen-1 sleep counter style: the asm re-rolls a
437 // 0 rather than skewing low values): accept only bytes below the
438 // largest multiple of `span` that fits in a byte, so `byte % span`
439 // is uniform. BOUNDED like the production damage-roll rejection so a
440 // degenerate scripted rng stream can never spin forever.
441 let limit = 256 - (256 % span);
442 let mut byte = ctx.rng.next_u8() as u16;
443 let mut tries = 0;
444 while byte >= limit && tries < 64 {
445 byte = ctx.rng.next_u8() as u16;
446 tries += 1;
447 }
448 lo + (byte % span)
449 }
450 }
451}
452
453/// Resolve a [`Selector`] to a concrete [`BattlerRef`] (doc 11 §1.1).
454fn resolve(sel: Selector, target: BattlerRef, source: BattlerRef) -> BattlerRef {
455 match sel {
456 Selector::Target | Selector::Host => target,
457 Selector::Source => source,
458 Selector::Foe => BattlerRef::new(if target.side == 0 { 1 } else { 0 }, target.slot),
459 }
460}
461
462/// `of` base × num / den, integer-truncated (mirrors `RelayVar::scale`). Div-by-0
463/// clamps den to 1 (doc 11 §4.2).
464///
465/// [`FractionOf::LastDamage`] bases off `ctx.mv.last_damage` (the damage the
466/// in-flight move just dealt) and **floors the non-zero result at 1** — the legacy
467/// Gen-1 Drain `(dealt/2).max(1)` / Recoil `(dealt/4).max(1)`
468/// (`damage_effects.rs`). A 0-damage event yields 0 (no drain / no recoil); any
469/// positive dealt yields at least 1. `MaxHp`/`CurHp` keep their plain truncation
470/// (Recover has no min). Pure read of `ctx.mv`; no entropy.
471fn fraction_amount<P: RulesProvider>(
472 ctx: &BattleCtx<'_, P>,
473 who: BattlerRef,
474 of: FractionOf,
475 num: u32,
476 den: u32,
477) -> u16 {
478 let den = den.max(1) as u64;
479 match of {
480 FractionOf::MaxHp => {
481 let base = ctx.battler(who).max_hp as u64;
482 ((base * num as u64) / den).min(u16::MAX as u64) as u16
483 }
484 FractionOf::CurHp => {
485 let base = ctx.battler(who).hp as u64;
486 ((base * num as u64) / den).min(u16::MAX as u64) as u16
487 }
488 FractionOf::LastDamage => {
489 let base = ctx.mv.last_damage as u64;
490 let amt = ((base * num as u64) / den).min(u16::MAX as u64) as u16;
491 // Legacy `.max(1)` floor: a non-zero damage event always moves ≥1 HP.
492 if base > 0 {
493 amt.max(1)
494 } else {
495 0
496 }
497 }
498 }
499}
500
501/// Evaluate a closed [`Predicate`] (doc 11 §1.1). Pure read; no RNG.
502///
503/// `hook` is threaded so `MoveTypeIsDefenderType` can recover the in-flight move's
504/// `move_type_index` (the record's `type:`). The predicates evaluate against the
505/// `target` selector (the dispatch target — the defender on `DamagingHit`).
506fn pred_holds<P: RulesProvider>(
507 ctx: &BattleCtx<'_, P>,
508 bindings: &P::Bindings,
509 relay: RelayVar,
510 target: BattlerRef,
511 source: BattlerRef,
512 hook: &CompiledHook,
513 pred: Option<&Predicate>,
514) -> bool {
515 let Some(pred) = pred else { return false };
516 match pred {
517 Predicate::HasType(name) => match host_type_index::<P>(name) {
518 Some(idx) => bindings.has_type(ctx.battler(target), idx),
519 None => false,
520 },
521 Predicate::StatIs(name) => {
522 match (host_stat_index::<P>(name), bindings.current_stat_index(ctx)) {
523 (Some(want), Some(cur)) => want == cur,
524 _ => false,
525 }
526 }
527 Predicate::RelayIntLt(n) => relay.as_int() < *n,
528 // The Substitute block: does the defender have the named live volatile?
529 // The game inspects its own arena (the engine treats EffectStateKind
530 // opaquely). Pure read; no entropy.
531 Predicate::HasVolatile(name) => bindings.has_volatile(ctx, target, name),
532 // The Gen-1 self-type immunity quirk #23: the move's type == one of the
533 // defender's types. The move type is the compiled hook's move_type_index;
534 // an untyped record (no `type:`) ⇒ never matches.
535 Predicate::MoveTypeIsDefenderType => match hook.move_type_index {
536 Some(mti) => bindings.move_type_is_defender_type(ctx, mti, target),
537 None => false,
538 },
539 // The Dream Eater sleep gate: the defender currently has the named status.
540 Predicate::TargetHasStatus(name) => match status_index::<P>(name) {
541 Some(idx) => bindings.has_status(ctx.battler(target), idx),
542 None => false,
543 },
544 // Logical negation of any inner predicate (Dream Eater: NOT asleep).
545 Predicate::Not(inner) => {
546 !pred_holds(ctx, bindings, relay, target, source, hook, Some(inner))
547 }
548 // The Toxic guard: the defender already has any non-volatile status.
549 Predicate::TargetHasAnyStatus => bindings.has_any_status(ctx.battler(target)),
550 // The OHKO gate (bug #19): the SOURCE (user) level ≥ the TARGET (foe)
551 // level. The level is the game's per-battler quantity (the binding answers
552 // it; the engine carries no level). Pure read.
553 Predicate::LevelGE => {
554 bindings.battler_level(ctx.battler(source))
555 >= bindings.battler_level(ctx.battler(target))
556 }
557 // The wuxia 「血越低攻越高」 gate: the SOURCE (the acting battler) HP fraction
558 // strictly below num/den. `hp`/`max_hp` are engine fields, read directly off
559 // ctx (no binding). Pure read; no entropy.
560 Predicate::SelfHpBelow { num, den } => {
561 let b = ctx.battler(source);
562 let den = (*den).max(1) as u64;
563 (b.hp as u64) * den < (b.max_hp as u64) * (*num as u64)
564 }
565 // Like `TargetHasStatus` but on the SOURCE (the acting battler) — the wuxia
566 // 眩晕/控制 BeforeMove veto gate. Reuses the EXISTING `has_status` binding.
567 Predicate::SourceHasStatus(name) => match status_index::<P>(name) {
568 Some(idx) => bindings.has_status(ctx.battler(source), idx),
569 None => false,
570 },
571 }
572}
573
574// ── interned-name lookups against the installed registry vocabularies ─────────
575//
576// The compiled registry owns the interned `types`/`stats` lists; status indices
577// are the game's vocabulary (resolved by the binding through a game-supplied
578// `status_index_of` at compile, and looked up here against the same vocabulary).
579// All pure, no RNG.
580
581fn host_type_index<P: RulesProvider>(name: &str) -> Option<usize> {
582 P::rules_host().and_then(|h| h.compiled.types.iter().position(|t| t == name))
583}
584
585fn host_stat_index<P: RulesProvider>(name: &str) -> Option<usize> {
586 P::rules_host().and_then(|h| h.compiled.stats.iter().position(|s| s == name))
587}
588
589/// Resource index against the installed registry's interned `resources:` list
590/// (the MP/SP/mana cost gate, doc 13 §4). Pure, no RNG.
591fn host_resource_index<P: RulesProvider>(name: &str) -> Option<usize> {
592 P::rules_host().and_then(|h| h.compiled.resources.iter().position(|r| r == name))
593}
594
595/// Status index: the game's status vocabulary is NOT part of the RON `stats:`
596/// list (it is the game's own enum). The loader interns each `InflictStatus`
597/// status name → the game's status index at compile (via the game-supplied
598/// `status_index_of`), storing it in [`CompiledRuleset::statuses`]. The
599/// interpreter recovers it here, then hands the index to `bindings.set_status`.
600/// Pure, no RNG.
601fn status_index<P: RulesProvider>(name: &str) -> Option<usize> {
602 P::rules_host().and_then(|h| h.compiled.status_index(name))
603}