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/reference/project-manifest.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(e) => {
830                // An absent default file (battle.rules unset) is a legal
831                // project — no type chart. A manifest-named file that can't
832                // be read is a configuration slip: warn loudly so the chart's
833                // silent absence is visible (`dotzuki check` errors on it).
834                if section.rules.is_some() {
835                    log::warn!(
836                        "battle.rules '{rules_rel}' could not be read ({e:#}); battles run with an empty type chart"
837                    );
838                }
839                TypeChart::default()
840            }
841        };
842
843        Ok(Self {
844            files: Arc::clone(project.files()),
845            party_dir,
846            enemies_dir,
847            encounters_dir,
848            skills_dir,
849            skills_field,
850            category_field,
851            cost_field,
852            stats: section.stats.clone().unwrap_or_default(),
853            resource: section.resource.clone(),
854            chart,
855            ron,
856            items,
857            levels: section.levels.as_ref().map(LevelsSetup::from_manifest),
858        })
859    }
860
861    /// Build a battle with a FRESH party state (every member at full HP/MP,
862    /// no status) and the manifest's starting inventory. Convenience for
863    /// tests and tools; the runner calls [`start_with`](Self::start_with).
864    ///
865    /// # Errors
866    ///
867    /// Fails when the party table holds no readable record.
868    pub fn start(&self, enemy_id: &str, rng: Box<dyn BattleRng>) -> Result<Battle> {
869        self.start_with(enemy_id, rng, None, None)
870    }
871
872    /// Build a battle: the WHOLE party table (sorted by record id) against
873    /// `enemy_id`. Resolution order (v2-d): when the manifest has an
874    /// `encounters` block AND `enemy_id` names an encounter record, the
875    /// enemy side is that record's ordered enemy party (a queue) with its
876    /// trainer flag and money reward; otherwise `enemy_id` names an enemy
877    /// record (a single implicitly-wild enemy); an id in NEITHER table falls
878    /// back to the first enemy record with a warning. `party_state`
879    /// (runner-owned, from the previous battle or a save) re-applies current
880    /// HP/MP/status per member id — base stats always come fresh from the
881    /// records; `inventory` overrides the manifest's `items.starting`
882    /// counts. The first LIVING member leads; a party with no living member
883    /// arms an immediate loss.
884    ///
885    /// # Errors
886    ///
887    /// Fails when the party table holds no readable record, or when the
888    /// encounter record is malformed (no `enemies` list, or an enemy id in
889    /// it that names no enemy record).
890    pub fn start_with(
891        &self,
892        enemy_id: &str,
893        rng: Box<dyn BattleRng>,
894        party_state: Option<&[PartyMemberState]>,
895        inventory: Option<&HashMap<String, u32>>,
896    ) -> Result<Battle> {
897        let ids = record_ids(self.files.as_ref(), &self.party_dir);
898        if ids.is_empty() {
899            anyhow::bail!("party table {} has no records", self.party_dir);
900        }
901        let mut party = Vec::with_capacity(ids.len());
902        for id in &ids {
903            let mut c = self.load_combatant(&self.party_dir, id)?;
904            if let Some(state) = party_state.and_then(|states| states.iter().find(|s| &s.id == id))
905            {
906                // v2-c: the persistent level/exp overrides the record's —
907                // the stats are re-grown first, then the pools clamp in.
908                if let Some(levels) = &self.levels {
909                    c.level = state.level.max(1);
910                    c.exp = state.exp;
911                    c.recompute_stats(levels);
912                }
913                c.hp = state.hp.min(c.max_hp);
914                c.mp = state.mp.min(c.max_mp);
915                c.status = state.status.clone();
916            }
917            party.push(c);
918        }
919        let active = party.iter().position(|c| c.hp > 0).unwrap_or(0);
920
921        // v2-d: an encounter record takes precedence over a single-enemy
922        // lookup; both miss ⇒ the v1 first-record fallback.
923        let (enemy, rest, trainer, money) = match self.load_encounter(enemy_id)? {
924            Some(encounter) => {
925                let mut enemies = Vec::with_capacity(encounter.enemy_ids.len());
926                for id in &encounter.enemy_ids {
927                    enemies.push(self.load_combatant(&self.enemies_dir, id)?);
928                }
929                let mut enemies = enemies.into_iter();
930                let first = enemies.next().expect("encounter enemies non-empty");
931                let money = if encounter.trainer { encounter.money } else { 0 };
932                log::info!(
933                    "encounter '{}' ({}): {} enemies, trainer {}",
934                    enemy_id,
935                    encounter.name,
936                    encounter.enemy_ids.len(),
937                    encounter.trainer
938                );
939                (first, enemies.collect(), encounter.trainer, money)
940            }
941            None => {
942                let enemy_id = if self
943                    .files
944                    .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
945                {
946                    enemy_id.to_string()
947                } else {
948                    let fallback =
949                        first_record_id(self.files.as_ref(), &self.enemies_dir).with_context(|| {
950                            format!("enemies table {} has no records", self.enemies_dir)
951                        })?;
952                    log::warn!(
953                        "unknown enemy id '{enemy_id}' — using first enemy record '{fallback}'"
954                    );
955                    fallback
956                };
957                (self.load_combatant(&self.enemies_dir, &enemy_id)?, Vec::new(), false, 0)
958            }
959        };
960
961        let items = self.load_items();
962        let inventory = inventory.cloned().unwrap_or_else(|| {
963            self.items
964                .as_ref()
965                .map(|i| i.starting.clone())
966                .unwrap_or_default()
967        });
968
969        log::info!(
970            "battle: {} (hp {}, party of {}) vs {} (hp {})",
971            party[active].name,
972            party[active].max_hp,
973            party.len(),
974            enemy.name,
975            enemy.max_hp
976        );
977        // RON hooks: (re-)install the thread-local rules host (the parallel
978        // test harness runs battles on many threads) and mirror the ACTIVE
979        // member + the enemy into the engine battle state.
980        let hook_state = self.ron.as_ref().map(|ron| {
981            hooks::install_compiled(ron.compiled.clone());
982            HookState {
983                state: BattleState::new(
984                    vec![hooks::mirror_of(
985                        &party[active],
986                        &ron.stat_names,
987                        &ron.status_names,
988                        ron.has_resource,
989                    )],
990                    vec![hooks::mirror_of(
991                        &enemy,
992                        &ron.stat_names,
993                        &ron.status_names,
994                        ron.has_resource,
995                    )],
996                ),
997                effects: Vec::new(),
998                mv: dotzuki_engine::battle::stack::MoveContext::default(),
999                registry: ron.registry.clone(),
1000                move_records: ron.move_records.clone(),
1001                status_names: ron.status_names.clone(),
1002                stat_names: ron.stat_names.clone(),
1003                has_resource: ron.has_resource,
1004            }
1005        });
1006        let mut battle = Battle::full(
1007            party, active, enemy, items, inventory, self.chart.clone(), rng, hook_state,
1008        );
1009        battle.set_levels(self.levels.clone());
1010        battle.set_enemy_party(rest, trainer, money);
1011        if battle.party.iter().all(|c| c.hp == 0) {
1012            battle.arm_loss();
1013        }
1014        Ok(battle)
1015    }
1016
1017    /// Parse the encounter record `id` (v2-d): `None` when the manifest has
1018    /// no encounters table or the id names no record in it. An `enemies`
1019    /// list that is missing/empty or references an unknown enemy record is a
1020    /// hard error (a clear battle-start failure, never a silent fallback).
1021    fn load_encounter(&self, id: &str) -> Result<Option<Encounter>> {
1022        let Some(dir) = &self.encounters_dir else {
1023            return Ok(None);
1024        };
1025        if !self.files.exists(&format!("{dir}/{id}.json")) {
1026            return Ok(None);
1027        }
1028        let record = read_record(self.files.as_ref(), dir, id)?;
1029        let enemy_ids: Vec<String> = record
1030            .get("enemies")
1031            .and_then(|v| v.as_array())
1032            .map(|ids| {
1033                ids.iter()
1034                    .filter_map(|v| v.as_str().map(str::to_string))
1035                    .collect()
1036            })
1037            .unwrap_or_default();
1038        if enemy_ids.is_empty() {
1039            anyhow::bail!("encounter '{id}' has no 'enemies' list (or it is empty)");
1040        }
1041        for enemy_id in &enemy_ids {
1042            if !self
1043                .files
1044                .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
1045            {
1046                anyhow::bail!(
1047                    "encounter '{id}' references unknown enemy id '{enemy_id}' \
1048                     (no record in the enemies table)"
1049                );
1050            }
1051        }
1052        Ok(Some(Encounter {
1053            name: get_str(&record, "name").unwrap_or(id).to_string(),
1054            enemy_ids,
1055            trainer: record
1056                .get("trainer")
1057                .and_then(|v| v.as_bool())
1058                .unwrap_or(false),
1059            money: get_num(&record, "money").unwrap_or(0),
1060        }))
1061    }
1062
1063    /// The battle-usable items: every record of the items table whose heal
1064    /// field holds a positive number (sorted by record id). Empty when the
1065    /// manifest has no `items` block.
1066    fn load_items(&self) -> Vec<BattleItem> {
1067        let Some(items) = &self.items else {
1068            return Vec::new();
1069        };
1070        let mut out = Vec::new();
1071        for id in record_ids(self.files.as_ref(), &items.dir) {
1072            let record = match read_record(self.files.as_ref(), &items.dir, &id) {
1073                Ok(record) => record,
1074                Err(e) => {
1075                    log::warn!("item record '{id}' skipped: {e:#}");
1076                    continue;
1077                }
1078            };
1079            let heal = get_num(&record, &items.heal_field).unwrap_or(0);
1080            if heal > 0 {
1081                out.push(BattleItem {
1082                    name: get_str(&record, "name").unwrap_or(&id).to_string(),
1083                    id,
1084                    heal,
1085                });
1086            }
1087        }
1088        out
1089    }
1090
1091    /// The `kind: Move` RON record overriding skill `id`, if any.
1092    fn ron_move(&self, id: &str) -> Option<&hooks::RonMove> {
1093        self.ron.as_ref()?.move_records.get(id)
1094    }
1095
1096    /// Load one combatant record from `dir` (stats via the field mapping,
1097    /// skills via the skills table; full HP/MP). With a `levels` block the
1098    /// stats carry the level-growth multiplier (the record's `levelField`,
1099    /// default 1 ⇒ ×1) and the enemy side reads its `expField` reward.
1100    fn load_combatant(&self, dir: &str, id: &str) -> Result<Combatant> {
1101        let record = read_record(self.files.as_ref(), dir, id)?;
1102        let level_field = self
1103            .levels
1104            .as_ref()
1105            .map(|l| l.level_field.as_str())
1106            .unwrap_or("level");
1107        let level = get_num(&record, level_field).unwrap_or(1).min(255) as u8;
1108        let stat = |field: &str| get_num(&record, field).unwrap_or(1);
1109        let base = BaseStats {
1110            max_hp: stat(&self.stats.hp),
1111            max_mp: self
1112                .resource
1113                .as_deref()
1114                .map(|f| get_num(&record, f).unwrap_or(0))
1115                .unwrap_or(0),
1116            attack: stat(&self.stats.attack),
1117            defense: stat(&self.stats.defense),
1118            speed: stat(&self.stats.speed),
1119        };
1120        let grown = |raw: u32| match &self.levels {
1121            Some(levels) => levels.growth(raw, level),
1122            None => raw,
1123        };
1124        let (max_hp, mp) = (grown(base.max_hp), grown(base.max_mp));
1125        let exp_reward = self
1126            .levels
1127            .as_ref()
1128            .and_then(|l| get_num(&record, &l.exp_field))
1129            .unwrap_or(0);
1130        Ok(Combatant {
1131            id: id.to_string(),
1132            name: get_str(&record, "name").unwrap_or(id).to_string(),
1133            element: get_str(&record, "element").map(str::to_string),
1134            hp: max_hp,
1135            max_hp,
1136            attack: grown(base.attack),
1137            defense: grown(base.defense),
1138            speed: grown(base.speed),
1139            level,
1140            base,
1141            exp: 0,
1142            exp_reward,
1143            stages: Stages::default(),
1144            mp,
1145            max_mp: mp,
1146            skills: self.load_skills(&record),
1147            status: None,
1148            ability: get_str(&record, "ability").map(str::to_string),
1149            held_item: get_str(&record, "heldItem").map(str::to_string),
1150        })
1151    }
1152
1153    /// A combatant's move list: the configured skills field (an array of
1154    /// skill ids looked up in the skills table; unknown ids are skipped with
1155    /// a warning) — or just the built-in Attack when no skills table is
1156    /// configured or the list is empty/missing.
1157    fn load_skills(&self, record: &serde_json::Value) -> Vec<Skill> {
1158        let Some(skills_dir) = &self.skills_dir else {
1159            return vec![basic_attack()];
1160        };
1161        let mut skills = Vec::new();
1162        if let Some(ids) = record.get(&self.skills_field).and_then(|v| v.as_array()) {
1163            for id in ids {
1164                let Some(id) = id.as_str() else {
1165                    continue;
1166                };
1167                match read_record(self.files.as_ref(), skills_dir, id) {
1168                    Ok(rec) => skills.push(skill_from_record(id, &rec, self)),
1169                    Err(e) => log::warn!("unknown skill id '{id}' skipped: {e:#}"),
1170                }
1171            }
1172        }
1173        if skills.is_empty() {
1174            skills.push(basic_attack());
1175        }
1176        skills
1177    }
1178}
1179
1180/// Every record id (sorted `.json` filename stems) directly in a table dir
1181/// (a project-relative POSIX path read through the VFS).
1182pub(crate) fn record_ids(files: &dyn ProjectFiles, dir: &str) -> Vec<String> {
1183    let prefix = format!("{dir}/");
1184    let mut ids: Vec<String> = files
1185        .list(dir)
1186        .into_iter()
1187        .filter_map(|p| {
1188            // Direct children only (records live flat in the table dir).
1189            let rest = p.strip_prefix(&prefix)?;
1190            if rest.contains('/') {
1191                return None;
1192            }
1193            rest.strip_suffix(".json").map(str::to_string)
1194        })
1195        .collect();
1196    ids.sort();
1197    ids.dedup();
1198    ids
1199}
1200
1201/// The first record id (sorted `.json` filename stem) in a table dir.
1202fn first_record_id(files: &dyn ProjectFiles, dir: &str) -> Option<String> {
1203    record_ids(files, dir).into_iter().next()
1204}
1205
1206/// Read and parse `<dir>/<id>.json`.
1207pub(crate) fn read_record(
1208    files: &dyn ProjectFiles,
1209    dir: &str,
1210    id: &str,
1211) -> Result<serde_json::Value> {
1212    let rel = format!("{dir}/{id}.json");
1213    let bytes = files
1214        .read(&rel)
1215        .with_context(|| format!("failed to read {rel}"))?;
1216    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {rel}"))
1217}
1218
1219/// A record's numeric field (accepts ints and floats); `None` when missing
1220/// or not a number.
1221pub(crate) fn get_num(record: &serde_json::Value, field: &str) -> Option<u32> {
1222    match record.get(field)? {
1223        serde_json::Value::Number(n) => n
1224            .as_u64()
1225            .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
1226            .map(|v| v.min(u32::MAX as u64) as u32),
1227        _ => None,
1228    }
1229}
1230
1231/// A record's string field; `None` when missing or not a string.
1232pub(crate) fn get_str<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1233    record.get(field).and_then(|v| v.as_str())
1234}
1235
1236// ── the formula ─────────────────────────────────────────────────────────────
1237
1238/// Accuracy roll: the hit lands iff `rng % 100 < accuracy`.
1239pub fn accuracy_roll(accuracy: u32, rng: &mut dyn BattleRng) -> bool {
1240    u32::from(rng.byte() % 100) < accuracy
1241}
1242
1243/// The outcome of one damaging hit.
1244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1245pub struct DamageRoll {
1246    /// Final damage (≥ 1).
1247    pub damage: u32,
1248    /// Whether the hit was critical (×1.5).
1249    pub crit: bool,
1250    /// Effectiveness numerator.
1251    pub mult_num: u32,
1252    /// Effectiveness denominator.
1253    pub mult_den: u32,
1254}
1255
1256/// The standard damage roll: `power × eff_atk / max(1, eff_def)`, then
1257/// variance ×(85+rng%16)/100, crit (rng%16==0) ×3/2, then the type-chart
1258/// multiplier; floored at 1. Consumes exactly two rng bytes (variance, crit)
1259/// — accuracy is rolled separately by the caller.
1260pub fn damage_roll(
1261    power: u32,
1262    eff_atk: u32,
1263    eff_def: u32,
1264    mult: (u32, u32),
1265    rng: &mut dyn BattleRng,
1266) -> DamageRoll {
1267    let base = power as u64 * eff_atk as u64 / eff_def.max(1) as u64;
1268    let varied = base * (85 + (rng.byte() % 16) as u64) / 100;
1269    let crit = rng.byte().is_multiple_of(16);
1270    let after_crit = if crit { varied * 3 / 2 } else { varied };
1271    let damage = (after_crit * mult.0 as u64 / mult.1.max(1) as u64)
1272        .max(1)
1273        .min(u32::MAX as u64) as u32;
1274    DamageRoll {
1275        damage,
1276        crit,
1277        mult_num: mult.0,
1278        mult_den: mult.1,
1279    }
1280}
1281
1282// ── the battle (turn loop + screen) ─────────────────────────────────────────
1283
1284/// The battle's result.
1285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286pub enum BattleOutcome {
1287    /// The enemy fainted.
1288    Win,
1289    /// The player fainted.
1290    Lose,
1291    /// The player ran from a wild battle (v2-d): no EXP/money, the party
1292    /// state carries over. Reaches the scene as the `"run"` string — scenes
1293    /// branching on `== "win"` treat it as not-won.
1294    Run,
1295}
1296
1297/// Which combatant is acting.
1298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1299pub enum Side {
1300    Player,
1301    Enemy,
1302}
1303
1304/// What follows the current narration queue.
1305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306enum After {
1307    /// Back to the root menu.
1308    Menu,
1309    /// The active member fainted and a replacement must be picked (a free
1310    /// action); the enemy's deferred action then resolves against the new
1311    /// member.
1312    ForcedSwitch,
1313    End(BattleOutcome),
1314}
1315
1316/// The turn-loop phase.
1317#[derive(Debug)]
1318enum Phase {
1319    /// The root menu: Fight / Party (/ Item when items are configured).
1320    Root,
1321    /// The skill menu (Fight).
1322    Skills,
1323    /// The party list from the root menu (B backs out; a legal pick consumes
1324    /// the player's turn).
1325    Party,
1326    /// The item list (B backs out; using one consumes the player's turn).
1327    Items,
1328    /// Forced replacement after a faint (no backing out; the pick is free).
1329    ForcedSwitch,
1330    /// Narration lines are showing; A advances, `after` runs when drained.
1331    Narrate { lines: VecDeque<String>, after: After },
1332}
1333
1334/// The outcome of a faint check after an action or residual.
1335enum FaintFlow {
1336    /// Both sides stand.
1337    Continue,
1338    /// The active enemy fainted and the encounter queue sent out the next
1339    /// one (v2-d): the round ends, back to the root menu.
1340    SentOut,
1341    /// The active member fainted but other members live: a replacement must
1342    /// be picked before play resumes.
1343    Switch,
1344    /// The battle ended.
1345    End(BattleOutcome),
1346}
1347
1348/// A live battle: the player's party (the active member fights) against one
1349/// enemy, the item inventory, the type chart, and the phase machine. Drive
1350/// with [`update`](Self::update) + [`draw`](Self::draw);
1351/// [`outcome`](Self::outcome) reports the end (the runner then resumes the
1352/// suspended scene with `"win"`/`"lose"` and harvests the party state).
1353pub struct Battle {
1354    /// The whole party table (sorted by record id); `active` fights.
1355    party: Vec<Combatant>,
1356    /// Index of the active (fighting) member.
1357    active: usize,
1358    enemy: Combatant,
1359    /// The enemies waiting behind the active one (v2-d encounters); empty
1360    /// for a single-enemy (wild) battle.
1361    enemies: VecDeque<Combatant>,
1362    /// Whether this is a trainer battle (v2-d): blocks Run.
1363    trainer: bool,
1364    /// The money the runner pays the player on a win (0 for wild battles).
1365    trainer_money: u32,
1366    /// The SUM of every defeated enemy's EXP reward (v2-d; equals the single
1367    /// enemy's `expField` in a wild battle — identical to v1).
1368    exp_pool: u32,
1369    /// The currency label for the trainer-money narration (runner's
1370    /// `shop.currency`, default "G").
1371    currency: String,
1372    /// Every battle-usable item record (heal field > 0); the counts live in
1373    /// `inventory`.
1374    items: Vec<BattleItem>,
1375    /// The battle inventory (record id → count), written back to the runner.
1376    inventory: HashMap<String, u32>,
1377    chart: TypeChart,
1378    rng: Box<dyn BattleRng>,
1379    /// The RON hook machinery (v2-a): `Some` when the project's rules file
1380    /// compiled a non-empty `effects` registry. The player mirror tracks the
1381    /// ACTIVE member (re-built on switch).
1382    hooks: Option<HookState>,
1383    /// EXP/level growth (v2-c): `None` without a manifest `levels` block —
1384    /// the win then awards nothing (v1 behavior).
1385    levels: Option<LevelsSetup>,
1386    /// Narration language (`"en"`/`"zh"`) for the EXP/level-up lines.
1387    lang: String,
1388    phase: Phase,
1389    cursor: usize,
1390    outcome: Option<BattleOutcome>,
1391    /// The active weather: a `kind: Weather` RON record id (v2-e), armed by a
1392    /// scene's `setWeather` before the battle started. Battle-local: dropped
1393    /// with the battle, never saved. Its `FieldResidual` hooks fire on each
1394    /// combatant's residual while set.
1395    weather: Option<String>,
1396    /// The enemy's skill when its action was deferred by a forced switch.
1397    pending_enemy: Option<Skill>,
1398    /// Every narration line produced so far (acceptance log, tests).
1399    log: Vec<String>,
1400}
1401
1402impl Battle {
1403    /// A 1v1 battle between two already-built combatants (no RON hooks, no
1404    /// items — the unit-test shape).
1405    pub fn new(
1406        player: Combatant,
1407        enemy: Combatant,
1408        chart: TypeChart,
1409        rng: Box<dyn BattleRng>,
1410    ) -> Self {
1411        Self::with_hooks(player, enemy, chart, rng, None)
1412    }
1413
1414    /// A battle between two already-built combatants, with the RON hook
1415    /// state when the project compiled one.
1416    pub fn with_hooks(
1417        player: Combatant,
1418        enemy: Combatant,
1419        chart: TypeChart,
1420        rng: Box<dyn BattleRng>,
1421        hooks: Option<HookState>,
1422    ) -> Self {
1423        Self::full(
1424            vec![player],
1425            0,
1426            enemy,
1427            Vec::new(),
1428            HashMap::new(),
1429            chart,
1430            rng,
1431            hooks,
1432        )
1433    }
1434
1435    /// The full constructor: the party + its active member, the enemy, the
1436    /// usable items and the inventory counts.
1437    #[allow(clippy::too_many_arguments)]
1438    pub fn full(
1439        party: Vec<Combatant>,
1440        active: usize,
1441        enemy: Combatant,
1442        items: Vec<BattleItem>,
1443        inventory: HashMap<String, u32>,
1444        chart: TypeChart,
1445        rng: Box<dyn BattleRng>,
1446        hooks: Option<HookState>,
1447    ) -> Self {
1448        Self {
1449            party,
1450            active,
1451            enemy,
1452            enemies: VecDeque::new(),
1453            trainer: false,
1454            trainer_money: 0,
1455            exp_pool: 0,
1456            currency: "G".to_string(),
1457            items,
1458            inventory,
1459            chart,
1460            rng,
1461            hooks,
1462            levels: None,
1463            lang: "en".to_string(),
1464            weather: None,
1465            phase: Phase::Root,
1466            cursor: 0,
1467            outcome: None,
1468            pending_enemy: None,
1469            log: Vec::new(),
1470        }
1471    }
1472
1473    /// A party with no living member loses before the first frame.
1474    pub(crate) fn arm_loss(&mut self) {
1475        self.outcome = Some(BattleOutcome::Lose);
1476    }
1477
1478    /// Arm the levels config (v2-c; [`BattleSetup::start_with`] calls this).
1479    pub fn set_levels(&mut self, levels: Option<LevelsSetup>) {
1480        self.levels = levels;
1481    }
1482
1483    /// Set the narration language (`"en"`/`"zh"`) for the EXP/level-up
1484    /// lines; the runner passes its `--lang` here.
1485    pub fn set_lang(&mut self, lang: &str) {
1486        self.lang = lang.to_string();
1487    }
1488
1489    /// Set the currency label for the trainer-money narration (the runner's
1490    /// `shop.currency`); default "G".
1491    pub fn set_currency(&mut self, currency: &str) {
1492        self.currency = currency.to_string();
1493    }
1494
1495    /// Arm the enemy party extras (v2-d): the enemies queued behind the
1496    /// active one plus the trainer flag and the win's money reward
1497    /// ([`BattleSetup::start_with`] calls this; the plain constructors leave
1498    /// a single wild enemy).
1499    pub fn set_enemy_party(&mut self, rest: Vec<Combatant>, trainer: bool, money: u32) {
1500        self.enemies = rest.into();
1501        self.trainer = trainer;
1502        self.trainer_money = money;
1503    }
1504
1505    /// Arm the battle-local weather (v2-e): a `kind: Weather` RON record id,
1506    /// `None` to clear. The runner sets this from a scene's `setWeather` /
1507    /// `clearWeather` before the battle begins; it is never saved and dies
1508    /// with the battle.
1509    pub fn set_weather(&mut self, weather: Option<String>) {
1510        self.weather = weather;
1511    }
1512
1513    /// The armed weather record id, if any (tests, introspection).
1514    pub fn weather(&self) -> Option<&str> {
1515        self.weather.as_deref()
1516    }
1517
1518    /// Battle-start hook pass (v2-e): narrate the armed weather's intro,
1519    /// then fire both active combatants' ability `SwitchIn` hooks (player
1520    /// first). Any produced lines queue as the battle's opening narration;
1521    /// with nothing to say the battle opens on the root menu exactly as v1.
1522    /// The runner calls this once per battle, after [`set_weather`]; the
1523    /// plain constructors leave it to tests. No-op without hooks or once the
1524    /// battle is already decided.
1525    pub fn begin(&mut self) {
1526        if self.outcome.is_some() || self.hooks.is_none() {
1527            return;
1528        }
1529        let mut lines = VecDeque::new();
1530        if let Some(weather) = self.weather.clone() {
1531            if self.record_has_hooks(&weather) {
1532                narrate(&mut self.log, &mut lines, weather_start_line(&self.lang, &weather));
1533            } else {
1534                log::warn!("weather '{weather}' names no rules.ron record — ignored");
1535                self.weather = None;
1536            }
1537        }
1538        for side in [Side::Player, Side::Enemy] {
1539            self.fire_switch_in(side, &mut lines);
1540        }
1541        if !lines.is_empty() {
1542            self.phase = Phase::Narrate {
1543                lines,
1544                after: After::Menu,
1545            };
1546        }
1547    }
1548
1549    /// Whether any compiled hook is sourced from record `id` (any kind).
1550    fn record_has_hooks(&self, id: &str) -> bool {
1551        GenericProvider::rules_host()
1552            .is_some_and(|host| host.compiled.hooks.values().any(|h| h.source_id == id))
1553    }
1554
1555    /// Fire one side's ability `SwitchIn` hooks (v2-e): at battle start, on a
1556    /// voluntary/forced switch-in, and when an encounter sends out the next
1557    /// enemy. An intro line (`"Aria's Intimidate!"`) narrates first when the
1558    /// ability record subscribes to `SwitchIn`; the state changes ride the
1559    /// snapshot-diff narration. No-op without an ability or a subscription.
1560    fn fire_switch_in(&mut self, side: Side, lines: &mut VecDeque<String>) {
1561        if self.hooks.is_none() {
1562            return;
1563        }
1564        let combatant = match side {
1565            Side::Player => &self.party[self.active],
1566            Side::Enemy => &self.enemy,
1567        };
1568        let Some(ability) = combatant.ability.clone() else {
1569            return;
1570        };
1571        if !self.subscribes(&ability, Event::SwitchIn) {
1572            return;
1573        }
1574        let name = combatant.name.clone();
1575        narrate(
1576            &mut self.log,
1577            lines,
1578            ability_intro_line(&self.lang, &name, &ability),
1579        );
1580        self.sync_to_mirrors();
1581        let who = HookState::battler_ref(side);
1582        let before = snap_mirrors(self.hooks.as_ref().unwrap());
1583        self.fire(Event::SwitchIn, &[&ability], who, who, RelayVar::Unit);
1584        self.narrate_diffs(&before, lines, false);
1585        self.sync_from_mirrors();
1586    }
1587
1588    // ── introspection (runner, tests) ───────────────────────────────────────
1589
1590    /// The active player combatant.
1591    pub fn player(&self) -> &Combatant {
1592        &self.party[self.active]
1593    }
1594    /// The whole party (index 0 fights unless switched).
1595    pub fn party(&self) -> &[Combatant] {
1596        &self.party
1597    }
1598    /// The active member's index in [`party`](Self::party).
1599    pub fn active_index(&self) -> usize {
1600        self.active
1601    }
1602    /// The enemy combatant.
1603    pub fn enemy(&self) -> &Combatant {
1604        &self.enemy
1605    }
1606    /// How many enemies still wait behind the active one (v2-d encounters).
1607    pub fn enemies_remaining(&self) -> usize {
1608        self.enemies.len()
1609    }
1610    /// Whether this is a trainer battle (Run is blocked).
1611    pub fn is_trainer(&self) -> bool {
1612        self.trainer
1613    }
1614    /// The money the runner pays the player on a win (0 for wild battles).
1615    pub fn trainer_money(&self) -> u32 {
1616        self.trainer_money
1617    }
1618    /// The result, once the battle has ended (set when the last narration
1619    /// line is dismissed).
1620    pub fn outcome(&self) -> Option<BattleOutcome> {
1621        self.outcome
1622    }
1623    /// The full narration history.
1624    pub fn log(&self) -> &[String] {
1625        &self.log
1626    }
1627    /// The RON hook state, when this battle compiled one (tests, debug).
1628    pub fn hooks(&self) -> Option<&HookState> {
1629        self.hooks.as_ref()
1630    }
1631    /// The live inventory (record id → count).
1632    pub fn inventory(&self) -> &HashMap<String, u32> {
1633        &self.inventory
1634    }
1635    /// The persistent party state (v2-b) for the runner to keep between
1636    /// battles: every member's current HP/MP and status — plus level/exp
1637    /// (v2-c).
1638    pub fn party_state(&self) -> Vec<PartyMemberState> {
1639        self.party
1640            .iter()
1641            .map(|c| PartyMemberState {
1642                id: c.id.clone(),
1643                hp: c.hp,
1644                mp: c.mp,
1645                status: c.status.clone(),
1646                level: c.level,
1647                exp: c.exp,
1648            })
1649            .collect()
1650    }
1651    /// `true` while a player menu owns input.
1652    pub fn in_menu(&self) -> bool {
1653        !matches!(self.phase, Phase::Narrate { .. }) && self.outcome.is_none()
1654    }
1655    /// The narration line currently on screen.
1656    pub fn current_line(&self) -> Option<&str> {
1657        match &self.phase {
1658            Phase::Narrate { lines, .. } => lines.front().map(String::as_str),
1659            _ => None,
1660        }
1661    }
1662    /// The current menu's labels (marked `×` entries are unselectable).
1663    pub fn menu_items(&self) -> Vec<String> {
1664        match &self.phase {
1665            Phase::Root => self.root_items(),
1666            Phase::Skills => self.skill_items(),
1667            Phase::Party | Phase::ForcedSwitch => self.party_items(),
1668            Phase::Items => self.item_items(),
1669            Phase::Narrate { .. } => Vec::new(),
1670        }
1671    }
1672
1673    /// The root menu (Item only when the project configures usable items;
1674    /// Run always — v2-d).
1675    fn root_items(&self) -> Vec<String> {
1676        let mut items = vec!["Fight".to_string(), "Party".to_string()];
1677        if !self.items.is_empty() {
1678            items.push("Item".to_string());
1679        }
1680        items.push("Run".to_string());
1681        items
1682    }
1683
1684    /// The skill-menu labels (name + cost; unaffordable entries are marked).
1685    fn skill_items(&self) -> Vec<String> {
1686        self.player()
1687            .skills
1688            .iter()
1689            .map(|s| {
1690                let label = if s.cost > 0 {
1691                    format!("{} {}MP", s.name, s.cost)
1692                } else {
1693                    s.name.clone()
1694                };
1695                if s.cost > self.player().mp {
1696                    format!("× {label}")
1697                } else {
1698                    label
1699                }
1700            })
1701            .collect()
1702    }
1703
1704    /// The party-list labels (name + HP + status; the active member and
1705    /// fainted members are marked `×` and cannot be picked).
1706    fn party_items(&self) -> Vec<String> {
1707        self.party
1708            .iter()
1709            .enumerate()
1710            .map(|(i, c)| {
1711                let mut label = format!("{} {}/{}", c.name, c.hp, c.max_hp);
1712                if let Some(status) = &c.status {
1713                    label.push_str(&format!(" ({status})"));
1714                }
1715                if i == self.active || c.hp == 0 {
1716                    format!("× {label}")
1717                } else {
1718                    label
1719                }
1720            })
1721            .collect()
1722    }
1723
1724    /// The item-list labels (name + count) for items still in the inventory.
1725    fn item_items(&self) -> Vec<String> {
1726        self.usable_items()
1727            .iter()
1728            .map(|&i| {
1729                let item = &self.items[i];
1730                let count = self.inventory.get(&item.id).copied().unwrap_or(0);
1731                format!("{} ×{count}", item.name)
1732            })
1733            .collect()
1734    }
1735
1736    /// Indexes into `items` of the items with a positive inventory count.
1737    fn usable_items(&self) -> Vec<usize> {
1738        self.items
1739            .iter()
1740            .enumerate()
1741            .filter(|(_, item)| self.inventory.get(&item.id).copied().unwrap_or(0) > 0)
1742            .map(|(i, _)| i)
1743            .collect()
1744    }
1745
1746    /// The first party index a switch may target (living, not active).
1747    fn first_switchable(&self) -> usize {
1748        self.party
1749            .iter()
1750            .enumerate()
1751            .position(|(i, c)| i != self.active && c.hp > 0)
1752            .unwrap_or(0)
1753    }
1754
1755    /// Move a cursor over `n` entries with Up/Down.
1756    fn move_cursor(&mut self, input: &InputState, n: usize) {
1757        let n = n.max(1);
1758        if input.is_just_pressed(GbButton::Up) {
1759            self.cursor = (self.cursor + n - 1) % n;
1760        } else if input.is_just_pressed(GbButton::Down) {
1761            self.cursor = (self.cursor + 1) % n;
1762        }
1763    }
1764
1765    // ── per-frame update ────────────────────────────────────────────────────
1766
1767    /// Advance the battle one frame: menu cursor / confirm / cancel, or
1768    /// narration paging. Sets [`outcome`](Self::outcome) when the battle
1769    /// resolves.
1770    pub fn update(&mut self, input: &InputState) {
1771        match std::mem::replace(&mut self.phase, Phase::Root) {
1772            Phase::Root => {
1773                let n = self.root_items().len();
1774                self.move_cursor(input, n);
1775                if input.is_just_pressed(GbButton::A) {
1776                    if self.cursor == n - 1 {
1777                        // The last entry is always Run (v2-d).
1778                        self.try_run();
1779                        return;
1780                    }
1781                    self.phase = match self.cursor {
1782                        0 => Phase::Skills,
1783                        1 => {
1784                            self.cursor = self.first_switchable();
1785                            Phase::Party
1786                        }
1787                        _ => Phase::Items,
1788                    };
1789                    if !matches!(self.phase, Phase::Party) {
1790                        self.cursor = 0;
1791                    }
1792                }
1793            }
1794            Phase::Skills => {
1795                if input.is_just_pressed(GbButton::B) {
1796                    self.phase = Phase::Root;
1797                    self.cursor = 0;
1798                    return;
1799                }
1800                let n = self.player().skills.len();
1801                self.move_cursor(input, n);
1802                if input.is_just_pressed(GbButton::A) {
1803                    // Unaffordable skills are unselectable.
1804                    if self.player().skills[self.cursor].cost <= self.player().mp {
1805                        let pick = self.cursor;
1806                        self.execute_round(pick);
1807                        return;
1808                    }
1809                }
1810                self.phase = Phase::Skills;
1811            }
1812            Phase::Party => {
1813                if input.is_just_pressed(GbButton::B) {
1814                    self.phase = Phase::Root;
1815                    self.cursor = 0;
1816                    return;
1817                }
1818                let n = self.party.len();
1819                self.move_cursor(input, n);
1820                if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1821                    let pick = self.cursor;
1822                    self.execute_switch_round(pick);
1823                    return;
1824                }
1825                self.phase = Phase::Party;
1826            }
1827            Phase::Items => {
1828                if input.is_just_pressed(GbButton::B) {
1829                    self.phase = Phase::Root;
1830                    self.cursor = 0;
1831                    return;
1832                }
1833                let n = self.usable_items().len();
1834                self.move_cursor(input, n);
1835                if input.is_just_pressed(GbButton::A) && n > 0 {
1836                    let pick = self.cursor;
1837                    self.execute_item_round(pick);
1838                    return;
1839                }
1840                self.phase = Phase::Items;
1841            }
1842            Phase::ForcedSwitch => {
1843                let n = self.party.len();
1844                self.move_cursor(input, n);
1845                if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1846                    let pick = self.cursor;
1847                    self.forced_switch_to(pick);
1848                    return;
1849                }
1850                self.phase = Phase::ForcedSwitch;
1851            }
1852            Phase::Narrate { mut lines, after } => {
1853                if input.is_just_pressed(GbButton::A) {
1854                    lines.pop_front();
1855                }
1856                if lines.is_empty() && input.is_just_pressed(GbButton::A) {
1857                    match after {
1858                        After::Menu => {
1859                            self.phase = Phase::Root;
1860                            self.cursor = 0;
1861                        }
1862                        After::ForcedSwitch => {
1863                            self.cursor = self.first_switchable();
1864                            self.phase = Phase::ForcedSwitch;
1865                        }
1866                        After::End(o) => self.outcome = Some(o),
1867                    }
1868                } else {
1869                    self.phase = Phase::Narrate { lines, after };
1870                }
1871            }
1872        }
1873    }
1874
1875    /// Whether party index `idx` is a legal switch target (living, not the
1876    /// active member).
1877    fn switch_legal(&self, idx: usize) -> bool {
1878        idx != self.active && self.party.get(idx).is_some_and(|c| c.hp > 0)
1879    }
1880
1881    /// The Run root entry (v2-d): a wild battle ends on the spot with the
1882    /// `"run"` outcome (no EXP/money; the party state carries over); a
1883    /// trainer battle REFUSES — the line narrates and the turn is NOT
1884    /// consumed (back to the root menu).
1885    fn try_run(&mut self) {
1886        let mut lines = VecDeque::new();
1887        if self.trainer {
1888            narrate(&mut self.log, &mut lines, run_blocked_line(&self.lang));
1889            self.phase = Phase::Narrate {
1890                lines,
1891                after: After::Menu,
1892            };
1893        } else {
1894            narrate(&mut self.log, &mut lines, run_safe_line(&self.lang));
1895            self.phase = Phase::Narrate {
1896                lines,
1897                after: After::End(BattleOutcome::Run),
1898            };
1899        }
1900    }
1901
1902    /// One full round: the player's pick vs the enemy AI's pick, faster side
1903    /// first (eff speed; ties go to the player), resolving each action in
1904    /// order and queueing the narration. After each action the acting side's
1905    /// status residuals fire (RON hooks), then the faint checks run.
1906    fn execute_round(&mut self, player_pick: usize) {
1907        let player_skill = self.player().skills[player_pick].clone();
1908        let enemy_skill = ai_pick(&self.enemy);
1909        let player_first = self.player().eff_speed() >= self.enemy.eff_speed();
1910        let order = if player_first {
1911            [Side::Player, Side::Enemy]
1912        } else {
1913            [Side::Enemy, Side::Player]
1914        };
1915
1916        let mut lines = VecDeque::new();
1917        let mut after = After::Menu;
1918        for (pos, side) in order.iter().enumerate() {
1919            if self.player().hp == 0 || self.enemy.hp == 0 {
1920                break; // a faint mid-round cancels the remaining action
1921            }
1922            let skill = match side {
1923                Side::Player => player_skill.clone(),
1924                Side::Enemy => enemy_skill.clone(),
1925            };
1926            self.perform(*side, &skill, &mut lines);
1927            match self.faint_flow(&mut lines) {
1928                FaintFlow::Continue => {}
1929                FaintFlow::SentOut => {
1930                    // The replacement never acts the turn it comes in.
1931                    after = After::Menu;
1932                    break;
1933                }
1934                FaintFlow::Switch => {
1935                    // The enemy's action still resolves — against the
1936                    // replacement, once picked.
1937                    if order.get(pos + 1) == Some(&Side::Enemy) {
1938                        self.pending_enemy = Some(enemy_skill);
1939                    }
1940                    after = After::ForcedSwitch;
1941                    break;
1942                }
1943                FaintFlow::End(o) => {
1944                    after = After::End(o);
1945                    break;
1946                }
1947            }
1948            self.residual(*side, &mut lines);
1949            match self.faint_flow(&mut lines) {
1950                FaintFlow::Continue => {}
1951                FaintFlow::SentOut => {
1952                    after = After::Menu;
1953                    break;
1954                }
1955                FaintFlow::Switch => {
1956                    if order.get(pos + 1) == Some(&Side::Enemy) {
1957                        self.pending_enemy = Some(enemy_skill);
1958                    }
1959                    after = After::ForcedSwitch;
1960                    break;
1961                }
1962                FaintFlow::End(o) => {
1963                    after = After::End(o);
1964                    break;
1965                }
1966            }
1967        }
1968        self.phase = Phase::Narrate { lines, after };
1969    }
1970
1971    /// The faint check after an action or residual: narrates the faint and
1972    /// decides what follows — the next queued enemy (v2-d), a forced
1973    /// replacement while the party has living members, else the win/lose
1974    /// ending.
1975    fn faint_flow(&mut self, lines: &mut VecDeque<String>) -> FaintFlow {
1976        if self.enemy.hp == 0 {
1977            narrate(&mut self.log, lines, format!("{} fainted!", self.enemy.name));
1978            // v2-d: the EXP of every defeated enemy accumulates into the
1979            // end-of-battle award.
1980            self.exp_pool = self.exp_pool.saturating_add(self.enemy.exp_reward);
1981            if let Some(next) = self.enemies.pop_front() {
1982                self.send_out(next, lines);
1983                return FaintFlow::SentOut;
1984            }
1985            narrate(&mut self.log, lines, "You won the battle!".to_string());
1986            self.award_exp(lines);
1987            self.award_trainer_money(lines);
1988            FaintFlow::End(BattleOutcome::Win)
1989        } else if self.player().hp == 0 {
1990            let name = self.player().name.clone();
1991            narrate(&mut self.log, lines, format!("{name} fainted!"));
1992            if self.party.iter().any(|c| c.hp > 0) {
1993                FaintFlow::Switch
1994            } else {
1995                narrate(&mut self.log, lines, "You lost the battle…".to_string());
1996                FaintFlow::End(BattleOutcome::Lose)
1997            }
1998        } else {
1999            FaintFlow::Continue
2000        }
2001    }
2002
2003    /// Send out the next queued enemy (v2-d): a fresh combatant (its own
2004    /// stats/level, no status); the RON opponent mirror is rebuilt and the
2005    /// old enemy's volatiles drop. The round then ends (the replacement
2006    /// never acts the turn it comes in).
2007    fn send_out(&mut self, next: Combatant, lines: &mut VecDeque<String>) {
2008        self.enemy = next;
2009        if let Some(hooks) = &mut self.hooks {
2010            hooks.state.opponent_battlers[0] = hooks::mirror_of(
2011                &self.enemy,
2012                &hooks.stat_names,
2013                &hooks.status_names,
2014                hooks.has_resource,
2015            );
2016            let enemy_ref = HookState::battler_ref(Side::Enemy);
2017            hooks.effects.retain(|e| e.host != enemy_ref);
2018        }
2019        let name = self.enemy.name.clone();
2020        narrate(&mut self.log, lines, sent_out_line(&self.lang, &name));
2021        // The incoming enemy's ability fires on switch-in (v2-e).
2022        self.fire_switch_in(Side::Enemy, lines);
2023    }
2024
2025    /// The EXP award on a win (v2-c): every NON-fainted party member gains
2026    /// the SUM of every defeated enemy's `expField` value (v2-d; a wild
2027    /// battle's single enemy, identical to v1), then levels up while
2028    /// its progress covers the curve (`exp_to_next(L) = base × L^exponent`,
2029    /// capped at `maxLevel`), each level-up recomputing its stats and
2030    /// healing the max-HP/MP deltas. No `levels` block ⇒ nothing happens
2031    /// (v1 behavior, byte-for-byte).
2032    fn award_exp(&mut self, lines: &mut VecDeque<String>) {
2033        let Some(levels) = self.levels.clone() else {
2034            return;
2035        };
2036        let reward = self.exp_pool;
2037        let lang = self.lang.clone();
2038        for i in 0..self.party.len() {
2039            if self.party[i].hp == 0 {
2040                continue; // fainted members gain nothing
2041            }
2042            let name = self.party[i].name.clone();
2043            narrate(&mut self.log, lines, gained_exp_line(&lang, &name, reward));
2044            let c = &mut self.party[i];
2045            c.exp = c.exp.saturating_add(reward);
2046            loop {
2047                let need = levels.exp_to_next(c.level);
2048                if c.exp < need || c.level >= levels.max_level {
2049                    break;
2050                }
2051                c.exp -= need;
2052                c.level += 1;
2053                c.recompute_stats(&levels);
2054                narrate(&mut self.log, lines, level_up_line(&lang, &name, c.level));
2055            }
2056        }
2057    }
2058
2059    /// The trainer-money narration on a win (v2-d): the runner reads
2060    /// [`trainer_money`](Self::trainer_money) and pays it when the battle
2061    /// ends in a win; here we only narrate. Wild battles award nothing.
2062    fn award_trainer_money(&mut self, lines: &mut VecDeque<String>) {
2063        if self.trainer_money > 0 {
2064            let line = trainer_money_line(&self.lang, self.trainer_money, &self.currency);
2065            narrate(&mut self.log, lines, line);
2066        }
2067    }
2068
2069    /// A voluntary switch (the Party menu): costs the player's turn — the
2070    /// enemy acts after the new member comes in.
2071    fn execute_switch_round(&mut self, idx: usize) {
2072        let mut lines = VecDeque::new();
2073        let old_name = self.player().name.clone();
2074        narrate(&mut self.log, &mut lines, format!("Come back, {old_name}!"));
2075        self.switch_to(idx, &mut lines);
2076        let after = self.enemy_turn(&mut lines);
2077        self.phase = Phase::Narrate { lines, after };
2078    }
2079
2080    /// An item use (the Item menu): heals the active member (capped at max),
2081    /// decrements the inventory, and costs the player's turn.
2082    fn execute_item_round(&mut self, pick: usize) {
2083        let usable = self.usable_items();
2084        let Some(&item_idx) = usable.get(pick) else {
2085            return;
2086        };
2087        let item = self.items[item_idx].clone();
2088        let mut lines = VecDeque::new();
2089        let before = self.player().hp;
2090        let healed = (before + item.heal).min(self.player().max_hp);
2091        self.party[self.active].hp = healed;
2092        if let Some(count) = self.inventory.get_mut(&item.id) {
2093            *count = count.saturating_sub(1);
2094            if *count == 0 {
2095                self.inventory.remove(&item.id);
2096            }
2097        }
2098        let name = self.player().name.clone();
2099        narrate(&mut self.log, &mut lines, format!("{name} used {}!", item.name));
2100        narrate(
2101            &mut self.log,
2102            &mut lines,
2103            format!("{name} recovered {} HP!", healed - before),
2104        );
2105        let after = self.enemy_turn(&mut lines);
2106        self.phase = Phase::Narrate { lines, after };
2107    }
2108
2109    /// The enemy's half of a switch/item round: its AI pick, then its
2110    /// residuals, with the faint checks between.
2111    fn enemy_turn(&mut self, lines: &mut VecDeque<String>) -> After {
2112        let skill = ai_pick(&self.enemy);
2113        self.perform(Side::Enemy, &skill, lines);
2114        match self.faint_flow(lines) {
2115            FaintFlow::SentOut => After::Menu,
2116            FaintFlow::Switch => After::ForcedSwitch,
2117            FaintFlow::End(o) => After::End(o),
2118            FaintFlow::Continue => {
2119                self.residual(Side::Enemy, lines);
2120                match self.faint_flow(lines) {
2121                    FaintFlow::SentOut => After::Menu,
2122                    FaintFlow::Switch => After::ForcedSwitch,
2123                    FaintFlow::End(o) => After::End(o),
2124                    FaintFlow::Continue => After::Menu,
2125                }
2126            }
2127        }
2128    }
2129
2130    /// A forced replacement after a faint (a free action): the new member
2131    /// comes in, then the enemy's deferred action (if any) resolves.
2132    fn forced_switch_to(&mut self, idx: usize) {
2133        let mut lines = VecDeque::new();
2134        self.switch_to(idx, &mut lines);
2135        let mut after = After::Menu;
2136        if let Some(skill) = self.pending_enemy.take() {
2137            self.perform(Side::Enemy, &skill, &mut lines);
2138            match self.faint_flow(&mut lines) {
2139                FaintFlow::SentOut => after = After::Menu,
2140                FaintFlow::Switch => after = After::ForcedSwitch,
2141                FaintFlow::End(o) => after = After::End(o),
2142                FaintFlow::Continue => {
2143                    self.residual(Side::Enemy, &mut lines);
2144                    after = match self.faint_flow(&mut lines) {
2145                        FaintFlow::SentOut => After::Menu,
2146                        FaintFlow::Switch => After::ForcedSwitch,
2147                        FaintFlow::End(o) => After::End(o),
2148                        FaintFlow::Continue => After::Menu,
2149                    };
2150                }
2151            }
2152        }
2153        self.phase = Phase::Narrate { lines, after };
2154    }
2155
2156    /// Bring party member `idx` in: its stat stages reset (documented), the
2157    /// RON mirror is re-built from the member's CURRENT state (status
2158    /// persists with the member), and the old battler's volatiles drop.
2159    fn switch_to(&mut self, idx: usize, lines: &mut VecDeque<String>) {
2160        self.active = idx;
2161        self.party[idx].stages = Stages::default();
2162        if let Some(hooks) = &mut self.hooks {
2163            hooks.state.player_battlers[0] = hooks::mirror_of(
2164                &self.party[idx],
2165                &hooks.stat_names,
2166                &hooks.status_names,
2167                hooks.has_resource,
2168            );
2169            let player_ref = HookState::battler_ref(Side::Player);
2170            hooks.effects.retain(|e| e.host != player_ref);
2171        }
2172        let name = self.party[idx].name.clone();
2173        narrate(&mut self.log, lines, format!("Go, {name}!"));
2174        // The incoming member's ability fires on switch-in (v2-e).
2175        self.fire_switch_in(Side::Player, lines);
2176    }
2177
2178    /// Resolve one action: the MP gate (re-checked), the accuracy roll, then
2179    /// the skill's effect (damage / heal / stage change), narrating each step.
2180    /// A RON-taken-over skill runs through the stack interpreter instead
2181    /// ([`perform_ron`](Self::perform_ron)).
2182    fn perform(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2183        if skill.ron && self.hooks.is_some() {
2184            self.perform_ron(side, skill, lines);
2185            return;
2186        }
2187        let (attacker, defender) = match side {
2188            Side::Player => (&mut self.party[self.active], &mut self.enemy),
2189            Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2190        };
2191
2192        // The MP gate is re-checked at resolution time.
2193        if skill.cost > attacker.mp {
2194            narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2195            narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2196            return;
2197        }
2198        attacker.mp -= skill.cost;
2199        narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2200
2201        if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2202            narrate(&mut self.log, lines, "But it missed!".to_string());
2203            return;
2204        }
2205
2206        match skill.category {
2207            SkillCategory::Damage => {
2208                let mult = self
2209                    .chart
2210                    .mult(skill.element.as_deref(), defender.element.as_deref());
2211                let roll = damage_roll(
2212                    skill.power,
2213                    attacker.eff_attack(),
2214                    defender.eff_defense(),
2215                    mult,
2216                    self.rng.as_mut(),
2217                );
2218                defender.hp = defender.hp.saturating_sub(roll.damage);
2219                if roll.crit {
2220                    narrate(&mut self.log, lines, "Critical hit!".to_string());
2221                }
2222                if roll.mult_num > roll.mult_den {
2223                    narrate(&mut self.log, lines, "It's super effective!".to_string());
2224                } else if roll.mult_num < roll.mult_den {
2225                    narrate(&mut self.log, lines, "It's not very effective…".to_string());
2226                }
2227                narrate(&mut self.log, lines, format!("{} damage!", roll.damage));
2228            }
2229            SkillCategory::Heal => {
2230                let before = attacker.hp;
2231                attacker.hp = (attacker.hp + skill.power).min(attacker.max_hp);
2232                narrate(&mut self.log, lines,
2233                    format!("{} recovered {} HP!", attacker.name, attacker.hp - before),
2234                );
2235            }
2236            SkillCategory::Buff => {
2237                attacker.stages.bump(&skill.stat, 1);
2238                narrate(&mut self.log, lines,
2239                    format!("{}'s {} rose!", attacker.name, stat_label(&skill.stat)),
2240                );
2241            }
2242            SkillCategory::Debuff => {
2243                defender.stages.bump(&skill.stat, -1);
2244                narrate(&mut self.log, lines,
2245                    format!("{}'s {} fell!", defender.name, stat_label(&skill.stat)),
2246                );
2247            }
2248        }
2249    }
2250
2251    // ── RON effect hooks (v2-a) ─────────────────────────────────────────────
2252
2253    /// Fire one stack event for the hooks sourced from ANY of `source_ids`
2254    /// (a skill id plus — v2-e — the acting combatant's ability / held-item
2255    /// record ids, or a status / weather record id), threading `relay`
2256    /// through the fold (the minimon/wuxia harness shape: per-record filter →
2257    /// `collect_handlers` → `run_event`). Returns the fold's output relay.
2258    fn fire(
2259        &mut self,
2260        event: Event,
2261        source_ids: &[&str],
2262        target: BattlerRef,
2263        source: BattlerRef,
2264        relay: RelayVar,
2265    ) -> RelayVar {
2266        let hooks = self.hooks.as_mut().expect("fire requires hook state");
2267        let host = GenericProvider::rules_host().expect("rules host installed");
2268        let provider = GenericProvider;
2269        let mut adapter = hooks::RngAdapter(self.rng.as_mut());
2270        let mut ctx = BattleCtx {
2271            state: &mut hooks.state,
2272            effects: &mut hooks.effects,
2273            mv: &mut hooks.mv,
2274            rng: &mut adapter,
2275        };
2276        let mut hs = Vec::new();
2277        for eff in &hooks.registry {
2278            let matches = host
2279                .compiled
2280                .hook(eff.id)
2281                .map(|h| source_ids.contains(&h.source_id.as_str()))
2282                .unwrap_or(false);
2283            if matches {
2284                collect_handlers(&ctx, &provider, Some(eff), event, target, source, &mut hs);
2285            }
2286        }
2287        run_event(&mut ctx, hs, relay, false)
2288    }
2289
2290    /// Copy the live pools (HP/MP/stats/stages/status) into the engine mirrors.
2291    fn sync_to_mirrors(&mut self) {
2292        let Some(hooks) = &mut self.hooks else { return };
2293        let (stat_names, status_names, has_resource) =
2294            (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2295        hooks::sync_to_mirror(
2296            &self.party[self.active],
2297            &mut hooks.state.player_battlers[0],
2298            stat_names,
2299            status_names,
2300            has_resource,
2301        );
2302        hooks::sync_to_mirror(
2303            &self.enemy,
2304            &mut hooks.state.opponent_battlers[0],
2305            stat_names,
2306            status_names,
2307            has_resource,
2308        );
2309    }
2310
2311    /// Copy the pools back from the engine mirrors after a fire.
2312    fn sync_from_mirrors(&mut self) {
2313        let Some(hooks) = &mut self.hooks else { return };
2314        let (stat_names, status_names, has_resource) =
2315            (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2316        hooks::sync_from_mirror(
2317            &hooks.state.player_battlers[0],
2318            &mut self.party[self.active],
2319            stat_names,
2320            status_names,
2321            has_resource,
2322        );
2323        hooks::sync_from_mirror(
2324            &hooks.state.opponent_battlers[0],
2325            &mut self.enemy,
2326            stat_names,
2327            status_names,
2328            has_resource,
2329        );
2330    }
2331
2332    /// Narrate the state changes a fire produced (status inflicted/cured,
2333    /// stat stages, HP/MP moved) by diffing the mirror snapshots. `residual`
2334    /// flavors HP loss as the status chip ("… is hurt by poison!").
2335    fn narrate_diffs(&mut self, before: &[MirrorSnap; 2], lines: &mut VecDeque<String>, residual: bool) {
2336        let names = [self.player().name.clone(), self.enemy.name.clone()];
2337        let Some(hooks) = &self.hooks else { return };
2338        let produced = diff_lines(hooks, before, [&names[0], &names[1]], residual);
2339        for line in produced {
2340            narrate(&mut self.log, lines, line);
2341        }
2342    }
2343
2344    /// Resolve one RON-taken-over skill: the v1 MP gate + accuracy roll, then
2345    /// the stack event sequence over the mirrored battlers — `BeforeMove`
2346    /// gate (when subscribed) → damage precompute (the v1 formula into
2347    /// `ctx.mv.damage`) → `ModifyDamage` → `Effectiveness` → `Damage` → apply
2348    /// → `DamagingHit` → `AfterMove` (the minimon/wuxia fire order).
2349    fn perform_ron(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2350        let (attacker, defender) = match side {
2351            Side::Player => (&mut self.party[self.active], &mut self.enemy),
2352            Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2353        };
2354
2355        // The MP gate is re-checked at resolution time (v1 parity); the RON
2356        // record's `cost:` already fed `skill.cost` at load.
2357        if skill.cost > attacker.mp {
2358            narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
2359            narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
2360            return;
2361        }
2362        attacker.mp -= skill.cost;
2363        narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
2364
2365        if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2366            narrate(&mut self.log, lines, "But it missed!".to_string());
2367            return;
2368        }
2369
2370        let eff_atk = attacker.eff_attack();
2371        let eff_def = defender.eff_defense();
2372        let def_element = defender.element.clone();
2373        // v2-e: the acting combatant's ability and held-item records join its
2374        // per-action event sequence (an ability hooking `ModifyDamage` etc.
2375        // fires alongside the skill's own hooks).
2376        let ability = attacker.ability.clone();
2377        let held_item = attacker.held_item.clone();
2378        let mut ids: Vec<&str> = vec![skill.id.as_str()];
2379        if let Some(a) = &ability {
2380            ids.push(a);
2381        }
2382        if let Some(i) = &held_item {
2383            ids.push(i);
2384        }
2385        let source = HookState::battler_ref(side);
2386        let target = HookState::battler_ref(match side {
2387            Side::Player => Side::Enemy,
2388            Side::Enemy => Side::Player,
2389        });
2390
2391        self.sync_to_mirrors();
2392
2393        // BeforeMove gate — only when the record subscribes. Relay starts
2394        // `Bool(true)`; a `Fail` (`VetoIf` / unaffordable `PayResource`)
2395        // yields `Bool(false)`, a silent veto `Unit`.
2396        if self.subscribes_any(&ids, Event::BeforeMove) {
2397            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2398            let out = self.fire(Event::BeforeMove, &ids, target, source, RelayVar::Bool(true));
2399            self.narrate_diffs(&before, lines, false);
2400            match out {
2401                RelayVar::Bool(false) => {
2402                    narrate(&mut self.log, lines, "But it failed!".to_string());
2403                    self.sync_from_mirrors();
2404                    return;
2405                }
2406                RelayVar::Unit => {
2407                    self.sync_from_mirrors();
2408                    return;
2409                }
2410                _ => {}
2411            }
2412        }
2413
2414        // When the record subscribes to `Effectiveness` the hooks own the
2415        // scaling (author `ApplyTypeChart` for the chart); otherwise the v1
2416        // direct chart application applies in the precompute.
2417        let has_effectiveness_hooks = self.subscribes_any(&ids, Event::Effectiveness);
2418
2419        if skill.power > 0 {
2420            let mult = if has_effectiveness_hooks {
2421                (1, 1)
2422            } else {
2423                self.chart
2424                    .mult(skill.element.as_deref(), def_element.as_deref())
2425            };
2426            let roll = damage_roll(skill.power, eff_atk, eff_def, mult, self.rng.as_mut());
2427            self.hooks.as_mut().unwrap().mv.damage =
2428                roll.damage.min(u32::from(u16::MAX)) as u16;
2429            if roll.crit {
2430                narrate(&mut self.log, lines, "Critical hit!".to_string());
2431            }
2432            if !has_effectiveness_hooks {
2433                if roll.mult_num > roll.mult_den {
2434                    narrate(&mut self.log, lines, "It's super effective!".to_string());
2435                } else if roll.mult_num < roll.mult_den {
2436                    narrate(&mut self.log, lines, "It's not very effective…".to_string());
2437                }
2438            }
2439
2440            // ModifyDamage fold (ScaleRelay/SetDamage ride here).
2441            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2442            let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2443            let out = self.fire(
2444                Event::ModifyDamage,
2445                &ids,
2446                target,
2447                source,
2448                RelayVar::Damage(in_damage),
2449            );
2450            self.narrate_diffs(&before, lines, false);
2451            match out {
2452                RelayVar::Damage(d) => self.hooks.as_mut().unwrap().mv.damage = d,
2453                RelayVar::Bool(false) => {
2454                    narrate(&mut self.log, lines, "But it failed!".to_string());
2455                    self.sync_from_mirrors();
2456                    return;
2457                }
2458                RelayVar::Unit => {
2459                    self.sync_from_mirrors();
2460                    return;
2461                }
2462                _ => {}
2463            }
2464
2465            // Effectiveness fold (ApplyTypeChart; effectiveness narrated from
2466            // what the fold actually did to the number).
2467            if has_effectiveness_hooks {
2468                let before = snap_mirrors(self.hooks.as_ref().unwrap());
2469                let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2470                let out = self.fire(
2471                    Event::Effectiveness,
2472                    &ids,
2473                    target,
2474                    source,
2475                    RelayVar::Damage(in_damage),
2476                );
2477                self.narrate_diffs(&before, lines, false);
2478                match out {
2479                    RelayVar::Damage(d) => {
2480                        self.hooks.as_mut().unwrap().mv.damage = d;
2481                        if d > in_damage {
2482                            narrate(&mut self.log, lines, "It's super effective!".to_string());
2483                        } else if d < in_damage {
2484                            narrate(&mut self.log, lines, "It's not very effective…".to_string());
2485                        }
2486                    }
2487                    RelayVar::Bool(false) => {
2488                        narrate(&mut self.log, lines, "But it failed!".to_string());
2489                        self.sync_from_mirrors();
2490                        return;
2491                    }
2492                    RelayVar::Unit => {
2493                        self.sync_from_mirrors();
2494                        return;
2495                    }
2496                    _ => {}
2497                }
2498            }
2499
2500            // The Damage fold (absorb / floor / veto hooks), then apply.
2501            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2502            let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2503            let out = self.fire(
2504                Event::Damage,
2505                &ids,
2506                target,
2507                source,
2508                RelayVar::Damage(in_damage),
2509            );
2510            self.narrate_diffs(&before, lines, false);
2511            let final_damage = match out {
2512                RelayVar::Damage(d) => d,
2513                RelayVar::Bool(false) => {
2514                    narrate(&mut self.log, lines, "But it failed!".to_string());
2515                    self.sync_from_mirrors();
2516                    return;
2517                }
2518                RelayVar::Unit => {
2519                    self.sync_from_mirrors();
2520                    return;
2521                }
2522                _ => in_damage,
2523            };
2524            {
2525                let hooks = self.hooks.as_mut().unwrap();
2526                let b = if target.side == 0 {
2527                    &mut hooks.state.player_battlers[target.slot as usize]
2528                } else {
2529                    &mut hooks.state.opponent_battlers[target.slot as usize]
2530                };
2531                b.take_damage(final_damage);
2532                hooks.mv.last_damage = final_damage;
2533            }
2534            narrate(&mut self.log, lines, format!("{final_damage} damage!"));
2535            // Keep the pools synced so the faint checks read live HP.
2536            self.sync_from_mirrors();
2537        }
2538
2539        // DamagingHit (secondary effects: InflictStatus riders etc.) — fired
2540        // after any landed hit, damaging or not, so a power-0 status skill's
2541        // riders still run.
2542        let before = snap_mirrors(self.hooks.as_ref().unwrap());
2543        let last_damage = self.hooks.as_ref().unwrap().mv.last_damage;
2544        self.fire(
2545            Event::DamagingHit,
2546            &ids,
2547            target,
2548            source,
2549            RelayVar::Damage(last_damage),
2550        );
2551        self.narrate_diffs(&before, lines, false);
2552
2553        // AfterMove (per-action cleanup: self-chips, volatiles).
2554        let before = snap_mirrors(self.hooks.as_ref().unwrap());
2555        self.fire(Event::AfterMove, &ids, target, source, RelayVar::Unit);
2556        self.narrate_diffs(&before, lines, false);
2557
2558        self.sync_from_mirrors();
2559    }
2560
2561    /// Whether the skill's RON record subscribes to `event`.
2562    fn subscribes(&self, skill_id: &str, event: Event) -> bool {
2563        self.hooks
2564            .as_ref()
2565            .is_some_and(|h| h.subscribes(skill_id, event))
2566    }
2567
2568    /// Whether ANY of `ids` (a skill plus the acting combatant's ability /
2569    /// held-item records, v2-e) subscribes to `event`.
2570    fn subscribes_any(&self, ids: &[&str], event: Event) -> bool {
2571        ids.iter().any(|id| self.subscribes(id, event))
2572    }
2573
2574    /// The end-of-action residual: the acting combatant's status record's
2575    /// `Residual` hooks (poison chip etc., v2-a), its held-item record's
2576    /// `Residual` hooks (Leftovers-style heal, v2-e), and the active
2577    /// weather's `FieldResidual` hooks with this side as the target (v2-e —
2578    /// so on a full round each side ticks once). No-op without hooks.
2579    fn residual(&mut self, side: Side, lines: &mut VecDeque<String>) {
2580        if self.hooks.is_none() {
2581            return;
2582        }
2583        // The mirror must see the action's results first (a v1-path action
2584        // mutates the Combatant directly).
2585        self.sync_to_mirrors();
2586        let who = HookState::battler_ref(side);
2587
2588        // 1. The persistent status's residual (v2-a).
2589        let status_id = {
2590            let hooks = self.hooks.as_ref().unwrap();
2591            hooks
2592                .battler(side)
2593                .status
2594                .clone()
2595                .and_then(|hooks::StatusId(idx)| hooks.status_names.get(idx as usize).cloned())
2596        };
2597        if let Some(source_id) = status_id {
2598            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2599            self.fire(Event::Residual, &[&source_id], who, who, RelayVar::Unit);
2600            self.narrate_diffs(&before, lines, true);
2601        }
2602
2603        // 2. The held item's residual (v2-e): persistent — never consumed.
2604        let held_item = match side {
2605            Side::Player => self.party[self.active].held_item.clone(),
2606            Side::Enemy => self.enemy.held_item.clone(),
2607        };
2608        if let Some(item_id) = held_item {
2609            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2610            self.fire(Event::Residual, &[&item_id], who, who, RelayVar::Unit);
2611            self.narrate_diffs(&before, lines, false);
2612        }
2613
2614        // 3. The weather's field residual (v2-e).
2615        if let Some(weather) = self.weather.clone() {
2616            let before = snap_mirrors(self.hooks.as_ref().unwrap());
2617            self.fire(Event::FieldResidual, &[&weather], who, who, RelayVar::Unit);
2618            self.narrate_diffs(&before, lines, false);
2619        }
2620
2621        self.sync_from_mirrors();
2622    }
2623
2624    // ── rendering ───────────────────────────────────────────────────────────
2625
2626    /// Draw the battle screen: a two-tone field with placeholder combatant
2627    /// blobs, an enemy panel (name, element, HP bar) up top, a player panel
2628    /// (name, HP bar, MP) below, and the current menu or narration line at
2629    /// the bottom.
2630    pub fn draw(&self, fb: &mut FrameBuffer) {
2631        // Field.
2632        fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::rgb(0x18, 0x18, 0x28));
2633        fb.fill_rect(
2634            0,
2635            130,
2636            SCREEN_W as u32,
2637            SCREEN_H as u32 - 130,
2638            Rgba::rgb(0x20, 0x2A, 0x20),
2639        );
2640
2641        // Placeholder combatants (colored blobs, palette from the record id).
2642        draw_blob(fb, 228, 28, 56, blob_color(&self.enemy.id));
2643        draw_blob(fb, 52, 108, 64, blob_color(&self.player().id));
2644
2645        // Enemy panel: name + element + HP bar.
2646        draw_panel(fb, 8, 8, 184, 46);
2647        text(fb, &self.enemy.name, 16, 14, Rgba::rgb(0xF0, 0xF0, 0xF0));
2648        if let Some(element) = &self.enemy.element {
2649            text(fb, element, 16, 26, Rgba::rgb(0x90, 0xA8, 0xC8));
2650        }
2651        draw_bar(fb, 80, 28, 104, 6, self.enemy.hp, self.enemy.max_hp);
2652
2653        // Player panel (the ACTIVE member): name + HP bar + numbers + MP.
2654        draw_panel(fb, 132, 136, 180, 46);
2655        text(fb, &self.player().name, 140, 142, Rgba::rgb(0xF0, 0xF0, 0xF0));
2656        draw_bar(fb, 140, 156, 104, 6, self.player().hp, self.player().max_hp);
2657        text(
2658            fb,
2659            &format!("{}/{}", self.player().hp, self.player().max_hp),
2660            250,
2661            154,
2662            Rgba::rgb(0xC8, 0xC8, 0xC8),
2663        );
2664        text(
2665            fb,
2666            &format!("MP {}/{}", self.player().mp, self.player().max_mp),
2667            140,
2668            168,
2669            Rgba::rgb(0x90, 0xA8, 0xC8),
2670        );
2671
2672        // Bottom: the current menu (+prompt) or the current narration line.
2673        match &self.phase {
2674            Phase::Root => {
2675                draw_textbox(fb, "What will you do?");
2676                self.draw_menu(fb);
2677            }
2678            Phase::Skills => {
2679                draw_textbox(fb, "Choose a skill!");
2680                self.draw_menu(fb);
2681            }
2682            Phase::Party => {
2683                draw_textbox(fb, "Switch to whom?");
2684                self.draw_menu(fb);
2685            }
2686            Phase::Items => {
2687                draw_textbox(fb, "Use which item?");
2688                self.draw_menu(fb);
2689            }
2690            Phase::ForcedSwitch => {
2691                draw_textbox(fb, "Choose your next fighter!");
2692                self.draw_menu(fb);
2693            }
2694            Phase::Narrate { lines, .. } => {
2695                if let Some(line) = lines.front() {
2696                    draw_textbox(fb, line);
2697                }
2698            }
2699        }
2700    }
2701
2702    /// The current menu above the dialogue area (the cursor marks the
2703    /// selection; entries marked `×` cannot be confirmed).
2704    fn draw_menu(&self, fb: &mut FrameBuffer) {
2705        let items = self.menu_items();
2706        let n = items.len() as u32;
2707        if n == 0 {
2708            return;
2709        }
2710        let max_len = items.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2711        // +4: left/right border, cursor column, one padding column.
2712        let w = (max_len + 4).clamp(10, 24);
2713        let h = n + 2;
2714        let tx = (40 - w) as i32;
2715        let ty = DIALOG_AREA.ty as i32 - h as i32;
2716        let config = MenuConfig::new(
2717            TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2718            None,
2719            TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2720            Default::default(),
2721        );
2722        let state = FlexMenuState {
2723            cursor: self.cursor,
2724            scroll_offset: 0,
2725        };
2726        let mut painter = FrameBufferPainter::new(fb);
2727        let mut ui = Ui::new(&mut painter);
2728        draw_flex_menu(&items, &[config], &state, items.len(), &mut ui);
2729    }
2730}
2731
2732/// The enemy AI's skill pick: highest-power affordable (ties → earliest in
2733/// the list); with no affordable skill, the built-in Attack.
2734fn ai_pick(combatant: &Combatant) -> Skill {
2735    combatant
2736        .skills
2737        .iter()
2738        .filter(|s| s.cost <= combatant.mp)
2739        .max_by_key(|s| s.power)
2740        .cloned()
2741        .unwrap_or_else(basic_attack)
2742}
2743
2744/// Push a narration line onto the queue and the battle's running log.
2745fn narrate(log: &mut Vec<String>, lines: &mut VecDeque<String>, line: String) {
2746    log.push(line.clone());
2747    lines.push_back(line);
2748}
2749
2750/// "<name> gained <n> EXP!" (interpolated, so it can't sit in a label table).
2751fn gained_exp_line(lang: &str, name: &str, n: u32) -> String {
2752    if lang == "zh" {
2753        format!("{name} 获得了 {n} 点经验!")
2754    } else {
2755        format!("{name} gained {n} EXP!")
2756    }
2757}
2758
2759/// "<name> grew to level <n>!" (interpolated).
2760fn level_up_line(lang: &str, name: &str, level: u8) -> String {
2761    if lang == "zh" {
2762        format!("{name} 升到了 {level} 级!")
2763    } else {
2764        format!("{name} grew to level {level}!")
2765    }
2766}
2767
2768/// "Foe sent out <name>!" (v2-d encounter queue; interpolated).
2769fn sent_out_line(lang: &str, name: &str) -> String {
2770    if lang == "zh" {
2771        format!("对方派出了 {name}!")
2772    } else {
2773        format!("Foe sent out {name}!")
2774    }
2775}
2776
2777/// "Got away safely!" — a successful Run from a wild battle.
2778fn run_safe_line(lang: &str) -> String {
2779    if lang == "zh" {
2780        "顺利逃走了!".to_string()
2781    } else {
2782        "Got away safely!".to_string()
2783    }
2784}
2785
2786/// "Can't escape from a trainer battle!" — Run blocked (turn not consumed).
2787fn run_blocked_line(lang: &str) -> String {
2788    if lang == "zh" {
2789        "无法从训练家的战斗中逃走!".to_string()
2790    } else {
2791        "Can't escape from a trainer battle!".to_string()
2792    }
2793}
2794
2795/// "Got <n> <currency> for winning!" — the trainer-money award.
2796fn trainer_money_line(lang: &str, money: u32, currency: &str) -> String {
2797    if lang == "zh" {
2798        format!("赢得了 {money} {currency}!")
2799    } else {
2800        format!("Got {money} {currency} for winning!")
2801    }
2802}
2803
2804/// "<name>'s <Ability>!" — the switch-in intro when an ability fires (v2-e).
2805/// The RON record has no display name, so the id is prettified
2806/// (`swift-swim` → `Swift Swim`).
2807fn ability_intro_line(lang: &str, name: &str, ability: &str) -> String {
2808    let label = prettify_id(ability);
2809    if lang == "zh" {
2810        format!("{name} 的 {label}!")
2811    } else {
2812        format!("{name}'s {label}!")
2813    }
2814}
2815
2816/// "A <weather> rages!" — the battle-start weather intro (v2-e; the id
2817/// stays as authored).
2818fn weather_start_line(lang: &str, weather: &str) -> String {
2819    if lang == "zh" {
2820        format!("{weather} 开始了!")
2821    } else {
2822        format!("A {weather} rages!")
2823    }
2824}
2825
2826/// Prettify a record id for narration: `kebab-case`/`snake_case` → `Title Case`.
2827fn prettify_id(id: &str) -> String {
2828    id.split(['-', '_'])
2829        .filter(|w| !w.is_empty())
2830        .map(|w| {
2831            let mut chars = w.chars();
2832            match chars.next() {
2833                Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
2834                None => String::new(),
2835            }
2836        })
2837        .collect::<Vec<_>>()
2838        .join(" ")
2839}
2840
2841// ── hook-fire narration (v2-a) ──────────────────────────────────────────────
2842
2843/// A snapshot of one mirrored battler's narratable state (diffed across a
2844/// stack fire).
2845#[derive(Clone)]
2846struct MirrorSnap {
2847    hp: u16,
2848    status: Option<hooks::StatusId>,
2849    stages: Vec<i8>,
2850}
2851
2852/// Snapshot both engine mirrors (index 0 = player, 1 = enemy).
2853fn snap_mirrors(hooks: &HookState) -> [MirrorSnap; 2] {
2854    [Side::Player, Side::Enemy].map(|side| {
2855        let b = hooks.battler(side);
2856        MirrorSnap {
2857            hp: b.hp,
2858            status: b.status.clone(),
2859            stages: (0..hooks.stat_names.len())
2860                .map(|i| {
2861                    b.stat_stages
2862                        .get(hooks::StatId(i as u16))
2863                        .copied()
2864                        .unwrap_or(0)
2865                })
2866                .collect(),
2867        }
2868    })
2869}
2870
2871/// The narration lines for the difference between `before` and the mirrors'
2872/// current state: status inflicted/cured, stat stages, HP movement. With
2873/// `residual`, HP loss on a statused combatant reads as the status chip.
2874fn diff_lines(hooks: &HookState, before: &[MirrorSnap; 2], names: [&str; 2], residual: bool) -> Vec<String> {
2875    let after = snap_mirrors(hooks);
2876    let mut out = Vec::new();
2877    for i in 0..2 {
2878        let (b, a, name) = (&before[i], &after[i], names[i]);
2879        let status_name = |id: &hooks::StatusId| {
2880            hooks
2881                .status_names
2882                .get(id.0 as usize)
2883                .map(String::as_str)
2884                .unwrap_or("?")
2885        };
2886        match (&b.status, &a.status) {
2887            (None, Some(id)) => out.push(format!("{name} was afflicted with {}!", status_name(id))),
2888            (Some(id), None) => out.push(format!("{name} is no longer {}!", status_name(id))),
2889            _ => {}
2890        }
2891        for (s, stat) in hooks.stat_names.iter().enumerate() {
2892            let (bv, av) = (b.stages[s], a.stages[s]);
2893            if av > bv {
2894                out.push(format!("{name}'s {} rose!", stat_label(&normalize_stat_key(stat))));
2895            } else if av < bv {
2896                out.push(format!("{name}'s {} fell!", stat_label(&normalize_stat_key(stat))));
2897            }
2898        }
2899        if a.hp < b.hp {
2900            match (&a.status, &b.status, residual) {
2901                (Some(id), _, true) | (_, Some(id), true) => {
2902                    out.push(format!("{name} is hurt by {}!", status_name(id)));
2903                }
2904                _ => out.push(format!("{name} lost {} HP!", b.hp - a.hp)),
2905            }
2906        } else if a.hp > b.hp {
2907            out.push(format!("{name} recovered {} HP!", a.hp - b.hp));
2908        }
2909    }
2910    out
2911}
2912
2913// ── drawing helpers ─────────────────────────────────────────────────────────
2914
2915/// Text via the embedded font.
2916pub(crate) fn text(fb: &mut FrameBuffer, s: &str, x: u32, y: u32, color: Rgba) {
2917    embedded_font::draw_text(s, x, y, color, fb);
2918}
2919
2920/// A bordered info panel.
2921pub(crate) fn draw_panel(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32) {
2922    fb.fill_rect(x, y, w, h, Rgba::rgb(0xC8, 0xC8, 0xD0));
2923    fb.fill_rect(x + 1, y + 1, w - 2, h - 2, Rgba::rgb(0x28, 0x28, 0x38));
2924}
2925
2926/// A proportional HP bar (green, turning red below a quarter).
2927fn draw_bar(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32, hp: u32, max_hp: u32) {
2928    let frac = if max_hp == 0 {
2929        0.0
2930    } else {
2931        hp as f32 / max_hp as f32
2932    };
2933    fb.fill_rect(x, y, w, h, Rgba::rgb(0x10, 0x10, 0x18));
2934    let fill = if frac < 0.25 {
2935        Rgba::rgb(0xD0, 0x40, 0x38)
2936    } else {
2937        Rgba::rgb(0x40, 0xC0, 0x58)
2938    };
2939    let inner = ((w - 2) as f32 * frac.clamp(0.0, 1.0)) as u32;
2940    if inner > 0 {
2941        fb.fill_rect(x + 1, y + 1, inner, h - 2, fill);
2942    }
2943}
2944
2945/// A blob color derived from a record id (distinct ids read as distinct monsters).
2946fn blob_color(id: &str) -> Rgba {
2947    let hash = id
2948        .bytes()
2949        .fold(0x811C_9DC5_u32, |h, b| h.wrapping_mul(16_777_619) ^ b as u32);
2950    let hue = (hash >> 8) as u8;
2951    Rgba::rgb(0x60 + hue % 0x70, 0x60 + (hue / 3) % 0x70, 0x60 + (hue / 7) % 0x70)
2952}
2953
2954/// A round-ish solid placeholder combatant blob.
2955fn draw_blob(fb: &mut FrameBuffer, x: i32, y: i32, size: i32, color: Rgba) {
2956    let r = (size / 7).max(2);
2957    for dy in 0..size {
2958        for dx in 0..size {
2959            let (px, py) = (x + dx, y + dy);
2960            if px < 0 || py < 0 || px >= SCREEN_W || py >= SCREEN_H {
2961                continue;
2962            }
2963            if (dx < r || dx >= size - r) && (dy < r || dy >= size - r) {
2964                continue;
2965            }
2966            fb.set_pixel(px as u32, py as u32, color);
2967        }
2968    }
2969}
2970
2971#[cfg(test)]
2972mod tests;