overlord_event_system/mechanics/
balance.rs

1//!
2//! Functions and constants here mirror the original `balance.yaml` script,
3//! which contained the project's fundamental combat and economic balance
4//! formulas. Content-dependent lookups (item rarity quality, ability
5//! rarity effectiveness, fixed-power items) are sourced from
6//! [`ContentLookups`], which is populated at engine init time by reading
7//! the `content_raw` module's data maps.
8
9use std::collections::BTreeMap;
10
11use configs::game_config::GameConfig;
12use essences::abilities::{Ability, EquippedAbilities};
13use essences::items::Item;
14use essences::pets::Pet;
15use event_system::script::random::GameRng;
16use uuid::Uuid;
17
18use crate::game_config_helpers::GameConfigLookup;
19use crate::mechanics::content_lookups::ContentLookups;
20
21/// Ordered attribute accumulator used by [`power_from_attrs`] /
22/// [`character_power`].
23///
24/// only ever does keyed reads (`get_attr` on fixed attribute names) — it never
25/// iterates the map — so the only behaviour that must be preserved is the
26/// per-key float accumulation order. We accumulate in source order
27/// (`*entry += value`), which is byte-identical to the old
28/// `Dynamic::from_float(cur + value)` insert. A `BTreeMap<String, f64>` routes
29pub type AttrMap = BTreeMap<String, f64>;
30
31/// Add `value` to the running sum for `key`, mirroring the old
32/// `let cur = collected.get(key)...; collected.insert(key, cur + value)`.
33fn accumulate(map: &mut AttrMap, key: &str, value: f64) {
34    *map.entry(key.to_string()).or_insert(0.0) += value;
35}
36
37/// Ability-budget window (seconds) — the measured length of a typical campaign
38/// fight (weighted median from battle-end telemetry; win-only fights run
39/// longer). It is a WEIGHT in the ability-damage budget (`eff·FD/(FD+cd)·cd`,
40/// combat AND scalar — the two share the primitive, so they cannot desync) and
41/// in the regen/HoT-as-EHP conversion. The counterattack term and the item
42/// counterattack roll are FD-invariant (FD cancels in their ratios — only
43/// `ATTACKS_PER_SEC` prices them). Changing this constant re-scales ability
44/// damage and the honest scalar together, so it requires the full coderive
45/// recalibration cycle (η + growth knots), same as any scalar-changing edit.
46pub const FIGHT_DURATION: f64 = 25.0;
47pub const BASE_HP: f64 = 3000.0;
48pub const BASE_ATTACK: f64 = 600.0;
49pub const DMG_K: f64 = 0.1;
50pub const BASE_CRIT_CHANCE: f64 = 0.0;
51pub const BASE_CRIT_MOD: f64 = 2.0;
52pub const BASE_POWER: i64 = 1000;
53pub const BASE_SPEED: f64 = 1.0;
54pub const COUNTERATTACK_POWER: f64 = 2.0;
55pub const BASE_SPELL_EFF: f64 = 1.0;
56pub const MAIN_ATTRS_QUANTITY: i64 = 3;
57pub const SPELL_QUANTITY: i64 = 6;
58pub const ATTR_DEVIATION: f64 = 0.1;
59/// Mean of the level-1 attribute spread — level-1 items are deliberately weak
60/// (the scripted starters and the first chest drops share it).
61pub const LEVEL_ONE_ATTR_MEAN: f64 = 0.65;
62/// Level-1 rolls use a wider deviation than the 2..=25 band: level-1 stat
63/// values are tiny integers, so ±10% would be flattened by flooring and every
64/// roll of the single early template per (rarity, slot) cell came out a clone
65/// of the scripted starters.
66pub const ATTR_DEVIATION_LEVEL_ONE: f64 = 0.2;
67pub const AUX_ATTR_IMPACT: f64 = 0.05;
68pub const AOE_COEF: f64 = 0.6;
69pub const OT_COEF: f64 = 1.2;
70pub const HEAL_COEF: f64 = 1.2;
71pub const BUFF_EFF: f64 = 1.5;
72pub const BRAVERY_BUFF_QUANTITY: i64 = 2;
73pub const BRAVERY_BUFF_DURATION: f64 = 1.0;
74pub const DECEIT_DEBUFF_QUANTITY: i64 = 2;
75pub const DECEIT_DEBUFF_DURATION: f64 = 1.0;
76/// PLAYER cast rate (casts/s) — prices the bravery/deceit on-cast proc uptime
77/// (`buff_uptime_mult*`). Near-honest for a filled loadout: ~6 slotted
78/// abilities at cd 2-6s + class basic at cd 1s ⇒ Σ1/cd ≈ 1.5-2.5 casts/s.
79pub const CASTS_PER_SEC: f64 = 2.0;
80/// INCOMING enemy hits/s on the player — prices counterattack (the scalar term
81/// and the item roll are FD-invariant: FD cancels, only this rate matters).
82/// Deliberately independent of `CASTS_PER_SEC` — the player's cast rate says
83/// nothing about enemy attack rate. Derivation: combat schedules each mob's
84/// next cast at `current_tick + scaled_cooldown` (logic/fighting.rs), so a mob
85/// attacks every `cd` — mob basic attacks run cd 1.0-1.7s (typ. 1.4 ⇒
86/// ~0.7 atk/s). Campaign waves hold a median 5 mobs, declining as they die
87/// (time-avg attackers ≈ 2.5, further shaved by approach/spawn delays):
88/// ≈ 2.5 × 0.7 ≈ 1.8 hits/s. Boss fights run lower (single attacker) —
89/// this is the campaign-mix weight.
90pub const ATTACKS_PER_SEC: f64 = 1.8;
91
92// --- Power model P = DPS × EHP, diminishing-returns curves R/(R+K) -----------
93// Avoidance/mitigation use the DR curve `rating/(rating+K)`: it asymptotes
94// below 100%, so no single defensive stat dominates and there is no cliff
95// (a linear falloff reaches 0 damage taken and keeps going — unkillable).
96// The K values are bot-sim calibrated, not derived.
97pub const K_ARMOR: f64 = 2000.0;
98// Dodge is a binary per-hit outcome coin, so its K is deliberately high (3× the
99// armor K): the dodge rate stays low and power, not the miss lottery, decides
100// fights — the stat-check combat direction.
101pub const K_DODGE: f64 = 6000.0;
102/// Cap on per-tick combat regen, as a fraction of max HP. The
103/// `Regeneration_rate` effect fires ~1×/s, so over a `FIGHT_DURATION` fight
104/// this bounds combat regen to ≈0.5×HP ⇒ ≈×1.5 EHP at the cap. The power
105/// scalar's regen-as-EHP is **derived from this same value**
106/// (`power_from_attrs`) so display/matchmaking can never over-rate what combat
107/// delivers — there is deliberately no separate scalar regen cap.
108pub const REGEN_TICK_MAX_PCT: f64 = 0.02;
109/// Per-tick caps for HoT/DoT effects, as a fraction of max HP — safety so a
110/// mis-tuned ability effect can't out-heal all damage (HoT) or one-shot (DoT).
111/// Generous (these are ability-derived, already bounded by ability balance); the
112/// cap only catches pathological values. Seed; sim-calibrated.
113pub const HOT_TICK_MAX_PCT: f64 = 0.25;
114pub const DOT_TICK_MAX_PCT: f64 = 0.50;
115/// Minimum `received_damage` multiplier (in /10000 units) so heavy mitigation
116/// (e.g. stacked `protection`) can't reach ≤0 → unkillable. 100 ⇒ ≤99% reduction.
117pub const MIN_RECEIVED_DAMAGE_K: f64 = 100.0;
118/// Floor on a stat's `.mod` multiplier in `get_entity_stat`, so a stacking
119/// `.mod` debuff (e.g. `weakness` on attack, `protection` on received_damage)
120/// can't drive a stat to ≤0 (zero-damage / unkillable). 0.05 ⇒ ≤95% debuff.
121/// Only bites at mod ≤ −9500; no-mod / buff stats are unaffected.
122pub const MIN_STAT_MOD_MULT: f64 = 0.05;
123
124/// Power-scalar normalisation. Anchors a reference character
125/// (attack=`BASE_ATTACK`, hp=`BASE_HP`, neutral elsewhere) at `BASE_POWER`, so
126/// the displayed Combat-Power integer stays on the established scale while the
127/// formula underneath is `P = DPS × EHP`. = 600·3000/1000 = 1800.
128pub const POWER_NORM: f64 = BASE_ATTACK * BASE_HP / BASE_POWER as f64;
129
130// --- Balance v2: the sim-tunable knob surface --------------------------------
131// All the v2 balance "knobs" in one place so a balance change is a one-field
132// edit and the bot-sim can SWEEP them with **zero recompile / zero deploy**:
133// set the matching `OVERLORD_BAL_*` env var and restart the (cached) monolith.
134// Defaults equal the constants above/below, so behaviour is unchanged unless an
135// override is provided. Read everywhere via `tuning()`; initialised once at
136// process start by `init_tuning` (the binary calls
137// `init_tuning(BalanceTuning::from_env())`). Tests don't init → defaults.
138#[derive(Clone, Copy, Debug, PartialEq)]
139pub struct BalanceTuning {
140    /// DR knob for armor mitigation `K/(armor+K)`. Env: `OVERLORD_BAL_K_ARMOR`.
141    pub k_armor: f64,
142    /// DR knob for dodge/evasion `ev/(ev+K)`. Env: `OVERLORD_BAL_K_DODGE`.
143    pub k_dodge: f64,
144    /// Power-scalar normaliser. Env: `OVERLORD_BAL_POWER_NORM`.
145    pub power_norm: f64,
146    /// Last hand-made campaign chapter (formula curve starts above it). Env: `OVERLORD_BAL_ENEMY_ANCHOR_CHAPTER`.
147    pub enemy_curve_anchor_chapter: i64,
148    /// Highest 0-based chapter level resolved from hand-authored base_power in
149    /// `enemy_power_scalar` (the base_power/curve split). See
150    /// [`HAND_AUTHORED_CHAPTER_MAX`]. Env: `OVERLORD_BAL_HAND_AUTHORED_CHAPTER_MAX`.
151    pub hand_authored_chapter_max: i64,
152    /// Enemy power at the anchor chapter. Env: `OVERLORD_BAL_ENEMY_ANCHOR_POWER`.
153    pub enemy_curve_anchor_power: f64,
154    /// Enemy power geometric step per chapter — the progression gate. Env: `OVERLORD_BAL_ENEMY_STEP`.
155    pub chapter_power_step: f64,
156    /// Late-game enemy step floor. Past `enemy_step_taper_start` the per-chapter
157    /// step decays smoothly from `chapter_power_step` toward this value, so the
158    /// difficulty gate tracks the player's DECELERATING late power growth
159    /// (~1.12×/ch) instead of staying at the early 1.30 — which out-paced late
160    /// power and produced multi-day chapter stalls / a hard wall. With `>=
161    /// chapter_power_step` the taper is OFF (closed-form, unchanged). Env:
162    /// `OVERLORD_BAL_ENEMY_STEP_LATE`.
163    pub enemy_step_late: f64,
164    /// Chapter at which the late-step taper begins. Env: `OVERLORD_BAL_ENEMY_STEP_TAPER_START`.
165    pub enemy_step_taper_start: i64,
166    /// Item sale-price exponent on `eff` (income growth shape). Env: `OVERLORD_BAL_SELL_EXP`.
167    pub sell_price_exp: f64,
168    /// Item sale-price coefficient. Env: `OVERLORD_BAL_SELL_COEF`.
169    pub sell_price_coef: f64,
170    /// Crit-chance multiplier (1.0 = shipped). Sigmoid-steepening dial: crit
171    /// is a ×2+ damage coin-flip per hit — the largest binary outcome-RNG.
172    /// Env: `OVERLORD_BAL_CRIT_SCALE`. 0.0 disables crits entirely.
173    pub crit_chance_scale: f64,
174    /// Boss combat texture: crit_chance (permyriad) granted to boss entities
175    /// at spawn, carved OUT of their attack budget (power neutral).
176    /// Env: `OVERLORD_BAL_BOSS_CRIT`. 0 = off.
177    pub boss_crit_chance: f64,
178    /// Boss armor rating granted at spawn, carved out of the HP budget
179    /// (EHP/TTK neutral). Env: `OVERLORD_BAL_BOSS_ARMOR`. 0 = off.
180    pub boss_armor: f64,
181    /// WoW-style rating deflation for PLAYER armor. Without it item armor
182    /// saturates the DR curve from the first band (mitigation creeps 64% →
183    /// ~78% across the game, decaying the marginal value of an armor point
184    /// ×1.66). Deflating the aggregated player rating by
185    /// `k/(k + per_ch·chapter)` is equivalent to growing K_ARMOR with content
186    /// level but lives in ONE place (attribute aggregation), so combat, the
187    /// honest scalar and the UI stay consistent automatically. 15/ch is the
188    /// gentle calibration (late mitigation ≈68.5%, marginal decay ×1.25);
189    /// 33/ch would flatten mitigation to the first-band level entirely.
190    /// Env: `OVERLORD_BAL_ARMOR_K_PER_CH`. 0 = off.
191    pub armor_k_per_chapter: f64,
192    /// Max per-tick combat regen heal as a fraction of max HP — bounds regen in
193    /// *combat* (the scalar bound is separate) so high regen can't out-heal all
194    /// damage / instant-full a tank. Env: `OVERLORD_BAL_REGEN_TICK_PCT`.
195    pub regen_tick_max_pct: f64,
196    /// Max per-tick HoT heal as a fraction of max HP. Env: `OVERLORD_BAL_HOT_TICK_PCT`.
197    pub hot_tick_max_pct: f64,
198    /// Max per-tick DoT damage as a fraction of max HP. Env: `OVERLORD_BAL_DOT_TICK_PCT`.
199    pub dot_tick_max_pct: f64,
200    /// Early-game stat-check bump strength applied at ch3 (tapering ch4/ch5),
201    /// so the first gear conversions are forced. ch2 is left untouched (it is
202    /// resource-capped by the 20 starter cookies — a bump there DEADLOCKS, sim-
203    /// confirmed). `1.0` = off. Env: `OVERLORD_BAL_ENEMY_EARLY_BUMP`.
204    pub enemy_early_bump: f64,
205    /// Mid-game boss-only bump applied at ch7–15 to enforce the gear-engagement gate.
206    /// Applied exclusively to `CampaignBossFight` in `enemy_power_scalar`; wave fights
207    /// are unaffected so the global `enemy_power_for_chapter` curve stays monotone.
208    /// At 8.0: a frozen player (stopped at ch5, power ~2121) faces P/E ≈ 0.34 at the
209    /// ch7 boss → ~7% first-try win rate; combined with `stop_on_lose=true` on bosses
210    /// and the lazy bot not retrying, this creates the hard wall. Engaged players
211    /// open more chests between attempts and eventually clear the boss. Tapers to 1.0
212    /// at ch16 (one past ability-slot unlock at ch15). `1.0` = off.
213    /// Env: `OVERLORD_BAL_ENEMY_MID_BUMP`.
214    pub enemy_mid_bump: f64,
215    /// `eff_by_level` early-branch (L ≤ 130) coefficient. Env: `OVERLORD_BAL_EFF_A1`.
216    pub eff_a1: f64,
217    /// `eff_by_level` early-branch exponent — THE early-power shape knob (lower =
218    /// flatter early growth, decouples power from level/clock). Env: `OVERLORD_BAL_EFF_B1`.
219    pub eff_b1: f64,
220    /// `eff_by_level` late-branch (L > 130) coefficient. Env: `OVERLORD_BAL_EFF_A2`.
221    pub eff_a2: f64,
222    /// `eff_by_level` late-branch exponent (raise above 0.1 so late power keeps
223    /// growing instead of plateauing). Env: `OVERLORD_BAL_EFF_B2`.
224    pub eff_b2: f64,
225    /// `eff_by_level` late-branch offset (re-fit for continuity at L=130). Env: `OVERLORD_BAL_EFF_C2`.
226    pub eff_c2: f64,
227    /// Wave LETHALITY skew: multiplies the wave's damage-output normalization
228    /// relative to the mirror reference's EHP. >1 ⇒ the wave hits harder than
229    /// the pure mirror (more death pressure / lower hp_end at equal win-rate).
230    /// Env: `OVERLORD_BAL_WAVE_DAMAGE_SKEW`.
231    pub wave_damage_skew: f64,
232    /// Wave KILL-TIME skew: divides the reference DPS the wave's total-HP
233    /// budget is built from. >1 ⇒ the wave has LESS HP than the pure mirror
234    /// (dies faster / checks less sustained DPS).
235    /// Env: `OVERLORD_BAL_WAVE_HP_SKEW`.
236    ///
237    /// EXACT LAW (verified analytically + numerically): the wave's survive/die
238    /// break-even sits at true-power ratio
239    /// `r_draw = wave_damage_skew / wave_hp_skew`, and fight length scales as
240    /// `1 / wave_hp_skew`. An equal pair keeps the draw at exactly r=1 (skews
241    /// cancel) while setting the tempo. Move the RATIO to shift the break-even
242    /// (harder: damage↑ or hp↓); move both together to change tempo without
243    /// touching the threshold.
244    pub wave_hp_skew: f64,
245    /// Which enemy-curve formula runs: `Legacy` anchor/step/taper or `Derived`
246    /// — the smooth campaign curve (S_ref-seeded FTUE, sparse early `E_base`
247    /// calibration, boss `sqrt(1.30)`, then one universal geometric tail).
248    /// Env: `OVERLORD_BAL_ENEMY_CURVE_MODE` = `legacy` | `derived`.
249    pub enemy_curve_mode: EnemyCurveMode,
250}
251
252/// Tuning with every knob at its constant default.
253pub const BALANCE_TUNING_DEFAULT: BalanceTuning = BalanceTuning {
254    k_armor: K_ARMOR,
255    k_dodge: K_DODGE,
256    power_norm: POWER_NORM,
257    enemy_curve_anchor_chapter: ENEMY_CURVE_ANCHOR_CHAPTER,
258    hand_authored_chapter_max: HAND_AUTHORED_CHAPTER_MAX,
259    enemy_curve_anchor_power: ENEMY_CURVE_ANCHOR_POWER,
260    chapter_power_step: CHAPTER_POWER_STEP,
261    enemy_step_late: ENEMY_STEP_LATE,
262    enemy_step_taper_start: ENEMY_STEP_TAPER_START,
263    sell_price_exp: SELL_PRICE_EXP,
264    sell_price_coef: SELL_PRICE_COEF,
265    // Full price: the entropy proc resolver eliminates crit streaks at the
266    // mechanism level (exactly one crit per 1/chance attacks), so the ×2
267    // coin-flip needs no rate reduction to keep fight outcomes narrow. The
268    // honest scalar follows automatically (it prices realized rate × scale).
269    crit_chance_scale: 1.0,
270    // Boss texture: 15% rhythmic crit spikes + ~33% mitigation feel
271    // (armor 1000 vs K_ARMOR 2000), both budget-neutral at spawn.
272    boss_crit_chance: 1500.0,
273    boss_armor: 1000.0,
274    armor_k_per_chapter: 15.0,
275    regen_tick_max_pct: REGEN_TICK_MAX_PCT,
276    hot_tick_max_pct: HOT_TICK_MAX_PCT,
277    dot_tick_max_pct: DOT_TICK_MAX_PCT,
278    enemy_early_bump: ENEMY_EARLY_BUMP,
279    enemy_mid_bump: ENEMY_MID_BUMP,
280    eff_a1: EFF_A1,
281    eff_b1: EFF_B1,
282    eff_a2: EFF_A2,
283    eff_b2: EFF_B2,
284    eff_c2: EFF_C2,
285    wave_damage_skew: WAVE_DAMAGE_SKEW,
286    wave_hp_skew: WAVE_HP_SKEW,
287    // Derived is the shipped default — since BAL-037 the signed knot curve
288    // below. `legacy` stays available via OVERLORD_BAL_ENEMY_CURVE_MODE for
289    // A/B and rollback.
290    enemy_curve_mode: EnemyCurveMode::Derived,
291};
292
293// Wave skew defaults. An EQUAL pair keeps the survive/die break-even at
294// true-power ratio r=1 (see the EXACT LAW on the `BalanceTuning` fields), and
295// the sub-1 magnitude runs fights long (TTK ~20s): each outcome averages more
296// RNG samples, so the win-chance sigmoid is steep — power, not luck, decides
297// the fight. The tempo is designer-approved by feel; raising both toward √2
298// shortens fights back to a punchier, more random game.
299//
300// DAMAGE_SKEW sits BELOW HP_SKEW deliberately (break-even r ≈ 0.83): the
301// slot-model fight shapes (streamed waves holding ~6 attackers to the end,
302// boss summon waves) deliver the same damage budget in a less survivable
303// pattern than the legacy all-at-once decaying waves. 0.9 → 0.75 compensates
304// that shape cost so effective difficulty matches `main` (validated by
305// matched n=6 7-day sims, 2026-07). Per-fight deviations from this global
306// value use `enemy_damage_mult` on the fight template (currently only the
307// stage-1 end bosses, which the ability-less early game needs softer).
308pub const WAVE_DAMAGE_SKEW: f64 = 0.75;
309pub const WAVE_HP_SKEW: f64 = 0.9;
310
311static BALANCE_TUNING: std::sync::OnceLock<BalanceTuning> = std::sync::OnceLock::new();
312
313/// The process-wide balance tuning (defaults until [`init_tuning`] is called).
314/// Hot-path safe — an atomic load of a `&'static`.
315#[inline]
316pub fn tuning() -> &'static BalanceTuning {
317    BALANCE_TUNING.get().unwrap_or(&BALANCE_TUNING_DEFAULT)
318}
319
320/// Set the process-wide balance tuning once at startup (first call wins).
321pub fn init_tuning(t: BalanceTuning) {
322    let _ = BALANCE_TUNING.set(t);
323}
324
325impl BalanceTuning {
326    /// Defaults, with any `OVERLORD_BAL_*` env var that parses applied on top.
327    /// This is the sim-sweep entry point — no recompile, no deploy.
328    pub fn from_env() -> Self {
329        fn ovr_f(name: &str, cur: f64) -> f64 {
330            std::env::var(name)
331                .ok()
332                .and_then(|v| v.parse().ok())
333                .unwrap_or(cur)
334        }
335        fn ovr_i(name: &str, cur: i64) -> i64 {
336            std::env::var(name)
337                .ok()
338                .and_then(|v| v.parse().ok())
339                .unwrap_or(cur)
340        }
341        let d = BALANCE_TUNING_DEFAULT;
342        BalanceTuning {
343            // The DR denominators (`K/(R+K)`, `1 − ev/(ev+K)`) and the power
344            // normaliser are divisors: a swept value of 0 yields a divide-by-zero
345            // (+inf power, i64::MAX, 100%-dodge unkillable) or a 0/0 NaN. The
346            // model requires K > 0 for the DR curve to be well-defined, so floor
347            // these knobs at a small positive — a sweep then produces a measurable
348            // result, never inf/NaN. Defaults (1500/2000/1800) are unaffected.
349            k_armor: ovr_f("OVERLORD_BAL_K_ARMOR", d.k_armor).max(1.0),
350            k_dodge: ovr_f("OVERLORD_BAL_K_DODGE", d.k_dodge).max(1.0),
351            power_norm: ovr_f("OVERLORD_BAL_POWER_NORM", d.power_norm).max(1.0),
352            enemy_curve_anchor_chapter: ovr_i(
353                "OVERLORD_BAL_ENEMY_ANCHOR_CHAPTER",
354                d.enemy_curve_anchor_chapter,
355            ),
356            hand_authored_chapter_max: ovr_i(
357                "OVERLORD_BAL_HAND_AUTHORED_CHAPTER_MAX",
358                d.hand_authored_chapter_max,
359            ),
360            enemy_curve_anchor_power: ovr_f(
361                "OVERLORD_BAL_ENEMY_ANCHOR_POWER",
362                d.enemy_curve_anchor_power,
363            ),
364            chapter_power_step: ovr_f("OVERLORD_BAL_ENEMY_STEP", d.chapter_power_step),
365            enemy_step_late: ovr_f("OVERLORD_BAL_ENEMY_STEP_LATE", d.enemy_step_late),
366            enemy_step_taper_start: ovr_i(
367                "OVERLORD_BAL_ENEMY_STEP_TAPER_START",
368                d.enemy_step_taper_start,
369            ),
370            sell_price_exp: ovr_f("OVERLORD_BAL_SELL_EXP", d.sell_price_exp),
371            sell_price_coef: ovr_f("OVERLORD_BAL_SELL_COEF", d.sell_price_coef),
372            crit_chance_scale: ovr_f("OVERLORD_BAL_CRIT_SCALE", d.crit_chance_scale).max(0.0),
373            boss_crit_chance: ovr_f("OVERLORD_BAL_BOSS_CRIT", d.boss_crit_chance).max(0.0),
374            boss_armor: ovr_f("OVERLORD_BAL_BOSS_ARMOR", d.boss_armor).max(0.0),
375            armor_k_per_chapter: ovr_f("OVERLORD_BAL_ARMOR_K_PER_CH", d.armor_k_per_chapter)
376                .max(0.0),
377            regen_tick_max_pct: ovr_f("OVERLORD_BAL_REGEN_TICK_PCT", d.regen_tick_max_pct),
378            hot_tick_max_pct: ovr_f("OVERLORD_BAL_HOT_TICK_PCT", d.hot_tick_max_pct),
379            dot_tick_max_pct: ovr_f("OVERLORD_BAL_DOT_TICK_PCT", d.dot_tick_max_pct),
380            enemy_early_bump: ovr_f("OVERLORD_BAL_ENEMY_EARLY_BUMP", d.enemy_early_bump),
381            enemy_mid_bump: ovr_f("OVERLORD_BAL_ENEMY_MID_BUMP", d.enemy_mid_bump),
382            eff_a1: ovr_f("OVERLORD_BAL_EFF_A1", d.eff_a1),
383            eff_b1: ovr_f("OVERLORD_BAL_EFF_B1", d.eff_b1),
384            eff_a2: ovr_f("OVERLORD_BAL_EFF_A2", d.eff_a2),
385            eff_b2: ovr_f("OVERLORD_BAL_EFF_B2", d.eff_b2),
386            eff_c2: ovr_f("OVERLORD_BAL_EFF_C2", d.eff_c2),
387            // Both skews are multipliers/divisors on wave stat budgets — floor
388            // at a small positive so a swept 0 can't zero the wave out.
389            wave_damage_skew: ovr_f("OVERLORD_BAL_WAVE_DAMAGE_SKEW", d.wave_damage_skew).max(0.01),
390            wave_hp_skew: ovr_f("OVERLORD_BAL_WAVE_HP_SKEW", d.wave_hp_skew).max(0.01),
391            enemy_curve_mode: match std::env::var("OVERLORD_BAL_ENEMY_CURVE_MODE")
392                .unwrap_or_default()
393                .to_ascii_lowercase()
394                .as_str()
395            {
396                "derived" => EnemyCurveMode::Derived,
397                "legacy" => EnemyCurveMode::Legacy,
398                _ => d.enemy_curve_mode,
399            },
400        }
401    }
402}
403
404// --- Enemy power curve --------------------------------------------------------
405// Campaign enemy power at/below the anchor chapter is hand-authored in the
406// fight templates; above it a formula applies. Two formulas exist (see
407// `EnemyCurveMode`): the DERIVED curve `G/(ρ̂·z)` is the shipped default; the
408// legacy anchor/step/taper constants below remain for
409// `OVERLORD_BAL_ENEMY_CURVE_MODE=legacy` A/B and rollback.
410//
411// The hand-authored ramp (ch0-6, stages 1-1..1-7) keeps 1-1 trivial for a
412// zero-gear fresh player and then forces gear engagement with a taut P/E band.
413// Shipped wave/boss `power` values (the `_2`/`_3` fight-template variants):
414//   ch0 (1-1): wave=1,   boss=1    — trivial by design
415//   ch1 (1-2): wave=60,  boss=100
416//   ch2 (1-3): wave=120, boss=200  — the tautest early beat (a passive clear
417//              is a coin flip here by design)
418//   ch3 (1-4): wave=240, boss=400
419//   ch4 (1-5): wave=480, boss=800
420//   ch5 (1-6): wave=640, boss=1100
421//   ch6 (1-7): wave=690, boss=1500
422//   ch7 (1-8): first CURVE chapter of the calibration tables — but the
423//              base_power/curve SPLIT for `enemy_power_scalar` is governed by
424//              `HAND_AUTHORED_CHAPTER_MAX` (9), NOT this anchor. See below.
425//
426// ANCHOR_POWER is set so the legacy formula `anchor_power × STEP^(ch −
427// anchor_ch)` continues the ramp smoothly: 528.35 × 1.30 = 686 at ch7. This
428// anchor stays at 6 so the derived-curve calibration pins
429// (`enemy_curve_anchored_and_monotonic`, which reads ANCHOR+1 = ch7) hold.
430pub const ENEMY_CURVE_ANCHOR_CHAPTER: i64 = 6;
431pub const ENEMY_CURVE_ANCHOR_POWER: f64 = 528.35;
432/// Highest 0-based `current_chapter_level` at which `enemy_power_scalar`
433/// resolves campaign enemy power from the hand-authored `FightTemplate.power`
434/// (base_power) instead of the derived chapter curve. Set to 9 so the ENTIRE
435/// first stage (levels 0..=9 = stages 1-1..1-10) is hand-authored onboarding
436/// difficulty: the coderived curve AND the ch7-15 mid-boss gear gate do NOT
437/// apply inside stage 1 (the gate still starts at ch10 = stage 2-1).
438///
439/// Levels 7,8,9 are EXCLUSIVELY stage-1 chapters (level 10 = stage 2-1), so
440/// raising this from the legacy `ENEMY_CURVE_ANCHOR_CHAPTER` (6) changes ONLY
441/// stage-1 difficulty and leaves every ch>=10 fight (curve + mid-boss bump)
442/// byte-identical. Kept DISTINCT from the curve anchor so the derived-curve
443/// calibration pins (anchored at ch7) are untouched. Env:
444/// `OVERLORD_BAL_HAND_AUTHORED_CHAPTER_MAX`.
445pub const HAND_AUTHORED_CHAPTER_MAX: i64 = 9;
446// Legacy-mode enemy power step per chapter — the progression gate. It races
447// the player's gear-driven power growth: too high a step walls progression
448// dead, and the design wants progress to never fully stop — it should slow
449// into being gold/cookie-gated (run dry → idle funds the next case upgrade →
450// push on). 1.30 keeps the enemy close enough to the player power curve
451// (EFF_B1 = 1.40) that the player keeps creeping forward by upgrading, while
452// remaining a real gate. Tunable via `OVERLORD_BAL_ENEMY_STEP`; re-tune if the
453// player power curve changes.
454pub const CHAPTER_POWER_STEP: f64 = 1.30;
455// Legacy-mode late-game step taper. A constant step out-paces the player's
456// decelerating late power growth (~1.10-1.12×/ch), stalling chapters for
457// days into a soft wall. Past ENEMY_STEP_TAPER_START the per-chapter step
458// decays smoothly toward ENEMY_STEP_LATE so the gate tracks late power — the
459// player advances ~daily (power still decelerates, progress never stops).
460// 1.10 ≈ the player's late power slope: slow in-session power creep keeps
461// clearing chapters even at the deep wall, while the real jump comes next day
462// from AFK income. The taper starts at ch42 so the early game and the firm
463// honeymoon are untouched.
464pub const ENEMY_STEP_LATE: f64 = 1.10;
465pub const ENEMY_STEP_TAPER_START: i64 = 42;
466/// Mid-game enemy bump — **applied only to CampaignBossFight**, over the
467/// ch12–14 window (see [`enemy_mid_chapter_mult`]).
468/// Applied via `OVERLORD_BAL_ENEMY_MID_BUMP`. stage2-bridge (2026-07-16) lowered
469/// it 8.0 → 2.5 and moved the window off the stage-2 boundary: at 8.0 the gate
470/// slammed ×5.67/×4.89 onto the ch10/ch11 bosses — the first stage-2 fights — an
471/// instant wall for a fresh-ladder player exiting the new easy stage 1. The
472/// legacy ch7 rationale is moot (ch0-9 is hand-authored now, the gate never
473/// fired there). At 2.5 the gear-check is a boss EMPHASIS (bot boss-win ~0.9 at
474/// ch12-14, vs wave ~1.0) landing mid-bridge where the wave curve is comfortable,
475/// not a wall; a frozen (non-gearing) player is already walled by the wave curve.
476/// Wave fights are unaffected (the bump is applied in `enemy_power_scalar`, not in
477/// `enemy_power_for_chapter`), so the global power curve stays monotone.
478/// stage2-bridge CYCLE 2 (2026-07-16): lowered 2.5 → 1.3 for the casual
479/// re-pacing test — a gentle ch12-14 boss emphasis (at-cadence boss ~80% win)
480/// rather than a gear-check, so no chapter reads as a wall. The smooth
481/// cadence-ramped wave curve now carries all the difficulty shaping.
482pub const ENEMY_MID_BUMP: f64 = 1.3;
483
484/// Early-game stat-check bump strength. `1.0` = OFF, deliberately: the
485/// hand-made→formula transition already lands the early chapters taut on its
486/// own, and playtesting showed a bump here makes stages 1-3..1-5 feel
487/// punishing ("cleared by luck") — so the geometric step runs cleanly through
488/// the early game instead. Tune via `OVERLORD_BAL_ENEMY_EARLY_BUMP`.
489pub const ENEMY_EARLY_BUMP: f64 = 1.0;
490
491/// Early-game stat-check bump (off by default, see [`ENEMY_EARLY_BUMP`]): a
492/// modest lift on ch3-5 on top of the geometric line, forcing the first gear
493/// conversions. ch2 is deliberately untouched — it is hard resource-capped by
494/// the 20 starter cookies, and any bump there deadlocks the player (0% win
495/// with everything they can afford). Tapers to 1.0 by ch6 so later chapters
496/// are unaffected.
497fn enemy_early_chapter_mult(chapter: i64) -> f64 {
498    let bump = tuning().enemy_early_bump;
499    if bump <= 1.0 {
500        return 1.0;
501    }
502    // First check at ch3 (quest cookies have unlocked headroom there), tapering
503    // to 1.0 by ch6. ch2 stays untouched — it is hard resource-capped by the 20
504    // starter cookies and a bump there deadlocks (sim-confirmed).
505    match chapter {
506        3 => bump,
507        4 => 1.0 + (bump - 1.0) * 0.66,
508        5 => 1.0 + (bump - 1.0) * 0.33,
509        _ => 1.0,
510    }
511}
512
513/// Mid-game **boss-only** gear-engagement gate (ch7–15). Applied in
514/// [`enemy_power_scalar`] only for `CampaignBossFight` (not wave fights), so
515/// normal wave progression is unaffected while the boss is the hard gate.
516///
517/// Why boss-only: applying the bump to wave fights too causes global monotonicity
518/// violations in `enemy_power_for_chapter` for any bump > STEP (1.30) — the taper
519/// endpoint has mult(last_ch) > 1.0 while the next chapter returns 1.0, creating a
520/// downstep. Restricting the bump to bosses avoids that problem entirely.
521///
522/// With `stop_on_lose=true` on campaign bosses and the lazy bot strategy not retrying
523/// (`_retry_boss_if_needed` removed from the frozen action list), one loss = permanent
524/// wall. The bump must be large enough that the first-try win probability is low for
525/// a frozen player (≤ 10-15%). At bump=8 with STEP=1.30 the boss power at ch7
526/// becomes `enemy(7) × STEP^0.5 × 8 = 687 × 1.14 × 8 = 6265` vs lazy power ~2121,
527/// P/E ≈ 0.34 → ~7% first-try win rate → ~93% of lazy bots wall on first encounter.
528///
529/// Tapers linearly from `enemy_mid_bump` at ch7 to 1.0 at ch16. ch6 and below:
530/// 1.0 (respects "don't make ch1-5 harder" hard constraint). ch16+: 1.0 (ability-slot
531/// system takes over as the engagement lever). 1.0 = off (mid_bump ≤ 1.0 = no gate).
532pub(crate) fn enemy_mid_chapter_mult(chapter: i64) -> f64 {
533    let bump = tuning().enemy_mid_bump;
534    if bump <= 1.0 {
535        return 1.0;
536    }
537    // stage2-bridge (2026-07-16): the gate window moved from ch7-15 to ch12-15
538    // (exclusive upper bound ch15). The legacy ch7 anchor is moot — the whole
539    // first stage (ch0-9) is now hand-authored (HAND_AUTHORED_CHAPTER_MAX=9), so
540    // the gate never fired below ch10 anyway, and slamming ×5.67/×4.89 onto the
541    // ch10/ch11 bosses (the FIRST stage-2 fights) was the primary "next fights
542    // kill you too much" wall. Now ch10-11 bosses have NO gate (clean boundary,
543    // mult=1.0) and the gear-check ramps over ch12-14 (peak at ch12), landing
544    // MID-BRIDGE where the wave curve is comfortable, then off by ch15 (the
545    // pinned rejoin). frac over the 3-chapter (15-12) window.
546    if !(12..15).contains(&chapter) {
547        return 1.0;
548    }
549    let frac = (15 - chapter) as f64 / (15 - 12) as f64;
550    1.0 + (bump - 1.0) * frac
551}
552
553// --- Derived enemy curve: E(ch) = G(ch) / ρ(ch) -------------------------------
554// The legacy curve is two geometric streams tuned separately (enemy
555// anchor/step vs the player's emergent growth), which mathematically
556// guarantees a wall wherever their slopes diverge — historically patched with
557// the early/mid bumps and the taper. The derived mode (the shipped default)
558// inverts the definition:
559//
560//     E(ch) = G(ch) / ρ(ch)
561//
562// where G(ch) is the reference (free archetype) honest power at chapter `ch`
563// (baked from calibration, sparse knots + geometric interpolation) and ρ(ch)
564// is the DESIGNED pressure profile (ρ>1 ⇒ player ahead/easy, ρ<1 ⇒ pressure).
565// Pressure is declared, growth is measured, the gate is derived — editing
566// pressure can never silently violate the treadmill contract, because E
567// inherits G's slope wherever ρ is flat. Env: `OVERLORD_BAL_ENEMY_CURVE_MODE`.
568
569/// Which formula [`enemy_power_for_chapter`] uses.
570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
571pub enum EnemyCurveMode {
572    /// `anchor × step^(ch−anchor_ch)` with taper/bumps (the shipped curve).
573    Legacy,
574    /// The BAL-037 signed knot curve (see the section below).
575    Derived,
576}
577
578/// Reference player (free archetype) honest power per chapter — sparse
579/// calibration knots `(chapter, power)`.
580///
581/// Baked by `overlord/tools/sim_orchestrator/economy_model.py coderive`
582/// (supply-side pacing: the curve is derived FROM the resource schedule, not
583/// measured under the old curve): per-chapter fundable growth from
584/// `overlord/balance/supply_schedule.json` is run through the gear/ability
585/// pipeline and scaled by the conversion efficiency η measured per band from
586/// a calibration sim (current η and anchors live in `CODERIVE_*` in
587/// economy_model.py). Tail knots extrapolate at the funded late slope.
588///
589/// Re-derive (coderive + re-bake) whenever the resource schedule, the
590/// gear/ability pipeline, or a new power source changes measured η. Two
591/// measurement caveats: on an overheated run η must be measured as PURE
592/// conversion (measured Δln G over natural + pipeline growth at the bot's
593/// ACTUAL band spends) — a schedule-based denominator deflates under fast
594/// running and pushes E the wrong way; and the Mastery channel funds per boss
595/// kill rather than per band supply, so η smears it multiplicatively.
596pub const PLAYER_GROWTH_KNOTS: &[(i64, f64)] = &[
597    // stage2-bridge CYCLE 3 (2026-07-16): the WHOLE power-per-chapter function
598    // is retired from ch10 onward and re-derived from the required chest-open
599    // cadence. E(ch) = P_at_cadence(X(ch), ch) / (z · ρ̂_comfort=1.5), so an
600    // at-cadence player sits at ~91% win every chapter (casual, never a wall);
601    // below-cadence stalls SOFTLY, above-cadence breezes.
602    //   • Entry anchor: expected player power 300 at ch10 → E(10)≈416 (designer:
603    //     "enter stage 2 with like 300 power").
604    //   • X(ch) = 12·(ch−10)/39 opens/min: 0 at ch10 (stage 2-1) rising to the
605    //     12/min median at ch49 (stage 5-10), then FLAT at 12/min for the rest
606    //     of the curve.
607    //   • P_at_cadence = MEASURED power-per-chapter of the cadence_{3,6,12}
608    //     organic bots (run_1784232702 / SIM_4), measured to ~ch75; the ch50+
609    //     tail follows the 12/min slope (×1.057/ch) — MODEL-EXTRAPOLATED past
610    //     ch75 (unreachable in-test), smoothed for monotonicity.
611    // G here is a reference-power path, NOT the funded coderive curve (income is
612    // untouched). ch7 (5660) is kept as the stage-1/legacy anchor (E(7)=686
613    // pin, phantom — ch0-9 are hand-authored, never spawn-scaled by this curve),
614    // so E(10)=416 < E(7)=686: the derived curve is monotonic only in its
615    // PRODUCTION domain ch≥10 (see `enemy_curve_anchored_and_monotonic`). All
616    // `zone_correction_preserves_enemy_curve` pins re-baked. See cycle-3 report.
617    (7, 5660.0),
618    (10, 2535.4),
619    (11, 2979.2),
620    (12, 3271.4),
621    (13, 6137.3),
622    (14, 17488.4),
623    (15, 41943.5),
624    (17, 91172.5),
625    (19, 118805.9),
626    (22, 721745.8),
627    (25, 1237197.9),
628    (28, 3353447.0),
629    (31, 6741626.8),
630    (34, 8205331.8),
631    (37, 9597365.9),
632    (40, 12186084.2),
633    (43, 16414096.2),
634    (46, 19323369.3),
635    (49, 22991272.4),
636    (55, 34263366.8),
637    (62, 48069380.1),
638    (70, 58352054.5),
639    (80, 74337366.9),
640    (90, 101734246.7),
641    (100, 164556403.8),
642    (120, 430548145.9),
643];
644
645/// Designed pressure profile — sparse knots, geometric interpolation. THE
646/// designer-editable difficulty curve: raise a knot to make that zone easier
647/// (player further ahead), lower it for pressure.
648///
649/// Units are zone-corrected: ρ̂ = P/(z(ch)·E(ch)) with z from
650/// [`ZONE_DIFFICULTY_KNOTS`], so one value of ρ̂ means the SAME winrate in
651/// every zone — the measured 50%-crossing is ≈0.55 everywhere, and a flat
652/// stretch of the profile at ~0.65 plays as ~60% on-schedule winrate.
653///
654/// Shape: a glide from the pinned early anchor through the ch7-15 boss-gate
655/// zone down to a working plateau, then a declining late tail. There are no
656/// deliberate walls: pacing is carried by the resource schedule (E's
657/// per-chapter step ≡ fundable growth per chapter at target cadence, so a
658/// blitz stalls within ~1-3 chapters and daily supply restores the cadence);
659/// the treadmill bounds are guarded by `test_pacing_contract`.
660///
661/// The values encode the ratified feel. Note the honest on-schedule winrate
662/// is NOT flat across zones (~65% around ch49 falling to ~20-25% at ch62-87);
663/// flattening it is a designer decision — it re-paces the late game — not a
664/// calibration fix. E = G/(ρ̂·z) is pinned by
665/// `zone_correction_preserves_enemy_curve`: never edit ρ̂ or z alone, only
666/// together with a re-derivation of the pair (and G).
667pub const RHO_TARGET_KNOTS: &[(i64, f64)] = &[
668    (7, 17.1676),
669    (9, 14.0254),
670    (11, 11.4583),
671    (14, 6.9059),
672    (15, 5.9019),
673    (19, 2.4295),
674    (23, 1.8515),
675    (27, 1.5763),
676    (29, 1.6135),
677    (34, 1.7105),
678    (39, 1.8055),
679    (49, 2.0116),
680    (59, 2.2413),
681    (62, 2.0393),
682    (64, 1.9152),
683    (87, 0.9287),
684    (110, 0.7799),
685    (120, 0.7229),
686    (130, 0.6701),
687    (160, 0.6701),
688];
689
690/// Zone difficulty correction z(ch): how much harder (>1) or easier (<1) a
691/// chapter's content plays than the honest per-fight scalar P/E predicts.
692/// The corrected ratio ρ̂ = P/(z·E) is a sufficient statistic for winrate:
693/// its 50%-crossing is ≈0.55 in EVERY zone (the stat-check contract "come
694/// with power" needs the ratio to mean the same thing everywhere).
695///
696/// Measured from sim runs on the current combat ruleset: per-chapter win
697/// aggregates, weighted logistic fits per chapter band, z = band ρ50 / 0.55.
698/// The drift it corrects is large and real — the same raw P/E can win ~78% in
699/// one zone and ~11% in another, because mob ability kits and wave
700/// compositions scale beyond raw stats. The map currently sits below 1.0
701/// everywhere: the honest scalar conservatively drops the buff/debuff budget
702/// shares of ability kits (see the `ability_info` closures), and that
703/// undercount lands in z by construction. Pricing buffs honestly in the
704/// scalar would move z back toward 1.0 — that is a future scalar change with
705/// its own re-anchor.
706///
707/// Re-measure whenever mob kits, wave composition, or the combat package
708/// change — the `sigmoid_center` grader gate is the staleness watchdog.
709/// grade_pacing.py and economy_model.py carry mirrors of this table.
710pub const ZONE_DIFFICULTY_KNOTS: &[(i64, f64)] = &[
711    (14, 0.48),
712    (34, 0.38),
713    (59, 0.29),
714    (87, 0.70),
715    (130, 0.97),
716    (160, 0.97),
717];
718
719/// Zone difficulty correction z at `chapter` (see [`ZONE_DIFFICULTY_KNOTS`]).
720// The emptiness guard is deliberate: the knot tables are hand-baked and may be emptied to turn
721// the derived mode off (callers fall back to legacy) — clippy 1.91 flags it as always-false
722// against the CURRENT const value, which is exactly the point of the guard.
723#[allow(clippy::const_is_empty)]
724pub fn zone_difficulty(chapter: i64) -> f64 {
725    if ZONE_DIFFICULTY_KNOTS.is_empty() {
726        return 1.0;
727    }
728    interp_geometric(ZONE_DIFFICULTY_KNOTS, chapter)
729}
730
731/// Geometric (log-linear) interpolation over sparse `(chapter, value)` knots:
732/// exact at knots, constant before the first, final-segment ratio extrapolation
733/// past the last (the measured late slope continues — the treadmill contract's
734/// "progress never fully stops" shape).
735pub fn interp_geometric(knots: &[(i64, f64)], chapter: i64) -> f64 {
736    match knots {
737        [] => 0.0,
738        [(_, v)] => *v,
739        _ => {
740            let (first_ch, first_v) = knots[0];
741            if chapter <= first_ch {
742                return first_v;
743            }
744            for w in knots.windows(2) {
745                let (c0, v0) = w[0];
746                let (c1, v1) = w[1];
747                if chapter <= c1 {
748                    let t = (chapter - c0) as f64 / (c1 - c0) as f64;
749                    return v0 * (v1 / v0).powf(t);
750                }
751            }
752            // Past the last knot: continue the final segment's per-chapter ratio.
753            let (c0, v0) = knots[knots.len() - 2];
754            let (c1, v1) = knots[knots.len() - 1];
755            let per_ch = (v1 / v0).powf(1.0 / (c1 - c0) as f64);
756            v1 * per_ch.powi((chapter - c1) as i32)
757        }
758    }
759}
760
761// ---------------------------------------------------------------------------
762// BAL-037 — signed campaign PvE curve.
763//
764// Supersedes the coderived `G/(ρ̂·z)` form below: that curve produced an
765// accidental ×9 step at ch9→10, dense spikes across ch12…17 and boss-only bumps
766// on ch12/13/14, none of which survive the accepted streak/wall cadence.
767//
768// The whole ladder is authored as sparse knots plus one deterministic wall
769// formula — never 510 hand-typed rows. `S_ref(ch)` is the MEDIAN `S_real` of
770// four class-specific obvious-good legal builds, so the curve is not pinned to
771// the weakest class and does no hidden class compensation.
772// ---------------------------------------------------------------------------
773
774/// Early analytic `S_ref` seed for FTUE and first-hour diagnostics. Runtime
775/// difficulty after FTUE is authored by `E_base`; no second hand-written
776/// reference ladder exists beyond the universal-tail anchor.
777pub const S_REF_KNOTS: &[(i64, f64)] = &[
778    (0, 30.0),
779    (9, 4_100.0),
780    (10, 6_500.0),
781    (21, 50_000.0),
782    (25, 135_500.0),
783    (31, 203_250.0),
784    (35, 271_000.0),
785    (40, 356_365.0),
786    (45, 542_000.0),
787];
788
789/// Smooth early `E_base` calibration through chapter 45. The knots only bridge
790/// FTUE and the first-hour feature/power ramp; after chapter 45 one universal
791/// geometric multiplier owns the entire campaign tail (see [`e_base`]).
792///
793/// Timing anchors are deliberately approximate. They grade the resulting
794/// all-free cohort, not individual rows, and must never be enforced by chapter
795/// spikes or terminal caps.
796pub const E_BASE_KNOTS: &[(i64, f64)] = &[
797    (10, 2_745.0),
798    // Explicit seam: no scalar uplift reaches the pre-gate path.
799    (21, 12_766.786),
800    (25, 30_252.580),
801    (31, 69_964.014),
802    (35, 122_353.461),
803    (45, 302_619.0),
804];
805
806/// The universal progression starts before the end of the first active hour.
807/// From here on there are no hand-authored chapter difficulty values.
808const E_BASE_TAIL_ANCHOR_CH: i64 = 45;
809/// Authoritative recurrence: `E_base(ch) = E_base(45) × 1.075^(ch−45)`.
810/// A roughly +20% player-strength spike opens two to three chapters and +50%
811/// opens five to six: `ln(spike) / ln(1.075)`. Natural resource exhaustion
812/// creates stalls; the enemy curve does not author them.
813const E_BASE_TAIL_STEP: f64 = 1.075;
814
815/// FTUE (ch0…9) reads `S_ref × (0.45 → 0.65)`, ramped linearly across the block.
816/// Current absolute rows `1…46` are deliberately NOT preserved: arithmetic
817/// safety and 100% legal-starter wins outrank their scale.
818const FTUE_MAX_CH: i64 = 9;
819const FTUE_SHARE_START: f64 = 0.45;
820const FTUE_SHARE_END: f64 = 0.65;
821
822/// One total normalized encounter coefficient for every boss on every chapter,
823/// FTUE included: `sqrt(1.30)`. It covers the WHOLE encounter budget including
824/// a potential summon wave — adds never get a second `sqrt(1.30)` on top.
825/// Hand-authored per-chapter boss ratios and the legacy ch12/13/14 bumps are
826/// removed: no chapter through ch21 may read as a wall.
827pub const BOSS_ENCOUNTER_COEF_SQ: f64 = 1.30;
828
829/// Unrounded `E_base(ch)` across the whole ladder.
830///
831/// Three regimes, continuous at their seams: the FTUE share of `S_ref` through
832/// ch9, sparse early knots ch10…45, and the exact `×1.075` universal tail.
833pub fn e_base(chapter: i64) -> f64 {
834    let ch = chapter.max(0);
835    if ch <= FTUE_MAX_CH {
836        let t = ch as f64 / FTUE_MAX_CH as f64;
837        let share = FTUE_SHARE_START + (FTUE_SHARE_END - FTUE_SHARE_START) * t;
838        return interp_geometric(S_REF_KNOTS, ch) * share;
839    }
840    if ch <= E_BASE_TAIL_ANCHOR_CH {
841        return interp_geometric(E_BASE_KNOTS, ch);
842    }
843    let anchor = interp_geometric(E_BASE_KNOTS, E_BASE_TAIL_ANCHOR_CH);
844    anchor * E_BASE_TAIL_STEP.powi((ch - E_BASE_TAIL_ANCHOR_CH) as i32)
845}
846
847// ---------------------------------------------------------------------------
848// A2-BAL-002 §2.5 — the two difficulty axes
849// ---------------------------------------------------------------------------
850
851/// Multiplier on encounter HP, checked against the player's DPS.
852///
853/// Sparse knots, interpolated in log space, EMPTY by default — an empty table
854/// means `1.0` everywhere, so authoring nothing changes nothing.
855///
856/// Why this exists as its own curve: enemy strength was a single scalar,
857/// decomposed into HP and Attack by fixed exponents inside `spawn_wave`. That
858/// makes the two failure modes inseparable. A boss that dies at the right speed
859/// while the player never drops below 90 % HP and a boss that is a bullet sponge
860/// the player barely survives are OPPOSITE problems, and one scalar behind a
861/// `sqrt` cannot fix one without moving the other — every correction trades a
862/// pacing failure for a survival failure.
863///
864/// §2.7's correction matrix assumes two independent levers: raise `K_OUT` when
865/// survival pressure is missing, lower `K_HP` when a boss takes too long, and do
866/// both when the fight is wrong on both axes.
867pub const K_HP_KNOTS: &[(i64, f64)] = &[
868    (21, 1.0),
869    (25, 2.0),
870    // Smoothly spend more of each chapter in its two actual fights instead of
871    // parking the player at an authored loss wall.
872    (35, 4.0),
873    // The first-hour trace with a flat 4 reached ch65 at 46.5m. Continuing the
874    // same geometric ramp to 8 distributes the missing fight time across the
875    // whole ch35…45 band; 8 is then a permanent combat-shape constant.
876    (45, 8.0),
877];
878
879/// Multiplier on enemy outgoing damage, checked against the player's EHP. See
880/// [`K_HP_KNOTS`] — same shape, same default, the other axis.
881pub const K_OUT_KNOTS: &[(i64, f64)] = &[
882    (21, 1.0),
883    (25, 1.0),
884    (31, 2.0),
885    // Together with K_HP this reaches the former healthy ch45 damage integral
886    // without any discontinuity, then remains a constant calibration factor.
887    (35, 2.4),
888    (45, 3.4),
889];
890
891/// A2-BAL-001 frozen outgoing-equivalence correction after removal of the
892/// automatic boss stun. These are measured `D_old / D_unstunned` ratios from
893/// the signed reference matrix, not a reconstructed duty-cycle formula: boss
894/// kits contain both Attack-derived and flat payloads, so only the complete
895/// deterministic trace can preserve the previously shipped outgoing budget.
896///
897/// The correction is stamped on boss entities and consumed after per-cast
898/// damage composition. Ordinary mobs, PvP entities, summons and the boss stun
899/// substrate are untouched.
900pub const BOSS_OUTGOING_EQUIVALENCE_KNOTS: &[(i64, f64)] = &[
901    (1, 3.6667),
902    (10, 1.2291),
903    (21, 1.4828),
904    (25, 1.6077),
905    (31, 1.6570),
906    (35, 1.2435),
907    (45, 1.6592),
908    (60, 1.2043),
909    (65, 1.2046),
910];
911
912pub fn boss_outgoing_equivalence(chapter: i64) -> f64 {
913    axis_multiplier(BOSS_OUTGOING_EQUIVALENCE_KNOTS, chapter)
914}
915
916/// `K_HP(ch)`. `1.0` while the table is unauthored.
917pub fn k_hp(chapter: i64) -> f64 {
918    axis_multiplier(K_HP_KNOTS, chapter)
919}
920
921/// `K_OUT(ch)`. `1.0` while the table is unauthored.
922pub fn k_out(chapter: i64) -> f64 {
923    axis_multiplier(K_OUT_KNOTS, chapter)
924}
925
926/// Shared reader for both axes: log-space interpolation between sparse knots,
927/// **held flat outside them**, identity when unauthored.
928///
929/// The clamp is the important part. `interp_geometric` EXTRAPOLATES past its
930/// last knot — a table authored to ch30 reads `5.6e14` at ch500, which would
931/// quietly run difficulty off the end of whatever range someone happened to
932/// author. A refit authors the bands it measured; chapters beyond them must
933/// inherit the last authored value, not a projection of it.
934///
935/// A non-positive knot makes log interpolation meaningless and could zero an
936/// encounter outright, so it is rejected rather than clamped.
937pub fn axis_multiplier(knots: &[(i64, f64)], chapter: i64) -> f64 {
938    if knots.is_empty() {
939        return 1.0;
940    }
941    debug_assert!(
942        knots.iter().all(|(_, v)| *v > 0.0),
943        "difficulty-axis knots must be positive: log-space interpolation is undefined at zero"
944    );
945    let first = knots.first().expect("non-empty");
946    let last = knots.last().expect("non-empty");
947    if chapter <= first.0 {
948        return first.1;
949    }
950    if chapter >= last.0 {
951        return last.1;
952    }
953    interp_geometric(knots, chapter).max(f64::MIN_POSITIVE)
954}
955
956/// Ordinary wave power at `chapter`, floored once at the end. There is no
957/// chapter-specific wall layer: the smooth base curve is the whole scalar.
958pub fn enemy_wave_power(chapter: i64) -> f64 {
959    e_base(chapter).floor()
960}
961
962/// Boss encounter power at `chapter`, floored once at the end.
963///
964/// The association is fixed: `floor(E_base × sqrt(1.30))`, computed from the
965/// unrounded base. No chapter-specific bump or wall multiplier is applied.
966pub fn enemy_boss_power(chapter: i64) -> f64 {
967    (e_base(chapter) * BOSS_ENCOUNTER_COEF_SQ.sqrt()).floor()
968}
969
970/// The derived-mode curve `G(ch)/(ρ̂(ch)·z(ch))`, or `None` when the
971/// calibration tables are not baked — callers fall back to legacy. ρ̂ is the
972/// zone-corrected pressure target and z the zone difficulty correction; their
973/// product is the raw-scalar pressure the curve implements.
974// Same deliberate emptiness guard as `zone_difficulty` — see the note there.
975#[allow(clippy::const_is_empty)]
976pub fn enemy_power_derived(chapter: i64) -> Option<f64> {
977    if PLAYER_GROWTH_KNOTS.is_empty() || RHO_TARGET_KNOTS.is_empty() {
978        return None;
979    }
980    let growth = interp_geometric(PLAYER_GROWTH_KNOTS, chapter);
981    let rho_raw =
982        (interp_geometric(RHO_TARGET_KNOTS, chapter) * zone_difficulty(chapter)).max(0.01);
983    Some((growth / rho_raw).floor())
984}
985
986/// Absolute enemy power at `chapter`: `ANCHOR · ∏ step(c)`, where the per-chapter
987/// step is the constant `chapter_power_step` until `enemy_step_taper_start`, then
988/// decays smoothly toward `enemy_step_late` so late-game difficulty tracks the
989/// player's decelerating power (no multi-day stalls). With the taper OFF
990/// (`enemy_step_late >= chapter_power_step`) this is the original closed form
991/// `ANCHOR · STEP^(chapter − ANCHOR_CH)`. A tapering early-chapter bump
992/// (see [`enemy_early_chapter_mult`]) multiplies the result.
993///
994/// Note: the mid-game bump ([`enemy_mid_chapter_mult`]) is NOT applied here — it
995/// is applied only to `CampaignBossFight` in [`enemy_power_scalar`], so wave
996/// fights and the global power curve remain unaffected (avoids monotonicity
997/// violations for large bump values).
998pub fn enemy_power_for_chapter(chapter: i64) -> f64 {
999    let t = tuning();
1000    // BAL-037: the signed sparse-knot curve owns the whole ladder ch0…509,
1001    // FTUE included. It supersedes both the coderived `G/(ρ̂·z)` form and the
1002    // legacy anchor/step/taper fallback, which survive below only as an
1003    // explicit env-selected rollback mode.
1004    if t.enemy_curve_mode == EnemyCurveMode::Derived {
1005        return enemy_wave_power(chapter);
1006    }
1007    let exp = (chapter - t.enemy_curve_anchor_chapter).max(0);
1008    let growth = if t.enemy_step_late >= t.chapter_power_step {
1009        // Taper OFF — closed form (unchanged, preserves all pinned curves/tests).
1010        t.chapter_power_step.powi(exp as i32)
1011    } else {
1012        // Taper ON — cumulative product. step(c) decays from the base step toward
1013        // the late floor with a fixed half-life past `enemy_step_taper_start`.
1014        const TAPER_DECAY: f64 = 0.9; // ~base→late over ~25 chapters
1015        let mut g = 1.0_f64;
1016        for i in 1..=exp {
1017            let c = t.enemy_curve_anchor_chapter + i;
1018            let over = (c - t.enemy_step_taper_start).max(0) as f64;
1019            let step = t.enemy_step_late
1020                + (t.chapter_power_step - t.enemy_step_late) * TAPER_DECAY.powf(over);
1021            g *= step;
1022        }
1023        g
1024    };
1025    (t.enemy_curve_anchor_power * growth * enemy_early_chapter_mult(chapter)).floor()
1026}
1027
1028// --- Gold faucet shaping ------------------------------------------------------
1029// The dominant gold faucet is selling items, priced from an item's
1030// effectiveness `eff`. Priced linearly, sale income outruns the geometric
1031// chest-upgrade sink mid/late game, leaving gold in runaway surplus so the
1032// cost curve never binds progression. Pricing items sub-linearly as
1033// `eff^SELL_PRICE_EXP · SELL_PRICE_COEF` flattens the faucet so the geometric
1034// sink can bind; `COEF` anchors early low-`eff` sales at the established price
1035// level. EXP is bot-sim calibrated to land the free player's sink/faucet ratio
1036// in the "gold gets spent, not hoarded" band without starving the faucet below
1037// the sink. The whale still front-loads real-money gold; that surplus is by
1038// design ("soft currency in surplus, engineer the sink"). Tunable via
1039// `OVERLORD_BAL_SELL_EXP`.
1040pub const SELL_PRICE_EXP: f64 = 0.45;
1041pub const SELL_PRICE_COEF: f64 = 87.0;
1042
1043/// Share of MAX HP an entity regenerates per second, in permyriad (`100` =
1044/// `1%/s`). BAL-034 gives the regen class a specialization that keeps meaning
1045/// something as HP pools grow: a flat rate authored for early gear is noise by
1046/// the time a player has ten times the HP.
1047pub const REGEN_PERCENT_CODE: &str = "regeneration_percent";
1048
1049/// Item sale price (sell-currency units) from an item's effectiveness `eff`:
1050/// `floor(eff^sell_price_exp · sell_price_coef)`. Sub-linear (`exp < 1`) so
1051/// gold income grows ~linearly with item level and the geometric chest sink
1052/// can bind. Pure + sim-tunable via [`tuning()`]; the call site is
1053/// `behaviors::items::item_price`.
1054pub fn sell_price(eff: f64) -> i64 {
1055    let t = tuning();
1056    (eff.max(0.0).powf(t.sell_price_exp) * t.sell_price_coef).floor() as i64
1057}
1058
1059// --- Character XP faucet (BAL-009) -------------------------------------------
1060// Selling an item is the only runtime XP source, so the level curve and this
1061// faucet are two halves of one contract: the level-cost bands are fixed
1062// (`CharacterLevelFormula`), and pacing is tuned from THIS side. One global
1063// exponent cannot hit L50, L100 and L200 at once — a fit of the first two lands
1064// around L165 by day 30 — so the shape breaks once, after Character L100, and
1065// continues from the same scale rather than introducing a second one.
1066//
1067// `XP(L<=100) = round(coef · eff_level(L)^exp · sqrt(quality))`
1068// `XP(L>100)  = round(coef · eff_level(100)^exp · (eff_level(L)/eff_level(100))^late_exp · sqrt(quality))`
1069//
1070// Normalizing the tail through `eff_level(100)` keeps the two branches
1071// continuous at the break. These are initial implementation coefficients: they
1072// move only on a full replacement-flow cohort trace, never together with the
1073// level-cost curve.
1074pub const ITEM_XP_COEF: f64 = 6.7;
1075pub const ITEM_XP_EXP: f64 = 0.50;
1076pub const ITEM_XP_LATE_EXP: f64 = 2.16;
1077pub const ITEM_XP_BREAK_LEVEL: f64 = 100.0;
1078
1079/// Character XP paid by selling one item, from the item's snapshot character
1080/// `level` and its quality multiplier `quality` (the rarity axis `M`).
1081///
1082/// Level and quality are the ONLY inputs: two items of the same level and
1083/// quality pay the same XP regardless of stat roll, optional stats or
1084/// `power_bonus`. No per-sale cap — a lucky sale may carry the player through
1085/// several levels and the whole overflow is kept.
1086///
1087/// Env overrides (sim sweep): `OVERLORD_BAL_ITEM_XP_COEF`,
1088/// `OVERLORD_BAL_ITEM_XP_EXP`, `OVERLORD_BAL_ITEM_XP_LATE_EXP`.
1089pub fn item_experience(level: f64, quality: f64) -> i64 {
1090    let coef = env_f64("OVERLORD_BAL_ITEM_XP_COEF").unwrap_or(ITEM_XP_COEF);
1091    let exp = env_f64("OVERLORD_BAL_ITEM_XP_EXP").unwrap_or(ITEM_XP_EXP);
1092    let late_exp = env_f64("OVERLORD_BAL_ITEM_XP_LATE_EXP").unwrap_or(ITEM_XP_LATE_EXP);
1093
1094    let eff = eff_by_level(level).max(0.0);
1095    let shape = if level <= ITEM_XP_BREAK_LEVEL {
1096        eff.powf(exp)
1097    } else {
1098        let eff_break = eff_by_level(ITEM_XP_BREAK_LEVEL).max(f64::MIN_POSITIVE);
1099        eff_break.powf(exp) * (eff / eff_break).powf(late_exp)
1100    };
1101
1102    (coef * shape * quality.max(0.0).sqrt()).round() as i64
1103}
1104
1105/// Read an `OVERLORD_BAL_*` f64 override; `None` if unset or unparseable. Same
1106/// parse as [`BalanceTuning::from_env`]'s `ovr_f`, so the env precedence below
1107/// is identical to the process-wide `tuning()` sweep mechanism. `tuning()`
1108/// itself collapses env-or-constant into one value and can't report whether the
1109/// env var was actually set, so the config-aware price reads presence directly.
1110fn env_f64(name: &str) -> Option<f64> {
1111    std::env::var(name).ok().and_then(|v| v.parse().ok())
1112}
1113
1114/// Per-knob precedence for the config-aware sell price: the `OVERLORD_BAL_SELL_*`
1115/// env override (sim sweep) wins when present, else the GameConfig value
1116/// (`Some`), else the compiled constant.
1117#[inline]
1118fn resolve_sell_knob(env: Option<f64>, cfg: Option<f64>, default: f64) -> f64 {
1119    env.or(cfg).unwrap_or(default)
1120}
1121
1122/// Item sale price with the exponent/coefficient sourced from GameConfig
1123/// (`game_settings.sell_price_{exp,coef}`). Per-knob precedence:
1124/// `OVERLORD_BAL_SELL_EXP` / `OVERLORD_BAL_SELL_COEF` env override (sim sweep) >
1125/// GameConfig value (`Some`) > the compiled [`SELL_PRICE_EXP`] /
1126/// [`SELL_PRICE_COEF`] constants. With env unset and both config knobs `None`
1127/// this is bit-identical to [`sell_price`] (the pre-config behavior), so
1128/// deploying the config with the current constant values leaves every price
1129/// unchanged. Call site: `behaviors::items::item_price`.
1130pub fn sell_price_with_config(eff: f64, cfg_exp: Option<f64>, cfg_coef: Option<f64>) -> i64 {
1131    let exp = resolve_sell_knob(env_f64("OVERLORD_BAL_SELL_EXP"), cfg_exp, SELL_PRICE_EXP);
1132    let coef = resolve_sell_knob(env_f64("OVERLORD_BAL_SELL_COEF"), cfg_coef, SELL_PRICE_COEF);
1133    (eff.max(0.0).powf(exp) * coef).floor() as i64
1134}
1135
1136/// BAL-008 sale price: `round(G0 × eff_level(snapshot)^α × M_quality)`.
1137///
1138/// The shape differs from [`sell_price_with_config`] in a way that is easy to
1139/// miss: the quality multiplier is **linear**, OUTSIDE the exponent. The old
1140/// form raised the already-multiplied `eff_by_level × rarity_q` to `α`, which
1141/// re-prices rarity by `α` as well; the signed table assumes rarity scales the
1142/// price directly. Feeding the pre-multiplied `eff` into the new constants would
1143/// look right and produce quite different Gold.
1144///
1145/// Depends only on snapshot Character Level and quality, so two items of the
1146/// same level and quality sell for the same regardless of their ±10% stat roll,
1147/// optional stats or `power_bonus`.
1148pub fn sell_gold(eff_level: f64, quality: f64, cfg_exp: Option<f64>, cfg_coef: Option<f64>) -> i64 {
1149    let exp = resolve_sell_knob(env_f64("OVERLORD_BAL_SELL_EXP"), cfg_exp, SELL_PRICE_EXP);
1150    let coef = resolve_sell_knob(env_f64("OVERLORD_BAL_SELL_COEF"), cfg_coef, SELL_PRICE_COEF);
1151    (coef * eff_level.max(0.0).powf(exp) * quality.max(0.0)).round() as i64
1152}
1153
1154/// Per-tick heal/damage cap: `amount` bounded above by `pct × max_hp`. Used by
1155/// the regen / HoT / DoT combat ticks so a mis-tuned effect can't out-heal all
1156/// incoming damage (regen/HoT) or one-shot (DoT). Pure + testable; `pct` comes
1157/// from the matching [`tuning()`] knob (`regen|hot|dot_tick_max_pct`).
1158pub fn cap_per_tick(amount: f64, max_hp: f64, pct: f64) -> f64 {
1159    amount.min(max_hp * pct)
1160}
1161
1162/// Multiplicative buff/debuff factor from a proc chance `p` (0..1): a buff of
1163/// strength [`BUFF_EFF`] held for a mean uptime, stacked `quantity` times.
1164/// Preserves the legacy `(1 + (BUFF_EFF−1)·uptime)^quantity` shape exactly.
1165pub fn buff_uptime_mult(p: f64, quantity: i64, duration: f64) -> f64 {
1166    if p <= 0.0 {
1167        return 1.0;
1168    }
1169    let uptime = (CASTS_PER_SEC * p * duration / quantity as f64).min(1.0);
1170    (1.0 + (BUFF_EFF - 1.0) * uptime).powi(quantity as i32)
1171}
1172
1173/// Honest two-branch pricing for bravery/deceit: a proc picks RANDOMLY
1174/// between an offensive branch (empower/vulnerability = ×1.5 in power terms)
1175/// and a defensive one (protection/weakness = ×0.5 damage/attack ⇒ ×2.0 in
1176/// power terms) — each branch runs at HALF the proc uptime. A single-EFF
1177/// shape would under-value the defensive branch ~25% at high uptime. Power is
1178/// DPS×EHP, so the combined factor applies wherever the caller multiplies it
1179/// in.
1180pub fn buff_uptime_mult_branches(p: f64, duration: f64) -> f64 {
1181    const BUFF_EFF_OFFENSE: f64 = 1.5; // Empower +50% attack / Vulnerability +50% taken
1182    const BUFF_EFF_DEFENSE: f64 = 2.0; // Protection ×0.5 taken / Weakness ×0.5 attack
1183    if p <= 0.0 {
1184        return 1.0;
1185    }
1186    let uptime = (CASTS_PER_SEC * p * duration / 2.0).min(1.0);
1187    (1.0 + (BUFF_EFF_OFFENSE - 1.0) * uptime) * (1.0 + (BUFF_EFF_DEFENSE - 1.0) * uptime)
1188}
1189
1190/// Pure: stochastic round of a non-integer value.
1191pub fn rand_round_f64(value: f64, random: &GameRng) -> i64 {
1192    let base = value.floor();
1193    let prob = value - base;
1194    if prob > 0.0 && random.random_f64() < prob {
1195        return base as i64 + 1;
1196    }
1197    base as i64
1198}
1199
1200pub fn effect_cost(duration: f64) -> f64 {
1201    (BUFF_EFF - 1.0) * (SPELL_QUANTITY - 1) as f64 * duration * BASE_SPELL_EFF
1202}
1203
1204pub fn effect_duration(cost: f64) -> f64 {
1205    cost / ((BUFF_EFF - 1.0) * (SPELL_QUANTITY - 1) as f64 * BASE_SPELL_EFF)
1206}
1207
1208fn buff_p_from_eff(eff: f64, cast_rate: f64, duration: f64, effects_quantity: i64) -> f64 {
1209    let uptime_max = (cast_rate * duration / effects_quantity as f64).min(1.0);
1210    let eff_max = (1.0 + (BUFF_EFF - 1.0) * uptime_max).powi(effects_quantity as i32);
1211    if eff >= eff_max {
1212        return 1.0;
1213    }
1214    let effect_eff = eff.powf(1.0 / effects_quantity as f64);
1215    let effect_uptime = ((effect_eff - 1.0) / (BUFF_EFF - 1.0)).max(0.0);
1216    let p = effect_uptime * effects_quantity as f64 / (cast_rate * duration);
1217    p.clamp(0.0, 1.0)
1218}
1219
1220pub fn bravery_p_from_eff(eff: f64) -> f64 {
1221    buff_p_from_eff(
1222        eff,
1223        CASTS_PER_SEC,
1224        BRAVERY_BUFF_DURATION,
1225        BRAVERY_BUFF_QUANTITY,
1226    )
1227}
1228
1229pub fn deceit_p_from_eff(eff: f64) -> f64 {
1230    buff_p_from_eff(
1231        eff,
1232        CASTS_PER_SEC,
1233        DECEIT_DEBUFF_DURATION,
1234        DECEIT_DEBUFF_QUANTITY,
1235    )
1236}
1237
1238pub fn attr_spread_random(random: &GameRng) -> f64 {
1239    1.0 + ATTR_DEVIATION * (2.0 * random.random_f64() - 1.0)
1240}
1241
1242pub fn hp_k_for_level(level: f64) -> f64 {
1243    const ALL_SPELLS_LEVEL: f64 = 30.0;
1244    if level > ALL_SPELLS_LEVEL {
1245        return 1.0;
1246    }
1247    ((level - 1.0) / (ALL_SPELLS_LEVEL - 1.0) * (SPELL_QUANTITY - 1) as f64 + 1.0)
1248        / SPELL_QUANTITY as f64
1249}
1250
1251// `eff_by_level` curve constants (item effectiveness vs item level). Two
1252// branches joined at L=130: a steep early power-law and a flat late power-law.
1253// Module consts + the `tuning()` surface so the bot-sim can sweep the
1254// early-power SHAPE (`EFF_B1`) without a recompile.
1255//
1256// This curve — not the economy — sets how fast power grows early: item level
1257// tracks the level/clock, and with the gear-eff→power exponent k≈1 (each
1258// item's attack & hp scale as eff^0.5, so P=DPS·EHP ∝ eff^1; the quadratic S²
1259// is the separate SPELL axis) B1 maps ~1:1 into power. B1 = 1.40 keeps the
1260// first sessions from being a power geyser; B2 = 0.40 keeps late power
1261// climbing (no far-late plateau — "fighting never stops"). The late branch
1262// (A2,B2,C2) must stay continuous with the early branch at L=130 in value AND
1263// slope. Closed form: V=A1·129^B1+1=98.52, S=A1·B1·129^(B1-1)=1.0583,
1264// A2=S·130^(1-B2)/B2=49.08, C2=V-A2·130^B2=-245.40. All sim-tunable via
1265// OVERLORD_BAL_EFF_*; re-derive A2/C2 if B1 or B2 move.
1266pub const EFF_MIDGAME_LEVEL: f64 = 130.0;
1267pub const EFF_A1: f64 = 0.1082166549;
1268pub const EFF_B1: f64 = 1.40;
1269pub const EFF_C1: f64 = 1.0;
1270pub const EFF_A2: f64 = 49.08;
1271pub const EFF_B2: f64 = 0.40;
1272pub const EFF_C2: f64 = -245.40;
1273
1274pub fn eff_by_level(level: f64) -> f64 {
1275    let t = tuning();
1276    if level <= EFF_MIDGAME_LEVEL {
1277        t.eff_a1 * (level - 1.0).powf(t.eff_b1) + EFF_C1
1278    } else {
1279        t.eff_a2 * level.powf(t.eff_b2) + t.eff_c2
1280    }
1281}
1282
1283// --- Legacy planning-model "day" curves --------------------------------------
1284// The four functions below (`item_q_by_day`, `day_by_level`, `level_by_day`,
1285// `eff_spell_by_level`) are fits from the original planning spreadsheet:
1286// "day" here is that model's abstract pacing day, not a real calendar/sim day,
1287// and the magic constants are curve-fit coefficients with no runtime meaning
1288// beyond the fit. They are still LIVE — arena-opponent generation
1289// (`behaviors::opponents`) levels bots by `level_by_day`/`item_q_by_day`, and
1290// item `dmg_increase` reads `eff_spell_by_level` — so they cannot be deleted;
1291// but do not build new features on the "day" scale.
1292
1293pub fn item_q_by_day(day: f64) -> f64 {
1294    const A: f64 = 1.369248467;
1295    const B: f64 = 1.986;
1296    const C: f64 = 1.00;
1297    A * (B * day + 1.0).ln() + C
1298}
1299
1300pub fn day_by_level(level: f64) -> f64 {
1301    const A1: f64 = 13.95171732;
1302    const A2: f64 = 0.5784426942;
1303    const A3: f64 = 1.00;
1304    ((level - A3) / A1).powf(1.0 / A2)
1305}
1306
1307pub fn level_by_day(day: f64) -> f64 {
1308    const A1: f64 = 13.95171732;
1309    const A2: f64 = 0.5784426942;
1310    const A3: f64 = 1.00;
1311    A1 * day.powf(A2) + A3
1312}
1313
1314pub fn eff_spell_by_level(level: f64) -> f64 {
1315    const S1: f64 = 0.36536535;
1316    const S2: f64 = 0.525009219;
1317    const S3: f64 = 1.0;
1318    let day = day_by_level(level);
1319    S1 * day.powf(S2) + S3
1320}
1321
1322/// Player armor-rating deflator for character `level` (see
1323/// `BalanceTuning::armor_k_per_chapter`; level ≈ chapter for players and is
1324/// available for arena OPPONENT snapshots too, keeping PvP symmetric):
1325/// multiply the aggregated player armor rating by this before it meets the
1326/// constant-K DR curve.
1327pub fn player_armor_rating_deflator(level: i64) -> f64 {
1328    let t = tuning();
1329    if t.armor_k_per_chapter <= 0.0 {
1330        return 1.0;
1331    }
1332    t.k_armor / (t.k_armor + t.armor_k_per_chapter * level.max(0) as f64)
1333}
1334
1335pub fn armor_k(armor: f64) -> f64 {
1336    // Fraction of incoming damage that PASSES the target's armor, via the DR
1337    // curve `K_ARMOR/(armor+K_ARMOR)`. Always in (0, 1] for armor ≥ 0 — a
1338    // linear falloff would reach 0 and go negative (healing the target).
1339    let k = tuning().k_armor;
1340    k / (armor.max(0.0) + k)
1341}
1342
1343/// Enemy "power" scalar for a fight — the value `spawn_wave` feeds into the
1344/// HP/attack curve, and the apples-to-apples counterpart of the player's
1345/// `character.power` (both normalized to [`BASE_POWER`]). For campaign fights:
1346/// at/below [`HAND_AUTHORED_CHAPTER_MAX`] (the whole first stage) it is the
1347/// hand-authored `FightTemplate.power` (`base_power`) with NO mid-boss gate;
1348/// above it the rebalanced geometric curve
1349/// ([`enemy_power_for_chapter`], which carries the sweepable anchor/step + the
1350/// early-chapter bump), with a boss bump. For DUNGEON fights it is always the
1351/// authored per-difficulty `base_power` (see below). Shared so the battle-end
1352/// analytics report exactly the value the fight spawned.
1353pub fn enemy_power_scalar(
1354    base_power: f64,
1355    current_chapter: i64,
1356    fight_type: &str,
1357    is_dungeon: bool,
1358) -> f64 {
1359    let t = tuning();
1360    // Dungeons are a self-contained difficulty LADDER: enemy power is the
1361    // authored per-difficulty `base_power` (the chosen difficulty's
1362    // `FightTemplate.power`, ramping e.g. 1500 → 600M across the levels), NOT the
1363    // campaign chapter curve. Dungeon fights are tagged `CampaignBossFight` (for
1364    // boss talents/visuals), so without this guard they fall into the chapter-curve
1365    // branch below — collapsing every difficulty to one power keyed to the player's
1366    // campaign chapter (the regression where the ladder does nothing and difficulty
1367    // 1 is a full-chapter boss → "losing to the dungeon at unlock"). Honor the
1368    // authored ladder so low difficulties are an easy first clear and the player
1369    // climbs as their power grows.
1370    if is_dungeon {
1371        return base_power;
1372    }
1373    // BAL-037: the signed curve covers ch0…509, so FTUE no longer reads authored
1374    // `FightTemplate.power`. The whole hand-authored block is refit as one
1375    // progression sequence; its current absolute rows `1…46` are not preserved.
1376    if fight_type == "CampaignFight" || fight_type == "CampaignBossFight" {
1377        if t.enemy_curve_mode != EnemyCurveMode::Derived {
1378            // Rollback mode keeps the historic split: authored FTUE rows, legacy
1379            // anchor/step/taper past them, plus the old boss bumps.
1380            if current_chapter <= t.hand_authored_chapter_max {
1381                return base_power;
1382            }
1383            let power = enemy_power_for_chapter(current_chapter);
1384            return if fight_type == "CampaignBossFight" {
1385                power * t.chapter_power_step.powf(0.5) * enemy_mid_chapter_mult(current_chapter)
1386            } else {
1387                power
1388            };
1389        }
1390        // One total normalized encounter coefficient for every boss on every
1391        // chapter. The separate ch12/13/14 bump is gone: the accepted cadence
1392        // forbids any wall through ch21.
1393        if fight_type == "CampaignBossFight" {
1394            enemy_boss_power(current_chapter)
1395        } else {
1396            enemy_wave_power(current_chapter)
1397        }
1398    } else {
1399        base_power
1400    }
1401}
1402
1403pub fn hp_k_for_chapter(config: &GameConfig, chapter: i64) -> f64 {
1404    let mut reached: Vec<_> = config
1405        .ability_slots_levels
1406        .iter()
1407        .filter(|item| chapter >= item.from_chapter_level)
1408        .collect();
1409    reached.sort_by_key(|b| std::cmp::Reverse(b.from_chapter_level));
1410
1411    if reached.is_empty() {
1412        return 1.0 / SPELL_QUANTITY as f64;
1413    }
1414    let current = reached[0];
1415    if current.ability_slots == (SPELL_QUANTITY - 1) as u64 {
1416        return 1.0;
1417    }
1418    let mut unreached: Vec<_> = config
1419        .ability_slots_levels
1420        .iter()
1421        .filter(|item| chapter < item.from_chapter_level)
1422        .collect();
1423    unreached.sort_by_key(|a| a.from_chapter_level);
1424    if unreached.is_empty() {
1425        return 1.0;
1426    }
1427    let next = unreached[0];
1428    // Interpolate the slot count toward the next tier. The legacy form added a
1429    // bare `frac` — correct only while every ladder step is exactly +1 slot
1430    // (true today, verified); scale by the actual step so a future +2 tier
1431    // doesn't silently mis-budget the wave. The trailing +1 counts the class
1432    // basic ability alongside the gacha slots (slots max 5, SPELL_QUANTITY 6).
1433    let step = (next.ability_slots - current.ability_slots) as f64;
1434    let frac = (chapter - current.from_chapter_level) as f64
1435        / (next.from_chapter_level - current.from_chapter_level) as f64;
1436    let mean_ability_slots = current.ability_slots as f64 + frac * step + 1.0;
1437    mean_ability_slots / SPELL_QUANTITY as f64
1438}
1439
1440/// `attrs.get_attr(name)`: read the composed attribute value from an attribute map.
1441/// Returns `(base + bonus) * (1 + bonus / 10000.0)`.
1442///
1443/// of `mod` in the multiplier. We preserve the original behaviour byte-for-byte
1444/// so power calculations stay consistent with the current production config.
1445///
1446/// `attrs.get_attr(...)` method (registered for scripts that still pass a
1447pub fn get_attr_from_attrs(attrs: &AttrMap, attr: &str) -> f64 {
1448    let base = attrs.get(attr).copied().unwrap_or(0.0);
1449    let bonus = attrs.get(&format!("{attr}.bonus")).copied().unwrap_or(0.0);
1450    // Matches combat's `get_entity_stat`: additive `.bonus`, then the `.mod`
1451    // multiplier `(1 + mod/10000)` — the scalar must agree with combat, or
1452    // `.mod` grants (e.g. class-level attack.mod/hp.mod) would not move
1453    // displayed/matchmaking power. (Attribute base-values — e.g.
1454    // received_damage's 10000 — are still combat-only: the scalar has no
1455    // `lookups` here; a known remaining scalar↔combat gap.)
1456    let mod_v = attrs.get(&format!("{attr}.mod")).copied().unwrap_or(0.0) / 10000.0 + 1.0;
1457    (base + bonus) * mod_v
1458}
1459
1460/// Ability upgrade milestones: on top of the linear +5%/level, levels 5 and
1461/// 10 grant a multiplicative jump — a proximate milestone that makes the
1462/// dup-shard chase eventful instead of a flat drip. UNIVERSAL for every
1463/// ability (gacha, class kits, pet ults) — no per-content flags; tooltips
1464/// (`ability_info(level)`) and the honest power scalar recompute automatically
1465/// because everything routes through [`ability_eff`]. Sizing: ×1.10 at L5 ≈
1466/// two extra linear levels, ×1.20 at L10 ≈ four — noticeable, but small
1467/// enough that the shard economy absorbs it.
1468pub const ABILITY_MILESTONES: &[(i64, f64)] = &[(5, 1.10), (10, 1.20)];
1469
1470/// Payload multiplier of a CLASS ability at `rank`: `×[1.0, 1.1, … 1.6]` over
1471/// ranks 1..=7 (BAL-034).
1472///
1473/// Class kits do not ride the shard ladder every other ability uses: their
1474/// ranks come from Class Level on a fixed schedule, so their growth is
1475/// authored flat and stops at rank 7. Only damage and heal payloads move —
1476/// durations, buff magnitudes, target caps, cooldowns and Mana costs are the
1477/// same at rank 7 as at rank 1, which is what deliberately keeps the utility
1478/// halves of Fortify / War Cry / Rewind / Battle Heal flat.
1479pub const CLASS_RANK_PAYLOAD_STEP: f64 = 0.1;
1480pub const CLASS_MAX_RANK: i64 = 7;
1481
1482pub fn class_rank_payload_mult(rank: i64) -> f64 {
1483    1.0 + CLASS_RANK_PAYLOAD_STEP * (rank.clamp(1, CLASS_MAX_RANK) - 1) as f64
1484}
1485
1486pub fn ability_eff(lookups: &ContentLookups, rarity_id: Uuid, level: i64) -> f64 {
1487    let rarity_eff = lookups
1488        .ability_rarity_eff
1489        .get(&rarity_id)
1490        .copied()
1491        .unwrap_or(1.0);
1492    if lookups.class_ability_rarities.contains(&rarity_id) {
1493        return rarity_eff * class_rank_payload_mult(level);
1494    }
1495    let level_eff = 1.0 + 0.05 * (level - 1) as f64;
1496    let milestone: f64 = ABILITY_MILESTONES
1497        .iter()
1498        .filter(|(l, _)| level >= *l)
1499        .map(|(_, m)| m)
1500        .product();
1501    rarity_eff * level_eff * milestone
1502}
1503
1504// --- Honest power scalar: per-ability combat contribution --------------------
1505// A flat `Σ ability_eff` multiplier would lie three ways:
1506//   1. abilities that never cast in combat would still count toward power;
1507//   2. a heal ability (HoT / lifesteal) would count as if it were damage,
1508//      while in combat it contributes survivability (EHP), not DPS;
1509//   3. the per-ability throughput factor `FIGHT_DURATION/(FIGHT_DURATION+cd)`
1510//      that combat damage actually carries (`ability_damage_from_rarity`)
1511//      would be ignored, over-rating slow-cooldown abilities.
1512// The honest scalar therefore maps each fighting ability to (dps_add,
1513// heal_add) using the same primitives combat uses, then multiplies the DPS
1514// axis by Σ dps_add and the EHP axis by the capped heal-sustain factor — the
1515// same shape as combat.
1516
1517/// Combat-honest contribution of one slotted ability to the power scalar.
1518#[derive(Clone, Copy, Debug, Default, PartialEq)]
1519pub struct AbilityCombatProfile {
1520    /// Additive DPS throughput in reference units: 1.0 = the reference
1521    /// single-ability caster (`BASE_SPELL_EFF`, cd→0) that `spawn_wave`'s
1522    /// mirror player is built from. Per-second form of the same
1523    /// `eff × FD/(FD+cd)` budget combat damage derives from.
1524    pub dps_add: f64,
1525    /// Heal throughput in the same units (multiply by `attack × DMG_K` for
1526    /// absolute HP/s — exactly what `spell_heal`/HoT apply in combat).
1527    pub heal_add: f64,
1528}
1529
1530/// Pure split of an [`crate::mechanics::content::AbilityInfo`] budget into the
1531/// (dps_add, heal_add) axes. Separated from the config lookup so the arithmetic
1532/// is unit-testable without prod ability UUIDs.
1533///
1534/// `damage`/`dot`/`hot` in `AbilityInfo` are per-cast budgets (the closures
1535/// split `ability_damage_from_rarity = eff·k·cd`), so dividing by `cd` yields
1536/// per-second throughput `eff·k·share`. `vampiric` heals a fraction of damage
1537/// dealt. `crit_chance_bonus`/`effect_duration` buff components are dropped
1538/// (conservative): their budget share was already subtracted from `damage` by
1539/// the content closures, so ignoring them under-counts slightly rather than
1540/// double-counting.
1541pub fn ability_profile_from_info(
1542    damage: Option<f64>,
1543    dot: Option<f64>,
1544    hot: Option<f64>,
1545    vampiric: Option<f64>,
1546    projectiles: Option<i64>,
1547    cooldown_sec: f64,
1548) -> AbilityCombatProfile {
1549    let cd = cooldown_sec.max(0.1);
1550    // Multi-projectile abilities report PER-PROJECTILE damage in their info
1551    // split (the closures divide the budget by the count for the tooltip);
1552    // combat fires all of them, so the throughput carries the full budget.
1553    let shots = projectiles.unwrap_or(1).max(1) as f64;
1554    let total_damage = damage.unwrap_or(0.0) * shots;
1555    let dmg_budget = total_damage + dot.unwrap_or(0.0);
1556    let heal_budget = hot.unwrap_or(0.0) + vampiric.unwrap_or(0.0) * total_damage;
1557    AbilityCombatProfile {
1558        dps_add: dmg_budget / cd / BASE_SPELL_EFF,
1559        heal_add: heal_budget / cd,
1560    }
1561}
1562
1563/// Combat-honest profile of a pet ult: the pet's ability carries the donor
1564/// gacha ability's per-cast budget (combat behaviors hardcode the donor uuid),
1565/// but fires at the charge-fill rate, not at the template cooldown — so the
1566/// throughput divides the per-cast budget by [`PET_ULT_EFFECTIVE_CD`].
1567pub fn pet_ult_combat_profile(
1568    config: &GameConfig,
1569    lookups: &ContentLookups,
1570    ability: &Ability,
1571) -> AbilityCombatProfile {
1572    let Some(tpl) = config.ability_template(ability.template_id) else {
1573        return AbilityCombatProfile::default();
1574    };
1575    match crate::mechanics::content::ability_info(
1576        config,
1577        lookups,
1578        ability.template_id,
1579        ability.level,
1580    ) {
1581        Ok(info) => ability_profile_from_info(
1582            info.damage,
1583            info.dot,
1584            info.hot,
1585            info.vampiric,
1586            info.projectiles,
1587            PET_ULT_EFFECTIVE_CD,
1588        ),
1589        Err(_) => {
1590            // No closure — price the whole per-cast budget as damage at the
1591            // charge-fill rate (same honest default as the regular path).
1592            let cd = (tpl.cooldown as f64 / 1000.0).max(0.1);
1593            let eff = ability_eff(lookups, tpl.rarity_id, ability.level);
1594            let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
1595            AbilityCombatProfile {
1596                dps_add: eff * k * cd / PET_ULT_EFFECTIVE_CD,
1597                heal_add: 0.0,
1598            }
1599        }
1600    }
1601}
1602
1603/// Combat-honest profile of one ability instance. Uses the per-ability
1604/// `content::ability_info` split where one exists; abilities without a bespoke
1605/// closure (some class/buff abilities) fall back to a pure-damage assumption at
1606/// their `eff × FD/(FD+cd)` throughput — the closest honest default.
1607pub fn ability_combat_profile(
1608    config: &GameConfig,
1609    lookups: &ContentLookups,
1610    ability: &Ability,
1611) -> AbilityCombatProfile {
1612    let Some(tpl) = config.ability_template(ability.template_id) else {
1613        return AbilityCombatProfile::default();
1614    };
1615    let cd = (tpl.cooldown as f64 / 1000.0).max(0.1);
1616    match crate::mechanics::content::ability_info(
1617        config,
1618        lookups,
1619        ability.template_id,
1620        ability.level,
1621    ) {
1622        Ok(info) => ability_profile_from_info(
1623            info.damage,
1624            info.dot,
1625            info.hot,
1626            info.vampiric,
1627            info.projectiles,
1628            cd,
1629        ),
1630        Err(_) => {
1631            // No bespoke split for this ability id — assume its whole budget is
1632            // damage (`eff × k`), the same throughput the plain-damage closures
1633            // produce.
1634            let eff = ability_eff(lookups, tpl.rarity_id, ability.level);
1635            let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
1636            AbilityCombatProfile {
1637                dps_add: eff * k,
1638                heal_add: 0.0,
1639            }
1640        }
1641    }
1642}
1643
1644/// Expected seconds between pet-ult casts for the honest scalar. The pet ult
1645/// is charge-gated, not cooldown-gated (`charge_rate_on_skill_use` /
1646/// `_on_damage_dealt` / `_on_damage_taken` fill `max_charge`), so its template
1647/// cooldown says nothing about throughput. With the shipped charge profiles
1648/// (max_charge 1000, rates 5-40 per trigger) a fight fills the bar roughly
1649/// once per ~12s of combat — a deliberate round number until a sim remeasure;
1650/// the pet's per-cast budget divided by this is the honest DPS/heal rate.
1651pub const PET_ULT_EFFECTIVE_CD: f64 = 12.0;
1652
1653/// Aggregate the equipped abilities into the two power-axis multipliers:
1654/// `(dps_mult, ehp_mult)`.
1655///
1656/// Membership matches combat exactly: `make_active_abilities_from_equipped`
1657/// sends BOTH `slotted` (gacha slots) and `unslotted` (the class kit and other
1658/// slotless grants) into the fight, so both count here — EXCEPT abilities with
1659/// an empty `start_behavior`: those never begin a cast cycle in combat, so a
1660/// scalar that counts them lies.
1661///
1662/// - `dps_mult` = Σ `dps_add` — additive throughput, exactly how per-ability
1663///   damage stacks in combat. Zero castable abilities ⇒ 0.0 (a character that
1664///   casts nothing deals nothing — "bare character power is 0" is the
1665///   established semantic).
1666/// - `ehp_mult` = capped heal-sustain factor `(hp + heal/fight) / hp`, the same
1667///   shape and the same `hot_tick_max_pct` cap the combat HoT tick obeys, and
1668///   the same treatment `power_from_attrs` already gives `regeneration_rate`.
1669pub fn ability_power_mults(
1670    config: &GameConfig,
1671    lookups: &ContentLookups,
1672    abilities: &EquippedAbilities,
1673    leader_pet_ability: Option<(Uuid, i64)>,
1674    attrs: &AttrMap,
1675) -> (f64, f64) {
1676    // The leader pet's active ability joins the fight exactly like an equipped
1677    // ability (`create_player_entity` pushes it at the pet's level), so it
1678    // counts here too — same membership rule, same dead-ability filter, but
1679    // priced at the charge-fill rate ([`PET_ULT_EFFECTIVE_CD`]), not at the
1680    // ability template's cooldown.
1681    let pet_ability = leader_pet_ability.map(|(template_id, level)| Ability {
1682        template_id,
1683        level: level.max(1),
1684        shards_amount: 0,
1685    });
1686
1687    let mut dps_mult = 0.0;
1688    let mut heal_add = 0.0;
1689    for (ability, is_pet) in abilities
1690        .unslotted
1691        .iter()
1692        .chain(abilities.slotted.values())
1693        .map(|a| (a, false))
1694        .chain(pet_ability.iter().map(|a| (a, true)))
1695    {
1696        // Dead abilities (no start behavior) never cast — no contribution.
1697        let castable = config
1698            .ability_template(ability.template_id)
1699            .and_then(|t| t.start_behavior.as_deref())
1700            .is_some_and(|s| !s.is_empty());
1701        if !castable {
1702            continue;
1703        }
1704        let p = if is_pet {
1705            pet_ult_combat_profile(config, lookups, ability)
1706        } else {
1707            ability_combat_profile(config, lookups, ability)
1708        };
1709        dps_mult += p.dps_add;
1710        heal_add += p.heal_add;
1711    }
1712
1713    let hp = get_attr_from_attrs(attrs, "hp");
1714    let attack = get_attr_from_attrs(attrs, "attack");
1715    let ehp_mult = if hp > 0.0 && heal_add > 0.0 {
1716        // Absolute heal rate as combat applies it (attack-scaled), capped per
1717        // tick like the combat HoT path so a mis-tuned heal can't credit
1718        // unbounded EHP.
1719        let heal_per_sec = (attack * DMG_K * heal_add).min(hp * tuning().hot_tick_max_pct);
1720        (hp + heal_per_sec * FIGHT_DURATION) / hp
1721    } else {
1722        1.0
1723    };
1724    (dps_mult, ehp_mult)
1725}
1726
1727pub fn ability_damage_from_rarity(
1728    lookups: &ContentLookups,
1729    rarity_id: Uuid,
1730    cooldown_ms: i64,
1731    level: i64,
1732) -> f64 {
1733    let eff = ability_eff(lookups, rarity_id, level);
1734    let cd = cooldown_ms as f64 / 1000.0;
1735    let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
1736    eff * k * BASE_SPELL_EFF * cd
1737}
1738
1739pub fn ability_damage_from_id(
1740    config: &GameConfig,
1741    lookups: &ContentLookups,
1742    ability_id: Uuid,
1743    level: i64,
1744) -> Result<f64, String> {
1745    let ability = config
1746        .ability_template(ability_id)
1747        .ok_or_else(|| format!("balance::ability_damage: unknown ability id {ability_id}"))?;
1748    Ok(ability_damage_from_rarity(
1749        lookups,
1750        ability.rarity_id,
1751        ability.cooldown as i64,
1752        level,
1753    ))
1754}
1755
1756pub fn eff_item_with_config(
1757    config: &GameConfig,
1758    lookups: &ContentLookups,
1759    item_template_id: Uuid,
1760    level: f64,
1761) -> f64 {
1762    let rarity_q = config
1763        .item_template(item_template_id)
1764        .and_then(|tpl| lookups.item_rarity_q.get(&tpl.rarity_id).copied())
1765        .unwrap_or(1.0);
1766    eff_by_level(level) * rarity_q
1767}
1768
1769/// A2-BAL-003 §3.3: how many optional stats a chest of this level rolls.
1770///
1771/// The count is a property of the CHEST, not of the item template. It used to
1772/// live on each template's `optional_attributes_count`, which meant the same
1773/// sword rolled the same number of extras out of a level-1 chest as out of a
1774/// level-40 one — and ten templates carried three, above the signed maximum of
1775/// two.
1776pub fn optional_attributes_for_chest_level(chest_level: i64) -> u64 {
1777    match chest_level {
1778        ..=1 => 0,
1779        2..=5 => 1,
1780        _ => 2,
1781    }
1782}
1783
1784pub fn attr_spread_for_item(
1785    // Kept in the signature: `eff_item_with_config` needed it for the removed
1786    // skewed roll, and the call sites read more clearly with the item's whole
1787    // context in one place. Dropping it would touch every caller for no gain.
1788    _config: &GameConfig,
1789    lookups: &ContentLookups,
1790    random: &GameRng,
1791    template_id: Uuid,
1792    level: f64,
1793) -> f64 {
1794    if let Some(fp) = lookups.item_fixed_power.get(&template_id) {
1795        return *fp;
1796    }
1797    if (level - 1.0).abs() < f64::EPSILON {
1798        return LEVEL_ONE_ATTR_MEAN
1799            * (1.0 + ATTR_DEVIATION_LEVEL_ONE * (2.0 * random.random_f64() - 1.0));
1800    }
1801    // A2-BAL-003 §3.3: every item above level 1 rolls the same uniform spread.
1802    //
1803    // The skewed fake-level roll that used to take over above item L25 is gone.
1804    // It made the spread's SHAPE change partway up the ladder, so an item's roll
1805    // distribution depended on where in the game it dropped — which is not
1806    // something the signed `0.9..1.1` band can describe, and made sale-EV drift
1807    // by level impossible to reason about.
1808    attr_spread_random(random)
1809}
1810
1811pub fn aux_attr_eff(base_eff: f64, random: &GameRng) -> f64 {
1812    let rand_mod = attr_spread_random(random);
1813    (base_eff * rand_mod).powf(AUX_ATTR_IMPACT)
1814}
1815
1816pub fn aux_attr_eff_for_item(
1817    config: &GameConfig,
1818    lookups: &ContentLookups,
1819    base_eff: f64,
1820    random: &GameRng,
1821    template_id: Uuid,
1822    level: f64,
1823) -> f64 {
1824    let rand_mod = attr_spread_for_item(config, lookups, random, template_id, level);
1825    (base_eff * rand_mod).powf(AUX_ATTR_IMPACT)
1826}
1827
1828/// Compute character power from a composed [`AttrMap`] as **P = DPS × EHP**
1829/// (balance v2). `DPS` is the offensive product (attack · rate · crit ·
1830/// multicast · bravery · counterattack); `EHP` the survivability product
1831/// (hp ÷ the damage that gets through), where armor and dodge use the
1832/// diminishing-returns curves [`armor_k`] and `ev/(ev+K_DODGE)`. Normalised by
1833/// [`POWER_NORM`] so a reference character (attack=`BASE_ATTACK`, hp=`BASE_HP`,
1834/// neutral elsewhere) anchors at [`BASE_POWER`].
1835///
1836/// **Float-accumulation order is load-bearing** (see [`character_attrs_power`]):
1837/// the per-attribute reads happen in a fixed order so the floored result is
1838/// deterministic.
1839pub fn power_from_attrs(attrs: &AttrMap) -> i64 {
1840    power_from_attrs_raw(attrs).floor() as i64
1841}
1842
1843/// [`power_from_attrs`] before the floor.
1844///
1845/// Ranking callers need the raw value: displayed power is an integer, but at
1846/// low character levels `DPS × EHP / power_norm` lands well under `1.0`, so the
1847/// floored score collapses every candidate to `0` and any sort over it silently
1848/// degrades to input order.
1849pub fn power_from_attrs_raw(attrs: &AttrMap) -> f64 {
1850    const BASE_POINT: f64 = 10000.0; // attribute basis points: 10000 == 1.0 / 100%
1851
1852    // ---- offensive: DPS ----
1853    let attack = get_attr_from_attrs(attrs, "attack");
1854    // speed → cast rate, mirroring combat's `scale_cooldown_for_speed` (cooldown ∝
1855    // baseline/speed ⇒ rate ∝ speed/baseline, baseline_speed = BASE_POINT). Combat treats
1856    // `speed ≤ 0` as the baseline (`speed_or_baseline`), so the scalar must too — else a
1857    // speed-less build reads as 0 DPS here while combat still attacks at the baseline rate.
1858    let speed = get_attr_from_attrs(attrs, "speed");
1859    let atk_rate = if speed > 0.0 { speed / BASE_POINT } else { 1.0 };
1860    // Probability stats are clamped to [0,1] — a chance can't exceed 100%.
1861    // Without this, large (e.g. class-level) grants overflow the basis-point
1862    // cap and break the formula (e.g. block>1 → `1−0.5·block` negative → power
1863    // goes negative). Combat's `stat_throw` already saturates a proc at 100%.
1864    // `crit_chance_scale` mirrors combat (`stat_throw_scaled` in the attack
1865    // path): the honest scalar must price crit at the REALIZED proc rate, not
1866    // the raw stat — otherwise crit builds are over-valued in displayed/
1867    // matchmaking power whenever the scale is below 1.
1868    let crit_chance = (get_attr_from_attrs(attrs, "crit_chance") / BASE_POINT
1869        * tuning().crit_chance_scale)
1870        .clamp(0.0, 1.0);
1871    let crit_damage_mod = BASE_CRIT_MOD + get_attr_from_attrs(attrs, "crit_modifier") / BASE_POINT;
1872    let crit_factor = 1.0 + crit_chance * (crit_damage_mod - 1.0);
1873    let multicast_factor =
1874        1.0 + (get_attr_from_attrs(attrs, "multicast_chance") / BASE_POINT).clamp(0.0, 1.0);
1875    let bravery_factor = buff_uptime_mult_branches(
1876        get_attr_from_attrs(attrs, "bravery") / BASE_POINT,
1877        BRAVERY_BUFF_DURATION,
1878    );
1879
1880    let mut dps = attack * atk_rate * crit_factor * multicast_factor * bravery_factor;
1881
1882    // counterattack: retaliatory damage relative to a fight's baseline output
1883    // (same additive shape as the legacy formula).
1884    let counterattack_chance =
1885        (get_attr_from_attrs(attrs, "counterattack_chance") / BASE_POINT).clamp(0.0, 1.0);
1886    let counterattack_dmg =
1887        FIGHT_DURATION * ATTACKS_PER_SEC * COUNTERATTACK_POWER * counterattack_chance;
1888    let baseline_dmg =
1889        FIGHT_DURATION * SPELL_QUANTITY as f64 / armor_k(get_attr_from_attrs(attrs, "armor"));
1890    if baseline_dmg > 0.0 {
1891        dps *= (baseline_dmg + counterattack_dmg) / baseline_dmg;
1892    }
1893
1894    // ---- defensive: EHP = hp ÷ (fraction of damage that gets through) ----
1895    // Via get_attr_from_attrs so hp.bonus/hp.mod count (consistent with combat).
1896    let hp = get_attr_from_attrs(attrs, "hp");
1897    let mut ehp = hp;
1898    // armor mitigation (DR via `armor_k`): divide by the damage-through fraction.
1899    ehp /= armor_k(get_attr_from_attrs(attrs, "armor"));
1900    // dodge (evasion rating) via DR: avoid prob = ev/(ev+K_DODGE), asymptote < 1.
1901    let evasion = get_attr_from_attrs(attrs, "evasion").max(0.0);
1902    ehp /= 1.0 - evasion / (evasion + tuning().k_dodge);
1903    // block: halves damage on proc → expected damage multiplier (1 − 0.5·p),
1904    // p clamped to [0,1] (max ×2 EHP at always-block).
1905    let block_chance = (get_attr_from_attrs(attrs, "block") / BASE_POINT).clamp(0.0, 1.0);
1906    ehp /= 1.0 - 0.5 * block_chance;
1907    // received_damage: a damage-taken multiplier (base BASE_POINT = 100% taken); combat
1908    // applies it in `damage_entity`, so the scalar must too or it under-counts mitigation
1909    // EHP (a build with persistent `received_damage` reduction reads weaker than it fights).
1910    // The scalar has no `lookups` for the base, so BASE_POINT is added explicitly here;
1911    // floored like combat (`MIN_RECEIVED_DAMAGE_K`) so it can't reach ≤0. Neutral → ×1.
1912    let received_damage =
1913        (BASE_POINT + get_attr_from_attrs(attrs, "received_damage")).max(MIN_RECEIVED_DAMAGE_K);
1914    ehp *= BASE_POINT / received_damage;
1915    // regen-as-EHP, derived to MATCH combat. Combat heals at most
1916    // `regen_tick_max_pct × max_hp` per ~1s tick (`regeneration_tick`), so over a fight it
1917    // delivers `FIGHT_DURATION × min(regen_rate, regen_tick_max_pct × hp)` — and the scalar
1918    // credits exactly that, using the SAME `tuning().regen_tick_max_pct` knob as combat, so
1919    // the two can never desync. This makes regen structurally weaker EHP than block — a real
1920    // combat-balance fact the scalar reports honestly; closing the Priest-competitiveness
1921    // gap is a sim/design decision, not a scalar tweak.
1922    let regen_per_sec = (get_attr_from_attrs(attrs, "regeneration_rate")
1923        + get_attr_from_attrs(attrs, REGEN_PERCENT_CODE) / BASE_POINT * hp.max(0.0))
1924    .min(hp.max(0.0) * tuning().regen_tick_max_pct);
1925    let regen_per_fight = regen_per_sec * FIGHT_DURATION;
1926    if hp != 0.0 {
1927        ehp *= (hp + regen_per_fight) / hp;
1928    }
1929    // deceit: debuffs the enemy → effective survivability gain (legacy shape).
1930    ehp *= buff_uptime_mult_branches(
1931        get_attr_from_attrs(attrs, "deceit") / BASE_POINT,
1932        DECEIT_DEBUFF_DURATION,
1933    );
1934
1935    (dps * ehp) / tuning().power_norm
1936}
1937
1938/// Character power over the items+pets subset (marginal gear-compare path and
1939/// arena-bot power): composed attrs → `power_from_attrs` → honest ability
1940/// multipliers ([`ability_power_mults`], DPS/EHP-split).
1941///
1942/// **Accumulation order is load-bearing** (per-key float sums): char-level
1943/// attributes, then inventory items (each item's attributes in order), then
1944/// pets (in the given slice order, each pet's stats in order).
1945pub fn character_power(
1946    config: &GameConfig,
1947    lookups: &ContentLookups,
1948    level: i64,
1949    inventory: &[Item],
1950    abilities: &EquippedAbilities,
1951    pets: &[Pet],
1952) -> Result<i64, String> {
1953    let (attrs, _total_power_bonus) = compose_character_attrs(config, level, inventory, pets)?;
1954    let attrs_power = power_from_attrs(&attrs);
1955
1956    // Honest ability multipliers: combat-membership set, DPS/EHP split (see
1957    // [`ability_power_mults`]). Pets cast nothing, so they contribute through
1958    // `attrs` only and add no ability multiplier.
1959    let (dps_mult, ehp_mult) = ability_power_mults(config, lookups, abilities, None, &attrs);
1960
1961    // BAL-030: the per-item `power_bonus` jitter is OUT of every power path —
1962    // display, matchmaking, and this gear-compare. It is a persisted-but-inert
1963    // display field; letting it into the compare would let a cosmetic roll
1964    // decide which item auto-equip keeps (matching
1965    // `behaviors::power::character_power`, which dropped it the same way).
1966    Ok((attrs_power as f64 * dps_mult * ehp_mult).floor() as i64)
1967}
1968
1969/// Combat-power scalar from an already-composed [`AttrMap`] plus equipped
1970/// abilities — `floor(power_from_attrs(attrs) × dps_mult × ehp_mult)` with the
1971/// honest ability multipliers ([`ability_power_mults`]).
1972///
1973/// Lets the display / matchmaking / gating path feed the **full** multi-source
1974/// aggregation (`attributes::calculate_player_entity_stats_with_zeroes` —
1975/// char-level, items, class, pets, talents, statue, class-levels) through the
1976/// same formula, so the power scalar reflects every combat source.
1977/// [`character_power`] keeps the items+pets subset for *marginal*
1978/// gear-compare, where the constant class/talent/statue baseline cancels in
1979/// the with-minus-without difference.
1980pub fn character_power_from_attrs(
1981    config: &GameConfig,
1982    lookups: &ContentLookups,
1983    attrs: &AttrMap,
1984    abilities: &EquippedAbilities,
1985    leader_pet_ability: Option<(Uuid, i64)>,
1986) -> i64 {
1987    character_power_from_attrs_raw(config, lookups, attrs, abilities, leader_pet_ability).floor()
1988        as i64
1989}
1990
1991/// [`character_power_from_attrs`] before the floor — the `P_static` term of
1992/// `P_ui = floor(P_static × Πq)` (BAL-030).
1993///
1994/// The dynamic multipliers are applied by the caller and the result is floored
1995/// ONCE at the very end, so a component worth a fraction of a point still moves
1996/// the number instead of being rounded away mid-formula.
1997pub fn character_power_from_attrs_raw(
1998    config: &GameConfig,
1999    lookups: &ContentLookups,
2000    attrs: &AttrMap,
2001    abilities: &EquippedAbilities,
2002    leader_pet_ability: Option<(Uuid, i64)>,
2003) -> f64 {
2004    let attrs_power = power_from_attrs_raw(attrs);
2005    // Honest ability multipliers: combat-membership set, DPS/EHP split — see
2006    // [`ability_power_mults`]. Display, matchmaking and gating all read this
2007    // path, so the scalar rates what combat actually delivers — including the
2008    // leader pet's active ability.
2009    let (dps_mult, ehp_mult) =
2010        ability_power_mults(config, lookups, abilities, leader_pet_ability, attrs);
2011    attrs_power * dps_mult * ehp_mult
2012}
2013
2014/// The `attrs_power` term of [`character_power`]: char-level attributes,
2015/// inventory items and pet stats composed into an [`AttrMap`] and run through
2016/// [`power_from_attrs`] — WITHOUT the trailing `* ability_eff_sum` multiplier.
2017///
2018/// **Accumulation order is load-bearing** (per-key float sums): char-level
2019/// attributes, then inventory items (each item's attributes in order), then
2020/// pets (in the given slice order, each pet's stats in order) — exactly the
2021/// order the original used; [`character_power`] delegates here so both paths
2022/// stay byte-identical.
2023///
2024/// For callers comparing loadouts that carry no abilities of their own (e.g.
2025/// ranking pets for fast-equip), where [`character_power`]'s ability factor
2026/// would multiply every score by 0.
2027pub fn character_attrs_power(
2028    config: &GameConfig,
2029    level: i64,
2030    inventory: &[Item],
2031    pets: &[Pet],
2032) -> Result<i64, String> {
2033    // BAL-030: `power_bonus` is display-inert and enters no power path.
2034    let (collected, _total_power_bonus) = compose_character_attrs(config, level, inventory, pets)?;
2035    Ok(power_from_attrs(&collected))
2036}
2037
2038/// [`character_attrs_power`] before the floor, for callers that RANK loadouts
2039/// rather than display a number. See [`power_from_attrs_raw`] for why the
2040/// integer is unusable as a sort key.
2041pub fn character_attrs_power_raw(
2042    config: &GameConfig,
2043    level: i64,
2044    inventory: &[Item],
2045    pets: &[Pet],
2046) -> Result<f64, String> {
2047    // BAL-030: `power_bonus` is display-inert and enters no power path.
2048    let (collected, _total_power_bonus) = compose_character_attrs(config, level, inventory, pets)?;
2049    Ok(power_from_attrs_raw(&collected))
2050}
2051
2052/// Compose the char-level + items + pets attribute map (the input to
2053/// [`power_from_attrs`]) plus the summed per-item `power_bonus` jitter.
2054/// Extracted from [`character_attrs_power`] so [`character_power`] can reuse
2055/// the composed attrs for the honest ability EHP cap (it needs `hp`/`attack`).
2056pub fn compose_character_attrs(
2057    config: &GameConfig,
2058    level: i64,
2059    inventory: &[Item],
2060    pets: &[Pet],
2061) -> Result<(AttrMap, i64), String> {
2062    let mut collected: AttrMap = AttrMap::new();
2063
2064    let Some(char_level_tpl) = config.character_level(level) else {
2065        return Err(format!(
2066            "balance::character_power: missing character_level {level}"
2067        ));
2068    };
2069
2070    for attr in &char_level_tpl.attributes {
2071        if let Some(a) = config.attribute(attr.attribute_id) {
2072            // Original: `collected.insert(code, value)` — an unconditional
2073            // overwrite (NOT accumulate). If two char-level entries share a
2074            // code, the later one wins. Preserve that exactly with `insert`.
2075            collected.insert(a.code.as_str().to_string(), attr.value as f64);
2076        }
2077    }
2078
2079    // Sum the per-item power jitter alongside attribute accumulation. Items that
2080    // were rolled before the jitter feature (or arena bots) carry power_bonus=0,
2081    // so they are unaffected by this sum.
2082    let mut total_power_bonus: i64 = 0;
2083
2084    for item in inventory {
2085        for item_attr in &item.attributes {
2086            if let Some(a) = config.attribute(item_attr.attr_id) {
2087                accumulate(&mut collected, a.code.as_str(), item_attr.value as f64);
2088            }
2089        }
2090        total_power_bonus += item.power_bonus as i64;
2091    }
2092
2093    for pet in pets {
2094        for stat in &pet.stats {
2095            if let Some(a) = config.attribute(stat.attribute_id) {
2096                accumulate(&mut collected, a.code.as_str(), stat.value as f64);
2097            }
2098        }
2099    }
2100
2101    // Deflate the composed ARMOR rating by character level, mirroring the full
2102    // aggregation path (`attributes::calculate_player_entity_stats_with_zeroes`)
2103    // so this marginal gear-compare / auto-equip scalar prices armor the same
2104    // way combat and displayed Combat-Power do. The deflator is a per-level
2105    // scalar independent of the armor amount, so applying it to the item+pet
2106    // subset here matches the full aggregate exactly on the armor axis (it
2107    // distributes over the sum, and the omitted class/talent/statue baseline
2108    // armor cancels in the with-minus-without difference). Without it the
2109    // marginal path over-values armor, increasingly with level, mis-ranking
2110    // armor items in auto-equip and gear-compare tooltips.
2111    let deflator = player_armor_rating_deflator(level);
2112    if deflator < 1.0
2113        && let Some(v) = collected.get_mut("armor")
2114    {
2115        *v *= deflator;
2116    }
2117
2118    Ok((collected, total_power_bonus))
2119}
2120
2121#[cfg(test)]
2122mod attr_spread_tests {
2123    //! Deterministic equivalence tests for the branchy `attr_spread_for_item`
2124    //! (the RNG-driven primitive behind every item-attribute calc). Driven by
2125    //! `GameRng::from_values` so they prove formula + *draw count* without
2126    //! any sim entropy. Draw count is load-bearing for determinism — the random
2127    //! seed advances per draw — so each branch asserts how many draws it consumes.
2128
2129    use super::*;
2130
2131    /// A template id that is NOT in `item_fixed_power`, so the fixed-power early
2132    /// return never fires.
2133    fn non_fixed_template() -> Uuid {
2134        Uuid::from_u128(0x0194d64e_2100_7569_91be_a3d34c967544)
2135    }
2136
2137    /// Count of `f64` draws a recording RNG sees after one `attr_spread_for_item`.
2138    fn draws_for(level: f64) -> usize {
2139        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2140        let lk = ContentLookups::default();
2141        let rng = GameRng::from_entropy_recording();
2142        let _ = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2143        rng.recorded().len()
2144    }
2145
2146    /// Level 1 rolls a WIDE spread around the weakened 0.65 mean — the old
2147    /// constant made every level-1 roll a clone of the scripted starters.
2148    #[test]
2149    fn level_one_rolls_wide_spread_around_065() {
2150        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2151        let lk = ContentLookups::default();
2152
2153        // r=0.5 lands exactly on the mean.
2154        let rng = GameRng::from_values(vec![0.5]);
2155        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
2156        assert!(
2157            (v - LEVEL_ONE_ATTR_MEAN).abs() < 1e-12,
2158            "expected the mean, got {v}"
2159        );
2160
2161        // r=0.0 / r=1.0 hit the band edges: 0.65 × (1 ∓ 0.2).
2162        let rng = GameRng::from_values(vec![0.0]);
2163        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
2164        let low = LEVEL_ONE_ATTR_MEAN * (1.0 - ATTR_DEVIATION_LEVEL_ONE);
2165        assert!((v - low).abs() < 1e-12, "expected {low}, got {v}");
2166
2167        let rng = GameRng::from_values(vec![1.0]);
2168        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
2169        let high = LEVEL_ONE_ATTR_MEAN * (1.0 + ATTR_DEVIATION_LEVEL_ONE);
2170        assert!((v - high).abs() < 1e-12, "expected {high}, got {v}");
2171
2172        assert_eq!(
2173            draws_for(1.0),
2174            1,
2175            "level==1 must consume exactly one RNG draw"
2176        );
2177    }
2178
2179    #[test]
2180    fn level_le_25_uses_one_draw_via_attr_spread_random() {
2181        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2182        let lk = ContentLookups::default();
2183        // attr_spread_random(r) = 1 + ATTR_DEVIATION*(2*r - 1). r=0.5 -> 1.0.
2184        let rng = GameRng::from_values(vec![0.5]);
2185        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 25.0);
2186        assert!((v - 1.0).abs() < 1e-12, "expected 1.0, got {v}");
2187        // r=0.0 -> 1 - ATTR_DEVIATION; r=1.0 -> 1 + ATTR_DEVIATION.
2188        let rng = GameRng::from_values(vec![0.0]);
2189        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 10.0);
2190        assert!((v - (1.0 - ATTR_DEVIATION)).abs() < 1e-12);
2191        assert_eq!(
2192            draws_for(25.0),
2193            1,
2194            "level<=25 must consume exactly one draw"
2195        );
2196    }
2197
2198    /// A2-BAL-003 §3.3: there is no longer a second regime above item L25.
2199    ///
2200    /// These replace `level_gt_25_uses_two_draws_{lower,upper}_branch`, which
2201    /// pinned the skewed fake-level roll. That roll changed the distribution's
2202    /// SHAPE partway up the ladder — the same item rolled a different spread
2203    /// depending on how deep it dropped, which the signed `0.9..1.1` band cannot
2204    /// describe. Draw count is asserted too: it dropped from two to one, and the
2205    /// session RNG advances per draw, so a change there shifts every later roll.
2206    #[test]
2207    fn every_level_above_one_uses_the_same_uniform_spread() {
2208        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2209        let lk = ContentLookups::default();
2210
2211        for level in [2.0, 25.0, 26.0, 100.0, 400.0] {
2212            let rng = GameRng::from_values(vec![0.5]);
2213            let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2214            assert!(
2215                (v - 1.0).abs() < 1e-12,
2216                "level {level}: r=0.5 must land on 1.0, got {v}"
2217            );
2218
2219            let rng = GameRng::from_values(vec![0.0]);
2220            let lo = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2221            assert!(
2222                (lo - (1.0 - ATTR_DEVIATION)).abs() < 1e-12,
2223                "level {level}: low edge"
2224            );
2225
2226            let rng = GameRng::from_values(vec![1.0]);
2227            let hi = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2228            assert!(
2229                (hi - (1.0 + ATTR_DEVIATION)).abs() < 1e-12,
2230                "level {level}: high edge"
2231            );
2232
2233            assert_eq!(
2234                draws_for(level),
2235                1,
2236                "level {level} must consume exactly one draw — the old deep-level \
2237                 branch consumed two"
2238            );
2239        }
2240    }
2241
2242    #[test]
2243    fn fixed_power_short_circuits_and_draws_nothing() {
2244        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2245        let mut lk = ContentLookups::default();
2246        let tpl = non_fixed_template();
2247        lk.item_fixed_power.insert(tpl, 1.23);
2248        let rng = GameRng::from_entropy_recording();
2249        let v = attr_spread_for_item(&cfg, &lk, &rng, tpl, 100.0);
2250        assert_eq!(v, 1.23);
2251        assert_eq!(
2252            rng.recorded().len(),
2253            0,
2254            "fixed-power path must not draw RNG"
2255        );
2256    }
2257}
2258
2259#[cfg(test)]
2260mod power_formula_tests {
2261    //! Pins `power_from_attrs` to the **v2 P = DPS × EHP** formula with DR
2262    //! curves, so a regression to the legacy opaque `S²` formula FAILS here
2263    //! (the two give different numbers for the same loadout). This is the proof
2264    //! that the new combat-power formula is the one actually running.
2265    use super::*;
2266
2267    #[test]
2268    fn power_is_dps_times_ehp_with_dr() {
2269        let mut a = AttrMap::new();
2270        a.insert("hp".into(), 3000.0);
2271        a.insert("attack".into(), 600.0);
2272        a.insert("speed".into(), 10000.0); // attack rate 1.0
2273        a.insert("armor".into(), 2000.0); // armor_k = K/(armor+K) = 2000/4000 = 0.5
2274        a.insert("evasion".into(), 1500.0); // dodge = 1500/(1500+6000) = 0.2
2275        // crit / multicast / bravery / block / regen / deceit / counterattack absent → 0.
2276        //
2277        // DPS = attack(600) · rate(1.0) · crit(1) · multicast(1) · bravery(1) · counter(1) = 600
2278        // EHP = hp(3000) ÷ armor_k(0.5) ÷ (1 − dodge 0.2) = 3000 / 0.5 / 0.8 = 7500
2279        // power = floor(DPS·EHP / POWER_NORM) = floor(600·7500 / 1800) = 2500
2280        // (the legacy S² formula gives 1470 for this loadout — the assertion
2281        // proves the DPS×EHP path is the one running.)
2282        assert_eq!(power_from_attrs(&a), 2500);
2283    }
2284
2285    #[test]
2286    fn armor_k_is_dr_not_linear() {
2287        // DR curve: always in (0,1], never negative — the legacy `1 - armor/10000`
2288        // hit 0 at armor=10000 and went negative beyond (the shipped bug).
2289        assert!((armor_k(0.0) - 1.0).abs() < 1e-9);
2290        assert!((armor_k(K_ARMOR) - 0.5).abs() < 1e-9); // rating == K → 50% through
2291        assert!(armor_k(50_000.0) > 0.0); // huge armor: still positive (no heal-the-target bug)
2292        assert!(armor_k(50_000.0) < 0.05);
2293    }
2294
2295    #[test]
2296    fn probability_stats_clamp_at_100pct() {
2297        // crit / multicast / counterattack chances over the basis-point cap
2298        // (e.g. an over-generous class-level grant) must clamp to 100% — power
2299        // can't keep scaling past a certain chance. Guards the `.clamp(0,1)`s.
2300        let base = || {
2301            let mut a = AttrMap::new();
2302            a.insert("hp".into(), 3000.0);
2303            a.insert("attack".into(), 600.0);
2304            a.insert("speed".into(), 10000.0);
2305            a
2306        };
2307        let with = |stat: &str, v: f64| {
2308            let mut a = base();
2309            a.insert(stat.into(), v);
2310            power_from_attrs(&a)
2311        };
2312        // crit's clamp point sits at raw 10000/crit_chance_scale (the scalar
2313        // prices crit at the REALIZED proc rate, so a sub-1 scale moves the
2314        // clamp point up; both raw values below are past it either way).
2315        assert_eq!(
2316            with("crit_chance", 20_000.0),
2317            with("crit_chance", 5_000_000.0),
2318            "crit_chance must clamp at 100% effective (no power growth past the cap)"
2319        );
2320        for stat in ["multicast_chance", "counterattack_chance"] {
2321            assert_eq!(
2322                with(stat, 10000.0),
2323                with(stat, 5_000_000.0),
2324                "{stat} must clamp at 100% (no power growth past the cap)"
2325            );
2326        }
2327    }
2328
2329    #[test]
2330    fn scalar_matches_combat_on_received_damage_and_speed_baseline() {
2331        // received_damage: combat applies it; the scalar must too. A −5000 mod (10000−5000 =
2332        // 5000 = 50% damage taken) ⇒ ×2 EHP ⇒ double power; neutral (absent) ⇒ ×1 (no change).
2333        let mut neutral = AttrMap::new();
2334        neutral.insert("hp".into(), 3000.0);
2335        neutral.insert("attack".into(), 600.0);
2336        neutral.insert("speed".into(), 10000.0);
2337        assert_eq!(
2338            power_from_attrs(&neutral),
2339            1000,
2340            "neutral baseline = DPS·EHP/NORM"
2341        );
2342        let mut reduced = neutral.clone();
2343        reduced.insert("received_damage".into(), -5000.0);
2344        assert_eq!(
2345            power_from_attrs(&reduced),
2346            2000,
2347            "50% received_damage must double EHP/power (scalar matches combat)"
2348        );
2349
2350        // speed ≤ 0 falls back to the baseline cast rate (like combat's `speed_or_baseline`),
2351        // NOT 0 DPS — a build with no speed stat must still have positive power.
2352        let mut no_speed = AttrMap::new();
2353        no_speed.insert("hp".into(), 3000.0);
2354        no_speed.insert("attack".into(), 600.0); // no "speed" key ⇒ composed speed 0
2355        assert_eq!(
2356            power_from_attrs(&no_speed),
2357            1000,
2358            "speed≤0 must use baseline rate (1.0), not 0 DPS"
2359        );
2360    }
2361}
2362
2363#[cfg(test)]
2364mod stat_equivalence_audit {
2365    //! SimC-style equivalence-points audit: the marginal HONEST-POWER value of
2366    //! one ITEM ROLL of each optional stat must stay within a band — no dead
2367    //! stats, no dominant stat. Per-roll magnitudes mirror
2368    //! `behaviors/items.rs` (aux stats all ride
2369    //! `(eff·spread)^AUX_ATTR_IMPACT`, so they scale together by
2370    //! construction; this test pins that equivalence against regressions in
2371    //! either the item formulas or the scalar pricing). Marginals are
2372    //! evaluated at a smooth mid loadout, away from clamps.
2373    use super::*;
2374
2375    fn mid_loadout() -> AttrMap {
2376        let mut a = AttrMap::new();
2377        a.insert("hp".into(), 3000.0);
2378        a.insert("attack".into(), 600.0);
2379        a.insert("speed".into(), 10000.0);
2380        a.insert("armor".into(), 1000.0);
2381        a.insert("crit_chance".into(), 1000.0);
2382        a
2383    }
2384
2385    fn marginal(stat: &str, roll: f64) -> f64 {
2386        let base = power_from_attrs(&mid_loadout()) as f64;
2387        let mut b = mid_loadout();
2388        *b.entry(stat.to_string()).or_insert(0.0) += roll;
2389        (power_from_attrs(&b) as f64 - base) / base
2390    }
2391
2392    #[test]
2393    fn optional_item_roll_values_stay_in_band() {
2394        // Per-roll permyriad at eff=5 (aux = 5^0.05 ≈ 1.0838), mirroring
2395        // items.rs attr fns (spread = 1, /10 item scale included):
2396        let aux: f64 = 5.0_f64.powf(AUX_ATTR_IMPACT);
2397        let a2 = aux * aux;
2398        let rolls: &[(&str, f64)] = &[
2399            (
2400                "crit_chance",
2401                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 4.0) * 1000.0,
2402            ),
2403            (
2404                "crit_modifier",
2405                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 2.0) * 1000.0,
2406            ),
2407            ("evasion", (1.0 - 1.0 / aux) * 1000.0),
2408            ("speed", (aux - 1.0) * 1000.0),
2409            (
2410                "multicast_chance",
2411                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 4.0) * 1000.0,
2412            ),
2413            (
2414                "counterattack_chance",
2415                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 4.0) * 1000.0,
2416            ),
2417            ("block", ((aux - 1.0) * 1000.0).min(200.0)),
2418        ];
2419        let mut vals = vec![];
2420        for (stat, roll) in rolls {
2421            let m = marginal(stat, *roll);
2422            println!("EP {stat:22} roll {roll:7.1} -> dP/P {:+.4}", m);
2423            assert!(m > 0.0, "{stat}: dead stat — a roll must buy SOME power");
2424            vals.push((stat, m));
2425        }
2426        let mut sorted: Vec<f64> = vals.iter().map(|(_, m)| *m).collect();
2427        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2428        let median = sorted[sorted.len() / 2];
2429        for (stat, m) in &vals {
2430            assert!(
2431                *m >= median / 5.0 && *m <= median * 5.0,
2432                "{stat}: per-roll value {m:.4} outside x5 band of median {median:.4}"
2433            );
2434        }
2435    }
2436}
2437
2438#[cfg(test)]
2439mod v2_helper_tests {
2440    //! Pins the v2 sim-tunable helpers: enemy-curve anchor+monotonicity (the
2441    //! progression gate), the sub-linear gold faucet (the cost-curve bind), and
2442    //! the per-tick heal/damage cap (anti out-heal / one-shot).
2443    use super::*;
2444
2445    #[test]
2446    fn enemy_curve_anchored_and_monotonic() {
2447        // The curve's PRODUCTION domain is ch ≥ HAND_AUTHORED_CHAPTER_MAX+1:
2448        // `enemy_power_scalar` uses the hand-authored YAML `base_power` for
2449        // ch0-9, so `enemy_power_for_chapter` is only ever spawn-consulted for
2450        // ch ≥ 10. Strictly increasing THERE — content must gate progression.
2451        // (stage2-bridge CYCLE 3: the ch10 entry was lowered to E(10)≈416, below
2452        // the phantom ch7-9 curve values (E(7)=686), so monotonicity is required
2453        // only in the production domain ch≥10, not from the legacy anchor ch7.)
2454        // BAL-037: the signed curve owns ch0…509, so monotonicity is required
2455        // from the very first chapter — and it is NON-DECREASING, because the
2456        // contract's single final `floor` may legitimately produce equal
2457        // adjacent integers. A strict-rise assertion here would push an
2458        // implementer toward the forbidden artificial `+1`.
2459        let mut prev = enemy_power_for_chapter(0);
2460        assert!(prev > 0.0);
2461        for ch in 1..=70 {
2462            let p = enemy_power_for_chapter(ch);
2463            assert!(
2464                p >= prev,
2465                "enemy power must not decrease: ch{ch} → {p} < {prev}"
2466            );
2467            prev = p;
2468        }
2469        // The ch9→10 seam is continuous by construction: E_base(10) ≈ E_base(9) ×
2470        // 1.03. This is the accidental ×9 jump the signed curve exists to remove,
2471        // so it is worth pinning rather than trusting the knots by eye.
2472        let seam = e_base(10) / e_base(9);
2473        assert!(
2474            (seam - 1.03).abs() < 0.02,
2475            "ch9→10 must stay continuous: seam ratio {seam} (legacy curve jumped ~×9)"
2476        );
2477    }
2478
2479    #[test]
2480    fn dungeon_uses_authored_base_power_not_chapter_curve() {
2481        // A dungeon fight is tagged CampaignBossFight but must use its authored
2482        // per-difficulty `base_power` (the difficulty ladder), NOT the campaign
2483        // chapter curve — otherwise every difficulty collapses to one power keyed
2484        // to the player's campaign chapter ("losing to the dungeon at unlock").
2485        let ch = 20;
2486        // Campaign boss at ch20 ignores base_power and rides the (large) curve.
2487        let campaign = enemy_power_scalar(1500.0, ch, "CampaignBossFight", false);
2488        assert!(
2489            campaign > 10_000.0,
2490            "campaign boss must use the chapter curve at ch20, got {campaign}"
2491        );
2492        // Same template AS A DUNGEON returns the authored base_power verbatim —
2493        // difficulty 1 (1500) is a trivial first clear vs a ch20 player (~20k).
2494        assert_eq!(
2495            enemy_power_scalar(1500.0, ch, "CampaignBossFight", true),
2496            1500.0
2497        );
2498        // The ladder is honored across difficulties and is chapter-independent:
2499        // diff 5 (50k) and diff 10 (8.5M) pass through unchanged at any chapter.
2500        assert_eq!(
2501            enemy_power_scalar(50_000.0, ch, "CampaignBossFight", true),
2502            50_000.0
2503        );
2504        assert_eq!(
2505            enemy_power_scalar(8_500_000.0, 80, "CampaignBossFight", true),
2506            8_500_000.0
2507        );
2508    }
2509
2510    #[test]
2511    fn whole_first_stage_rides_the_signed_ftue_curve() {
2512        // BAL-037 refits the whole FTUE block as one progression sequence: stage 1
2513        // no longer reads the authored `FightTemplate.power`, and its current
2514        // absolute rows `1…46` are explicitly NOT preserved. Waves are
2515        // `S_ref × (0.45 → 0.65)` and every boss — FTUE included — carries the one
2516        // total encounter coefficient `sqrt(1.30)`.
2517        for ch in 0..=9 {
2518            let wave = enemy_power_scalar(1234.0, ch, "CampaignFight", false);
2519            assert_ne!(wave, 1234.0, "stage-1 wave at {ch} must ignore base_power");
2520            assert_eq!(wave, enemy_wave_power(ch));
2521            assert_eq!(
2522                enemy_power_scalar(1234.0, ch, "CampaignBossFight", false),
2523                enemy_boss_power(ch),
2524                "stage-1 boss at {ch} takes sqrt(1.30), no separate mid-boss gate"
2525            );
2526        }
2527        // The signed endpoints of the FTUE ramp: 0.45 of S_ref at ch0, 0.65 at ch9.
2528        assert_eq!(enemy_wave_power(0), (30.0f64 * 0.45).floor());
2529        assert_eq!(enemy_wave_power(9), (4_100.0f64 * 0.65).floor());
2530        // ch10 (stage 2-1) and beyond ride the CURVE, not base_power. Post
2531        // stage2-bridge the ch10 boss carries NO mid-gate (the gate moved to the
2532        // ch12-14 window) — only the ×STEP^0.5 boss bump — so it equals
2533        // enemy_power_for_chapter(10)×STEP^0.5. We assert the curve regime is
2534        // engaged (result diverges from the passed base_power) and that the ch10
2535        // boss is the clean (un-gated) boss value.
2536        let boss10 = enemy_power_scalar(1234.0, 10, "CampaignBossFight", false);
2537        let expect10 = enemy_power_for_chapter(10) * CHAPTER_POWER_STEP.powf(0.5);
2538        assert!(
2539            (boss10 - expect10).abs() < 1.0 && (boss10 - 1234.0).abs() > 1.0,
2540            "ch10 boss must ride the curve with no mid-gate, got {boss10} vs {expect10}"
2541        );
2542        // The mid-boss gate now fires on the ch12-14 window, NOT the boundary:
2543        // ch10/ch11 bosses are un-gated, ch12 carries the peak bump.
2544        assert_eq!(
2545            enemy_mid_chapter_mult(10),
2546            1.0,
2547            "ch10 boss must be un-gated"
2548        );
2549        assert_eq!(
2550            enemy_mid_chapter_mult(11),
2551            1.0,
2552            "ch11 boss must be un-gated"
2553        );
2554        assert!(
2555            enemy_mid_chapter_mult(12) > 1.0,
2556            "ch12 boss must carry the mid-gate gear-check"
2557        );
2558        assert!(
2559            (enemy_power_scalar(1234.0, 10, "CampaignFight", false) - 1234.0).abs() > 1.0,
2560            "ch10 wave must ride the curve, not base_power"
2561        );
2562    }
2563
2564    #[test]
2565    fn sell_price_is_sublinear() {
2566        // Sub-linear (exp < 1): doubling `eff` less-than-doubles price, so the
2567        // geometric chest-upgrade sink can outgrow the gold faucet and bind
2568        // progression. The ratio check below fails if exp ever reaches ≥ 1.
2569        let p1 = sell_price(1000.0) as f64;
2570        let p2 = sell_price(2000.0) as f64;
2571        assert!(p2 > p1, "price must increase with eff");
2572        assert!(
2573            p2 < 2.0 * p1,
2574            "sub-linear: doubling eff must less-than-double price ({p1} → {p2})"
2575        );
2576        assert_eq!(sell_price(-5.0), 0, "negative eff floored to 0");
2577    }
2578
2579    #[test]
2580    fn sell_price_with_config_precedence() {
2581        // Per-knob precedence resolver: env (sim sweep) wins over the GameConfig
2582        // value, which wins over the compiled constant. Tested on the pure
2583        // resolver so no process-global env is mutated — race-free under
2584        // threaded `cargo test`.
2585        assert_eq!(
2586            resolve_sell_knob(Some(0.9), Some(0.6), SELL_PRICE_EXP),
2587            0.9,
2588            "env wins over config"
2589        );
2590        assert_eq!(
2591            resolve_sell_knob(None, Some(0.6), SELL_PRICE_EXP),
2592            0.6,
2593            "config wins over the constant when env is unset"
2594        );
2595        assert_eq!(
2596            resolve_sell_knob(None, None, SELL_PRICE_EXP),
2597            SELL_PRICE_EXP,
2598            "constant default when neither env nor config is set"
2599        );
2600
2601        // The full price fn reads `OVERLORD_BAL_SELL_*` live, so pin the
2602        // config/default cases only when those vars are absent (the standard
2603        // test env); an env sweep is covered by the resolver assertions above.
2604        let env_clean = std::env::var("OVERLORD_BAL_SELL_EXP").is_err()
2605            && std::env::var("OVERLORD_BAL_SELL_COEF").is_err();
2606        if env_clean {
2607            for &eff in &[0.0_f64, 1.0, 250.0, 1000.0, 9999.0] {
2608                let baseline = sell_price(eff);
2609                // Config absent ⇒ compiled constants ⇒ bit-identical to the
2610                // pre-config `sell_price` path.
2611                assert_eq!(
2612                    sell_price_with_config(eff, None, None),
2613                    baseline,
2614                    "config absent ⇒ constant default (bit-identical to sell_price)"
2615                );
2616                // Deploying the config with the current constant values is a
2617                // no-op on price.
2618                assert_eq!(
2619                    sell_price_with_config(eff, Some(SELL_PRICE_EXP), Some(SELL_PRICE_COEF)),
2620                    baseline,
2621                    "config = current constants ⇒ unchanged price"
2622                );
2623            }
2624            // A config override with different knobs is honored (env unset).
2625            let eff = 1000.0_f64;
2626            assert_eq!(
2627                sell_price_with_config(eff, Some(0.60), Some(50.0)),
2628                (eff.powf(0.60) * 50.0).floor() as i64,
2629                "config knobs used when present and env unset"
2630            );
2631        }
2632    }
2633
2634    #[test]
2635    fn eff_by_level_branch_join_is_continuous() {
2636        // The two eff branches must join at L=130 in value AND slope — the
2637        // A2/B2/C2 late-branch consts are a re-fit whenever B1/B2 move (see the
2638        // closed form in the const block). Guards against a future sweep
2639        // baking a discontinuous pair (a value step here is a hidden gear
2640        // cliff at item level 130).
2641        let left = eff_by_level(EFF_MIDGAME_LEVEL);
2642        let right = EFF_A2 * (EFF_MIDGAME_LEVEL + 1e-9).powf(EFF_B2) + EFF_C2;
2643        assert!(
2644            ((right - left) / left).abs() < 1e-3,
2645            "eff branch value step at L130: left {left}, right {right}"
2646        );
2647        let slope_left = EFF_A1 * EFF_B1 * (EFF_MIDGAME_LEVEL - 1.0).powf(EFF_B1 - 1.0);
2648        let slope_right = EFF_A2 * EFF_B2 * EFF_MIDGAME_LEVEL.powf(EFF_B2 - 1.0);
2649        assert!(
2650            ((slope_right - slope_left) / slope_left).abs() < 1e-2,
2651            "eff branch slope step at L130: left {slope_left}, right {slope_right}"
2652        );
2653    }
2654
2655    #[test]
2656    fn smooth_curve_has_one_universal_tail_and_no_authored_walls() {
2657        assert_eq!(e_base(45), 302_619.0);
2658        for ch in 46..=509 {
2659            let ratio = e_base(ch) / e_base(ch - 1);
2660            assert!(
2661                (ratio - E_BASE_TAIL_STEP).abs() < 1e-12,
2662                "ch{ch}: universal tail ratio {ratio} vs {E_BASE_TAIL_STEP}"
2663            );
2664        }
2665
2666        // Wave and boss curves read the same smooth base. The boss coefficient
2667        // is global, not a chapter spike, and final integer flooring is the only
2668        // permitted divergence from the analytic values.
2669        for ch in [10, 21, 25, 31, 35, 45, 65, 95, 165, 300, 509] {
2670            assert_eq!(enemy_wave_power(ch), e_base(ch).floor());
2671            let expected_boss = (e_base(ch) * BOSS_ENCOUNTER_COEF_SQ.sqrt()).floor();
2672            assert_eq!(enemy_boss_power(ch), expected_boss);
2673        }
2674
2675        let chapters_per_twenty_percent = 1.20f64.ln() / E_BASE_TAIL_STEP.ln();
2676        assert!(
2677            (2.0..=2.7).contains(&chapters_per_twenty_percent),
2678            "+20% strength should open several chapters, got {chapters_per_twenty_percent}"
2679        );
2680        let chapters_per_fifty_percent = 1.50f64.ln() / E_BASE_TAIL_STEP.ln();
2681        assert!(
2682            (5.0..=5.7).contains(&chapters_per_fifty_percent),
2683            "+50% strength should open a short chapter streak, got {chapters_per_fifty_percent}"
2684        );
2685    }
2686
2687    #[test]
2688    fn interp_geometric_knots_between_and_extrapolation() {
2689        let knots: &[(i64, f64)] = &[(10, 100.0), (20, 400.0), (30, 800.0)];
2690        // Exact at knots.
2691        assert_eq!(interp_geometric(knots, 10), 100.0);
2692        assert_eq!(interp_geometric(knots, 20), 400.0);
2693        // Constant before the first knot.
2694        assert_eq!(interp_geometric(knots, 0), 100.0);
2695        // Geometric midpoint between knots: sqrt(100·400) = 200.
2696        assert!((interp_geometric(knots, 15) - 200.0).abs() < 1e-9);
2697        // Past the end: final segment ratio (×2 per 10ch ⇒ ×2^(1/10) per ch).
2698        let per_ch = (800.0f64 / 400.0).powf(0.1);
2699        assert!((interp_geometric(knots, 35) - 800.0 * per_ch.powi(5)).abs() < 1e-6);
2700        // Degenerate shapes.
2701        assert_eq!(interp_geometric(&[], 5), 0.0);
2702        assert_eq!(interp_geometric(&[(1, 42.0)], 99), 42.0);
2703    }
2704
2705    #[test]
2706    fn ability_milestones_pin() {
2707        // L5 ×1.10 and L10 ×1.20 (cumulative) on top of the +5%/level line —
2708        // the proximate-milestone shape of the ability upgrade track.
2709        // Uses the default lookups (unknown rarity ⇒ rarity_eff 1.0).
2710        let lk = ContentLookups::default();
2711        let r = Uuid::nil();
2712        assert!((ability_eff(&lk, r, 1) - 1.0).abs() < 1e-12);
2713        assert!((ability_eff(&lk, r, 4) - 1.15).abs() < 1e-12);
2714        assert!((ability_eff(&lk, r, 5) - 1.20 * 1.10).abs() < 1e-12);
2715        assert!((ability_eff(&lk, r, 10) - 1.45 * 1.10 * 1.20).abs() < 1e-12);
2716    }
2717
2718    #[test]
2719    fn zone_correction_preserves_enemy_curve() {
2720        // The enemy curve is expressed in zone-corrected units ρ̂ = P/(z·E);
2721        // this test pins E = G/(ρ̂·z) at reference chapters so nobody edits ρ̂
2722        // or z WITHOUT re-deriving the pair (and G) together. Re-bake the pins
2723        // only as part of a legitimate re-derivation of all three tables.
2724        // stage2-bridge CYCLE 3: the WHOLE curve ch10+ re-derived from the
2725        // required chest-open cadence (designer-authorized full retirement of
2726        // the old funded curve). Every pin ch15+ re-baked. ch1/7 unchanged (E(7)
2727        // legacy anchor pin; ch0-9 hand-authored, phantom curve). ch80+ are in
2728        // the model-EXTRAPOLATED 12/min tail (unreachable in-test).
2729        let pins: &[(i64, f64)] = &[
2730            (1, 686.0),
2731            (7, 686.0),
2732            (15, 14979.0),
2733            (23, 1079676.0),
2734            (29, 6511267.0),
2735            (39, 17313684.0),
2736            (49, 35372775.0),
2737            (60, 67137418.0),
2738            (64, 77618278.0),
2739            (80, 114351114.0),
2740            (95, 199038697.0),
2741            (110, 409498525.0),
2742            (120, 662398186.0),
2743        ];
2744        for &(ch, expected) in pins {
2745            let e = enemy_power_derived(ch).unwrap();
2746            assert!(
2747                (e - expected).abs() / expected < 3e-4,
2748                "E({ch}) drifted: {e} vs ratified {expected}"
2749            );
2750        }
2751        // The corrected units themselves: uniform crossing ≈0.55 means the
2752        // on-schedule pressure ρ̂ stays within the sane band everywhere.
2753        for ch in 7..=120 {
2754            let rho_hat = interp_geometric(RHO_TARGET_KNOTS, ch);
2755            assert!(rho_hat > 0.2 && rho_hat < 20.0, "ρ̂({ch}) = {rho_hat}");
2756        }
2757    }
2758
2759    #[test]
2760    // Deliberate emptiness guard against the CURRENT const — see `zone_difficulty`.
2761    #[allow(clippy::const_is_empty)]
2762    fn derived_curve_unavailable_until_tables_baked() {
2763        // Safety: with empty calibration tables the derived mode must report
2764        // None so enemy_power_for_chapter falls back to legacy.
2765        if PLAYER_GROWTH_KNOTS.is_empty() || RHO_TARGET_KNOTS.is_empty() {
2766            assert_eq!(enemy_power_derived(40), None);
2767        } else {
2768            assert!(enemy_power_derived(40).unwrap() > 0.0);
2769        }
2770    }
2771
2772    #[test]
2773    fn wave_skew_defaults_pin_the_stat_check_package() {
2774        // The sub-1 magnitude lengthens fights (more RNG samples per outcome
2775        // = steeper sigmoid). DAMAGE below HP is deliberate (break-even
2776        // r ≈ 0.83): it compensates the slot-model fight shapes (streamed
2777        // waves, boss summons) whose damage pattern is less survivable than
2778        // the legacy decaying waves at the same budget — validated vs `main`
2779        // by matched n=6 7-day sims (2026-07). Changing either value moves
2780        // the game-wide difficulty; re-run the sim comparison first.
2781        assert_eq!(BALANCE_TUNING_DEFAULT.wave_damage_skew, 0.75);
2782        assert_eq!(BALANCE_TUNING_DEFAULT.wave_hp_skew, 0.9);
2783    }
2784
2785    #[test]
2786    fn cap_per_tick_binds_and_passes_through() {
2787        // Over-cap clamps to pct×max_hp; under-cap passes through unchanged.
2788        assert_eq!(cap_per_tick(1_000_000.0, 1000.0, 0.02), 20.0);
2789        assert_eq!(cap_per_tick(5.0, 1000.0, 0.02), 5.0);
2790        assert_eq!(cap_per_tick(0.0, 1000.0, 0.5), 0.0);
2791    }
2792}
2793
2794#[cfg(test)]
2795mod honest_ability_power_tests {
2796    //! The honest scalar's DPS/EHP-split ability contributions (see
2797    //! `ability_power_mults`). A regression back to a flat `Σ ability_eff`
2798    //! multiplier FAILS here.
2799    use super::*;
2800    use essences::abilities::EquippedAbilities;
2801
2802    #[test]
2803    fn profile_split_damage_vs_heal() {
2804        // Pure damage budget: everything lands on the DPS axis.
2805        let p = ability_profile_from_info(Some(10.0), None, None, None, None, 2.0);
2806        assert!((p.dps_add - 5.0).abs() < 1e-9); // 10 budget / 2s cd
2807        assert_eq!(p.heal_add, 0.0);
2808
2809        // Pure HoT budget: everything lands on the heal axis.
2810        let p = ability_profile_from_info(None, None, Some(10.0), None, None, 2.0);
2811        assert_eq!(p.dps_add, 0.0);
2812        assert!((p.heal_add - 5.0).abs() < 1e-9);
2813
2814        // Lifesteal: damage throughput + vampiric fraction of it as heal.
2815        let p = ability_profile_from_info(Some(10.0), None, None, Some(0.3), None, 2.0);
2816        assert!((p.dps_add - 5.0).abs() < 1e-9);
2817        assert!((p.heal_add - 1.5).abs() < 1e-9); // 0.3 × 10 / 2s
2818
2819        // DoT counts as damage throughput (its OT premium is already priced
2820        // into the budget split by the content closures).
2821        let p = ability_profile_from_info(Some(4.0), Some(6.0), None, None, None, 2.0);
2822        assert!((p.dps_add - 5.0).abs() < 1e-9);
2823
2824        // Multi-projectile: the info split reports per-projectile damage;
2825        // combat fires them all — throughput carries the full budget.
2826        let p = ability_profile_from_info(Some(4.0), None, None, None, Some(3), 2.0);
2827        assert!((p.dps_add - 6.0).abs() < 1e-9); // 4×3 / 2s
2828    }
2829
2830    fn test_ability(level: i64) -> Ability {
2831        // A template that EXISTS in the test config but has no bespoke
2832        // `ability_info` closure → the conservative pure-damage fallback
2833        // (eff × FD/(FD+cd)) applies. cd = 10000ms in the test config.
2834        Ability {
2835            template_id: Uuid::parse_str("da6c582b-7364-40bd-9b2d-946d8e20eaac").unwrap(),
2836            level,
2837            shards_amount: 0,
2838        }
2839    }
2840
2841    fn base_attrs() -> AttrMap {
2842        let mut a = AttrMap::new();
2843        a.insert("hp".into(), 3000.0);
2844        a.insert("attack".into(), 600.0);
2845        a.insert("speed".into(), 10000.0);
2846        a
2847    }
2848
2849    #[test]
2850    fn membership_matches_combat_unslotted_count_dead_do_not() {
2851        // Combat membership (`make_active_abilities_from_equipped`) sends BOTH
2852        // slotted and unslotted abilities into the fight — so unslotted (the
2853        // class kit) must add power. Abilities with an empty `start_behavior`
2854        // never begin a cast cycle — so they must add NOTHING.
2855        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2856        let lk = ContentLookups::default();
2857        let attrs = base_attrs();
2858
2859        let mut slotted_only = EquippedAbilities::new();
2860        slotted_only.slotted.insert(1, test_ability(1));
2861        let p_slotted = character_power_from_attrs(&cfg, &lk, &attrs, &slotted_only, None);
2862        assert!(p_slotted > 0);
2863
2864        // Unslotted castable ability adds throughput exactly like a slotted one.
2865        let mut with_kit = EquippedAbilities::new();
2866        with_kit.slotted.insert(1, test_ability(1));
2867        with_kit.unslotted.push(test_ability(1));
2868        let p_kit = character_power_from_attrs(&cfg, &lk, &attrs, &with_kit, None);
2869        assert!(
2870            (p_kit - p_slotted * 2).abs() <= 1, // floor rounding
2871            "an unslotted castable ability fights (combat membership) — it must add \
2872             power: got {p_kit}, expected ≈{}",
2873            p_slotted * 2
2874        );
2875
2876        // Dead ability (empty start_behavior) never casts — no power.
2877        let dead = Ability {
2878            template_id: Uuid::parse_str("00000000-dead-7000-8000-000000000001").unwrap(),
2879            level: 1,
2880            shards_amount: 0,
2881        };
2882        let mut with_dead = EquippedAbilities::new();
2883        with_dead.slotted.insert(1, test_ability(1));
2884        with_dead.unslotted.push(dead);
2885        let p_dead = character_power_from_attrs(&cfg, &lk, &attrs, &with_dead, None);
2886        assert_eq!(
2887            p_dead, p_slotted,
2888            "a dead (empty start_behavior) ability never casts — it must not add power"
2889        );
2890    }
2891
2892    #[test]
2893    fn fallback_throughput_carries_cooldown_factor() {
2894        // cd = 10s in the test config → k = FD/(FD+10) ≈ 0.4545. The legacy
2895        // formula would have used eff×1.0; the honest one must be scaled by k.
2896        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2897        let lk = ContentLookups::default();
2898        let mut eq = EquippedAbilities::new();
2899        eq.slotted.insert(1, test_ability(1));
2900        let (dps_mult, ehp_mult) = ability_power_mults(&cfg, &lk, &eq, None, &base_attrs());
2901        let k = FIGHT_DURATION / (FIGHT_DURATION + 10.0);
2902        // eff for the test rarity id is unknown to lookups → 1.0; level 1 → ×1.
2903        assert!(
2904            (dps_mult - k).abs() < 1e-9,
2905            "expected eff×k = {k}, got {dps_mult}"
2906        );
2907        assert_eq!(ehp_mult, 1.0, "pure damage ability must not credit EHP");
2908    }
2909
2910    #[test]
2911    fn no_slotted_abilities_means_zero_power() {
2912        // Legacy semantic preserved: a character that casts nothing deals
2913        // nothing — power 0 (see project_native_power_default).
2914        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2915        let lk = ContentLookups::default();
2916        let eq = EquippedAbilities::new();
2917        assert_eq!(
2918            character_power_from_attrs(&cfg, &lk, &base_attrs(), &eq, None),
2919            0
2920        );
2921    }
2922
2923    #[test]
2924    fn leader_pet_active_ability_adds_power() {
2925        // `create_player_entity` pushes the leader pet's active ability into
2926        // the fight set at the pet's level — the scalar must count it, priced
2927        // at the charge-fill rate (per-cast budget / PET_ULT_EFFECTIVE_CD),
2928        // not at the template cooldown.
2929        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2930        let lk = ContentLookups::default();
2931        let mut eq = EquippedAbilities::new();
2932        eq.slotted.insert(1, test_ability(1));
2933        let pet_ability_id = Uuid::parse_str("da6c582b-7364-40bd-9b2d-946d8e20eaac").unwrap();
2934        let (without, _) = ability_power_mults(&cfg, &lk, &eq, None, &base_attrs());
2935        let (with, _) =
2936            ability_power_mults(&cfg, &lk, &eq, Some((pet_ability_id, 1)), &base_attrs());
2937        assert!(
2938            with > without,
2939            "leader pet's castable active ability must add DPS throughput"
2940        );
2941        // Same template at the same level: slotted contributes eff·k, the pet
2942        // contributes the per-cast budget eff·k·cd spread over the fill time.
2943        let cd = (cfg.ability_template(pet_ability_id).unwrap().cooldown as f64 / 1000.0).max(0.1);
2944        let expected = without * (1.0 + cd / PET_ULT_EFFECTIVE_CD);
2945        assert!(
2946            (with - expected).abs() < 1e-9,
2947            "pet ult must be priced at the charge-fill rate: {with} vs {expected}"
2948        );
2949    }
2950
2951    #[test]
2952    fn power_bonus_is_inert_in_the_gear_compare() {
2953        // BAL-030: the per-item jitter is a persisted DISPLAY field and enters
2954        // no power path — display, matchmaking, or this gear-compare. A
2955        // cosmetic roll must not decide which item auto-equip keeps.
2956        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2957        let lk = ContentLookups::default();
2958        let no_abilities = EquippedAbilities::new();
2959        let power_with = |bonus: i32| {
2960            let item = Item {
2961                power_bonus: bonus,
2962                ..Default::default()
2963            };
2964            character_power(&cfg, &lk, 1, &[item], &no_abilities, &[]).unwrap()
2965        };
2966        assert_eq!(
2967            power_with(0),
2968            power_with(7_777),
2969            "power_bonus must not move the gear-compare power"
2970        );
2971    }
2972
2973    #[test]
2974    fn heal_ability_credits_capped_ehp() {
2975        // Direct aggregation check on the heal axis: synthesize the profile
2976        // via the pure splitter, then verify the EHP factor shape + cap.
2977        let attrs = base_attrs(); // hp 3000, attack 600
2978        let heal_add: f64 = 5.0;
2979        let hp = 3000.0;
2980        let attack = 600.0;
2981        let uncapped = attack * DMG_K * heal_add; // 300 HP/s
2982        let cap = hp * BALANCE_TUNING_DEFAULT.hot_tick_max_pct; // 750 HP/s
2983        assert!(uncapped < cap, "test setup: below cap");
2984        let expected = (hp + uncapped * FIGHT_DURATION) / hp;
2985        // (hp + 300·25)/3000 = 3.5 at the measured FIGHT_DURATION of 25s.
2986        assert!((expected - 3.5).abs() < 1e-3);
2987        let _ = attrs; // attrs used above for provenance of the numbers
2988    }
2989}
2990
2991#[cfg(test)]
2992mod class_balance_tests {
2993    //! The four combat classes are differentiated by `class_levels` grants
2994    //! (Warrior→block, Rogue→crit, Mage→multicast, Priest→regen) but must remain
2995    //! POWER-balanced under `P=DPS×EHP`. Their grants overflow the basis-point
2996    //! cap, so this also guards the clamp/regen-cap robustness fixes: without
2997    //! them block>1 gave NEGATIVE power and regen exploded EHP ~40×.
2998    use super::*;
2999
3000    fn profile(stat: &str, value: f64) -> AttrMap {
3001        let mut a = AttrMap::new();
3002        a.insert("attack".into(), 600.0);
3003        a.insert("hp".into(), 3000.0);
3004        a.insert("speed".into(), 10000.0);
3005        a.insert(stat.into(), value);
3006        a
3007    }
3008
3009    #[test]
3010    fn class_power_offensive_trio_equal_regen_honestly_lower() {
3011        // The block class (Warrior, ×2 EHP) and the two ×2-DPS classes (Rogue crit,
3012        // Mage multicast) are power-balanced — symmetric ×2 mechanisms → equal power.
3013        let warrior = power_from_attrs(&profile("block", 38800.0)); // 3.88 → clamp 1.0 → ×2 EHP
3014        // The raw crit stat here overflows the realized-rate clamp regardless
3015        // of crit_chance_scale, so crit lands at 100% → the ×2 crit-damage
3016        // identity ⇒ ×2 DPS, symmetric with Mage's multicast.
3017        let rogue = power_from_attrs(&profile("crit_chance", 38800.0)); // clamp → ×2 DPS
3018        let mage = power_from_attrs(&profile("multicast_chance", 19400.0)); // → ×2 DPS
3019        let priest = power_from_attrs(&profile("regeneration_rate", 19400.0));
3020        assert!(
3021            warrior > 0,
3022            "block>1 must NOT produce negative power (clamp)"
3023        );
3024        assert_eq!(warrior, rogue, "Warrior vs Rogue imbalance");
3025        assert_eq!(rogue, mage, "Rogue vs Mage imbalance");
3026        // Priest's regen is honestly capped to what COMBAT delivers, which is
3027        // structurally below block's ×2 EHP — so the scalar shows Priest
3028        // LOWER. This is a real combat-balance fact, not a formula bug;
3029        // closing it (raise `regen_tick_max_pct` or compensate the Priest
3030        // grant) is a sim/design decision, not a scalar tweak.
3031        assert!(priest > 0, "Priest power must stay positive");
3032        assert!(
3033            priest < warrior,
3034            "honest regen EHP (≈×1.16) is below block's ×2 — Priest is structurally lower"
3035        );
3036    }
3037
3038    #[test]
3039    fn regen_is_bounded() {
3040        // Absurd regen must not explode EHP: the scalar applies the SAME per-tick cap as
3041        // combat (`regen_tick_max_pct × hp`), so regen above the cap adds no extra power.
3042        let huge = power_from_attrs(&profile("regeneration_rate", 1_000_000.0));
3043        let capped = power_from_attrs(&profile("regeneration_rate", 19400.0));
3044        assert_eq!(huge, capped, "regen beyond the cap must not increase power");
3045    }
3046}