Skip to main content

dotzuki_runner/battle/
hooks.rs

1//! RON effect hooks (battle v2-a + v2-e): skill, status, ability, held-item
2//! and weather effects authored in the project's `rules.ron` (the dotzuki-rules
3//! closed `Op`/`Predicate` vocabulary) and executed through the engine's
4//! effect-stack interpreter, instead of the runner's hardcoded skill
5//! categories. This is the "no-Rust game features" milestone for battles: a
6//! new status, skill effect, ability, held item or weather is pure data.
7//!
8//! The shape mirrors the proven minimon/wuxia harnesses
9//! (`examples/minimon/src/data.rs`, `examples/wuxia/.../data.rs`):
10//!
11//! * [`GenericProvider`] / [`GenericBindings`] — the game side of the stack:
12//!   dynamic id types ([`StatId`]/[`StatusId`]/[`TypeId`], interned from the
13//!   RON vocabularies) and the pure name↔index bindings (boosts, statuses,
14//!   the type chart, volatiles, levels, the MP resource pool).
15//! * A **thread-local** [`RulesHost`] (re-installed per battle — the parallel
16//!   test harness stays isolated) the zero-capture `interpret` bridge reads.
17//! * [`HookState`] — the per-battle mirror: both combatants as engine
18//!   [`BattlerState`]s (1v1: side 0 = player, side 1 = enemy), the effect
19//!   arena, and the per-action scratch. The runner's own turn loop (v1,
20//!   unchanged) fires the event sequence per action through
21//!   `collect_handlers` + `run_event`; it does **not** use `StackDriver`.
22//!
23//! Naming conventions (documented in `docs/reference/project-manifest.md`):
24//!
25//! * RON `stats` names map onto the manifest `battle.stats` KEYS
26//!   (`"hp"|"attack"|"defense"|"speed"`; the usual aliases `atk`/`def`/`spd`
27//!   also resolve), so `Boost { stat: "attack" }` needs no per-game code.
28//! * RON `resources` names = the manifest `battle.resource` field name (e.g.
29//!   `"mp"`); the FIRST declared resource is mirrored onto the combatant's MP
30//!   pool (engine resource id 0), so `cost:` / `PayResource` flow through the
31//!   MP gate.
32//! * RON `types` names = the `element` strings on records (as the chart
33//!   already required), matched case-insensitively.
34//! * The closed status vocabulary = the ids of the ruleset's `kind: Status`
35//!   records, in declaration order (a `StatusId(u16)` interned per record).
36
37use std::cell::RefCell;
38use std::collections::HashMap;
39
40use dotzuki_engine::battle::rng::BattleRng as EngineRng;
41use dotzuki_engine::battle::stack::{
42    BattleCtx, Effect, EffectProvider, EffectState, Event, MoveContext,
43};
44use dotzuki_engine::battle::{
45    BattleProvider, BattleState, BattlerRef, BattlerState, DamageResult, EffectResult, EnumMap,
46    MoveEffect,
47};
48use dotzuki_rules::{
49    CompiledRuleset, EffectKind, LoadError, RuleBindings, RulesHost, RulesProvider, Ruleset,
50};
51
52use super::{basic_attack, normalize_stat_key, stage_multiplier, Combatant, Skill, MAX_STAGE};
53
54/// The `EffectId` base for the synthesized data hooks (well clear of the
55/// arena-allocated volatile ids, which count up from 1; minimon/wuxia use the
56/// same base).
57pub const DATA_ID_BASE: u32 = 0x10_000;
58
59// ── dynamic id types ────────────────────────────────────────────────────────
60
61/// A stat interned from the ruleset's `stats:` list (index = list position).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct StatId(pub u16);
64
65/// A status interned from the ruleset's `kind: Status` records (index =
66/// declaration order among those records).
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct StatusId(pub u16);
69
70/// A type interned from the ruleset's `types:` list.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct TypeId(pub u16);
73
74/// A game-opaque volatile (`InflictVolatile { kind, amount }`): the name is
75/// the RON vocabulary, the engine never interprets it.
76#[derive(Debug, Clone)]
77pub struct VolatileKind {
78    /// The volatile's RON name.
79    pub name: String,
80    /// The resolved numeric parameter (turns / counter seed).
81    pub amount: u16,
82}
83
84/// The provider's species payload: just the defending element (type-chart and
85/// `HasType` lookups). Everything else lives on the engine `BattlerState`.
86#[derive(Debug, Clone, Default)]
87pub struct SpeciesData {
88    /// The combatant's `element` field (defending side of chart lookups).
89    pub element: Option<String>,
90}
91
92// ── the provider ────────────────────────────────────────────────────────────
93
94/// The generic battle provider: dynamic ids + the runner's standard formula.
95/// The stack never drives a turn loop here (the runner's own loop fires the
96/// events), so `select_move`/`apply_move_effect`/`create_monster` are inert
97/// stubs; `calculate_damage` mirrors the runner formula for completeness.
98#[derive(Debug, Default, Clone, Copy)]
99pub struct GenericProvider;
100
101/// One battler's effective stat (raw × stage multiplier) by canonical stat key
102/// (`"attack"` etc.), resolved through the installed registry's stat names.
103fn eff_stat(b: &BattlerState<GenericProvider>, key: &str) -> u32 {
104    let Some(host) = GenericProvider::rules_host() else {
105        return 1;
106    };
107    let Some(idx) = host
108        .compiled
109        .stats
110        .iter()
111        .position(|s| normalize_stat_key(s) == key)
112    else {
113        return 1;
114    };
115    let id = StatId(idx as u16);
116    let raw = u32::from(b.stats.get(id).copied().unwrap_or(1));
117    let stage = b.stat_stages.get(id).copied().unwrap_or(0);
118    stage_multiplier(raw, stage)
119}
120
121impl BattleProvider for GenericProvider {
122    type Monster = ();
123    type Move = Skill;
124    type Ability = ();
125    type Status = StatusId;
126    type Stat = StatId;
127    type Species = SpeciesData;
128    type Type = TypeId;
129    type Item = ();
130
131    /// The runner's standard formula with the variance byte and crit flag
132    /// given (the stack driver's shape). The runner's own loop precomputes
133    /// damage itself (`damage_roll` → `ctx.mv.damage`); this mirrors it.
134    fn calculate_damage(
135        &self,
136        move_: &Skill,
137        attacker: &BattlerState<Self>,
138        defender: &BattlerState<Self>,
139        random: u8,
140        is_critical: bool,
141    ) -> DamageResult {
142        let base = move_.power as u64 * eff_stat(attacker, "attack") as u64
143            / eff_stat(defender, "defense").max(1) as u64;
144        let varied = base * (85 + u64::from(random % 16)) / 100;
145        let after_crit = if is_critical { varied * 3 / 2 } else { varied };
146        DamageResult {
147            damage: after_crit.max(1).min(u64::from(u16::MAX)) as u16,
148            effectiveness: 1.0,
149            is_miss: false,
150        }
151    }
152
153    fn select_move(&self, battler: &BattlerState<Self>, _state: &BattleState<Self>) -> Self::Move {
154        battler.moves.first().cloned().unwrap_or_else(basic_attack)
155    }
156
157    fn apply_move_effect(
158        &self,
159        _effect: MoveEffect,
160        _user: &mut BattlerState<Self>,
161        _target: &mut BattlerState<Self>,
162    ) -> EffectResult {
163        EffectResult::NoEffect
164    }
165
166    fn create_monster(&self, species: Self::Species, level: u8) -> BattlerState<Self> {
167        BattlerState::new(species, 1, 1, EnumMap::default(), Vec::new()).with_level(level)
168    }
169}
170
171impl EffectProvider for GenericProvider {
172    type EffectStateKind = VolatileKind;
173
174    /// The runner collects from the compiled registry directly (per-record),
175    /// never through the resolvers.
176    fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
177        None
178    }
179
180    /// See [`effect_for_move`](Self::effect_for_move).
181    fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
182        None
183    }
184
185    /// `(-priority, -effective speed)`; skills carry no priority (v1), so the
186    /// priority tier is 0.
187    fn turn_order_rank(
188        &self,
189        state: &BattleState<Self>,
190        who: BattlerRef,
191        _action: &Self::Move,
192    ) -> (i32, i32) {
193        let b = if who.side == 0 {
194            &state.player_battlers[who.slot as usize]
195        } else {
196            &state.opponent_battlers[who.slot as usize]
197        };
198        (0, -(eff_stat(b, "speed") as i32))
199    }
200}
201
202// ── the RulesProvider bridge — a thread-local `&'static RulesHost` ──────────
203//
204// Mirrors minimon/wuxia exactly: install (or re-install) the compiled registry
205// per battle by leaking a fresh host; the previous leak is abandoned (a
206// bounded, deliberate cost). Thread-local so the parallel test harness stays
207// isolated — each test re-installs on its own thread first.
208
209thread_local! {
210    static HOST: RefCell<Option<&'static RulesHost<GenericProvider>>> =
211        const { RefCell::new(None) };
212}
213
214/// Install (or hot-swap) the compiled registry the interpreter reads.
215pub fn install_compiled(compiled: CompiledRuleset) {
216    let host = RulesHost::new(compiled, GenericBindings);
217    let leaked: &'static RulesHost<GenericProvider> = Box::leak(Box::new(host));
218    HOST.with(|h| *h.borrow_mut() = Some(leaked));
219}
220
221impl RulesProvider for GenericProvider {
222    type Bindings = GenericBindings;
223
224    fn compiled(&self) -> &CompiledRuleset {
225        &Self::rules_host().expect("rules host installed").compiled
226    }
227    fn bindings(&self) -> &Self::Bindings {
228        &Self::rules_host().expect("rules host installed").bindings
229    }
230    fn rules_host() -> Option<&'static RulesHost<GenericProvider>> {
231        HOST.with(|h| *h.borrow())
232    }
233}
234
235// ── the bindings ────────────────────────────────────────────────────────────
236
237/// The generic [`RuleBindings`]: resolves interned indices against the
238/// installed registry's vocabularies and applies them to the engine
239/// `BattlerState`. All methods are pure / RNG-free (the trait contract).
240#[derive(Debug, Default, Clone, Copy)]
241pub struct GenericBindings;
242
243impl GenericBindings {
244    /// The canonical stat key (`"attack"` …) for an interned stat index.
245    fn stat_key(stat_index: usize) -> Option<String> {
246        let host = GenericProvider::rules_host()?;
247        let name = host.compiled.stats.get(stat_index)?;
248        Some(normalize_stat_key(name))
249    }
250
251    /// The ruleset's type name for an interned chart index.
252    fn type_name(type_index: usize) -> Option<String> {
253        let host = GenericProvider::rules_host()?;
254        host.compiled.types.get(type_index).cloned()
255    }
256}
257
258impl RuleBindings<GenericProvider> for GenericBindings {
259    fn apply_boost(
260        &self,
261        b: &mut BattlerState<GenericProvider>,
262        stat_index: usize,
263        stages: i8,
264    ) -> bool {
265        if Self::stat_key(stat_index).is_none() {
266            return false;
267        }
268        let id = StatId(stat_index as u16);
269        let cur = b.stat_stages.get(id).copied().unwrap_or(0);
270        b.stat_stages
271            .set(id, (cur + stages).clamp(-MAX_STAGE, MAX_STAGE));
272        true
273    }
274
275    fn set_status(&self, b: &mut BattlerState<GenericProvider>, status_index: usize) -> bool {
276        b.status = Some(StatusId(status_index as u16));
277        true
278    }
279
280    fn has_type(&self, b: &BattlerState<GenericProvider>, type_index: usize) -> bool {
281        match (Self::type_name(type_index), &b.species.element) {
282            (Some(name), Some(element)) => name.eq_ignore_ascii_case(element),
283            _ => false,
284        }
285    }
286
287    /// The chart fold for the in-flight move type vs the defender's element,
288    /// read from the COMPILED RON `type_chart` (the data layer owns the
289    /// relation). An untyped defender is neutral.
290    fn type_chart_mult(
291        &self,
292        ctx: &BattleCtx<'_, GenericProvider>,
293        move_type_index: usize,
294        defender: BattlerRef,
295    ) -> (u32, u32) {
296        let Some(host) = GenericProvider::rules_host() else {
297            return (1, 1);
298        };
299        let Some(element) = &ctx.battler(defender).species.element else {
300            return (1, 1);
301        };
302        let Some(def_index) = host
303            .compiled
304            .types
305            .iter()
306            .position(|t| t.eq_ignore_ascii_case(element))
307        else {
308            return (1, 1);
309        };
310        host.compiled.chart_mult(move_type_index, def_index)
311    }
312
313    fn make_volatile(&self, name: &str, amount: u16) -> Option<VolatileKind> {
314        Some(VolatileKind {
315            name: name.to_string(),
316            amount,
317        })
318    }
319
320    fn has_volatile(
321        &self,
322        ctx: &BattleCtx<'_, GenericProvider>,
323        who: BattlerRef,
324        name: &str,
325    ) -> bool {
326        ctx.effects
327            .iter()
328            .any(|e| e.host == who && e.kind.name == name)
329    }
330
331    fn battler_level(&self, b: &BattlerState<GenericProvider>) -> u16 {
332        u16::from(b.level)
333    }
334
335    fn has_status(&self, b: &BattlerState<GenericProvider>, status_index: usize) -> bool {
336        b.status == Some(StatusId(status_index as u16))
337    }
338
339    fn has_any_status(&self, b: &BattlerState<GenericProvider>) -> bool {
340        b.status.is_some()
341    }
342
343    // Resource ops use the defaults: resource index ↔ engine id is the
344    // identity, and the pool lives on `BattlerState.resources` (id 0 = the
345    // manifest resource — mirrored from the combatant's MP by the runner).
346}
347
348// ── compile / validate ──────────────────────────────────────────────────────
349
350/// The closed status vocabulary: the ruleset's `kind: Status` record ids, in
351/// declaration order. Supplied to the loader at compile so
352/// `InflictStatus(status: "...")` / `TargetHasStatus("...")` validate at LOAD.
353pub fn status_index_of(ruleset: &Ruleset, name: &str) -> Option<usize> {
354    ruleset
355        .effects
356        .iter()
357        .filter(|r| r.kind == EffectKind::Status)
358        .position(|r| r.id == name)
359}
360
361/// The status record ids in declaration order (`StatusId(idx)` → record id —
362/// the residual pass's effect source and the narrator's display name).
363pub fn status_names(ruleset: &Ruleset) -> Vec<String> {
364    ruleset
365        .effects
366        .iter()
367        .filter(|r| r.kind == EffectKind::Status)
368        .map(|r| r.id.clone())
369        .collect()
370}
371
372/// Compile a [`Ruleset`] into the generic registry, validating every name
373/// against the closed vocabulary NOW (unknown event/op/stat/type/resource/
374/// status ⇒ [`LoadError`] at load, never mid-battle).
375pub fn compile_ruleset(ruleset: &Ruleset) -> Result<CompiledRuleset, LoadError> {
376    CompiledRuleset::compile::<GenericProvider, GenericBindings>(
377        ruleset,
378        DATA_ID_BASE,
379        &GenericBindings,
380        |name| status_index_of(ruleset, name),
381    )
382}
383
384/// The `kind: Move` records' override map (skill id → [`RonMove`]). `cost:`
385/// entries naming the manifest `resource` become the skill's MP cost
386/// (summed); a record with no `cost:` leaves the table record's cost in
387/// place.
388pub fn ron_moves(ruleset: &Ruleset, resource: Option<&str>) -> HashMap<String, RonMove> {
389    ruleset
390        .effects
391        .iter()
392        .filter(|r| r.kind == EffectKind::Move)
393        .map(|rec| {
394            let cost = if rec.cost.is_empty() {
395                None
396            } else {
397                resource.map(|res| {
398                    rec.cost
399                        .iter()
400                        .filter(|c| c.resource == res)
401                        .map(|c| u32::from(c.amount))
402                        .sum()
403                })
404            };
405            (
406                rec.id.clone(),
407                RonMove {
408                    power: rec.power,
409                    accuracy: rec.accuracy,
410                    mtype: rec.mtype.clone(),
411                    cost,
412                },
413            )
414        })
415        .collect()
416}
417
418/// Full closed-vocabulary validation of a `rules.ron` text (the `dotzuki check`
419/// path): parse + compile. Returns one diagnostic per problem (empty = clean).
420pub fn validate_ruleset(rules_text: &str) -> Vec<String> {
421    match Ruleset::from_ron(rules_text) {
422        Err(e) => vec![e.to_string()],
423        Ok(ruleset) => match compile_ruleset(&ruleset) {
424            Ok(_) => Vec::new(),
425            Err(e) => vec![e.to_string()],
426        },
427    }
428}
429
430// ── the per-battle hook state ───────────────────────────────────────────────
431
432/// A `kind: Move` RON record's overrides for the matching skill-table record.
433/// Fields left `None` fall back to the table record.
434#[derive(Debug, Clone, Default)]
435pub struct RonMove {
436    /// Base power override.
437    pub power: Option<u32>,
438    /// Accuracy override.
439    pub accuracy: Option<u32>,
440    /// Attacking element override (the record's `type:`).
441    pub mtype: Option<String>,
442    /// Resource cost override (the record's `cost:` entries naming the
443    /// manifest resource, summed).
444    pub cost: Option<u32>,
445}
446
447/// The per-battle hook machinery: both combatants mirrored as engine
448/// [`BattlerState`]s (side 0 = player, side 1 = enemy), the effect arena, the
449/// per-action scratch, and the built effect registry (one leaked `Effect` per
450/// compiled hook — the deliberate one-time leak, minimon/wuxia precedent).
451pub struct HookState {
452    /// The engine-side battle state (the interpreter's mutation target).
453    pub state: BattleState<GenericProvider>,
454    /// The live volatile arena.
455    pub effects: Vec<EffectState<GenericProvider>>,
456    /// Per-action scratch (`damage`, `last_damage`).
457    pub mv: MoveContext,
458    /// Every synthesized per-hook effect (filtered by source record id at
459    /// fire time).
460    pub registry: Vec<&'static Effect<GenericProvider>>,
461    /// Skill id → its `kind: Move` RON record's overrides.
462    pub move_records: HashMap<String, RonMove>,
463    /// `StatusId(idx)` → the status record id (residual source + narration).
464    pub status_names: Vec<String>,
465    /// The interned stat names (the ruleset's `stats:` list, in order).
466    pub stat_names: Vec<String>,
467    /// Whether the manifest maps a resource field (the MP pool mirror).
468    pub has_resource: bool,
469}
470
471impl HookState {
472    /// The battler ref of one side (1v1: slot 0).
473    pub fn battler_ref(side: super::Side) -> BattlerRef {
474        match side {
475            super::Side::Player => BattlerRef::PLAYER,
476            super::Side::Enemy => BattlerRef::OPPONENT,
477        }
478    }
479
480    /// The engine battler for a side.
481    pub fn battler(&self, side: super::Side) -> &BattlerState<GenericProvider> {
482        let r = Self::battler_ref(side);
483        if r.side == 0 {
484            &self.state.player_battlers[r.slot as usize]
485        } else {
486            &self.state.opponent_battlers[r.slot as usize]
487        }
488    }
489
490    /// Whether the skill's RON record subscribes to `event` (used to decide
491    /// gate/fold firing and the no-hooks chart fallback).
492    pub fn subscribes(&self, skill_id: &str, event: Event) -> bool {
493        let Some(host) = GenericProvider::rules_host() else {
494            return false;
495        };
496        host.compiled
497            .hooks
498            .values()
499            .any(|h| h.source_id == skill_id && h.event == event)
500    }
501}
502
503/// The runner's byte-stream rng as the engine's rng trait (one `next_u8` per
504/// `byte`, so a scripted stream replays a battle exactly — accuracy, variance,
505/// crit, then the hooks' `chance` gates, in fire order).
506pub struct RngAdapter<'a>(pub &'a mut dyn super::BattleRng);
507
508impl EngineRng for RngAdapter<'_> {
509    fn next_u8(&mut self) -> u8 {
510        self.0.byte()
511    }
512}
513
514// ── Combatant ↔ BattlerState mirroring ──────────────────────────────────────
515//
516// The v1 `Combatant` stays the loop/UI authority for HP/MP/stages; the mirror
517// is the interpreter's mutation target. The runner syncs Combatant → mirror
518// before an action's event sequence and mirror → Combatant after, so v1 paths
519// (a non-RON skill mid-battle) and hook paths never diverge. The non-volatile
520// status rides the same sync as the record-id string on the Combatant (v2-b:
521// statuses persist on the party member across switches and battles); an id
522// outside the RON `kind: Status` vocabulary reads as no status. HP/MP clamp
523// into the engine's u16.
524
525/// A combatant's raw stat by RON stat name (canonical key mapping).
526fn raw_stat(c: &Combatant, name: &str) -> u32 {
527    match normalize_stat_key(name).as_str() {
528        "hp" => c.max_hp,
529        "defense" => c.defense,
530        "speed" => c.speed,
531        _ => c.attack,
532    }
533}
534
535/// The mirror status for a combatant's status record id (unknown ids drop —
536/// a status name outside the ruleset's `kind: Status` vocabulary can't be
537/// interpreted).
538fn status_id_of(status: &Option<String>, status_names: &[String]) -> Option<StatusId> {
539    status
540        .as_ref()
541        .and_then(|name| status_names.iter().position(|n| n == name))
542        .map(|idx| StatusId(idx as u16))
543}
544
545/// Build the engine mirror of a combatant (its CURRENT pools — HP/MP/stages
546/// and status; used at battle start and on switch-in).
547pub fn mirror_of(
548    c: &Combatant,
549    stat_names: &[String],
550    status_names: &[String],
551    has_resource: bool,
552) -> BattlerState<GenericProvider> {
553    let mut b = BattlerState::new(
554        SpeciesData {
555            element: c.element.clone(),
556        },
557        c.hp.min(u32::from(u16::MAX)) as u16,
558        c.max_hp.min(u32::from(u16::MAX)) as u16,
559        EnumMap::default(),
560        c.skills.clone(),
561    )
562    .with_level(c.level);
563    b.status = status_id_of(&c.status, status_names);
564    sync_to_mirror(c, &mut b, stat_names, status_names, has_resource);
565    b
566}
567
568/// Copy the mutable pools (HP/MP/levels/stats/stages/status) Combatant →
569/// mirror.
570pub fn sync_to_mirror(
571    c: &Combatant,
572    b: &mut BattlerState<GenericProvider>,
573    stat_names: &[String],
574    status_names: &[String],
575    has_resource: bool,
576) {
577    b.hp = c.hp.min(u32::from(u16::MAX)) as u16;
578    b.max_hp = c.max_hp.min(u32::from(u16::MAX)) as u16;
579    b.level = c.level;
580    b.status = status_id_of(&c.status, status_names);
581    for (i, name) in stat_names.iter().enumerate() {
582        let id = StatId(i as u16);
583        b.stats
584            .set(id, raw_stat(c, name).min(u32::from(u16::MAX)) as u16);
585        b.stat_stages.set(id, c.stages.get(name));
586    }
587    if has_resource {
588        b.resources.set(
589            0,
590            c.mp.min(u32::from(u16::MAX)) as u16,
591            c.max_mp.min(u32::from(u16::MAX)) as u16,
592        );
593    }
594}
595
596/// Copy the pools back mirror → Combatant (after each event fire).
597pub fn sync_from_mirror(
598    b: &BattlerState<GenericProvider>,
599    c: &mut Combatant,
600    stat_names: &[String],
601    status_names: &[String],
602    has_resource: bool,
603) {
604    c.hp = u32::from(b.hp);
605    c.max_hp = u32::from(b.max_hp);
606    c.status = b
607        .status
608        .as_ref()
609        .and_then(|id| status_names.get(id.0 as usize).cloned());
610    for (i, name) in stat_names.iter().enumerate() {
611        let id = StatId(i as u16);
612        if let Some(stage) = b.stat_stages.get(id) {
613            c.stages.set(name, *stage);
614        }
615    }
616    if has_resource {
617        c.mp = u32::from(b.resources.current(0).unwrap_or(0));
618        c.max_mp = u32::from(b.resources.max(0).unwrap_or(0));
619    }
620}