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