Skip to main content

dotzuki_runner/
battle.rs

1//! The generic, data-driven battle system for `dotzuki run` (parties + items).
2//!
3//! A project opts in with a top-level `battle` section in its
4//! `.dotzuki-editor.json` (see [`crate::manifest::BattleSection`] and
5//! `docs/game-project-spec.md`). Combatants are plain data-table records
6//! (`<dataRoot>/<tableDir>/<id>.json`); skills are records of the skills
7//! table; the optional rules file (dotzuki-rules `Ruleset` RON) contributes the
8//! type chart and — when it declares `effects` — **RON effect hooks** (v2-a):
9//! `kind: Move` records take over the matching skills and `kind: Status`
10//! records define statuses, all executed through the engine's effect-stack
11//! interpreter (see [`hooks`]). This module owns everything battle-side:
12//! config resolution, record loading, the damage formula, the turn loop, and
13//! the placeholder battle screen.
14//!
15//! # The standard formula (v1)
16//!
17//! Per damaging hit, all integer math:
18//!
19//! 1. `eff = raw × stage_mult` per stat — stage ∈ −4..=+4, ×(4+stage)/4 for
20//!    positive stages, ×4/(4−stage) for negative (+1 = ×1.25, −1 = ×0.8).
21//! 2. `base = power × eff_atk / max(1, eff_def)`.
22//! 3. variance: `× (85 + rng%16) / 100` (one rng byte).
23//! 4. crit: one rng byte; `rng % 16 == 0` (1/16) ⇒ ×3/2.
24//! 5. effectiveness: ×`num`/`den` from the type chart (skill `element` vs the
25//!    defender's `element` field; no edge ⇒ 1×).
26//! 6. `damage = max(1, …)`.
27//!
28//! Accuracy: one rng byte; the hit lands iff `rng % 100 < accuracy`. Every
29//! skill use consumes the accuracy byte first; damaging skills then consume
30//! the variance and crit bytes (heal/buff/debuff consume only accuracy).
31//!
32//! # Parties (v2-b)
33//!
34//! The player's party is EVERY record of the party table (sorted by record
35//! id; a 1-record party behaves like v1). The runner owns the persistent
36//! party state — each member's current HP/MP/status survives between
37//! battles (a member at 0 HP stays fainted until healed) — while base stats
38//! are rebuilt from the records at every battle start. The first LIVING
39//! member leads. The root menu offers **Fight** (the skill menu), **Party**
40//! (switch to a living non-active member — consumes the player's turn), and
41//! **Item** (when the manifest has an `items` block). When the active member
42//! faints the player is FORCED to pick a replacement (a free action; the
43//! enemy's deferred action then resolves against the new member); with no
44//! living member left, the battle is lost. Stat stages reset on switch-in;
45//! statuses persist with the member (the RON mirror is re-built from the
46//! member's current state on switch).
47//!
48//! # Items (v2-b)
49//!
50//! With a manifest `items` block (`{ table, healField, starting }`), records
51//! of the items table with a positive `healField` number are battle-usable:
52//! the Item menu lists them while the inventory count is positive, and using
53//! one heals the active member (capped at max), decrements the count, and
54//! consumes the player's turn. The runner owns the inventory between battles
55//! (initialized from `starting`). Free-text `effect` fields are display-only.
56//!
57//! # Turn loop (v1, kept for v2)
58//!
59//! The loop is intentionally **not** the engine's `StackDriver`: the stack
60//! engine has no Switch/Item/Run surface. The loop is a tiny phase machine
61//! instead: root menu → submenu → narration → won/lost. Per round the player
62//! picks a skill (unaffordable MP costs are unselectable), the enemy AI picks
63//! its highest-power affordable skill (fallback: its first affordable skill,
64//! else the built-in Attack); the faster side (eff speed) acts first, ties go
65//! to the player; each action re-checks the MP gate, rolls accuracy, resolves
66//! the skill and narrates. Switch/Item rounds act player-first.
67//!
68//! # RON effect hooks (v2-a)
69//!
70//! When the rules file declares `effects`, a `kind: Move` record whose `id`
71//! matches a skill id **takes over that skill**: its `power`/`type`/
72//! `accuracy`/`cost` fields override the table record (absent fields fall
73//! back), and the action runs through the stack interpreter instead of the
74//! built-in category behavior — MP gate → accuracy → damage precompute (the
75//! v1 formula → `ctx.mv.damage`) → `BeforeMove` gate (if subscribed) →
76//! `ModifyDamage` → `Effectiveness` → `Damage` → `DamagingHit` → `AfterMove`
77//! (the minimon/wuxia fire order). When the record subscribes to
78//! `Effectiveness`, the hooks own the scaling (author `ApplyTypeChart` for
79//! the chart); otherwise the v1 direct chart application applies in the
80//! precompute. A `kind: Status` record defines a status for
81//! `InflictStatus{status}` ops; its `Residual` hooks run after the afflicted
82//! combatant's action. Skills with NO matching RON record keep the v1
83//! built-in category behavior byte-for-byte (full backwards compat). The
84//! engine mirrors track the ACTIVE member — re-built on switch-in (stages
85//! reset, status carried, the old battler's volatiles dropped).
86//!
87//! # EXP & levels (v2-c)
88//!
89//! With an optional `battle.levels` manifest block (all keys optional:
90//! `{ expField: "exp", levelField: "level", curve: { base: 8, exponent: 3 },
91//! growth: 0.05, maxLevel: 100 }`), a combatant's effective stat is
92//! `floor(raw × (1 + growth × (level − 1)))` — applied wherever raw record
93//! stats are read (battle build, the RON mirror rides the same values, the
94//! menu Party view), with the level coming from the record's `levelField`
95//! (default 1 ⇒ ×1, numerically identical to v1). On a win each NON-fainted
96//! party member gains the enemy's `expField` value (0 when absent), then
97//! levels up while `exp >= exp_to_next(level)` and `level < maxLevel`
98//! (`exp_to_next(L) = curve.base × L^curve.exponent`); a level-up recomputes
99//! the member's stats and heals the max-HP/MP DELTAS into the current pools.
100//! Per-member `level` + `exp` ride the persistent party state and the save.
101//! Without the block nothing changes: no EXP narration, no growth.
102//!
103//! # Encounters, trainer battles & Run (v2-d)
104//!
105//! An optional `battle.encounters` block (`{ "table": "encounters" }`) names
106//! an ENCOUNTER table whose records describe enemy parties:
107//! `{ "id", "name", "enemies": ["slime", "bat"], "trainer": true, "money": 80 }`.
108//! `startBattle("x")` resolves in this order: an encounter record `x` (when
109//! the block is set) → a single enemy record `x` (implicitly wild, v1
110//! behavior) → the first enemy record + a warning. Unknown enemy ids INSIDE
111//! an encounter record are a clear error at battle start (and a `dotzuki check`
112//! schema diagnostic covers the block itself). In an encounter battle the
113//! enemy side is a QUEUE: the active enemy faints → the next is sent out
114//! (narrated `"Foe sent out Bat!"`, a fresh combatant with its own stats and
115//! no status; the RON mirror is rebuilt and its volatiles dropped) — the
116//! round then ends. The battle is won when the queue empties; the EXP award
117//! is the SUM of every defeated enemy's `expField`. A trainer encounter
118//! (`trainer: true`, default false) pays its `money` (default 0) on a win
119//! (narrated `"Got 80 G for winning!"`) and BLOCKS the Run action.
120//!
121//! The root menu's **Run** entry (Fight/Party/Item/Run) ends a WILD battle
122//! on the spot — narration `"Got away safely!"`, outcome `"run"` (no
123//! EXP/money; the party state carries over as after any battle) — and is
124//! blocked in trainer battles (`"Can't escape from a trainer battle!"`, the
125//! turn NOT consumed). Scenes branching on `result == "win"` treat `"run"`
126//! as not-won (the third outcome string).
127//!
128//! # Abilities, held items & weather (v2-e)
129//!
130//! The remaining RON kinds are live. A combatant record's optional `ability`
131//! field names a `kind: Ability` record; its `SwitchIn` hooks fire at battle
132//! start and on every switch-in of the ACTIVE combatant (benched members'
133//! abilities are inert), narrated with an intro line (`"Aria's
134//! Intimidate!"`), and its hooks also join the acting combatant's per-action
135//! event sequence (an ability hooking `ModifyDamage` fires alongside the
136//! skill's hooks). A record's optional `heldItem` field names a `kind: Item`
137//! record: its hooks fire the same way, with `Residual` hooks running after
138//! each of the holder's actions (Leftovers-style heal). Held items are
139//! persistent flags — nothing consumes them (berries are out of scope). A
140//! `kind: Weather` record's `FieldResidual` hooks fire on each combatant's
141//! residual while the weather is active; a scene arms the weather with
142//! `setWeather("sandstorm")` / clears it with `clearWeather()` before
143//! `startBattle` — the weather is battle-local (narrated at battle start,
144//! dropped at battle end, never saved).
145//!
146//! # Remaining limits (documented in the spec)
147//!
148//! Volatiles are basic (arena + `HasVolatile` only); HP/MP clamp into the
149//! engine's `u16` pools for RON skills; items only heal (no status cures /
150//! battle-only effects) and held items are never consumed; a defender's
151//! ability/held-item hooks do not join the ATTACKER's per-action sequence
152//! (they fire on their own switch-in/residual only); weather is armed from
153//! scenes, not by in-battle ops. A lost battle returns `"lose"` to the scene
154//! and then triggers the runner's game-over whiteout (see `game::menu`).
155
156use std::collections::{HashMap, VecDeque};
157use std::sync::Arc;
158
159use anyhow::{Context, Result};
160use dotzuki_engine::battle::stack::{collect_handlers, run_event, BattleCtx, Event, RelayVar};
161use dotzuki_engine::battle::{BattleState, BattlerRef};
162use dotzuki_engine::menu::MenuConfig;
163use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
164use dotzuki_renderer::embedded_font;
165use dotzuki_renderer::input::{GbButton, InputState};
166use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
167use dotzuki_ui::FrameBufferPainter;
168
169use crate::game::{draw_textbox, DIALOG_AREA, SCREEN_H, SCREEN_W};
170use crate::manifest::{BattleLevels, BattleStats, DEFAULT_RULES_FILE};
171use crate::project::LoadedProject;
172use crate::vfs::{join_path, ProjectFiles};
173
174use hooks::{GenericProvider, HookState};
175use dotzuki_rules::RulesProvider;
176
177pub mod hooks;
178
179/// The built-in fallback skill every combatant can use (no cost).
180pub const BASIC_ATTACK_NAME: &str = "Attack";
181/// Built-in Attack power.
182pub const BASIC_ATTACK_POWER: u32 = 40;
183/// Built-in Attack accuracy.
184pub const BASIC_ATTACK_ACCURACY: u32 = 100;
185/// Stat stages clamp to −4..=+4.
186const MAX_STAGE: i8 = 4;
187
188// ── rng ─────────────────────────────────────────────────────────────────────
189
190/// The battle's entropy source: a byte stream. Accuracy, variance and crit
191/// all fold bytes, so a scripted stream replays a battle exactly (tests).
192pub trait BattleRng {
193    /// Draw the next byte.
194    fn byte(&mut self) -> u8;
195}
196
197/// Production rng: a xorshift64\* PRNG seeded per battle.
198pub struct XorshiftRng(u64);
199
200impl XorshiftRng {
201    /// Seed a generator (a zero seed is remapped — xorshift sticks at 0).
202    pub fn seed(seed: u64) -> Self {
203        Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
204    }
205}
206
207impl BattleRng for XorshiftRng {
208    fn byte(&mut self) -> u8 {
209        let mut x = self.0;
210        x ^= x >> 12;
211        x ^= x << 25;
212        x ^= x >> 27;
213        self.0 = x;
214        (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8
215    }
216}
217
218/// Deterministic byte-scripted rng (tests / `RunnerOptions::rng_script`).
219/// Bytes are consumed in order, cycling back to the start on exhaustion, so a
220/// short script can drive an arbitrarily long battle. An empty script yields
221/// zeros (always hits, min variance, always crits).
222pub struct ScriptedRng {
223    bytes: Vec<u8>,
224    idx: usize,
225}
226
227impl ScriptedRng {
228    /// A scripted stream cycling over `bytes`.
229    pub fn new(bytes: Vec<u8>) -> Self {
230        Self { bytes, idx: 0 }
231    }
232}
233
234impl BattleRng for ScriptedRng {
235    fn byte(&mut self) -> u8 {
236        if self.bytes.is_empty() {
237            return 0;
238        }
239        let b = self.bytes[self.idx];
240        self.idx = (self.idx + 1) % self.bytes.len();
241        b
242    }
243}
244
245// ── type chart ──────────────────────────────────────────────────────────────
246
247/// The `(attacking element, defending element) → [num, den]` relation parsed
248/// from the rules file's `type_chart`. Element names match case-insensitively;
249/// missing pairs are neutral (1×).
250#[derive(Debug, Default, Clone)]
251pub struct TypeChart {
252    edges: HashMap<(String, String), (u32, u32)>,
253}
254
255impl TypeChart {
256    /// Build a chart from a parsed dotzuki-rules [`dotzuki_rules::Ruleset`].
257    pub fn from_ruleset(ruleset: &dotzuki_rules::Ruleset) -> Self {
258        let mut edges = HashMap::new();
259        for entry in &ruleset.type_chart {
260            edges.insert(
261                (entry.atk.to_lowercase(), entry.def.to_lowercase()),
262                (entry.mult.num, entry.mult.den.max(1)),
263            );
264        }
265        Self { edges }
266    }
267
268    /// The `[num, den]` multiplier for an attack of element `atk` against a
269    /// defender of element `def`; `(1, 1)` when either side is untyped or the
270    /// pair has no edge.
271    pub fn mult(&self, atk: Option<&str>, def: Option<&str>) -> (u32, u32) {
272        let (Some(atk), Some(def)) = (atk, def) else {
273            return (1, 1);
274        };
275        self.edges
276            .get(&(atk.to_lowercase(), def.to_lowercase()))
277            .copied()
278            .unwrap_or((1, 1))
279    }
280}
281
282// ── skills ──────────────────────────────────────────────────────────────────
283
284/// What a skill does when it connects.
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum SkillCategory {
287    /// Deal `power`-based damage to the target.
288    Damage,
289    /// Restore the user's HP by `power` (capped at max).
290    Heal,
291    /// Raise one of the user's stat stages by 1.
292    Buff,
293    /// Lower one of the target's stat stages by 1.
294    Debuff,
295}
296
297/// A usable skill (a record of the skills table, or the built-in Attack).
298#[derive(Debug, Clone)]
299pub struct Skill {
300    /// Record id (`"basic"` for the built-in Attack).
301    pub id: String,
302    /// Display name.
303    pub name: String,
304    /// Base power (damage, or heal amount); 0 for pure stage skills.
305    pub power: u32,
306    /// Accuracy percent (hit iff `rng % 100 < accuracy`).
307    pub accuracy: u32,
308    /// Optional attacking element (type-chart lookups).
309    pub element: Option<String>,
310    /// What the skill does (v1 built-in behavior; unused when `ron` is set).
311    pub category: SkillCategory,
312    /// Stat key (`"attack"` / `"defense"` / `"speed"` / `"hp"`) a buff/debuff
313    /// moves; default `"attack"`.
314    pub stat: String,
315    /// Resource (MP) cost.
316    pub cost: u32,
317    /// Whether a `kind: Move` RON record took this skill over (its hooks run
318    /// through the stack interpreter instead of the built-in category).
319    pub ron: bool,
320}
321
322/// The built-in basic Attack every combatant falls back to.
323pub fn basic_attack() -> Skill {
324    Skill {
325        id: "basic".to_string(),
326        name: BASIC_ATTACK_NAME.to_string(),
327        power: BASIC_ATTACK_POWER,
328        accuracy: BASIC_ATTACK_ACCURACY,
329        element: None,
330        category: SkillCategory::Damage,
331        stat: "attack".to_string(),
332        cost: 0,
333        ron: false,
334    }
335}
336
337/// Parse a skill record. `category` comes from the configured category field
338/// (case-insensitive): `attack`/`damage` → Damage, `heal` → Heal, `buff` →
339/// Buff, `debuff` → Debuff; anything unrecognized → Damage. A matching
340/// `kind: Move` RON record then overrides `power`/`accuracy`/`element`/`cost`
341/// and marks the skill RON-driven.
342fn skill_from_record(id: &str, record: &serde_json::Value, setup: &BattleSetup) -> Skill {
343    let category = match get_str(record, &setup.category_field)
344        .unwrap_or("")
345        .to_lowercase()
346        .as_str()
347    {
348        "heal" => SkillCategory::Heal,
349        "buff" => SkillCategory::Buff,
350        "debuff" => SkillCategory::Debuff,
351        // "attack" | "damage" | unrecognized → damage.
352        _ => SkillCategory::Damage,
353    };
354    let stat = normalize_stat_key(get_str(record, "stat").unwrap_or("attack"));
355    let mut skill = Skill {
356        id: id.to_string(),
357        name: get_str(record, "name").unwrap_or(id).to_string(),
358        power: get_num(record, "power").unwrap_or(0),
359        accuracy: get_num(record, "accuracy").unwrap_or(100),
360        element: get_str(record, "element").map(str::to_string),
361        category,
362        stat,
363        cost: get_num(record, &setup.cost_field).unwrap_or(0),
364        ron: false,
365    };
366    if let Some(ron_move) = setup.ron_move(id) {
367        skill.ron = true;
368        if let Some(power) = ron_move.power {
369            skill.power = power;
370        }
371        if let Some(accuracy) = ron_move.accuracy {
372            skill.accuracy = accuracy;
373        }
374        if let Some(mtype) = &ron_move.mtype {
375            skill.element = Some(mtype.clone());
376        }
377        if let Some(cost) = ron_move.cost {
378            skill.cost = cost;
379        }
380    }
381    skill
382}
383
384/// Map an arbitrary `stat` field value onto a known stat key.
385fn normalize_stat_key(stat: &str) -> String {
386    match stat.to_lowercase().as_str() {
387        "hp" => "hp",
388        "defense" | "def" => "defense",
389        "speed" | "spd" => "speed",
390        // "attack" | "atk" | unknown → attack.
391        _ => "attack",
392    }
393    .to_string()
394}
395
396/// Display label for a stat key ("Aria's Attack rose!").
397fn stat_label(stat: &str) -> &str {
398    match stat {
399        "hp" => "HP",
400        "defense" => "Defense",
401        "speed" => "Speed",
402        _ => "Attack",
403    }
404}
405
406// ── combatants ──────────────────────────────────────────────────────────────
407
408/// Stat stages (−4..=+4) for the four stat roles. `hp` is tracked for
409/// completeness but unused by the v1 formula.
410#[derive(Debug, Clone, Default)]
411pub struct Stages {
412    /// HP stage (unused by the v1 formula).
413    pub hp: i8,
414    /// Attack stage.
415    pub attack: i8,
416    /// Defense stage.
417    pub defense: i8,
418    /// Speed stage (affects turn order).
419    pub speed: i8,
420}
421
422impl Stages {
423    fn bump(&mut self, stat: &str, delta: i8) {
424        let stage = match stat {
425            "hp" => &mut self.hp,
426            "defense" => &mut self.defense,
427            "speed" => &mut self.speed,
428            _ => &mut self.attack,
429        };
430        *stage = (*stage + delta).clamp(-MAX_STAGE, MAX_STAGE);
431    }
432
433    /// The stage of a stat (RON stat names accepted — aliases normalize).
434    pub fn get(&self, stat: &str) -> i8 {
435        match normalize_stat_key(stat).as_str() {
436            "hp" => self.hp,
437            "defense" => self.defense,
438            "speed" => self.speed,
439            _ => self.attack,
440        }
441    }
442
443    /// Set a stat's stage absolutely (clamped to ±4; the mirror sync-back).
444    pub fn set(&mut self, stat: &str, value: i8) {
445        let value = value.clamp(-MAX_STAGE, MAX_STAGE);
446        match normalize_stat_key(stat).as_str() {
447            "hp" => self.hp = value,
448            "defense" => self.defense = value,
449            "speed" => self.speed = value,
450            _ => self.attack = value,
451        }
452    }
453}
454
455/// The stage multiplier: ×(4+stage)/4 above 0, ×4/(4−stage) below (+1 =
456/// ×1.25, −1 = ×0.8). Stages clamp to ±4.
457pub fn stage_multiplier(raw: u32, stage: i8) -> u32 {
458    match stage.clamp(-MAX_STAGE, MAX_STAGE) {
459        s if s >= 0 => raw * (4 + s as u32) / 4,
460        s => raw * 4 / (4 - s) as u32,
461    }
462}
463
464/// A combatant's raw record stats, before the level-growth multiplier
465/// (v2-c). Equal to the effective stats when levels are off or the level is
466/// 1; the level-up recompute reads these.
467#[derive(Debug, Clone, Copy)]
468pub struct BaseStats {
469    /// Raw max HP.
470    pub max_hp: u32,
471    /// Raw max resource.
472    pub max_mp: u32,
473    /// Raw attack.
474    pub attack: u32,
475    /// Raw defense.
476    pub defense: u32,
477    /// Raw speed.
478    pub speed: u32,
479}
480
481/// The level-growth multiplier (v2-c): `floor(raw × (1 + growth × (level −
482/// 1)))`. Level 1 (or 0) is always ×1 — records without a level field are
483/// numerically identical to v1.
484pub fn growth_stat(raw: u32, level: u8, growth: f64) -> u32 {
485    let mult = 1.0 + growth * f64::from(level.saturating_sub(1));
486    // The tiny epsilon keeps exact products (e.g. ×1.05 of 100) from
487    // flooring one short on float representation error.
488    ((raw as f64 * mult) + 1e-9).floor().max(0.0) as u32
489}
490
491/// The exp curve (v2-c): `exp_to_next(L) = base × L^exponent` (integer,
492/// saturating).
493pub fn exp_to_next(base: u32, exponent: u32, level: u8) -> u32 {
494    u64::from(base)
495        .saturating_mul(u64::from(level).saturating_pow(exponent))
496        .min(u64::from(u32::MAX)) as u32
497}
498
499/// A live combatant: one data-table record plus per-battle state (HP/MP
500/// pools, stat stages). Rebuilt from its record for every battle (base stats
501/// always fresh); the runner re-applies the persistent party state (current
502/// HP/MP/status, v2-b; level/exp, v2-c) on top.
503#[derive(Debug, Clone)]
504pub struct Combatant {
505    /// Record id (filename stem).
506    pub id: String,
507    /// Display name (`name` field, else the id).
508    pub name: String,
509    /// Optional element (`element` field) — the defending side of chart lookups.
510    pub element: Option<String>,
511    /// HP pool.
512    pub hp: u32,
513    /// Max HP (growth-applied when the levels block is present).
514    pub max_hp: u32,
515    /// Attack (growth-applied when the levels block is present).
516    pub attack: u32,
517    /// Defense (growth-applied when the levels block is present).
518    pub defense: u32,
519    /// Speed (growth-applied when the levels block is present).
520    pub speed: u32,
521    /// The record's level field (default 1; read by level-based RON ops
522    /// and the `LevelGE` predicate). With a `levels` block it also drives
523    /// the stat-growth multiplier and level-ups; the v1 formula ignores it.
524    pub level: u8,
525    /// Raw record stats (pre-growth), for the level-up recompute.
526    pub base: BaseStats,
527    /// EXP progress toward the next level (v2-c; persists on party members).
528    pub exp: u32,
529    /// The EXP this combatant awards when defeated (its `expField`; 0 when
530    /// the levels block is absent or the field missing).
531    pub exp_reward: u32,
532    /// Stat stages (reset when the combatant switches in, v2-b).
533    pub stages: Stages,
534    /// Resource pool (0 when the manifest maps no `resource` field).
535    pub mp: u32,
536    /// Max resource.
537    pub max_mp: u32,
538    /// Move list (never empty: falls back to the built-in Attack).
539    pub skills: Vec<Skill>,
540    /// The persistent non-volatile status (a `kind: Status` RON record id),
541    /// carried between battles by the party state (v2-b).
542    pub status: Option<String>,
543    /// The combatant's ability: a `kind: Ability` RON record id (the record's
544    /// `ability` field; v2-e). Fires at battle start / switch-in (`SwitchIn`)
545    /// and joins the ACTIVE combatant's per-action event sequence. Benched
546    /// members' abilities are inert.
547    pub ability: Option<String>,
548    /// The combatant's held item: a `kind: Item` RON record id (the record's
549    /// `heldItem` field; v2-e). Fires like an ability — its `Residual` hooks
550    /// run after each of the holder's actions (Leftovers-style). Items are
551    /// persistent flags: nothing consumes them (no berries).
552    pub held_item: Option<String>,
553}
554
555impl Combatant {
556    /// Effective attack (stat × stage multiplier).
557    pub fn eff_attack(&self) -> u32 {
558        stage_multiplier(self.attack, self.stages.attack)
559    }
560    /// Effective defense.
561    pub fn eff_defense(&self) -> u32 {
562        stage_multiplier(self.defense, self.stages.defense)
563    }
564    /// Effective speed (turn order).
565    pub fn eff_speed(&self) -> u32 {
566        stage_multiplier(self.speed, self.stages.speed)
567    }
568
569    /// Recompute every effective stat from the raw record stats at the
570    /// current level (v2-c level-up): max HP/MP move with the growth
571    /// multiplier and the DELTAS heal into the current pools.
572    pub fn recompute_stats(&mut self, levels: &LevelsSetup) {
573        let grown = |raw: u32| growth_stat(raw, self.level, levels.growth);
574        let (old_hp, old_mp) = (self.max_hp, self.max_mp);
575        self.max_hp = grown(self.base.max_hp);
576        self.max_mp = grown(self.base.max_mp);
577        self.attack = grown(self.base.attack);
578        self.defense = grown(self.base.defense);
579        self.speed = grown(self.base.speed);
580        self.hp = (self.hp + self.max_hp.saturating_sub(old_hp)).min(self.max_hp);
581        self.mp = (self.mp + self.max_mp.saturating_sub(old_mp)).min(self.max_mp);
582    }
583}
584
585// ── persistent party state + items (v2-b) ───────────────────────────────────
586
587/// A party member's persistent state, owned by the runner between battles:
588/// current HP/MP, status, and (v2-c) level/exp. Base stats are rebuilt from
589/// the record at every battle start; these pools carry over (a member at 0
590/// HP stays fainted until healed).
591#[derive(Debug, Clone)]
592pub struct PartyMemberState {
593    /// Party record id.
594    pub id: String,
595    /// Current HP (0 = fainted).
596    pub hp: u32,
597    /// Current MP (resource pool).
598    pub mp: u32,
599    /// The persistent status (a `kind: Status` RON record id), if any.
600    pub status: Option<String>,
601    /// Current level (v2-c; 1 when levels are off or the save predates them).
602    pub level: u8,
603    /// EXP progress toward the next level (v2-c).
604    pub exp: u32,
605}
606
607/// A battle-usable item: a record of the items table whose heal field holds
608/// a positive number. Free-text `effect` fields are display-only.
609#[derive(Debug, Clone)]
610pub struct BattleItem {
611    /// Record id.
612    pub id: String,
613    /// Display name.
614    pub name: String,
615    /// HP restored on use (capped at max).
616    pub heal: u32,
617}
618
619// ── setup (manifest + rules resolution) ─────────────────────────────────────
620
621/// The resolved battle configuration: record directories, field mapping and
622/// the type chart. Built lazily on the first `startBattle` (projects without
623/// a `battle` section never pay for it, and projects that never battle never
624/// fail on a broken section).
625pub struct BattleSetup {
626    /// The project file backend every record read goes through.
627    files: Arc<dyn ProjectFiles>,
628    /// Record dirs are project-relative POSIX paths (VFS key prefixes).
629    party_dir: String,
630    enemies_dir: String,
631    /// The encounters table's record dir (v2-d): `None` when the manifest
632    /// has no `encounters` block (every battle is a single wild enemy).
633    encounters_dir: Option<String>,
634    skills_dir: Option<String>,
635    skills_field: String,
636    category_field: String,
637    cost_field: String,
638    stats: BattleStats,
639    resource: Option<String>,
640    chart: TypeChart,
641    ron: Option<RonSetup>,
642    /// Battle-usable items (v2-b): `None` when the manifest has no `items`
643    /// block (no Item menu).
644    items: Option<ItemsSetup>,
645    /// EXP/level growth (v2-c): `None` when the manifest has no `levels`
646    /// block (v1 behavior — no EXP, stats never grow).
647    levels: Option<LevelsSetup>,
648}
649
650/// The resolved levels half of a [`BattleSetup`] (v2-c).
651#[derive(Debug, Clone)]
652pub struct LevelsSetup {
653    /// Enemy record field holding the EXP reward.
654    pub exp_field: String,
655    /// Combatant record field holding its starting level.
656    pub level_field: String,
657    /// Curve base (`exp_to_next(L) = base × L^exponent`).
658    pub curve_base: u32,
659    /// Curve exponent.
660    pub curve_exponent: u32,
661    /// Stat growth per level above 1 (0.05 ⇒ +5% per level).
662    pub growth: f64,
663    /// Level cap.
664    pub max_level: u8,
665}
666
667impl LevelsSetup {
668    /// Resolve the manifest `levels` block.
669    fn from_manifest(levels: &BattleLevels) -> Self {
670        Self {
671            exp_field: levels.exp_field.clone(),
672            level_field: levels.level_field.clone(),
673            curve_base: levels.curve.base,
674            curve_exponent: levels.curve.exponent,
675            growth: levels.growth,
676            max_level: levels.max_level.clamp(1, 255) as u8,
677        }
678    }
679
680    /// EXP needed to advance from `level` (`base × level^exponent`).
681    pub fn exp_to_next(&self, level: u8) -> u32 {
682        exp_to_next(self.curve_base, self.curve_exponent, level)
683    }
684
685    /// The growth multiplier applied to a raw stat at `level`.
686    pub fn growth(&self, raw: u32, level: u8) -> u32 {
687        growth_stat(raw, level, self.growth)
688    }
689}
690
691/// The resolved items half of a [`BattleSetup`] (v2-b).
692struct ItemsSetup {
693    /// Records directory of the items table (project-relative POSIX path).
694    dir: String,
695    /// The record field holding the heal amount (usability gate).
696    heal_field: String,
697    /// The starting inventory (record id → count).
698    starting: HashMap<String, u32>,
699}
700
701/// A parsed encounter record (v2-d): an ordered enemy party plus the
702/// trainer flag and money reward.
703struct Encounter {
704    /// Display name (log/diagnostics only — battles narrate the enemies).
705    name: String,
706    /// Ordered enemy-table record ids (validated non-empty and known).
707    enemy_ids: Vec<String>,
708    /// Whether this is a trainer battle (blocks Run; pays `money` on a win).
709    trainer: bool,
710    /// The money reward on a win (0 default; paid only when `trainer`).
711    money: u32,
712}
713
714/// The compiled RON-hooks half of a [`BattleSetup`] (v2-a): present when the
715/// rules file declares `effects`. Built once per setup; the thread-local
716/// `RulesHost` is (re-)installed from it on every battle start.
717struct RonSetup {
718    /// The compiled registry (hooks + interned vocabularies + chart).
719    compiled: dotzuki_rules::CompiledRuleset,
720    /// One leaked `Effect` per compiled hook (the deliberate one-time leak,
721    /// minimon/wuxia precedent).
722    registry: Vec<&'static dotzuki_engine::battle::stack::Effect<GenericProvider>>,
723    /// Skill id → its `kind: Move` RON record's overrides.
724    move_records: HashMap<String, hooks::RonMove>,
725    /// `StatusId(idx)` → status record id (declaration order).
726    status_names: Vec<String>,
727    /// The ruleset's interned stat names, in order.
728    stat_names: Vec<String>,
729    /// Whether the manifest maps a resource field (the MP pool mirror).
730    has_resource: bool,
731}
732
733impl BattleSetup {
734    /// Resolve the manifest's `battle` section against the project's data
735    /// tables and rules file.
736    ///
737    /// # Errors
738    ///
739    /// Fails when the section is absent, a referenced table id names no
740    /// declared data table, or the rules file exists but does not parse.
741    pub fn from_project(project: &LoadedProject) -> Result<Self> {
742        let section = project
743            .manifest()
744            .battle
745            .as_ref()
746            .context("manifest has no battle section")?;
747
748        let table_dir = |reference: Option<&crate::manifest::BattleTableRef>,
749                         what: &str|
750         -> Result<String> {
751            let id = reference
752                .map(|r| r.table.as_str())
753                .with_context(|| format!("battle.{what}.table is required when a battle starts"))?;
754            project
755                .table_dir_rel(id)
756                .with_context(|| format!("battle.{what}.table '{id}' is not a declared data table"))
757        };
758        let party_dir = table_dir(section.party.as_ref(), "party")?;
759        let enemies_dir = table_dir(section.enemies.as_ref(), "enemies")?;
760        let encounters_dir = match &section.encounters {
761            Some(encounters) => Some(project.table_dir_rel(&encounters.table).with_context(|| {
762                format!(
763                    "battle.encounters.table '{}' is not a declared data table",
764                    encounters.table
765                )
766            })?),
767            None => None,
768        };
769        let skills_dir = match &section.skills {
770            Some(skills) => Some(project.table_dir_rel(&skills.table).with_context(|| {
771                format!("battle.skills.table '{}' is not a declared data table", skills.table)
772            })?),
773            None => None,
774        };
775        let items = match &section.items {
776            Some(items) => Some(ItemsSetup {
777                dir: project.table_dir_rel(&items.table).with_context(|| {
778                    format!("battle.items.table '{}' is not a declared data table", items.table)
779                })?,
780                heal_field: items.heal_field.clone(),
781                starting: items.starting.clone(),
782            }),
783            None => None,
784        };
785
786        let (skills_field, category_field, cost_field) = match &section.skills {
787            Some(s) => (
788                s.field.clone(),
789                s.category_field.clone(),
790                s.cost_field.clone(),
791            ),
792            None => (
793                crate::manifest::DEFAULT_SKILLS_FIELD.to_string(),
794                crate::manifest::DEFAULT_CATEGORY_FIELD.to_string(),
795                crate::manifest::DEFAULT_COST_FIELD.to_string(),
796            ),
797        };
798
799        // The rules file contributes the type chart and — when it declares
800        // `effects` — the compiled RON hook registry (v2-a). It is parsed
801        // only when it exists; a malformed registry is a boot-time
802        // error at battle start (and a `dotzuki check` diagnostic).
803        let rules_rel = section.rules.as_deref().unwrap_or(DEFAULT_RULES_FILE);
804        let rules_rel = join_path("", rules_rel);
805        let mut ron = None;
806        let chart = match project.files().read(&rules_rel) {
807            Ok(bytes) => {
808                let text = String::from_utf8(bytes)
809                    .with_context(|| format!("{rules_rel} is not UTF-8"))?;
810                let ruleset = dotzuki_rules::Ruleset::from_ron(&text)
811                    .map_err(|e| anyhow::anyhow!("failed to parse {rules_rel}: {e}"))?;
812                let chart = TypeChart::from_ruleset(&ruleset);
813                if !ruleset.effects.is_empty() {
814                    let compiled = hooks::compile_ruleset(&ruleset)
815                        .map_err(|e| anyhow::anyhow!("failed to compile {rules_rel}: {e}"))?;
816                    let registry = compiled.build_effects::<GenericProvider>();
817                    let move_records = hooks::ron_moves(&ruleset, section.resource.as_deref());
818                    ron = Some(RonSetup {
819                        compiled,
820                        registry,
821                        move_records,
822                        status_names: hooks::status_names(&ruleset),
823                        stat_names: ruleset.stats.clone(),
824                        has_resource: section.resource.is_some(),
825                    });
826                }
827                chart
828            }
829            Err(_) => TypeChart::default(),
830        };
831
832        Ok(Self {
833            files: Arc::clone(project.files()),
834            party_dir,
835            enemies_dir,
836            encounters_dir,
837            skills_dir,
838            skills_field,
839            category_field,
840            cost_field,
841            stats: section.stats.clone().unwrap_or_default(),
842            resource: section.resource.clone(),
843            chart,
844            ron,
845            items,
846            levels: section.levels.as_ref().map(LevelsSetup::from_manifest),
847        })
848    }
849
850    /// Build a battle with a FRESH party state (every member at full HP/MP,
851    /// no status) and the manifest's starting inventory. Convenience for
852    /// tests and tools; the runner calls [`start_with`](Self::start_with).
853    ///
854    /// # Errors
855    ///
856    /// Fails when the party table holds no readable record.
857    pub fn start(&self, enemy_id: &str, rng: Box<dyn BattleRng>) -> Result<Battle> {
858        self.start_with(enemy_id, rng, None, None)
859    }
860
861    /// Build a battle: the WHOLE party table (sorted by record id) against
862    /// `enemy_id`. Resolution order (v2-d): when the manifest has an
863    /// `encounters` block AND `enemy_id` names an encounter record, the
864    /// enemy side is that record's ordered enemy party (a queue) with its
865    /// trainer flag and money reward; otherwise `enemy_id` names an enemy
866    /// record (a single implicitly-wild enemy); an id in NEITHER table falls
867    /// back to the first enemy record with a warning. `party_state`
868    /// (runner-owned, from the previous battle or a save) re-applies current
869    /// HP/MP/status per member id — base stats always come fresh from the
870    /// records; `inventory` overrides the manifest's `items.starting`
871    /// counts. The first LIVING member leads; a party with no living member
872    /// arms an immediate loss.
873    ///
874    /// # Errors
875    ///
876    /// Fails when the party table holds no readable record, or when the
877    /// encounter record is malformed (no `enemies` list, or an enemy id in
878    /// it that names no enemy record).
879    pub fn start_with(
880        &self,
881        enemy_id: &str,
882        rng: Box<dyn BattleRng>,
883        party_state: Option<&[PartyMemberState]>,
884        inventory: Option<&HashMap<String, u32>>,
885    ) -> Result<Battle> {
886        let ids = record_ids(self.files.as_ref(), &self.party_dir);
887        if ids.is_empty() {
888            anyhow::bail!("party table {} has no records", self.party_dir);
889        }
890        let mut party = Vec::with_capacity(ids.len());
891        for id in &ids {
892            let mut c = self.load_combatant(&self.party_dir, id)?;
893            if let Some(state) = party_state.and_then(|states| states.iter().find(|s| &s.id == id))
894            {
895                // v2-c: the persistent level/exp overrides the record's —
896                // the stats are re-grown first, then the pools clamp in.
897                if let Some(levels) = &self.levels {
898                    c.level = state.level.max(1);
899                    c.exp = state.exp;
900                    c.recompute_stats(levels);
901                }
902                c.hp = state.hp.min(c.max_hp);
903                c.mp = state.mp.min(c.max_mp);
904                c.status = state.status.clone();
905            }
906            party.push(c);
907        }
908        let active = party.iter().position(|c| c.hp > 0).unwrap_or(0);
909
910        // v2-d: an encounter record takes precedence over a single-enemy
911        // lookup; both miss ⇒ the v1 first-record fallback.
912        let (enemy, rest, trainer, money) = match self.load_encounter(enemy_id)? {
913            Some(encounter) => {
914                let mut enemies = Vec::with_capacity(encounter.enemy_ids.len());
915                for id in &encounter.enemy_ids {
916                    enemies.push(self.load_combatant(&self.enemies_dir, id)?);
917                }
918                let mut enemies = enemies.into_iter();
919                let first = enemies.next().expect("encounter enemies non-empty");
920                let money = if encounter.trainer { encounter.money } else { 0 };
921                log::info!(
922                    "encounter '{}' ({}): {} enemies, trainer {}",
923                    enemy_id,
924                    encounter.name,
925                    encounter.enemy_ids.len(),
926                    encounter.trainer
927                );
928                (first, enemies.collect(), encounter.trainer, money)
929            }
930            None => {
931                let enemy_id = if self
932                    .files
933                    .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
934                {
935                    enemy_id.to_string()
936                } else {
937                    let fallback =
938                        first_record_id(self.files.as_ref(), &self.enemies_dir).with_context(|| {
939                            format!("enemies table {} has no records", self.enemies_dir)
940                        })?;
941                    log::warn!(
942                        "unknown enemy id '{enemy_id}' — using first enemy record '{fallback}'"
943                    );
944                    fallback
945                };
946                (self.load_combatant(&self.enemies_dir, &enemy_id)?, Vec::new(), false, 0)
947            }
948        };
949
950        let items = self.load_items();
951        let inventory = inventory.cloned().unwrap_or_else(|| {
952            self.items
953                .as_ref()
954                .map(|i| i.starting.clone())
955                .unwrap_or_default()
956        });
957
958        log::info!(
959            "battle: {} (hp {}, party of {}) vs {} (hp {})",
960            party[active].name,
961            party[active].max_hp,
962            party.len(),
963            enemy.name,
964            enemy.max_hp
965        );
966        // RON hooks: (re-)install the thread-local rules host (the parallel
967        // test harness runs battles on many threads) and mirror the ACTIVE
968        // member + the enemy into the engine battle state.
969        let hook_state = self.ron.as_ref().map(|ron| {
970            hooks::install_compiled(ron.compiled.clone());
971            HookState {
972                state: BattleState::new(
973                    vec![hooks::mirror_of(
974                        &party[active],
975                        &ron.stat_names,
976                        &ron.status_names,
977                        ron.has_resource,
978                    )],
979                    vec![hooks::mirror_of(
980                        &enemy,
981                        &ron.stat_names,
982                        &ron.status_names,
983                        ron.has_resource,
984                    )],
985                ),
986                effects: Vec::new(),
987                mv: dotzuki_engine::battle::stack::MoveContext::default(),
988                registry: ron.registry.clone(),
989                move_records: ron.move_records.clone(),
990                status_names: ron.status_names.clone(),
991                stat_names: ron.stat_names.clone(),
992                has_resource: ron.has_resource,
993            }
994        });
995        let mut battle = Battle::full(
996            party, active, enemy, items, inventory, self.chart.clone(), rng, hook_state,
997        );
998        battle.set_levels(self.levels.clone());
999        battle.set_enemy_party(rest, trainer, money);
1000        if battle.party.iter().all(|c| c.hp == 0) {
1001            battle.arm_loss();
1002        }
1003        Ok(battle)
1004    }
1005
1006    /// Parse the encounter record `id` (v2-d): `None` when the manifest has
1007    /// no encounters table or the id names no record in it. An `enemies`
1008    /// list that is missing/empty or references an unknown enemy record is a
1009    /// hard error (a clear battle-start failure, never a silent fallback).
1010    fn load_encounter(&self, id: &str) -> Result<Option<Encounter>> {
1011        let Some(dir) = &self.encounters_dir else {
1012            return Ok(None);
1013        };
1014        if !self.files.exists(&format!("{dir}/{id}.json")) {
1015            return Ok(None);
1016        }
1017        let record = read_record(self.files.as_ref(), dir, id)?;
1018        let enemy_ids: Vec<String> = record
1019            .get("enemies")
1020            .and_then(|v| v.as_array())
1021            .map(|ids| {
1022                ids.iter()
1023                    .filter_map(|v| v.as_str().map(str::to_string))
1024                    .collect()
1025            })
1026            .unwrap_or_default();
1027        if enemy_ids.is_empty() {
1028            anyhow::bail!("encounter '{id}' has no 'enemies' list (or it is empty)");
1029        }
1030        for enemy_id in &enemy_ids {
1031            if !self
1032                .files
1033                .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
1034            {
1035                anyhow::bail!(
1036                    "encounter '{id}' references unknown enemy id '{enemy_id}' \
1037                     (no record in the enemies table)"
1038                );
1039            }
1040        }
1041        Ok(Some(Encounter {
1042            name: get_str(&record, "name").unwrap_or(id).to_string(),
1043            enemy_ids,
1044            trainer: record
1045                .get("trainer")
1046                .and_then(|v| v.as_bool())
1047                .unwrap_or(false),
1048            money: get_num(&record, "money").unwrap_or(0),
1049        }))
1050    }
1051
1052    /// The battle-usable items: every record of the items table whose heal
1053    /// field holds a positive number (sorted by record id). Empty when the
1054    /// manifest has no `items` block.
1055    fn load_items(&self) -> Vec<BattleItem> {
1056        let Some(items) = &self.items else {
1057            return Vec::new();
1058        };
1059        let mut out = Vec::new();
1060        for id in record_ids(self.files.as_ref(), &items.dir) {
1061            let record = match read_record(self.files.as_ref(), &items.dir, &id) {
1062                Ok(record) => record,
1063                Err(e) => {
1064                    log::warn!("item record '{id}' skipped: {e:#}");
1065                    continue;
1066                }
1067            };
1068            let heal = get_num(&record, &items.heal_field).unwrap_or(0);
1069            if heal > 0 {
1070                out.push(BattleItem {
1071                    name: get_str(&record, "name").unwrap_or(&id).to_string(),
1072                    id,
1073                    heal,
1074                });
1075            }
1076        }
1077        out
1078    }
1079
1080    /// The `kind: Move` RON record overriding skill `id`, if any.
1081    fn ron_move(&self, id: &str) -> Option<&hooks::RonMove> {
1082        self.ron.as_ref()?.move_records.get(id)
1083    }
1084
1085    /// Load one combatant record from `dir` (stats via the field mapping,
1086    /// skills via the skills table; full HP/MP). With a `levels` block the
1087    /// stats carry the level-growth multiplier (the record's `levelField`,
1088    /// default 1 ⇒ ×1) and the enemy side reads its `expField` reward.
1089    fn load_combatant(&self, dir: &str, id: &str) -> Result<Combatant> {
1090        let record = read_record(self.files.as_ref(), dir, id)?;
1091        let level_field = self
1092            .levels
1093            .as_ref()
1094            .map(|l| l.level_field.as_str())
1095            .unwrap_or("level");
1096        let level = get_num(&record, level_field).unwrap_or(1).min(255) as u8;
1097        let stat = |field: &str| get_num(&record, field).unwrap_or(1);
1098        let base = BaseStats {
1099            max_hp: stat(&self.stats.hp),
1100            max_mp: self
1101                .resource
1102                .as_deref()
1103                .map(|f| get_num(&record, f).unwrap_or(0))
1104                .unwrap_or(0),
1105            attack: stat(&self.stats.attack),
1106            defense: stat(&self.stats.defense),
1107            speed: stat(&self.stats.speed),
1108        };
1109        let grown = |raw: u32| match &self.levels {
1110            Some(levels) => levels.growth(raw, level),
1111            None => raw,
1112        };
1113        let (max_hp, mp) = (grown(base.max_hp), grown(base.max_mp));
1114        let exp_reward = self
1115            .levels
1116            .as_ref()
1117            .and_then(|l| get_num(&record, &l.exp_field))
1118            .unwrap_or(0);
1119        Ok(Combatant {
1120            id: id.to_string(),
1121            name: get_str(&record, "name").unwrap_or(id).to_string(),
1122            element: get_str(&record, "element").map(str::to_string),
1123            hp: max_hp,
1124            max_hp,
1125            attack: grown(base.attack),
1126            defense: grown(base.defense),
1127            speed: grown(base.speed),
1128            level,
1129            base,
1130            exp: 0,
1131            exp_reward,
1132            stages: Stages::default(),
1133            mp,
1134            max_mp: mp,
1135            skills: self.load_skills(&record),
1136            status: None,
1137            ability: get_str(&record, "ability").map(str::to_string),
1138            held_item: get_str(&record, "heldItem").map(str::to_string),
1139        })
1140    }
1141
1142    /// A combatant's move list: the configured skills field (an array of
1143    /// skill ids looked up in the skills table; unknown ids are skipped with
1144    /// a warning) — or just the built-in Attack when no skills table is
1145    /// configured or the list is empty/missing.
1146    fn load_skills(&self, record: &serde_json::Value) -> Vec<Skill> {
1147        let Some(skills_dir) = &self.skills_dir else {
1148            return vec![basic_attack()];
1149        };
1150        let mut skills = Vec::new();
1151        if let Some(ids) = record.get(&self.skills_field).and_then(|v| v.as_array()) {
1152            for id in ids {
1153                let Some(id) = id.as_str() else {
1154                    continue;
1155                };
1156                match read_record(self.files.as_ref(), skills_dir, id) {
1157                    Ok(rec) => skills.push(skill_from_record(id, &rec, self)),
1158                    Err(e) => log::warn!("unknown skill id '{id}' skipped: {e:#}"),
1159                }
1160            }
1161        }
1162        if skills.is_empty() {
1163            skills.push(basic_attack());
1164        }
1165        skills
1166    }
1167}
1168
1169/// Every record id (sorted `.json` filename stems) directly in a table dir
1170/// (a project-relative POSIX path read through the VFS).
1171pub(crate) fn record_ids(files: &dyn ProjectFiles, dir: &str) -> Vec<String> {
1172    let prefix = format!("{dir}/");
1173    let mut ids: Vec<String> = files
1174        .list(dir)
1175        .into_iter()
1176        .filter_map(|p| {
1177            // Direct children only (records live flat in the table dir).
1178            let rest = p.strip_prefix(&prefix)?;
1179            if rest.contains('/') {
1180                return None;
1181            }
1182            rest.strip_suffix(".json").map(str::to_string)
1183        })
1184        .collect();
1185    ids.sort();
1186    ids.dedup();
1187    ids
1188}
1189
1190/// The first record id (sorted `.json` filename stem) in a table dir.
1191fn first_record_id(files: &dyn ProjectFiles, dir: &str) -> Option<String> {
1192    record_ids(files, dir).into_iter().next()
1193}
1194
1195/// Read and parse `<dir>/<id>.json`.
1196pub(crate) fn read_record(
1197    files: &dyn ProjectFiles,
1198    dir: &str,
1199    id: &str,
1200) -> Result<serde_json::Value> {
1201    let rel = format!("{dir}/{id}.json");
1202    let bytes = files
1203        .read(&rel)
1204        .with_context(|| format!("failed to read {rel}"))?;
1205    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {rel}"))
1206}
1207
1208/// A record's numeric field (accepts ints and floats); `None` when missing
1209/// or not a number.
1210pub(crate) fn get_num(record: &serde_json::Value, field: &str) -> Option<u32> {
1211    match record.get(field)? {
1212        serde_json::Value::Number(n) => n
1213            .as_u64()
1214            .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
1215            .map(|v| v.min(u32::MAX as u64) as u32),
1216        _ => None,
1217    }
1218}
1219
1220/// A record's string field; `None` when missing or not a string.
1221pub(crate) fn get_str<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1222    record.get(field).and_then(|v| v.as_str())
1223}
1224
1225// ── the formula ─────────────────────────────────────────────────────────────
1226
1227/// Accuracy roll: the hit lands iff `rng % 100 < accuracy`.
1228pub fn accuracy_roll(accuracy: u32, rng: &mut dyn BattleRng) -> bool {
1229    u32::from(rng.byte() % 100) < accuracy
1230}
1231
1232/// The outcome of one damaging hit.
1233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1234pub struct DamageRoll {
1235    /// Final damage (≥ 1).
1236    pub damage: u32,
1237    /// Whether the hit was critical (×1.5).
1238    pub crit: bool,
1239    /// Effectiveness numerator.
1240    pub mult_num: u32,
1241    /// Effectiveness denominator.
1242    pub mult_den: u32,
1243}
1244
1245/// The standard damage roll: `power × eff_atk / max(1, eff_def)`, then
1246/// variance ×(85+rng%16)/100, crit (rng%16==0) ×3/2, then the type-chart
1247/// multiplier; floored at 1. Consumes exactly two rng bytes (variance, crit)
1248/// — accuracy is rolled separately by the caller.
1249pub fn damage_roll(
1250    power: u32,
1251    eff_atk: u32,
1252    eff_def: u32,
1253    mult: (u32, u32),
1254    rng: &mut dyn BattleRng,
1255) -> DamageRoll {
1256    let base = power as u64 * eff_atk as u64 / eff_def.max(1) as u64;
1257    let varied = base * (85 + (rng.byte() % 16) as u64) / 100;
1258    let crit = rng.byte().is_multiple_of(16);
1259    let after_crit = if crit { varied * 3 / 2 } else { varied };
1260    let damage = (after_crit * mult.0 as u64 / mult.1.max(1) as u64)
1261        .max(1)
1262        .min(u32::MAX as u64) as u32;
1263    DamageRoll {
1264        damage,
1265        crit,
1266        mult_num: mult.0,
1267        mult_den: mult.1,
1268    }
1269}
1270
1271// ── the battle (turn loop + screen) ─────────────────────────────────────────
1272
1273/// The battle's result.
1274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1275pub enum BattleOutcome {
1276    /// The enemy fainted.
1277    Win,
1278    /// The player fainted.
1279    Lose,
1280    /// The player ran from a wild battle (v2-d): no EXP/money, the party
1281    /// state carries over. Reaches the scene as the `"run"` string — scenes
1282    /// branching on `== "win"` treat it as not-won.
1283    Run,
1284}
1285
1286/// Which combatant is acting.
1287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1288pub enum Side {
1289    Player,
1290    Enemy,
1291}
1292
1293/// What follows the current narration queue.
1294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1295enum After {
1296    /// Back to the root menu.
1297    Menu,
1298    /// The active member fainted and a replacement must be picked (a free
1299    /// action); the enemy's deferred action then resolves against the new
1300    /// member.
1301    ForcedSwitch,
1302    End(BattleOutcome),
1303}
1304
1305/// The turn-loop phase.
1306#[derive(Debug)]
1307enum Phase {
1308    /// The root menu: Fight / Party (/ Item when items are configured).
1309    Root,
1310    /// The skill menu (Fight).
1311    Skills,
1312    /// The party list from the root menu (B backs out; a legal pick consumes
1313    /// the player's turn).
1314    Party,
1315    /// The item list (B backs out; using one consumes the player's turn).
1316    Items,
1317    /// Forced replacement after a faint (no backing out; the pick is free).
1318    ForcedSwitch,
1319    /// Narration lines are showing; A advances, `after` runs when drained.
1320    Narrate { lines: VecDeque<String>, after: After },
1321}
1322
1323/// The outcome of a faint check after an action or residual.
1324enum FaintFlow {
1325    /// Both sides stand.
1326    Continue,
1327    /// The active enemy fainted and the encounter queue sent out the next
1328    /// one (v2-d): the round ends, back to the root menu.
1329    SentOut,
1330    /// The active member fainted but other members live: a replacement must
1331    /// be picked before play resumes.
1332    Switch,
1333    /// The battle ended.
1334    End(BattleOutcome),
1335}
1336
1337/// A live battle: the player's party (the active member fights) against one
1338/// enemy, the item inventory, the type chart, and the phase machine. Drive
1339/// with [`update`](Self::update) + [`draw`](Self::draw);
1340/// [`outcome`](Self::outcome) reports the end (the runner then resumes the
1341/// suspended scene with `"win"`/`"lose"` and harvests the party state).
1342pub struct Battle {
1343    /// The whole party table (sorted by record id); `active` fights.
1344    party: Vec<Combatant>,
1345    /// Index of the active (fighting) member.
1346    active: usize,
1347    enemy: Combatant,
1348    /// The enemies waiting behind the active one (v2-d encounters); empty
1349    /// for a single-enemy (wild) battle.
1350    enemies: VecDeque<Combatant>,
1351    /// Whether this is a trainer battle (v2-d): blocks Run.
1352    trainer: bool,
1353    /// The money the runner pays the player on a win (0 for wild battles).
1354    trainer_money: u32,
1355    /// The SUM of every defeated enemy's EXP reward (v2-d; equals the single
1356    /// enemy's `expField` in a wild battle — identical to v1).
1357    exp_pool: u32,
1358    /// The currency label for the trainer-money narration (runner's
1359    /// `shop.currency`, default "G").
1360    currency: String,
1361    /// Every battle-usable item record (heal field > 0); the counts live in
1362    /// `inventory`.
1363    items: Vec<BattleItem>,
1364    /// The battle inventory (record id → count), written back to the runner.
1365    inventory: HashMap<String, u32>,
1366    chart: TypeChart,
1367    rng: Box<dyn BattleRng>,
1368    /// The RON hook machinery (v2-a): `Some` when the project's rules file
1369    /// compiled a non-empty `effects` registry. The player mirror tracks the
1370    /// ACTIVE member (re-built on switch).
1371    hooks: Option<HookState>,
1372    /// EXP/level growth (v2-c): `None` without a manifest `levels` block —
1373    /// the win then awards nothing (v1 behavior).
1374    levels: Option<LevelsSetup>,
1375    /// Narration language (`"en"`/`"zh"`) for the EXP/level-up lines.
1376    lang: String,
1377    phase: Phase,
1378    cursor: usize,
1379    outcome: Option<BattleOutcome>,
1380    /// The active weather: a `kind: Weather` RON record id (v2-e), armed by a
1381    /// scene's `setWeather` before the battle started. Battle-local: dropped
1382    /// with the battle, never saved. Its `FieldResidual` hooks fire on each
1383    /// combatant's residual while set.
1384    weather: Option<String>,
1385    /// The enemy's skill when its action was deferred by a forced switch.
1386    pending_enemy: Option<Skill>,
1387    /// Every narration line produced so far (acceptance log, tests).
1388    log: Vec<String>,
1389}
1390
1391impl Battle {
1392    /// A 1v1 battle between two already-built combatants (no RON hooks, no
1393    /// items — the unit-test shape).
1394    pub fn new(
1395        player: Combatant,
1396        enemy: Combatant,
1397        chart: TypeChart,
1398        rng: Box<dyn BattleRng>,
1399    ) -> Self {
1400        Self::with_hooks(player, enemy, chart, rng, None)
1401    }
1402
1403    /// A battle between two already-built combatants, with the RON hook
1404    /// state when the project compiled one.
1405    pub fn with_hooks(
1406        player: Combatant,
1407        enemy: Combatant,
1408        chart: TypeChart,
1409        rng: Box<dyn BattleRng>,
1410        hooks: Option<HookState>,
1411    ) -> Self {
1412        Self::full(
1413            vec![player],
1414            0,
1415            enemy,
1416            Vec::new(),
1417            HashMap::new(),
1418            chart,
1419            rng,
1420            hooks,
1421        )
1422    }
1423
1424    /// The full constructor: the party + its active member, the enemy, the
1425    /// usable items and the inventory counts.
1426    #[allow(clippy::too_many_arguments)]
1427    pub fn full(
1428        party: Vec<Combatant>,
1429        active: usize,
1430        enemy: Combatant,
1431        items: Vec<BattleItem>,
1432        inventory: HashMap<String, u32>,
1433        chart: TypeChart,
1434        rng: Box<dyn BattleRng>,
1435        hooks: Option<HookState>,
1436    ) -> Self {
1437        Self {
1438            party,
1439            active,
1440            enemy,
1441            enemies: VecDeque::new(),
1442            trainer: false,
1443            trainer_money: 0,
1444            exp_pool: 0,
1445            currency: "G".to_string(),
1446            items,
1447            inventory,
1448            chart,
1449            rng,
1450            hooks,
1451            levels: None,
1452            lang: "en".to_string(),
1453            weather: None,
1454            phase: Phase::Root,
1455            cursor: 0,
1456            outcome: None,
1457            pending_enemy: None,
1458            log: Vec::new(),
1459        }
1460    }
1461
1462    /// A party with no living member loses before the first frame.
1463    pub(crate) fn arm_loss(&mut self) {
1464        self.outcome = Some(BattleOutcome::Lose);
1465    }
1466
1467    /// Arm the levels config (v2-c; [`BattleSetup::start_with`] calls this).
1468    pub fn set_levels(&mut self, levels: Option<LevelsSetup>) {
1469        self.levels = levels;
1470    }
1471
1472    /// Set the narration language (`"en"`/`"zh"`) for the EXP/level-up
1473    /// lines; the runner passes its `--lang` here.
1474    pub fn set_lang(&mut self, lang: &str) {
1475        self.lang = lang.to_string();
1476    }
1477
1478    /// Set the currency label for the trainer-money narration (the runner's
1479    /// `shop.currency`); default "G".
1480    pub fn set_currency(&mut self, currency: &str) {
1481        self.currency = currency.to_string();
1482    }
1483
1484    /// Arm the enemy party extras (v2-d): the enemies queued behind the
1485    /// active one plus the trainer flag and the win's money reward
1486    /// ([`BattleSetup::start_with`] calls this; the plain constructors leave
1487    /// a single wild enemy).
1488    pub fn set_enemy_party(&mut self, rest: Vec<Combatant>, trainer: bool, money: u32) {
1489        self.enemies = rest.into();
1490        self.trainer = trainer;
1491        self.trainer_money = money;
1492    }
1493
1494    /// Arm the battle-local weather (v2-e): a `kind: Weather` RON record id,
1495    /// `None` to clear. The runner sets this from a scene's `setWeather` /
1496    /// `clearWeather` before the battle begins; it is never saved and dies
1497    /// with the battle.
1498    pub fn set_weather(&mut self, weather: Option<String>) {
1499        self.weather = weather;
1500    }
1501
1502    /// The armed weather record id, if any (tests, introspection).
1503    pub fn weather(&self) -> Option<&str> {
1504        self.weather.as_deref()
1505    }
1506
1507    /// Battle-start hook pass (v2-e): narrate the armed weather's intro,
1508    /// then fire both active combatants' ability `SwitchIn` hooks (player
1509    /// first). Any produced lines queue as the battle's opening narration;
1510    /// with nothing to say the battle opens on the root menu exactly as v1.
1511    /// The runner calls this once per battle, after [`set_weather`]; the
1512    /// plain constructors leave it to tests. No-op without hooks or once the
1513    /// battle is already decided.
1514    pub fn begin(&mut self) {
1515        if self.outcome.is_some() || self.hooks.is_none() {
1516            return;
1517        }
1518        let mut lines = VecDeque::new();
1519        if let Some(weather) = self.weather.clone() {
1520            if self.record_has_hooks(&weather) {
1521                narrate(&mut self.log, &mut lines, weather_start_line(&self.lang, &weather));
1522            } else {
1523                log::warn!("weather '{weather}' names no rules.ron record — ignored");
1524                self.weather = None;
1525            }
1526        }
1527        for side in [Side::Player, Side::Enemy] {
1528            self.fire_switch_in(side, &mut lines);
1529        }
1530        if !lines.is_empty() {
1531            self.phase = Phase::Narrate {
1532                lines,
1533                after: After::Menu,
1534            };
1535        }
1536    }
1537
1538    /// Whether any compiled hook is sourced from record `id` (any kind).
1539    fn record_has_hooks(&self, id: &str) -> bool {
1540        GenericProvider::rules_host()
1541            .is_some_and(|host| host.compiled.hooks.values().any(|h| h.source_id == id))
1542    }
1543
1544    /// Fire one side's ability `SwitchIn` hooks (v2-e): at battle start, on a
1545    /// voluntary/forced switch-in, and when an encounter sends out the next
1546    /// enemy. An intro line (`"Aria's Intimidate!"`) narrates first when the
1547    /// ability record subscribes to `SwitchIn`; the state changes ride the
1548    /// snapshot-diff narration. No-op without an ability or a subscription.
1549    fn fire_switch_in(&mut self, side: Side, lines: &mut VecDeque<String>) {
1550        if self.hooks.is_none() {
1551            return;
1552        }
1553        let combatant = match side {
1554            Side::Player => &self.party[self.active],
1555            Side::Enemy => &self.enemy,
1556        };
1557        let Some(ability) = combatant.ability.clone() else {
1558            return;
1559        };
1560        if !self.subscribes(&ability, Event::SwitchIn) {
1561            return;
1562        }
1563        let name = combatant.name.clone();
1564        narrate(
1565            &mut self.log,
1566            lines,
1567            ability_intro_line(&self.lang, &name, &ability),
1568        );
1569        self.sync_to_mirrors();
1570        let who = HookState::battler_ref(side);
1571        let before = snap_mirrors(self.hooks.as_ref().unwrap());
1572        self.fire(Event::SwitchIn, &[&ability], who, who, RelayVar::Unit);
1573        self.narrate_diffs(&before, lines, false);
1574        self.sync_from_mirrors();
1575    }
1576
1577    // ── introspection (runner, tests) ───────────────────────────────────────
1578
1579    /// The active player combatant.
1580    pub fn player(&self) -> &Combatant {
1581        &self.party[self.active]
1582    }
1583    /// The whole party (index 0 fights unless switched).
1584    pub fn party(&self) -> &[Combatant] {
1585        &self.party
1586    }
1587    /// The active member's index in [`party`](Self::party).
1588    pub fn active_index(&self) -> usize {
1589        self.active
1590    }
1591    /// The enemy combatant.
1592    pub fn enemy(&self) -> &Combatant {
1593        &self.enemy
1594    }
1595    /// How many enemies still wait behind the active one (v2-d encounters).
1596    pub fn enemies_remaining(&self) -> usize {
1597        self.enemies.len()
1598    }
1599    /// Whether this is a trainer battle (Run is blocked).
1600    pub fn is_trainer(&self) -> bool {
1601        self.trainer
1602    }
1603    /// The money the runner pays the player on a win (0 for wild battles).
1604    pub fn trainer_money(&self) -> u32 {
1605        self.trainer_money
1606    }
1607    /// The result, once the battle has ended (set when the last narration
1608    /// line is dismissed).
1609    pub fn outcome(&self) -> Option<BattleOutcome> {
1610        self.outcome
1611    }
1612    /// The full narration history.
1613    pub fn log(&self) -> &[String] {
1614        &self.log
1615    }
1616    /// The RON hook state, when this battle compiled one (tests, debug).
1617    pub fn hooks(&self) -> Option<&HookState> {
1618        self.hooks.as_ref()
1619    }
1620    /// The live inventory (record id → count).
1621    pub fn inventory(&self) -> &HashMap<String, u32> {
1622        &self.inventory
1623    }
1624    /// The persistent party state (v2-b) for the runner to keep between
1625    /// battles: every member's current HP/MP and status — plus level/exp
1626    /// (v2-c).
1627    pub fn party_state(&self) -> Vec<PartyMemberState> {
1628        self.party
1629            .iter()
1630            .map(|c| PartyMemberState {
1631                id: c.id.clone(),
1632                hp: c.hp,
1633                mp: c.mp,
1634                status: c.status.clone(),
1635                level: c.level,
1636                exp: c.exp,
1637            })
1638            .collect()
1639    }
1640    /// `true` while a player menu owns input.
1641    pub fn in_menu(&self) -> bool {
1642        !matches!(self.phase, Phase::Narrate { .. }) && self.outcome.is_none()
1643    }
1644    /// The narration line currently on screen.
1645    pub fn current_line(&self) -> Option<&str> {
1646        match &self.phase {
1647            Phase::Narrate { lines, .. } => lines.front().map(String::as_str),
1648            _ => None,
1649        }
1650    }
1651    /// The current menu's labels (marked `×` entries are unselectable).
1652    pub fn menu_items(&self) -> Vec<String> {
1653        match &self.phase {
1654            Phase::Root => self.root_items(),
1655            Phase::Skills => self.skill_items(),
1656            Phase::Party | Phase::ForcedSwitch => self.party_items(),
1657            Phase::Items => self.item_items(),
1658            Phase::Narrate { .. } => Vec::new(),
1659        }
1660    }
1661
1662    /// The root menu (Item only when the project configures usable items;
1663    /// Run always — v2-d).
1664    fn root_items(&self) -> Vec<String> {
1665        let mut items = vec!["Fight".to_string(), "Party".to_string()];
1666        if !self.items.is_empty() {
1667            items.push("Item".to_string());
1668        }
1669        items.push("Run".to_string());
1670        items
1671    }
1672
1673    /// The skill-menu labels (name + cost; unaffordable entries are marked).
1674    fn skill_items(&self) -> Vec<String> {
1675        self.player()
1676            .skills
1677            .iter()
1678            .map(|s| {
1679                let label = if s.cost > 0 {
1680                    format!("{} {}MP", s.name, s.cost)
1681                } else {
1682                    s.name.clone()
1683                };
1684                if s.cost > self.player().mp {
1685                    format!("× {label}")
1686                } else {
1687                    label
1688                }
1689            })
1690            .collect()
1691    }
1692
1693    /// The party-list labels (name + HP + status; the active member and
1694    /// fainted members are marked `×` and cannot be picked).
1695    fn party_items(&self) -> Vec<String> {
1696        self.party
1697            .iter()
1698            .enumerate()
1699            .map(|(i, c)| {
1700                let mut label = format!("{} {}/{}", c.name, c.hp, c.max_hp);
1701                if let Some(status) = &c.status {
1702                    label.push_str(&format!(" ({status})"));
1703                }
1704                if i == self.active || c.hp == 0 {
1705                    format!("× {label}")
1706                } else {
1707                    label
1708                }
1709            })
1710            .collect()
1711    }
1712
1713    /// The item-list labels (name + count) for items still in the inventory.
1714    fn item_items(&self) -> Vec<String> {
1715        self.usable_items()
1716            .iter()
1717            .map(|&i| {
1718                let item = &self.items[i];
1719                let count = self.inventory.get(&item.id).copied().unwrap_or(0);
1720                format!("{} ×{count}", item.name)
1721            })
1722            .collect()
1723    }
1724
1725    /// Indexes into `items` of the items with a positive inventory count.
1726    fn usable_items(&self) -> Vec<usize> {
1727        self.items
1728            .iter()
1729            .enumerate()
1730            .filter(|(_, item)| self.inventory.get(&item.id).copied().unwrap_or(0) > 0)
1731            .map(|(i, _)| i)
1732            .collect()
1733    }
1734
1735    /// The first party index a switch may target (living, not active).
1736    fn first_switchable(&self) -> usize {
1737        self.party
1738            .iter()
1739            .enumerate()
1740            .position(|(i, c)| i != self.active && c.hp > 0)
1741            .unwrap_or(0)
1742    }
1743
1744    /// Move a cursor over `n` entries with Up/Down.
1745    fn move_cursor(&mut self, input: &InputState, n: usize) {
1746        let n = n.max(1);
1747        if input.is_just_pressed(GbButton::Up) {
1748            self.cursor = (self.cursor + n - 1) % n;
1749        } else if input.is_just_pressed(GbButton::Down) {
1750            self.cursor = (self.cursor + 1) % n;
1751        }
1752    }
1753
1754    // ── per-frame update ────────────────────────────────────────────────────
1755
1756    /// Advance the battle one frame: menu cursor / confirm / cancel, or
1757    /// narration paging. Sets [`outcome`](Self::outcome) when the battle
1758    /// resolves.
1759    pub fn update(&mut self, input: &InputState) {
1760        match std::mem::replace(&mut self.phase, Phase::Root) {
1761            Phase::Root => {
1762                let n = self.root_items().len();
1763                self.move_cursor(input, n);
1764                if input.is_just_pressed(GbButton::A) {
1765                    if self.cursor == n - 1 {
1766                        // The last entry is always Run (v2-d).
1767                        self.try_run();
1768                        return;
1769                    }
1770                    self.phase = match self.cursor {
1771                        0 => Phase::Skills,
1772                        1 => {
1773                            self.cursor = self.first_switchable();
1774                            Phase::Party
1775                        }
1776                        _ => Phase::Items,
1777                    };
1778                    if !matches!(self.phase, Phase::Party) {
1779                        self.cursor = 0;
1780                    }
1781                }
1782            }
1783            Phase::Skills => {
1784                if input.is_just_pressed(GbButton::B) {
1785                    self.phase = Phase::Root;
1786                    self.cursor = 0;
1787                    return;
1788                }
1789                let n = self.player().skills.len();
1790                self.move_cursor(input, n);
1791                if input.is_just_pressed(GbButton::A) {
1792                    // Unaffordable skills are unselectable.
1793                    if self.player().skills[self.cursor].cost <= self.player().mp {
1794                        let pick = self.cursor;
1795                        self.execute_round(pick);
1796                        return;
1797                    }
1798                }
1799                self.phase = Phase::Skills;
1800            }
1801            Phase::Party => {
1802                if input.is_just_pressed(GbButton::B) {
1803                    self.phase = Phase::Root;
1804                    self.cursor = 0;
1805                    return;
1806                }
1807                let n = self.party.len();
1808                self.move_cursor(input, n);
1809                if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1810                    let pick = self.cursor;
1811                    self.execute_switch_round(pick);
1812                    return;
1813                }
1814                self.phase = Phase::Party;
1815            }
1816            Phase::Items => {
1817                if input.is_just_pressed(GbButton::B) {
1818                    self.phase = Phase::Root;
1819                    self.cursor = 0;
1820                    return;
1821                }
1822                let n = self.usable_items().len();
1823                self.move_cursor(input, n);
1824                if input.is_just_pressed(GbButton::A) && n > 0 {
1825                    let pick = self.cursor;
1826                    self.execute_item_round(pick);
1827                    return;
1828                }
1829                self.phase = Phase::Items;
1830            }
1831            Phase::ForcedSwitch => {
1832                let n = self.party.len();
1833                self.move_cursor(input, n);
1834                if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1835                    let pick = self.cursor;
1836                    self.forced_switch_to(pick);
1837                    return;
1838                }
1839                self.phase = Phase::ForcedSwitch;
1840            }
1841            Phase::Narrate { mut lines, after } => {
1842                if input.is_just_pressed(GbButton::A) {
1843                    lines.pop_front();
1844                }
1845                if lines.is_empty() && input.is_just_pressed(GbButton::A) {
1846                    match after {
1847                        After::Menu => {
1848                            self.phase = Phase::Root;
1849                            self.cursor = 0;
1850                        }
1851                        After::ForcedSwitch => {
1852                            self.cursor = self.first_switchable();
1853                            self.phase = Phase::ForcedSwitch;
1854                        }
1855                        After::End(o) => self.outcome = Some(o),
1856                    }
1857                } else {
1858                    self.phase = Phase::Narrate { lines, after };
1859                }
1860            }
1861        }
1862    }
1863
1864    /// Whether party index `idx` is a legal switch target (living, not the
1865    /// active member).
1866    fn switch_legal(&self, idx: usize) -> bool {
1867        idx != self.active && self.party.get(idx).is_some_and(|c| c.hp > 0)
1868    }
1869
1870    /// The Run root entry (v2-d): a wild battle ends on the spot with the
1871    /// `"run"` outcome (no EXP/money; the party state carries over); a
1872    /// trainer battle REFUSES — the line narrates and the turn is NOT
1873    /// consumed (back to the root menu).
1874    fn try_run(&mut self) {
1875        let mut lines = VecDeque::new();
1876        if self.trainer {
1877            narrate(&mut self.log, &mut lines, run_blocked_line(&self.lang));
1878            self.phase = Phase::Narrate {
1879                lines,
1880                after: After::Menu,
1881            };
1882        } else {
1883            narrate(&mut self.log, &mut lines, run_safe_line(&self.lang));
1884            self.phase = Phase::Narrate {
1885                lines,
1886                after: After::End(BattleOutcome::Run),
1887            };
1888        }
1889    }
1890
1891    /// One full round: the player's pick vs the enemy AI's pick, faster side
1892    /// first (eff speed; ties go to the player), resolving each action in
1893    /// order and queueing the narration. After each action the acting side's
1894    /// status residuals fire (RON hooks), then the faint checks run.
1895    fn execute_round(&mut self, player_pick: usize) {
1896        let player_skill = self.player().skills[player_pick].clone();
1897        let enemy_skill = ai_pick(&self.enemy);
1898        let player_first = self.player().eff_speed() >= self.enemy.eff_speed();
1899        let order = if player_first {
1900            [Side::Player, Side::Enemy]
1901        } else {
1902            [Side::Enemy, Side::Player]
1903        };
1904
1905        let mut lines = VecDeque::new();
1906        let mut after = After::Menu;
1907        for (pos, side) in order.iter().enumerate() {
1908            if self.player().hp == 0 || self.enemy.hp == 0 {
1909                break; // a faint mid-round cancels the remaining action
1910            }
1911            let skill = match side {
1912                Side::Player => player_skill.clone(),
1913                Side::Enemy => enemy_skill.clone(),
1914            };
1915            self.perform(*side, &skill, &mut lines);
1916            match self.faint_flow(&mut lines) {
1917                FaintFlow::Continue => {}
1918                FaintFlow::SentOut => {
1919                    // The replacement never acts the turn it comes in.
1920                    after = After::Menu;
1921                    break;
1922                }
1923                FaintFlow::Switch => {
1924                    // The enemy's action still resolves — against the
1925                    // replacement, once picked.
1926                    if order.get(pos + 1) == Some(&Side::Enemy) {
1927                        self.pending_enemy = Some(enemy_skill);
1928                    }
1929                    after = After::ForcedSwitch;
1930                    break;
1931                }
1932                FaintFlow::End(o) => {
1933                    after = After::End(o);
1934                    break;
1935                }
1936            }
1937            self.residual(*side, &mut lines);
1938            match self.faint_flow(&mut lines) {
1939                FaintFlow::Continue => {}
1940                FaintFlow::SentOut => {
1941                    after = After::Menu;
1942                    break;
1943                }
1944                FaintFlow::Switch => {
1945                    if order.get(pos + 1) == Some(&Side::Enemy) {
1946                        self.pending_enemy = Some(enemy_skill);
1947                    }
1948                    after = After::ForcedSwitch;
1949                    break;
1950                }
1951                FaintFlow::End(o) => {
1952                    after = After::End(o);
1953                    break;
1954                }
1955            }
1956        }
1957        self.phase = Phase::Narrate { lines, after };
1958    }
1959
1960    /// The faint check after an action or residual: narrates the faint and
1961    /// decides what follows — the next queued enemy (v2-d), a forced
1962    /// replacement while the party has living members, else the win/lose
1963    /// ending.
1964    fn faint_flow(&mut self, lines: &mut VecDeque<String>) -> FaintFlow {
1965        if self.enemy.hp == 0 {
1966            narrate(&mut self.log, lines, format!("{} fainted!", self.enemy.name));
1967            // v2-d: the EXP of every defeated enemy accumulates into the
1968            // end-of-battle award.
1969            self.exp_pool = self.exp_pool.saturating_add(self.enemy.exp_reward);
1970            if let Some(next) = self.enemies.pop_front() {
1971                self.send_out(next, lines);
1972                return FaintFlow::SentOut;
1973            }
1974            narrate(&mut self.log, lines, "You won the battle!".to_string());
1975            self.award_exp(lines);
1976            self.award_trainer_money(lines);
1977            FaintFlow::End(BattleOutcome::Win)
1978        } else if self.player().hp == 0 {
1979            let name = self.player().name.clone();
1980            narrate(&mut self.log, lines, format!("{name} fainted!"));
1981            if self.party.iter().any(|c| c.hp > 0) {
1982                FaintFlow::Switch
1983            } else {
1984                narrate(&mut self.log, lines, "You lost the battle…".to_string());
1985                FaintFlow::End(BattleOutcome::Lose)
1986            }
1987        } else {
1988            FaintFlow::Continue
1989        }
1990    }
1991
1992    /// Send out the next queued enemy (v2-d): a fresh combatant (its own
1993    /// stats/level, no status); the RON opponent mirror is rebuilt and the
1994    /// old enemy's volatiles drop. The round then ends (the replacement
1995    /// never acts the turn it comes in).
1996    fn send_out(&mut self, next: Combatant, lines: &mut VecDeque<String>) {
1997        self.enemy = next;
1998        if let Some(hooks) = &mut self.hooks {
1999            hooks.state.opponent_battlers[0] = hooks::mirror_of(
2000                &self.enemy,
2001                &hooks.stat_names,
2002                &hooks.status_names,
2003                hooks.has_resource,
2004            );
2005            let enemy_ref = HookState::battler_ref(Side::Enemy);
2006            hooks.effects.retain(|e| e.host != enemy_ref);
2007        }
2008        let name = self.enemy.name.clone();
2009        narrate(&mut self.log, lines, sent_out_line(&self.lang, &name));
2010        // The incoming enemy's ability fires on switch-in (v2-e).
2011        self.fire_switch_in(Side::Enemy, lines);
2012    }
2013
2014    /// The EXP award on a win (v2-c): every NON-fainted party member gains
2015    /// the SUM of every defeated enemy's `expField` value (v2-d; a wild
2016    /// battle's single enemy, identical to v1), then levels up while
2017    /// its progress covers the curve (`exp_to_next(L) = base × L^exponent`,
2018    /// capped at `maxLevel`), each level-up recomputing its stats and
2019    /// healing the max-HP/MP deltas. No `levels` block ⇒ nothing happens
2020    /// (v1 behavior, byte-for-byte).
2021    fn award_exp(&mut self, lines: &mut VecDeque<String>) {
2022        let Some(levels) = self.levels.clone() else {
2023            return;
2024        };
2025        let reward = self.exp_pool;
2026        let lang = self.lang.clone();
2027        for i in 0..self.party.len() {
2028            if self.party[i].hp == 0 {
2029                continue; // fainted members gain nothing
2030            }
2031            let name = self.party[i].name.clone();
2032            narrate(&mut self.log, lines, gained_exp_line(&lang, &name, reward));
2033            let c = &mut self.party[i];
2034            c.exp = c.exp.saturating_add(reward);
2035            loop {
2036                let need = levels.exp_to_next(c.level);
2037                if c.exp < need || c.level >= levels.max_level {
2038                    break;
2039                }
2040                c.exp -= need;
2041                c.level += 1;
2042                c.recompute_stats(&levels);
2043                narrate(&mut self.log, lines, level_up_line(&lang, &name, c.level));
2044            }
2045        }
2046    }
2047
2048    /// The trainer-money narration on a win (v2-d): the runner reads
2049    /// [`trainer_money`](Self::trainer_money) and pays it when the battle
2050    /// ends in a win; here we only narrate. Wild battles award nothing.
2051    fn award_trainer_money(&mut self, lines: &mut VecDeque<String>) {
2052        if self.trainer_money > 0 {
2053            let line = trainer_money_line(&self.lang, self.trainer_money, &self.currency);
2054            narrate(&mut self.log, lines, line);
2055        }
2056    }
2057
2058    /// A voluntary switch (the Party menu): costs the player's turn — the
2059    /// enemy acts after the new member comes in.
2060    fn execute_switch_round(&mut self, idx: usize) {
2061        let mut lines = VecDeque::new();
2062        let old_name = self.player().name.clone();
2063        narrate(&mut self.log, &mut lines, format!("Come back, {old_name}!"));
2064        self.switch_to(idx, &mut lines);
2065        let after = self.enemy_turn(&mut lines);
2066        self.phase = Phase::Narrate { lines, after };
2067    }
2068
2069    /// An item use (the Item menu): heals the active member (capped at max),
2070    /// decrements the inventory, and costs the player's turn.
2071    fn execute_item_round(&mut self, pick: usize) {
2072        let usable = self.usable_items();
2073        let Some(&item_idx) = usable.get(pick) else {
2074            return;
2075        };
2076        let item = self.items[item_idx].clone();
2077        let mut lines = VecDeque::new();
2078        let before = self.player().hp;
2079        let healed = (before + item.heal).min(self.player().max_hp);
2080        self.party[self.active].hp = healed;
2081        if let Some(count) = self.inventory.get_mut(&item.id) {
2082            *count = count.saturating_sub(1);
2083            if *count == 0 {
2084                self.inventory.remove(&item.id);
2085            }
2086        }
2087        let name = self.player().name.clone();
2088        narrate(&mut self.log, &mut lines, format!("{name} used {}!", item.name));
2089        narrate(
2090            &mut self.log,
2091            &mut lines,
2092            format!("{name} recovered {} HP!", healed - before),
2093        );
2094        let after = self.enemy_turn(&mut lines);
2095        self.phase = Phase::Narrate { lines, after };
2096    }
2097
2098    /// The enemy's half of a switch/item round: its AI pick, then its
2099    /// residuals, with the faint checks between.
2100    fn enemy_turn(&mut self, lines: &mut VecDeque<String>) -> After {
2101        let skill = ai_pick(&self.enemy);
2102        self.perform(Side::Enemy, &skill, lines);
2103        match self.faint_flow(lines) {
2104            FaintFlow::SentOut => After::Menu,
2105            FaintFlow::Switch => After::ForcedSwitch,
2106            FaintFlow::End(o) => After::End(o),
2107            FaintFlow::Continue => {
2108                self.residual(Side::Enemy, lines);
2109                match self.faint_flow(lines) {
2110                    FaintFlow::SentOut => After::Menu,
2111                    FaintFlow::Switch => After::ForcedSwitch,
2112                    FaintFlow::End(o) => After::End(o),
2113                    FaintFlow::Continue => After::Menu,
2114                }
2115            }
2116        }
2117    }
2118
2119    /// A forced replacement after a faint (a free action): the new member
2120    /// comes in, then the enemy's deferred action (if any) resolves.
2121    fn forced_switch_to(&mut self, idx: usize) {
2122        let mut lines = VecDeque::new();
2123        self.switch_to(idx, &mut lines);
2124        let mut after = After::Menu;
2125        if let Some(skill) = self.pending_enemy.take() {
2126            self.perform(Side::Enemy, &skill, &mut lines);
2127            match self.faint_flow(&mut lines) {
2128                FaintFlow::SentOut => after = After::Menu,
2129                FaintFlow::Switch => after = After::ForcedSwitch,
2130                FaintFlow::End(o) => after = After::End(o),
2131                FaintFlow::Continue => {
2132                    self.residual(Side::Enemy, &mut lines);
2133                    after = match self.faint_flow(&mut lines) {
2134                        FaintFlow::SentOut => After::Menu,
2135                        FaintFlow::Switch => After::ForcedSwitch,
2136                        FaintFlow::End(o) => After::End(o),
2137                        FaintFlow::Continue => After::Menu,
2138                    };
2139                }
2140            }
2141        }
2142        self.phase = Phase::Narrate { lines, after };
2143    }
2144
2145    /// Bring party member `idx` in: its stat stages reset (documented), the
2146    /// RON mirror is re-built from the member's CURRENT state (status
2147    /// persists with the member), and the old battler's volatiles drop.
2148    fn switch_to(&mut self, idx: usize, lines: &mut VecDeque<String>) {
2149        self.active = idx;
2150        self.party[idx].stages = Stages::default();
2151        if let Some(hooks) = &mut self.hooks {
2152            hooks.state.player_battlers[0] = hooks::mirror_of(
2153                &self.party[idx],
2154                &hooks.stat_names,
2155                &hooks.status_names,
2156                hooks.has_resource,
2157            );
2158            let player_ref = HookState::battler_ref(Side::Player);
2159            hooks.effects.retain(|e| e.host != player_ref);
2160        }
2161        let name = self.party[idx].name.clone();
2162        narrate(&mut self.log, lines, format!("Go, {name}!"));
2163        // The incoming member's ability fires on switch-in (v2-e).
2164        self.fire_switch_in(Side::Player, lines);
2165    }
2166
2167    /// Resolve one action: the MP gate (re-checked), the accuracy roll, then
2168    /// the skill's effect (damage / heal / stage change), narrating each step.
2169    /// A RON-taken-over skill runs through the stack interpreter instead
2170    /// ([`perform_ron`](Self::perform_ron)).
2171    fn perform(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2172        if skill.ron && self.hooks.is_some() {
2173            self.perform_ron(side, skill, lines);
2174            return;
2175        }
2176        let (attacker, defender) = match side {
2177            Side::Player => (&mut self.party[self.active], &mut self.enemy),
2178            Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2179        };
2180
2181        // The MP gate is re-checked at resolution time.
2182        if skill.cost > attacker.mp {
2183            narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2184            narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2185            return;
2186        }
2187        attacker.mp -= skill.cost;
2188        narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2189
2190        if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2191            narrate(&mut self.log, lines, "But it missed!".to_string());
2192            return;
2193        }
2194
2195        match skill.category {
2196            SkillCategory::Damage => {
2197                let mult = self
2198                    .chart
2199                    .mult(skill.element.as_deref(), defender.element.as_deref());
2200                let roll = damage_roll(
2201                    skill.power,
2202                    attacker.eff_attack(),
2203                    defender.eff_defense(),
2204                    mult,
2205                    self.rng.as_mut(),
2206                );
2207                defender.hp = defender.hp.saturating_sub(roll.damage);
2208                if roll.crit {
2209                    narrate(&mut self.log, lines, "Critical hit!".to_string());
2210                }
2211                if roll.mult_num > roll.mult_den {
2212                    narrate(&mut self.log, lines, "It's super effective!".to_string());
2213                } else if roll.mult_num < roll.mult_den {
2214                    narrate(&mut self.log, lines, "It's not very effective…".to_string());
2215                }
2216                narrate(&mut self.log, lines, format!("{} damage!", roll.damage));
2217            }
2218            SkillCategory::Heal => {
2219                let before = attacker.hp;
2220                attacker.hp = (attacker.hp + skill.power).min(attacker.max_hp);
2221                narrate(&mut self.log, lines,
2222                    format!("{} recovered {} HP!", attacker.name, attacker.hp - before),
2223                );
2224            }
2225            SkillCategory::Buff => {
2226                attacker.stages.bump(&skill.stat, 1);
2227                narrate(&mut self.log, lines,
2228                    format!("{}'s {} rose!", attacker.name, stat_label(&skill.stat)),
2229                );
2230            }
2231            SkillCategory::Debuff => {
2232                defender.stages.bump(&skill.stat, -1);
2233                narrate(&mut self.log, lines,
2234                    format!("{}'s {} fell!", defender.name, stat_label(&skill.stat)),
2235                );
2236            }
2237        }
2238    }
2239
2240    // ── RON effect hooks (v2-a) ─────────────────────────────────────────────
2241
2242    /// Fire one stack event for the hooks sourced from ANY of `source_ids`
2243    /// (a skill id plus — v2-e — the acting combatant's ability / held-item
2244    /// record ids, or a status / weather record id), threading `relay`
2245    /// through the fold (the minimon/wuxia harness shape: per-record filter →
2246    /// `collect_handlers` → `run_event`). Returns the fold's output relay.
2247    fn fire(
2248        &mut self,
2249        event: Event,
2250        source_ids: &[&str],
2251        target: BattlerRef,
2252        source: BattlerRef,
2253        relay: RelayVar,
2254    ) -> RelayVar {
2255        let hooks = self.hooks.as_mut().expect("fire requires hook state");
2256        let host = GenericProvider::rules_host().expect("rules host installed");
2257        let provider = GenericProvider;
2258        let mut adapter = hooks::RngAdapter(self.rng.as_mut());
2259        let mut ctx = BattleCtx {
2260            state: &mut hooks.state,
2261            effects: &mut hooks.effects,
2262            mv: &mut hooks.mv,
2263            rng: &mut adapter,
2264        };
2265        let mut hs = Vec::new();
2266        for eff in &hooks.registry {
2267            let matches = host
2268                .compiled
2269                .hook(eff.id)
2270                .map(|h| source_ids.contains(&h.source_id.as_str()))
2271                .unwrap_or(false);
2272            if matches {
2273                collect_handlers(&ctx, &provider, Some(eff), event, target, source, &mut hs);
2274            }
2275        }
2276        run_event(&mut ctx, hs, relay, false)
2277    }
2278
2279    /// Copy the live pools (HP/MP/stats/stages/status) into the engine mirrors.
2280    fn sync_to_mirrors(&mut self) {
2281        let Some(hooks) = &mut self.hooks else { return };
2282        let (stat_names, status_names, has_resource) =
2283            (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2284        hooks::sync_to_mirror(
2285            &self.party[self.active],
2286            &mut hooks.state.player_battlers[0],
2287            stat_names,
2288            status_names,
2289            has_resource,
2290        );
2291        hooks::sync_to_mirror(
2292            &self.enemy,
2293            &mut hooks.state.opponent_battlers[0],
2294            stat_names,
2295            status_names,
2296            has_resource,
2297        );
2298    }
2299
2300    /// Copy the pools back from the engine mirrors after a fire.
2301    fn sync_from_mirrors(&mut self) {
2302        let Some(hooks) = &mut self.hooks else { return };
2303        let (stat_names, status_names, has_resource) =
2304            (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2305        hooks::sync_from_mirror(
2306            &hooks.state.player_battlers[0],
2307            &mut self.party[self.active],
2308            stat_names,
2309            status_names,
2310            has_resource,
2311        );
2312        hooks::sync_from_mirror(
2313            &hooks.state.opponent_battlers[0],
2314            &mut self.enemy,
2315            stat_names,
2316            status_names,
2317            has_resource,
2318        );
2319    }
2320
2321    /// Narrate the state changes a fire produced (status inflicted/cured,
2322    /// stat stages, HP/MP moved) by diffing the mirror snapshots. `residual`
2323    /// flavors HP loss as the status chip ("… is hurt by poison!").
2324    fn narrate_diffs(&mut self, before: &[MirrorSnap; 2], lines: &mut VecDeque<String>, residual: bool) {
2325        let names = [self.player().name.clone(), self.enemy.name.clone()];
2326        let Some(hooks) = &self.hooks else { return };
2327        let produced = diff_lines(hooks, before, [&names[0], &names[1]], residual);
2328        for line in produced {
2329            narrate(&mut self.log, lines, line);
2330        }
2331    }
2332
2333    /// Resolve one RON-taken-over skill: the v1 MP gate + accuracy roll, then
2334    /// the stack event sequence over the mirrored battlers — `BeforeMove`
2335    /// gate (when subscribed) → damage precompute (the v1 formula into
2336    /// `ctx.mv.damage`) → `ModifyDamage` → `Effectiveness` → `Damage` → apply
2337    /// → `DamagingHit` → `AfterMove` (the minimon/wuxia fire order).
2338    fn perform_ron(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2339        let (attacker, defender) = match side {
2340            Side::Player => (&mut self.party[self.active], &mut self.enemy),
2341            Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2342        };
2343
2344        // The MP gate is re-checked at resolution time (v1 parity); the RON
2345        // record's `cost:` already fed `skill.cost` at load.
2346        if skill.cost > attacker.mp {
2347            narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2348            narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2349            return;
2350        }
2351        attacker.mp -= skill.cost;
2352        narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2353
2354        if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2355            narrate(&mut self.log, lines, "But it missed!".to_string());
2356            return;
2357        }
2358
2359        let eff_atk = attacker.eff_attack();
2360        let eff_def = defender.eff_defense();
2361        let def_element = defender.element.clone();
2362        // v2-e: the acting combatant's ability and held-item records join its
2363        // per-action event sequence (an ability hooking `ModifyDamage` etc.
2364        // fires alongside the skill's own hooks).
2365        let ability = attacker.ability.clone();
2366        let held_item = attacker.held_item.clone();
2367        let mut ids: Vec<&str> = vec![skill.id.as_str()];
2368        if let Some(a) = &ability {
2369            ids.push(a);
2370        }
2371        if let Some(i) = &held_item {
2372            ids.push(i);
2373        }
2374        let source = HookState::battler_ref(side);
2375        let target = HookState::battler_ref(match side {
2376            Side::Player => Side::Enemy,
2377            Side::Enemy => Side::Player,
2378        });
2379
2380        self.sync_to_mirrors();
2381
2382        // BeforeMove gate — only when the record subscribes. Relay starts
2383        // `Bool(true)`; a `Fail` (`VetoIf` / unaffordable `PayResource`)
2384        // yields `Bool(false)`, a silent veto `Unit`.
2385        if self.subscribes_any(&ids, Event::BeforeMove) {
2386            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2387            let out = self.fire(Event::BeforeMove, &ids, target, source, RelayVar::Bool(true));
2388            self.narrate_diffs(&before, lines, false);
2389            match out {
2390                RelayVar::Bool(false) => {
2391                    narrate(&mut self.log, lines, "But it failed!".to_string());
2392                    self.sync_from_mirrors();
2393                    return;
2394                }
2395                RelayVar::Unit => {
2396                    self.sync_from_mirrors();
2397                    return;
2398                }
2399                _ => {}
2400            }
2401        }
2402
2403        // When the record subscribes to `Effectiveness` the hooks own the
2404        // scaling (author `ApplyTypeChart` for the chart); otherwise the v1
2405        // direct chart application applies in the precompute.
2406        let has_effectiveness_hooks = self.subscribes_any(&ids, Event::Effectiveness);
2407
2408        if skill.power > 0 {
2409            let mult = if has_effectiveness_hooks {
2410                (1, 1)
2411            } else {
2412                self.chart
2413                    .mult(skill.element.as_deref(), def_element.as_deref())
2414            };
2415            let roll = damage_roll(skill.power, eff_atk, eff_def, mult, self.rng.as_mut());
2416            self.hooks.as_mut().unwrap().mv.damage =
2417                roll.damage.min(u32::from(u16::MAX)) as u16;
2418            if roll.crit {
2419                narrate(&mut self.log, lines, "Critical hit!".to_string());
2420            }
2421            if !has_effectiveness_hooks {
2422                if roll.mult_num > roll.mult_den {
2423                    narrate(&mut self.log, lines, "It's super effective!".to_string());
2424                } else if roll.mult_num < roll.mult_den {
2425                    narrate(&mut self.log, lines, "It's not very effective…".to_string());
2426                }
2427            }
2428
2429            // ModifyDamage fold (ScaleRelay/SetDamage ride here).
2430            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2431            let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2432            let out = self.fire(
2433                Event::ModifyDamage,
2434                &ids,
2435                target,
2436                source,
2437                RelayVar::Damage(in_damage),
2438            );
2439            self.narrate_diffs(&before, lines, false);
2440            match out {
2441                RelayVar::Damage(d) => self.hooks.as_mut().unwrap().mv.damage = d,
2442                RelayVar::Bool(false) => {
2443                    narrate(&mut self.log, lines, "But it failed!".to_string());
2444                    self.sync_from_mirrors();
2445                    return;
2446                }
2447                RelayVar::Unit => {
2448                    self.sync_from_mirrors();
2449                    return;
2450                }
2451                _ => {}
2452            }
2453
2454            // Effectiveness fold (ApplyTypeChart; effectiveness narrated from
2455            // what the fold actually did to the number).
2456            if has_effectiveness_hooks {
2457                let before = snap_mirrors(self.hooks.as_ref().unwrap());
2458                let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2459                let out = self.fire(
2460                    Event::Effectiveness,
2461                    &ids,
2462                    target,
2463                    source,
2464                    RelayVar::Damage(in_damage),
2465                );
2466                self.narrate_diffs(&before, lines, false);
2467                match out {
2468                    RelayVar::Damage(d) => {
2469                        self.hooks.as_mut().unwrap().mv.damage = d;
2470                        if d > in_damage {
2471                            narrate(&mut self.log, lines, "It's super effective!".to_string());
2472                        } else if d < in_damage {
2473                            narrate(&mut self.log, lines, "It's not very effective…".to_string());
2474                        }
2475                    }
2476                    RelayVar::Bool(false) => {
2477                        narrate(&mut self.log, lines, "But it failed!".to_string());
2478                        self.sync_from_mirrors();
2479                        return;
2480                    }
2481                    RelayVar::Unit => {
2482                        self.sync_from_mirrors();
2483                        return;
2484                    }
2485                    _ => {}
2486                }
2487            }
2488
2489            // The Damage fold (absorb / floor / veto hooks), then apply.
2490            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2491            let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2492            let out = self.fire(
2493                Event::Damage,
2494                &ids,
2495                target,
2496                source,
2497                RelayVar::Damage(in_damage),
2498            );
2499            self.narrate_diffs(&before, lines, false);
2500            let final_damage = match out {
2501                RelayVar::Damage(d) => d,
2502                RelayVar::Bool(false) => {
2503                    narrate(&mut self.log, lines, "But it failed!".to_string());
2504                    self.sync_from_mirrors();
2505                    return;
2506                }
2507                RelayVar::Unit => {
2508                    self.sync_from_mirrors();
2509                    return;
2510                }
2511                _ => in_damage,
2512            };
2513            {
2514                let hooks = self.hooks.as_mut().unwrap();
2515                let b = if target.side == 0 {
2516                    &mut hooks.state.player_battlers[target.slot as usize]
2517                } else {
2518                    &mut hooks.state.opponent_battlers[target.slot as usize]
2519                };
2520                b.take_damage(final_damage);
2521                hooks.mv.last_damage = final_damage;
2522            }
2523            narrate(&mut self.log, lines, format!("{final_damage} damage!"));
2524            // Keep the pools synced so the faint checks read live HP.
2525            self.sync_from_mirrors();
2526        }
2527
2528        // DamagingHit (secondary effects: InflictStatus riders etc.) — fired
2529        // after any landed hit, damaging or not, so a power-0 status skill's
2530        // riders still run.
2531        let before = snap_mirrors(self.hooks.as_ref().unwrap());
2532        let last_damage = self.hooks.as_ref().unwrap().mv.last_damage;
2533        self.fire(
2534            Event::DamagingHit,
2535            &ids,
2536            target,
2537            source,
2538            RelayVar::Damage(last_damage),
2539        );
2540        self.narrate_diffs(&before, lines, false);
2541
2542        // AfterMove (per-action cleanup: self-chips, volatiles).
2543        let before = snap_mirrors(self.hooks.as_ref().unwrap());
2544        self.fire(Event::AfterMove, &ids, target, source, RelayVar::Unit);
2545        self.narrate_diffs(&before, lines, false);
2546
2547        self.sync_from_mirrors();
2548    }
2549
2550    /// Whether the skill's RON record subscribes to `event`.
2551    fn subscribes(&self, skill_id: &str, event: Event) -> bool {
2552        self.hooks
2553            .as_ref()
2554            .is_some_and(|h| h.subscribes(skill_id, event))
2555    }
2556
2557    /// Whether ANY of `ids` (a skill plus the acting combatant's ability /
2558    /// held-item records, v2-e) subscribes to `event`.
2559    fn subscribes_any(&self, ids: &[&str], event: Event) -> bool {
2560        ids.iter().any(|id| self.subscribes(id, event))
2561    }
2562
2563    /// The end-of-action residual: the acting combatant's status record's
2564    /// `Residual` hooks (poison chip etc., v2-a), its held-item record's
2565    /// `Residual` hooks (Leftovers-style heal, v2-e), and the active
2566    /// weather's `FieldResidual` hooks with this side as the target (v2-e —
2567    /// so on a full round each side ticks once). No-op without hooks.
2568    fn residual(&mut self, side: Side, lines: &mut VecDeque<String>) {
2569        if self.hooks.is_none() {
2570            return;
2571        }
2572        // The mirror must see the action's results first (a v1-path action
2573        // mutates the Combatant directly).
2574        self.sync_to_mirrors();
2575        let who = HookState::battler_ref(side);
2576
2577        // 1. The persistent status's residual (v2-a).
2578        let status_id = {
2579            let hooks = self.hooks.as_ref().unwrap();
2580            hooks
2581                .battler(side)
2582                .status
2583                .clone()
2584                .and_then(|hooks::StatusId(idx)| hooks.status_names.get(idx as usize).cloned())
2585        };
2586        if let Some(source_id) = status_id {
2587            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2588            self.fire(Event::Residual, &[&source_id], who, who, RelayVar::Unit);
2589            self.narrate_diffs(&before, lines, true);
2590        }
2591
2592        // 2. The held item's residual (v2-e): persistent — never consumed.
2593        let held_item = match side {
2594            Side::Player => self.party[self.active].held_item.clone(),
2595            Side::Enemy => self.enemy.held_item.clone(),
2596        };
2597        if let Some(item_id) = held_item {
2598            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2599            self.fire(Event::Residual, &[&item_id], who, who, RelayVar::Unit);
2600            self.narrate_diffs(&before, lines, false);
2601        }
2602
2603        // 3. The weather's field residual (v2-e).
2604        if let Some(weather) = self.weather.clone() {
2605            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2606            self.fire(Event::FieldResidual, &[&weather], who, who, RelayVar::Unit);
2607            self.narrate_diffs(&before, lines, false);
2608        }
2609
2610        self.sync_from_mirrors();
2611    }
2612
2613    // ── rendering ───────────────────────────────────────────────────────────
2614
2615    /// Draw the battle screen: a two-tone field with placeholder combatant
2616    /// blobs, an enemy panel (name, element, HP bar) up top, a player panel
2617    /// (name, HP bar, MP) below, and the current menu or narration line at
2618    /// the bottom.
2619    pub fn draw(&self, fb: &mut FrameBuffer) {
2620        // Field.
2621        fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::rgb(0x18, 0x18, 0x28));
2622        fb.fill_rect(
2623            0,
2624            130,
2625            SCREEN_W as u32,
2626            SCREEN_H as u32 - 130,
2627            Rgba::rgb(0x20, 0x2A, 0x20),
2628        );
2629
2630        // Placeholder combatants (colored blobs, palette from the record id).
2631        draw_blob(fb, 228, 28, 56, blob_color(&self.enemy.id));
2632        draw_blob(fb, 52, 108, 64, blob_color(&self.player().id));
2633
2634        // Enemy panel: name + element + HP bar.
2635        draw_panel(fb, 8, 8, 184, 46);
2636        text(fb, &self.enemy.name, 16, 14, Rgba::rgb(0xF0, 0xF0, 0xF0));
2637        if let Some(element) = &self.enemy.element {
2638            text(fb, element, 16, 26, Rgba::rgb(0x90, 0xA8, 0xC8));
2639        }
2640        draw_bar(fb, 80, 28, 104, 6, self.enemy.hp, self.enemy.max_hp);
2641
2642        // Player panel (the ACTIVE member): name + HP bar + numbers + MP.
2643        draw_panel(fb, 132, 136, 180, 46);
2644        text(fb, &self.player().name, 140, 142, Rgba::rgb(0xF0, 0xF0, 0xF0));
2645        draw_bar(fb, 140, 156, 104, 6, self.player().hp, self.player().max_hp);
2646        text(
2647            fb,
2648            &format!("{}/{}", self.player().hp, self.player().max_hp),
2649            250,
2650            154,
2651            Rgba::rgb(0xC8, 0xC8, 0xC8),
2652        );
2653        text(
2654            fb,
2655            &format!("MP {}/{}", self.player().mp, self.player().max_mp),
2656            140,
2657            168,
2658            Rgba::rgb(0x90, 0xA8, 0xC8),
2659        );
2660
2661        // Bottom: the current menu (+prompt) or the current narration line.
2662        match &self.phase {
2663            Phase::Root => {
2664                draw_textbox(fb, "What will you do?");
2665                self.draw_menu(fb);
2666            }
2667            Phase::Skills => {
2668                draw_textbox(fb, "Choose a skill!");
2669                self.draw_menu(fb);
2670            }
2671            Phase::Party => {
2672                draw_textbox(fb, "Switch to whom?");
2673                self.draw_menu(fb);
2674            }
2675            Phase::Items => {
2676                draw_textbox(fb, "Use which item?");
2677                self.draw_menu(fb);
2678            }
2679            Phase::ForcedSwitch => {
2680                draw_textbox(fb, "Choose your next fighter!");
2681                self.draw_menu(fb);
2682            }
2683            Phase::Narrate { lines, .. } => {
2684                if let Some(line) = lines.front() {
2685                    draw_textbox(fb, line);
2686                }
2687            }
2688        }
2689    }
2690
2691    /// The current menu above the dialogue area (the cursor marks the
2692    /// selection; entries marked `×` cannot be confirmed).
2693    fn draw_menu(&self, fb: &mut FrameBuffer) {
2694        let items = self.menu_items();
2695        let n = items.len() as u32;
2696        if n == 0 {
2697            return;
2698        }
2699        let max_len = items.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2700        // +4: left/right border, cursor column, one padding column.
2701        let w = (max_len + 4).clamp(10, 24);
2702        let h = n + 2;
2703        let tx = (40 - w) as i32;
2704        let ty = DIALOG_AREA.ty as i32 - h as i32;
2705        let config = MenuConfig::new(
2706            TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2707            None,
2708            TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2709            Default::default(),
2710        );
2711        let state = FlexMenuState {
2712            cursor: self.cursor,
2713            scroll_offset: 0,
2714        };
2715        let mut painter = FrameBufferPainter::new(fb);
2716        let mut ui = Ui::new(&mut painter);
2717        draw_flex_menu(&items, &[config], &state, items.len(), &mut ui);
2718    }
2719}
2720
2721/// The enemy AI's skill pick: highest-power affordable (ties → earliest in
2722/// the list); with no affordable skill, the built-in Attack.
2723fn ai_pick(combatant: &Combatant) -> Skill {
2724    combatant
2725        .skills
2726        .iter()
2727        .filter(|s| s.cost <= combatant.mp)
2728        .max_by_key(|s| s.power)
2729        .cloned()
2730        .unwrap_or_else(basic_attack)
2731}
2732
2733/// Push a narration line onto the queue and the battle's running log.
2734fn narrate(log: &mut Vec<String>, lines: &mut VecDeque<String>, line: String) {
2735    log.push(line.clone());
2736    lines.push_back(line);
2737}
2738
2739/// "<name> gained <n> EXP!" (interpolated, so it can't sit in a label table).
2740fn gained_exp_line(lang: &str, name: &str, n: u32) -> String {
2741    if lang == "zh" {
2742        format!("{name} 获得了 {n} 点经验!")
2743    } else {
2744        format!("{name} gained {n} EXP!")
2745    }
2746}
2747
2748/// "<name> grew to level <n>!" (interpolated).
2749fn level_up_line(lang: &str, name: &str, level: u8) -> String {
2750    if lang == "zh" {
2751        format!("{name} 升到了 {level} 级!")
2752    } else {
2753        format!("{name} grew to level {level}!")
2754    }
2755}
2756
2757/// "Foe sent out <name>!" (v2-d encounter queue; interpolated).
2758fn sent_out_line(lang: &str, name: &str) -> String {
2759    if lang == "zh" {
2760        format!("对方派出了 {name}!")
2761    } else {
2762        format!("Foe sent out {name}!")
2763    }
2764}
2765
2766/// "Got away safely!" — a successful Run from a wild battle.
2767fn run_safe_line(lang: &str) -> String {
2768    if lang == "zh" {
2769        "顺利逃走了!".to_string()
2770    } else {
2771        "Got away safely!".to_string()
2772    }
2773}
2774
2775/// "Can't escape from a trainer battle!" — Run blocked (turn not consumed).
2776fn run_blocked_line(lang: &str) -> String {
2777    if lang == "zh" {
2778        "无法从训练家的战斗中逃走!".to_string()
2779    } else {
2780        "Can't escape from a trainer battle!".to_string()
2781    }
2782}
2783
2784/// "Got <n> <currency> for winning!" — the trainer-money award.
2785fn trainer_money_line(lang: &str, money: u32, currency: &str) -> String {
2786    if lang == "zh" {
2787        format!("赢得了 {money} {currency}!")
2788    } else {
2789        format!("Got {money} {currency} for winning!")
2790    }
2791}
2792
2793/// "<name>'s <Ability>!" — the switch-in intro when an ability fires (v2-e).
2794/// The RON record has no display name, so the id is prettified
2795/// (`swift-swim` → `Swift Swim`).
2796fn ability_intro_line(lang: &str, name: &str, ability: &str) -> String {
2797    let label = prettify_id(ability);
2798    if lang == "zh" {
2799        format!("{name} 的 {label}!")
2800    } else {
2801        format!("{name}'s {label}!")
2802    }
2803}
2804
2805/// "A <weather> rages!" — the battle-start weather intro (v2-e; the id
2806/// stays as authored).
2807fn weather_start_line(lang: &str, weather: &str) -> String {
2808    if lang == "zh" {
2809        format!("{weather} 开始了!")
2810    } else {
2811        format!("A {weather} rages!")
2812    }
2813}
2814
2815/// Prettify a record id for narration: `kebab-case`/`snake_case` → `Title Case`.
2816fn prettify_id(id: &str) -> String {
2817    id.split(['-', '_'])
2818        .filter(|w| !w.is_empty())
2819        .map(|w| {
2820            let mut chars = w.chars();
2821            match chars.next() {
2822                Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
2823                None => String::new(),
2824            }
2825        })
2826        .collect::<Vec<_>>()
2827        .join(" ")
2828}
2829
2830// ── hook-fire narration (v2-a) ──────────────────────────────────────────────
2831
2832/// A snapshot of one mirrored battler's narratable state (diffed across a
2833/// stack fire).
2834#[derive(Clone)]
2835struct MirrorSnap {
2836    hp: u16,
2837    status: Option<hooks::StatusId>,
2838    stages: Vec<i8>,
2839}
2840
2841/// Snapshot both engine mirrors (index 0 = player, 1 = enemy).
2842fn snap_mirrors(hooks: &HookState) -> [MirrorSnap; 2] {
2843    [Side::Player, Side::Enemy].map(|side| {
2844        let b = hooks.battler(side);
2845        MirrorSnap {
2846            hp: b.hp,
2847            status: b.status.clone(),
2848            stages: (0..hooks.stat_names.len())
2849                .map(|i| {
2850                    b.stat_stages
2851                        .get(hooks::StatId(i as u16))
2852                        .copied()
2853                        .unwrap_or(0)
2854                })
2855                .collect(),
2856        }
2857    })
2858}
2859
2860/// The narration lines for the difference between `before` and the mirrors'
2861/// current state: status inflicted/cured, stat stages, HP movement. With
2862/// `residual`, HP loss on a statused combatant reads as the status chip.
2863fn diff_lines(hooks: &HookState, before: &[MirrorSnap; 2], names: [&str; 2], residual: bool) -> Vec<String> {
2864    let after = snap_mirrors(hooks);
2865    let mut out = Vec::new();
2866    for i in 0..2 {
2867        let (b, a, name) = (&before[i], &after[i], names[i]);
2868        let status_name = |id: &hooks::StatusId| {
2869            hooks
2870                .status_names
2871                .get(id.0 as usize)
2872                .map(String::as_str)
2873                .unwrap_or("?")
2874        };
2875        match (&b.status, &a.status) {
2876            (None, Some(id)) => out.push(format!("{name} was afflicted with {}!", status_name(id))),
2877            (Some(id), None) => out.push(format!("{name} is no longer {}!", status_name(id))),
2878            _ => {}
2879        }
2880        for (s, stat) in hooks.stat_names.iter().enumerate() {
2881            let (bv, av) = (b.stages[s], a.stages[s]);
2882            if av > bv {
2883                out.push(format!("{name}'s {} rose!", stat_label(&normalize_stat_key(stat))));
2884            } else if av < bv {
2885                out.push(format!("{name}'s {} fell!", stat_label(&normalize_stat_key(stat))));
2886            }
2887        }
2888        if a.hp < b.hp {
2889            match (&a.status, &b.status, residual) {
2890                (Some(id), _, true) | (_, Some(id), true) => {
2891                    out.push(format!("{name} is hurt by {}!", status_name(id)));
2892                }
2893                _ => out.push(format!("{name} lost {} HP!", b.hp - a.hp)),
2894            }
2895        } else if a.hp > b.hp {
2896            out.push(format!("{name} recovered {} HP!", a.hp - b.hp));
2897        }
2898    }
2899    out
2900}
2901
2902// ── drawing helpers ─────────────────────────────────────────────────────────
2903
2904/// Text via the embedded font.
2905pub(crate) fn text(fb: &mut FrameBuffer, s: &str, x: u32, y: u32, color: Rgba) {
2906    embedded_font::draw_text(s, x, y, color, fb);
2907}
2908
2909/// A bordered info panel.
2910pub(crate) fn draw_panel(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32) {
2911    fb.fill_rect(x, y, w, h, Rgba::rgb(0xC8, 0xC8, 0xD0));
2912    fb.fill_rect(x + 1, y + 1, w - 2, h - 2, Rgba::rgb(0x28, 0x28, 0x38));
2913}
2914
2915/// A proportional HP bar (green, turning red below a quarter).
2916fn draw_bar(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32, hp: u32, max_hp: u32) {
2917    let frac = if max_hp == 0 {
2918        0.0
2919    } else {
2920        hp as f32 / max_hp as f32
2921    };
2922    fb.fill_rect(x, y, w, h, Rgba::rgb(0x10, 0x10, 0x18));
2923    let fill = if frac < 0.25 {
2924        Rgba::rgb(0xD0, 0x40, 0x38)
2925    } else {
2926        Rgba::rgb(0x40, 0xC0, 0x58)
2927    };
2928    let inner = ((w - 2) as f32 * frac.clamp(0.0, 1.0)) as u32;
2929    if inner > 0 {
2930        fb.fill_rect(x + 1, y + 1, inner, h - 2, fill);
2931    }
2932}
2933
2934/// A blob color derived from a record id (distinct ids read as distinct monsters).
2935fn blob_color(id: &str) -> Rgba {
2936    let hash = id
2937        .bytes()
2938        .fold(0x811C_9DC5_u32, |h, b| h.wrapping_mul(16_777_619) ^ b as u32);
2939    let hue = (hash >> 8) as u8;
2940    Rgba::rgb(0x60 + hue % 0x70, 0x60 + (hue / 3) % 0x70, 0x60 + (hue / 7) % 0x70)
2941}
2942
2943/// A round-ish solid placeholder combatant blob.
2944fn draw_blob(fb: &mut FrameBuffer, x: i32, y: i32, size: i32, color: Rgba) {
2945    let r = (size / 7).max(2);
2946    for dy in 0..size {
2947        for dx in 0..size {
2948            let (px, py) = (x + dx, y + dy);
2949            if px < 0 || py < 0 || px >= SCREEN_W || py >= SCREEN_H {
2950                continue;
2951            }
2952            if (dx < r || dx >= size - r) && (dy < r || dy >= size - r) {
2953                continue;
2954            }
2955            fb.set_pixel(px as u32, py as u32, color);
2956        }
2957    }
2958}
2959
2960#[cfg(test)]
2961mod tests;