Skip to main content

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}