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(
154        &self,
155        battler: &BattlerState<Self>,
156        _state: &BattleState<Self>,
157    ) -> Self::Move {
158        battler.moves.first().cloned().unwrap_or_else(basic_attack)
159    }
160
161    fn apply_move_effect(
162        &self,
163        _effect: MoveEffect,
164        _user: &mut BattlerState<Self>,
165        _target: &mut BattlerState<Self>,
166    ) -> EffectResult {
167        EffectResult::NoEffect
168    }
169
170    fn create_monster(&self, species: Self::Species, level: u8) -> BattlerState<Self> {
171        BattlerState::new(species, 1, 1, EnumMap::default(), Vec::new()).with_level(level)
172    }
173}
174
175impl EffectProvider for GenericProvider {
176    type EffectStateKind = VolatileKind;
177
178    /// The runner collects from the compiled registry directly (per-record),
179    /// never through the resolvers.
180    fn effect_for_move(&self, _m: &Self::Move) -> Option<&'static Effect<Self>> {
181        None
182    }
183
184    /// See [`effect_for_move`](Self::effect_for_move).
185    fn effect_for_status(&self, _s: &Self::Status) -> Option<&'static Effect<Self>> {
186        None
187    }
188
189    /// `(-priority, -effective speed)`; skills carry no priority (v1), so the
190    /// priority tier is 0.
191    fn turn_order_rank(
192        &self,
193        state: &BattleState<Self>,
194        who: BattlerRef,
195        _action: &Self::Move,
196    ) -> (i32, i32) {
197        let b = if who.side == 0 {
198            &state.player_battlers[who.slot as usize]
199        } else {
200            &state.opponent_battlers[who.slot as usize]
201        };
202        (0, -(eff_stat(b, "speed") as i32))
203    }
204}
205
206// ── the RulesProvider bridge — a thread-local `&'static RulesHost` ──────────
207//
208// Mirrors minimon/wuxia exactly: install (or re-install) the compiled registry
209// per battle by leaking a fresh host; the previous leak is abandoned (a
210// bounded, deliberate cost). Thread-local so the parallel test harness stays
211// isolated — each test re-installs on its own thread first.
212
213thread_local! {
214    static HOST: RefCell<Option<&'static RulesHost<GenericProvider>>> =
215        const { RefCell::new(None) };
216}
217
218/// Install (or hot-swap) the compiled registry the interpreter reads.
219pub fn install_compiled(compiled: CompiledRuleset) {
220    let host = RulesHost::new(compiled, GenericBindings);
221    let leaked: &'static RulesHost<GenericProvider> = Box::leak(Box::new(host));
222    HOST.with(|h| *h.borrow_mut() = Some(leaked));
223}
224
225impl RulesProvider for GenericProvider {
226    type Bindings = GenericBindings;
227
228    fn compiled(&self) -> &CompiledRuleset {
229        &Self::rules_host().expect("rules host installed").compiled
230    }
231    fn bindings(&self) -> &Self::Bindings {
232        &Self::rules_host().expect("rules host installed").bindings
233    }
234    fn rules_host() -> Option<&'static RulesHost<GenericProvider>> {
235        HOST.with(|h| *h.borrow())
236    }
237}
238
239// ── the bindings ────────────────────────────────────────────────────────────
240
241/// The generic [`RuleBindings`]: resolves interned indices against the
242/// installed registry's vocabularies and applies them to the engine
243/// `BattlerState`. All methods are pure / RNG-free (the trait contract).
244#[derive(Debug, Default, Clone, Copy)]
245pub struct GenericBindings;
246
247impl GenericBindings {
248    /// The canonical stat key (`"attack"` …) for an interned stat index.
249    fn stat_key(stat_index: usize) -> Option<String> {
250        let host = GenericProvider::rules_host()?;
251        let name = host.compiled.stats.get(stat_index)?;
252        Some(normalize_stat_key(name))
253    }
254
255    /// The ruleset's type name for an interned chart index.
256    fn type_name(type_index: usize) -> Option<String> {
257        let host = GenericProvider::rules_host()?;
258        host.compiled.types.get(type_index).cloned()
259    }
260}
261
262impl RuleBindings<GenericProvider> for GenericBindings {
263    fn apply_boost(&self, b: &mut BattlerState<GenericProvider>, stat_index: usize, stages: i8) -> bool {
264        if Self::stat_key(stat_index).is_none() {
265            return false;
266        }
267        let id = StatId(stat_index as u16);
268        let cur = b.stat_stages.get(id).copied().unwrap_or(0);
269        b.stat_stages.set(id, (cur + stages).clamp(-MAX_STAGE, MAX_STAGE));
270        true
271    }
272
273    fn set_status(&self, b: &mut BattlerState<GenericProvider>, status_index: usize) -> bool {
274        b.status = Some(StatusId(status_index as u16));
275        true
276    }
277
278    fn has_type(&self, b: &BattlerState<GenericProvider>, type_index: usize) -> bool {
279        match (Self::type_name(type_index), &b.species.element) {
280            (Some(name), Some(element)) => name.eq_ignore_ascii_case(element),
281            _ => false,
282        }
283    }
284
285    /// The chart fold for the in-flight move type vs the defender's element,
286    /// read from the COMPILED RON `type_chart` (the data layer owns the
287    /// relation). An untyped defender is neutral.
288    fn type_chart_mult(
289        &self,
290        ctx: &BattleCtx<'_, GenericProvider>,
291        move_type_index: usize,
292        defender: BattlerRef,
293    ) -> (u32, u32) {
294        let Some(host) = GenericProvider::rules_host() else {
295            return (1, 1);
296        };
297        let Some(element) = &ctx.battler(defender).species.element else {
298            return (1, 1);
299        };
300        let Some(def_index) = host
301            .compiled
302            .types
303            .iter()
304            .position(|t| t.eq_ignore_ascii_case(element))
305        else {
306            return (1, 1);
307        };
308        host.compiled.chart_mult(move_type_index, def_index)
309    }
310
311    fn make_volatile(&self, name: &str, amount: u16) -> Option<VolatileKind> {
312        Some(VolatileKind {
313            name: name.to_string(),
314            amount,
315        })
316    }
317
318    fn has_volatile(&self, ctx: &BattleCtx<'_, GenericProvider>, who: BattlerRef, name: &str) -> bool {
319        ctx.effects
320            .iter()
321            .any(|e| e.host == who && e.kind.name == name)
322    }
323
324    fn battler_level(&self, b: &BattlerState<GenericProvider>) -> u16 {
325        u16::from(b.level)
326    }
327
328    fn has_status(&self, b: &BattlerState<GenericProvider>, status_index: usize) -> bool {
329        b.status == Some(StatusId(status_index as u16))
330    }
331
332    fn has_any_status(&self, b: &BattlerState<GenericProvider>) -> bool {
333        b.status.is_some()
334    }
335
336    // Resource ops use the defaults: resource index ↔ engine id is the
337    // identity, and the pool lives on `BattlerState.resources` (id 0 = the
338    // manifest resource — mirrored from the combatant's MP by the runner).
339}
340
341// ── compile / validate ──────────────────────────────────────────────────────
342
343/// The closed status vocabulary: the ruleset's `kind: Status` record ids, in
344/// declaration order. Supplied to the loader at compile so
345/// `InflictStatus(status: "...")` / `TargetHasStatus("...")` validate at LOAD.
346pub fn status_index_of(ruleset: &Ruleset, name: &str) -> Option<usize> {
347    ruleset
348        .effects
349        .iter()
350        .filter(|r| r.kind == EffectKind::Status)
351        .position(|r| r.id == name)
352}
353
354/// The status record ids in declaration order (`StatusId(idx)` → record id —
355/// the residual pass's effect source and the narrator's display name).
356pub fn status_names(ruleset: &Ruleset) -> Vec<String> {
357    ruleset
358        .effects
359        .iter()
360        .filter(|r| r.kind == EffectKind::Status)
361        .map(|r| r.id.clone())
362        .collect()
363}
364
365/// Compile a [`Ruleset`] into the generic registry, validating every name
366/// against the closed vocabulary NOW (unknown event/op/stat/type/resource/
367/// status ⇒ [`LoadError`] at load, never mid-battle).
368pub fn compile_ruleset(ruleset: &Ruleset) -> Result<CompiledRuleset, LoadError> {
369    CompiledRuleset::compile::<GenericProvider, GenericBindings>(
370        ruleset,
371        DATA_ID_BASE,
372        &GenericBindings,
373        |name| status_index_of(ruleset, name),
374    )
375}
376
377/// The `kind: Move` records' override map (skill id → [`RonMove`]). `cost:`
378/// entries naming the manifest `resource` become the skill's MP cost
379/// (summed); a record with no `cost:` leaves the table record's cost in
380/// place.
381pub fn ron_moves(ruleset: &Ruleset, resource: Option<&str>) -> HashMap<String, RonMove> {
382    ruleset
383        .effects
384        .iter()
385        .filter(|r| r.kind == EffectKind::Move)
386        .map(|rec| {
387            let cost = if rec.cost.is_empty() {
388                None
389            } else {
390                resource.map(|res| {
391                    rec.cost
392                        .iter()
393                        .filter(|c| c.resource == res)
394                        .map(|c| u32::from(c.amount))
395                        .sum()
396                })
397            };
398            (
399                rec.id.clone(),
400                RonMove {
401                    power: rec.power,
402                    accuracy: rec.accuracy,
403                    mtype: rec.mtype.clone(),
404                    cost,
405                },
406            )
407        })
408        .collect()
409}
410
411/// Full closed-vocabulary validation of a `rules.ron` text (the `dotzuki check`
412/// path): parse + compile. Returns one diagnostic per problem (empty = clean).
413pub fn validate_ruleset(rules_text: &str) -> Vec<String> {
414    match Ruleset::from_ron(rules_text) {
415        Err(e) => vec![e.to_string()],
416        Ok(ruleset) => match compile_ruleset(&ruleset) {
417            Ok(_) => Vec::new(),
418            Err(e) => vec![e.to_string()],
419        },
420    }
421}
422
423// ── the per-battle hook state ───────────────────────────────────────────────
424
425/// A `kind: Move` RON record's overrides for the matching skill-table record.
426/// Fields left `None` fall back to the table record.
427#[derive(Debug, Clone, Default)]
428pub struct RonMove {
429    /// Base power override.
430    pub power: Option<u32>,
431    /// Accuracy override.
432    pub accuracy: Option<u32>,
433    /// Attacking element override (the record's `type:`).
434    pub mtype: Option<String>,
435    /// Resource cost override (the record's `cost:` entries naming the
436    /// manifest resource, summed).
437    pub cost: Option<u32>,
438}
439
440/// The per-battle hook machinery: both combatants mirrored as engine
441/// [`BattlerState`]s (side 0 = player, side 1 = enemy), the effect arena, the
442/// per-action scratch, and the built effect registry (one leaked `Effect` per
443/// compiled hook — the deliberate one-time leak, minimon/wuxia precedent).
444pub struct HookState {
445    /// The engine-side battle state (the interpreter's mutation target).
446    pub state: BattleState<GenericProvider>,
447    /// The live volatile arena.
448    pub effects: Vec<EffectState<GenericProvider>>,
449    /// Per-action scratch (`damage`, `last_damage`).
450    pub mv: MoveContext,
451    /// Every synthesized per-hook effect (filtered by source record id at
452    /// fire time).
453    pub registry: Vec<&'static Effect<GenericProvider>>,
454    /// Skill id → its `kind: Move` RON record's overrides.
455    pub move_records: HashMap<String, RonMove>,
456    /// `StatusId(idx)` → the status record id (residual source + narration).
457    pub status_names: Vec<String>,
458    /// The interned stat names (the ruleset's `stats:` list, in order).
459    pub stat_names: Vec<String>,
460    /// Whether the manifest maps a resource field (the MP pool mirror).
461    pub has_resource: bool,
462}
463
464impl HookState {
465    /// The battler ref of one side (1v1: slot 0).
466    pub fn battler_ref(side: super::Side) -> BattlerRef {
467        match side {
468            super::Side::Player => BattlerRef::PLAYER,
469            super::Side::Enemy => BattlerRef::OPPONENT,
470        }
471    }
472
473    /// The engine battler for a side.
474    pub fn battler(&self, side: super::Side) -> &BattlerState<GenericProvider> {
475        let r = Self::battler_ref(side);
476        if r.side == 0 {
477            &self.state.player_battlers[r.slot as usize]
478        } else {
479            &self.state.opponent_battlers[r.slot as usize]
480        }
481    }
482
483    /// Whether the skill's RON record subscribes to `event` (used to decide
484    /// gate/fold firing and the no-hooks chart fallback).
485    pub fn subscribes(&self, skill_id: &str, event: Event) -> bool {
486        let Some(host) = GenericProvider::rules_host() else {
487            return false;
488        };
489        host.compiled
490            .hooks
491            .values()
492            .any(|h| h.source_id == skill_id && h.event == event)
493    }
494}
495
496/// The runner's byte-stream rng as the engine's rng trait (one `next_u8` per
497/// `byte`, so a scripted stream replays a battle exactly — accuracy, variance,
498/// crit, then the hooks' `chance` gates, in fire order).
499pub struct RngAdapter<'a>(pub &'a mut dyn super::BattleRng);
500
501impl EngineRng for RngAdapter<'_> {
502    fn next_u8(&mut self) -> u8 {
503        self.0.byte()
504    }
505}
506
507// ── Combatant ↔ BattlerState mirroring ──────────────────────────────────────
508//
509// The v1 `Combatant` stays the loop/UI authority for HP/MP/stages; the mirror
510// is the interpreter's mutation target. The runner syncs Combatant → mirror
511// before an action's event sequence and mirror → Combatant after, so v1 paths
512// (a non-RON skill mid-battle) and hook paths never diverge. The non-volatile
513// status rides the same sync as the record-id string on the Combatant (v2-b:
514// statuses persist on the party member across switches and battles); an id
515// outside the RON `kind: Status` vocabulary reads as no status. HP/MP clamp
516// into the engine's u16.
517
518/// A combatant's raw stat by RON stat name (canonical key mapping).
519fn raw_stat(c: &Combatant, name: &str) -> u32 {
520    match normalize_stat_key(name).as_str() {
521        "hp" => c.max_hp,
522        "defense" => c.defense,
523        "speed" => c.speed,
524        _ => c.attack,
525    }
526}
527
528/// The mirror status for a combatant's status record id (unknown ids drop —
529/// a status name outside the ruleset's `kind: Status` vocabulary can't be
530/// interpreted).
531fn status_id_of(status: &Option<String>, status_names: &[String]) -> Option<StatusId> {
532    status
533        .as_ref()
534        .and_then(|name| status_names.iter().position(|n| n == name))
535        .map(|idx| StatusId(idx as u16))
536}
537
538/// Build the engine mirror of a combatant (its CURRENT pools — HP/MP/stages
539/// and status; used at battle start and on switch-in).
540pub fn mirror_of(
541    c: &Combatant,
542    stat_names: &[String],
543    status_names: &[String],
544    has_resource: bool,
545) -> BattlerState<GenericProvider> {
546    let mut b = BattlerState::new(
547        SpeciesData {
548            element: c.element.clone(),
549        },
550        c.hp.min(u32::from(u16::MAX)) as u16,
551        c.max_hp.min(u32::from(u16::MAX)) as u16,
552        EnumMap::default(),
553        c.skills.clone(),
554    )
555    .with_level(c.level);
556    b.status = status_id_of(&c.status, status_names);
557    sync_to_mirror(c, &mut b, stat_names, status_names, has_resource);
558    b
559}
560
561/// Copy the mutable pools (HP/MP/levels/stats/stages/status) Combatant →
562/// mirror.
563pub fn sync_to_mirror(
564    c: &Combatant,
565    b: &mut BattlerState<GenericProvider>,
566    stat_names: &[String],
567    status_names: &[String],
568    has_resource: bool,
569) {
570    b.hp = c.hp.min(u32::from(u16::MAX)) as u16;
571    b.max_hp = c.max_hp.min(u32::from(u16::MAX)) as u16;
572    b.level = c.level;
573    b.status = status_id_of(&c.status, status_names);
574    for (i, name) in stat_names.iter().enumerate() {
575        let id = StatId(i as u16);
576        b.stats.set(id, raw_stat(c, name).min(u32::from(u16::MAX)) as u16);
577        b.stat_stages.set(id, c.stages.get(name));
578    }
579    if has_resource {
580        b.resources.set(
581            0,
582            c.mp.min(u32::from(u16::MAX)) as u16,
583            c.max_mp.min(u32::from(u16::MAX)) as u16,
584        );
585    }
586}
587
588/// Copy the pools back mirror → Combatant (after each event fire).
589pub fn sync_from_mirror(
590    b: &BattlerState<GenericProvider>,
591    c: &mut Combatant,
592    stat_names: &[String],
593    status_names: &[String],
594    has_resource: bool,
595) {
596    c.hp = u32::from(b.hp);
597    c.max_hp = u32::from(b.max_hp);
598    c.status = b
599        .status
600        .as_ref()
601        .and_then(|id| status_names.get(id.0 as usize).cloned());
602    for (i, name) in stat_names.iter().enumerate() {
603        let id = StatId(i as u16);
604        if let Some(stage) = b.stat_stages.get(id) {
605            c.stages.set(name, *stage);
606        }
607    }
608    if has_resource {
609        c.mp = u32::from(b.resources.current(0).unwrap_or(0));
610        c.max_mp = u32::from(b.resources.max(0).unwrap_or(0));
611    }
612}