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::rng::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    event_system::rng::rand_round(value, random)
1193}
1194
1195pub fn effect_cost(duration: f64) -> f64 {
1196    (BUFF_EFF - 1.0) * (SPELL_QUANTITY - 1) as f64 * duration * BASE_SPELL_EFF
1197}
1198
1199pub fn effect_duration(cost: f64) -> f64 {
1200    cost / ((BUFF_EFF - 1.0) * (SPELL_QUANTITY - 1) as f64 * BASE_SPELL_EFF)
1201}
1202
1203fn buff_p_from_eff(eff: f64, cast_rate: f64, duration: f64, effects_quantity: i64) -> f64 {
1204    let uptime_max = (cast_rate * duration / effects_quantity as f64).min(1.0);
1205    let eff_max = (1.0 + (BUFF_EFF - 1.0) * uptime_max).powi(effects_quantity as i32);
1206    if eff >= eff_max {
1207        return 1.0;
1208    }
1209    let effect_eff = eff.powf(1.0 / effects_quantity as f64);
1210    let effect_uptime = ((effect_eff - 1.0) / (BUFF_EFF - 1.0)).max(0.0);
1211    let p = effect_uptime * effects_quantity as f64 / (cast_rate * duration);
1212    p.clamp(0.0, 1.0)
1213}
1214
1215pub fn bravery_p_from_eff(eff: f64) -> f64 {
1216    buff_p_from_eff(
1217        eff,
1218        CASTS_PER_SEC,
1219        BRAVERY_BUFF_DURATION,
1220        BRAVERY_BUFF_QUANTITY,
1221    )
1222}
1223
1224pub fn deceit_p_from_eff(eff: f64) -> f64 {
1225    buff_p_from_eff(
1226        eff,
1227        CASTS_PER_SEC,
1228        DECEIT_DEBUFF_DURATION,
1229        DECEIT_DEBUFF_QUANTITY,
1230    )
1231}
1232
1233pub fn attr_spread_random(random: &GameRng) -> f64 {
1234    1.0 + ATTR_DEVIATION * (2.0 * random.random_f64() - 1.0)
1235}
1236
1237pub fn hp_k_for_level(level: f64) -> f64 {
1238    const ALL_SPELLS_LEVEL: f64 = 30.0;
1239    if level > ALL_SPELLS_LEVEL {
1240        return 1.0;
1241    }
1242    ((level - 1.0) / (ALL_SPELLS_LEVEL - 1.0) * (SPELL_QUANTITY - 1) as f64 + 1.0)
1243        / SPELL_QUANTITY as f64
1244}
1245
1246// `eff_by_level` curve constants (item effectiveness vs item level). Two
1247// branches joined at L=130: a steep early power-law and a flat late power-law.
1248// Module consts + the `tuning()` surface so the bot-sim can sweep the
1249// early-power SHAPE (`EFF_B1`) without a recompile.
1250//
1251// This curve — not the economy — sets how fast power grows early: item level
1252// tracks the level/clock, and with the gear-eff→power exponent k≈1 (each
1253// item's attack & hp scale as eff^0.5, so P=DPS·EHP ∝ eff^1; the quadratic S²
1254// is the separate SPELL axis) B1 maps ~1:1 into power. B1 = 1.40 keeps the
1255// first sessions from being a power geyser; B2 = 0.40 keeps late power
1256// climbing (no far-late plateau — "fighting never stops"). The late branch
1257// (A2,B2,C2) must stay continuous with the early branch at L=130 in value AND
1258// slope. Closed form: V=A1·129^B1+1=98.52, S=A1·B1·129^(B1-1)=1.0583,
1259// A2=S·130^(1-B2)/B2=49.08, C2=V-A2·130^B2=-245.40. All sim-tunable via
1260// OVERLORD_BAL_EFF_*; re-derive A2/C2 if B1 or B2 move.
1261pub const EFF_MIDGAME_LEVEL: f64 = 130.0;
1262pub const EFF_A1: f64 = 0.1082166549;
1263pub const EFF_B1: f64 = 1.40;
1264pub const EFF_C1: f64 = 1.0;
1265pub const EFF_A2: f64 = 49.08;
1266pub const EFF_B2: f64 = 0.40;
1267pub const EFF_C2: f64 = -245.40;
1268
1269pub fn eff_by_level(level: f64) -> f64 {
1270    let t = tuning();
1271    if level <= EFF_MIDGAME_LEVEL {
1272        t.eff_a1 * (level - 1.0).powf(t.eff_b1) + EFF_C1
1273    } else {
1274        t.eff_a2 * level.powf(t.eff_b2) + t.eff_c2
1275    }
1276}
1277
1278// --- Legacy planning-model "day" curves --------------------------------------
1279// The four functions below (`item_q_by_day`, `day_by_level`, `level_by_day`,
1280// `eff_spell_by_level`) are fits from the original planning spreadsheet:
1281// "day" here is that model's abstract pacing day, not a real calendar/sim day,
1282// and the magic constants are curve-fit coefficients with no runtime meaning
1283// beyond the fit. They are still LIVE — arena-opponent generation
1284// (`behaviors::opponents`) levels bots by `level_by_day`/`item_q_by_day`, and
1285// item `dmg_increase` reads `eff_spell_by_level` — so they cannot be deleted;
1286// but do not build new features on the "day" scale.
1287
1288pub fn item_q_by_day(day: f64) -> f64 {
1289    const A: f64 = 1.369248467;
1290    const B: f64 = 1.986;
1291    const C: f64 = 1.00;
1292    A * (B * day + 1.0).ln() + C
1293}
1294
1295pub fn day_by_level(level: f64) -> f64 {
1296    const A1: f64 = 13.95171732;
1297    const A2: f64 = 0.5784426942;
1298    const A3: f64 = 1.00;
1299    ((level - A3) / A1).powf(1.0 / A2)
1300}
1301
1302pub fn level_by_day(day: f64) -> f64 {
1303    const A1: f64 = 13.95171732;
1304    const A2: f64 = 0.5784426942;
1305    const A3: f64 = 1.00;
1306    A1 * day.powf(A2) + A3
1307}
1308
1309pub fn eff_spell_by_level(level: f64) -> f64 {
1310    const S1: f64 = 0.36536535;
1311    const S2: f64 = 0.525009219;
1312    const S3: f64 = 1.0;
1313    let day = day_by_level(level);
1314    S1 * day.powf(S2) + S3
1315}
1316
1317/// Player armor-rating deflator for character `level` (see
1318/// `BalanceTuning::armor_k_per_chapter`; level ≈ chapter for players and is
1319/// available for arena OPPONENT snapshots too, keeping PvP symmetric):
1320/// multiply the aggregated player armor rating by this before it meets the
1321/// constant-K DR curve.
1322pub fn player_armor_rating_deflator(level: i64) -> f64 {
1323    let t = tuning();
1324    if t.armor_k_per_chapter <= 0.0 {
1325        return 1.0;
1326    }
1327    t.k_armor / (t.k_armor + t.armor_k_per_chapter * level.max(0) as f64)
1328}
1329
1330pub fn armor_k(armor: f64) -> f64 {
1331    // Fraction of incoming damage that PASSES the target's armor, via the DR
1332    // curve `K_ARMOR/(armor+K_ARMOR)`. Always in (0, 1] for armor ≥ 0 — a
1333    // linear falloff would reach 0 and go negative (healing the target).
1334    let k = tuning().k_armor;
1335    k / (armor.max(0.0) + k)
1336}
1337
1338/// Enemy "power" scalar for a fight — the value `spawn_wave` feeds into the
1339/// HP/attack curve, and the apples-to-apples counterpart of the player's
1340/// `character.power` (both normalized to [`BASE_POWER`]). For campaign fights:
1341/// at/below [`HAND_AUTHORED_CHAPTER_MAX`] (the whole first stage) it is the
1342/// hand-authored `FightTemplate.power` (`base_power`) with NO mid-boss gate;
1343/// above it the rebalanced geometric curve
1344/// ([`enemy_power_for_chapter`], which carries the sweepable anchor/step + the
1345/// early-chapter bump), with a boss bump. For DUNGEON fights it is always the
1346/// authored per-difficulty `base_power` (see below). Shared so the battle-end
1347/// analytics report exactly the value the fight spawned.
1348pub fn enemy_power_scalar(
1349    base_power: f64,
1350    current_chapter: i64,
1351    fight_type: &str,
1352    is_dungeon: bool,
1353) -> f64 {
1354    let t = tuning();
1355    // Dungeons are a self-contained difficulty LADDER: enemy power is the
1356    // authored per-difficulty `base_power` (the chosen difficulty's
1357    // `FightTemplate.power`, ramping e.g. 1500 → 600M across the levels), NOT the
1358    // campaign chapter curve. Dungeon fights are tagged `CampaignBossFight` (for
1359    // boss talents/visuals), so without this guard they fall into the chapter-curve
1360    // branch below — collapsing every difficulty to one power keyed to the player's
1361    // campaign chapter (the regression where the ladder does nothing and difficulty
1362    // 1 is a full-chapter boss → "losing to the dungeon at unlock"). Honor the
1363    // authored ladder so low difficulties are an easy first clear and the player
1364    // climbs as their power grows.
1365    if is_dungeon {
1366        return base_power;
1367    }
1368    // BAL-037: the signed curve covers ch0…509, so FTUE no longer reads authored
1369    // `FightTemplate.power`. The whole hand-authored block is refit as one
1370    // progression sequence; its current absolute rows `1…46` are not preserved.
1371    if fight_type == "CampaignFight" || fight_type == "CampaignBossFight" {
1372        if t.enemy_curve_mode != EnemyCurveMode::Derived {
1373            // Rollback mode keeps the historic split: authored FTUE rows, legacy
1374            // anchor/step/taper past them, plus the old boss bumps.
1375            if current_chapter <= t.hand_authored_chapter_max {
1376                return base_power;
1377            }
1378            let power = enemy_power_for_chapter(current_chapter);
1379            return if fight_type == "CampaignBossFight" {
1380                power * t.chapter_power_step.powf(0.5) * enemy_mid_chapter_mult(current_chapter)
1381            } else {
1382                power
1383            };
1384        }
1385        // One total normalized encounter coefficient for every boss on every
1386        // chapter. The separate ch12/13/14 bump is gone: the accepted cadence
1387        // forbids any wall through ch21.
1388        if fight_type == "CampaignBossFight" {
1389            enemy_boss_power(current_chapter)
1390        } else {
1391            enemy_wave_power(current_chapter)
1392        }
1393    } else {
1394        base_power
1395    }
1396}
1397
1398pub fn hp_k_for_chapter(config: &GameConfig, chapter: i64) -> f64 {
1399    let mut reached: Vec<_> = config
1400        .ability_slots_levels
1401        .iter()
1402        .filter(|item| chapter >= item.from_chapter_level)
1403        .collect();
1404    reached.sort_by_key(|b| std::cmp::Reverse(b.from_chapter_level));
1405
1406    if reached.is_empty() {
1407        return 1.0 / SPELL_QUANTITY as f64;
1408    }
1409    let current = reached[0];
1410    if current.ability_slots == (SPELL_QUANTITY - 1) as u64 {
1411        return 1.0;
1412    }
1413    let mut unreached: Vec<_> = config
1414        .ability_slots_levels
1415        .iter()
1416        .filter(|item| chapter < item.from_chapter_level)
1417        .collect();
1418    unreached.sort_by_key(|a| a.from_chapter_level);
1419    if unreached.is_empty() {
1420        return 1.0;
1421    }
1422    let next = unreached[0];
1423    // Interpolate the slot count toward the next tier. The legacy form added a
1424    // bare `frac` — correct only while every ladder step is exactly +1 slot
1425    // (true today, verified); scale by the actual step so a future +2 tier
1426    // doesn't silently mis-budget the wave. The trailing +1 counts the class
1427    // basic ability alongside the gacha slots (slots max 5, SPELL_QUANTITY 6).
1428    let step = (next.ability_slots - current.ability_slots) as f64;
1429    let frac = (chapter - current.from_chapter_level) as f64
1430        / (next.from_chapter_level - current.from_chapter_level) as f64;
1431    let mean_ability_slots = current.ability_slots as f64 + frac * step + 1.0;
1432    mean_ability_slots / SPELL_QUANTITY as f64
1433}
1434
1435/// `attrs.get_attr(name)`: read the composed attribute value from an attribute map.
1436/// Returns `(base + bonus) * (1 + bonus / 10000.0)`.
1437///
1438/// of `mod` in the multiplier. We preserve the original behaviour byte-for-byte
1439/// so power calculations stay consistent with the current production config.
1440///
1441/// `attrs.get_attr(...)` method (registered for scripts that still pass a
1442pub fn get_attr_from_attrs(attrs: &AttrMap, attr: &str) -> f64 {
1443    let base = attrs.get(attr).copied().unwrap_or(0.0);
1444    let bonus = attrs.get(&format!("{attr}.bonus")).copied().unwrap_or(0.0);
1445    // Matches combat's `get_entity_stat`: additive `.bonus`, then the `.mod`
1446    // multiplier `(1 + mod/10000)` — the scalar must agree with combat, or
1447    // `.mod` grants (e.g. class-level attack.mod/hp.mod) would not move
1448    // displayed/matchmaking power. (Attribute base-values — e.g.
1449    // received_damage's 10000 — are still combat-only: the scalar has no
1450    // `lookups` here; a known remaining scalar↔combat gap.)
1451    let mod_v = attrs.get(&format!("{attr}.mod")).copied().unwrap_or(0.0) / 10000.0 + 1.0;
1452    (base + bonus) * mod_v
1453}
1454
1455/// Ability upgrade milestones: on top of the linear +5%/level, levels 5 and
1456/// 10 grant a multiplicative jump — a proximate milestone that makes the
1457/// dup-shard chase eventful instead of a flat drip. UNIVERSAL for every
1458/// ability (gacha, class kits, pet ults) — no per-content flags; tooltips
1459/// (`ability_info(level)`) and the honest power scalar recompute automatically
1460/// because everything routes through [`ability_eff`]. Sizing: ×1.10 at L5 ≈
1461/// two extra linear levels, ×1.20 at L10 ≈ four — noticeable, but small
1462/// enough that the shard economy absorbs it.
1463pub const ABILITY_MILESTONES: &[(i64, f64)] = &[(5, 1.10), (10, 1.20)];
1464
1465/// Payload multiplier of a CLASS ability at `rank`: `×[1.0, 1.1, … 1.6]` over
1466/// ranks 1..=7 (BAL-034).
1467///
1468/// Class kits do not ride the shard ladder every other ability uses: their
1469/// ranks come from Class Level on a fixed schedule, so their growth is
1470/// authored flat and stops at rank 7. Only damage and heal payloads move —
1471/// durations, buff magnitudes, target caps, cooldowns and Mana costs are the
1472/// same at rank 7 as at rank 1, which is what deliberately keeps the utility
1473/// halves of Fortify / War Cry / Rewind / Battle Heal flat.
1474pub const CLASS_RANK_PAYLOAD_STEP: f64 = 0.1;
1475pub const CLASS_MAX_RANK: i64 = 7;
1476
1477pub fn class_rank_payload_mult(rank: i64) -> f64 {
1478    1.0 + CLASS_RANK_PAYLOAD_STEP * (rank.clamp(1, CLASS_MAX_RANK) - 1) as f64
1479}
1480
1481pub fn ability_eff(lookups: &ContentLookups, rarity_id: Uuid, level: i64) -> f64 {
1482    let rarity_eff = lookups
1483        .ability_rarity_eff
1484        .get(&rarity_id)
1485        .copied()
1486        .unwrap_or(1.0);
1487    if lookups.class_ability_rarities.contains(&rarity_id) {
1488        return rarity_eff * class_rank_payload_mult(level);
1489    }
1490    let level_eff = 1.0 + 0.05 * (level - 1) as f64;
1491    let milestone: f64 = ABILITY_MILESTONES
1492        .iter()
1493        .filter(|(l, _)| level >= *l)
1494        .map(|(_, m)| m)
1495        .product();
1496    rarity_eff * level_eff * milestone
1497}
1498
1499// --- Honest power scalar: per-ability combat contribution --------------------
1500// A flat `Σ ability_eff` multiplier would lie three ways:
1501//   1. abilities that never cast in combat would still count toward power;
1502//   2. a heal ability (HoT / lifesteal) would count as if it were damage,
1503//      while in combat it contributes survivability (EHP), not DPS;
1504//   3. the per-ability throughput factor `FIGHT_DURATION/(FIGHT_DURATION+cd)`
1505//      that combat damage actually carries (`ability_damage_from_rarity`)
1506//      would be ignored, over-rating slow-cooldown abilities.
1507// The honest scalar therefore maps each fighting ability to (dps_add,
1508// heal_add) using the same primitives combat uses, then multiplies the DPS
1509// axis by Σ dps_add and the EHP axis by the capped heal-sustain factor — the
1510// same shape as combat.
1511
1512/// Combat-honest contribution of one slotted ability to the power scalar.
1513#[derive(Clone, Copy, Debug, Default, PartialEq)]
1514pub struct AbilityCombatProfile {
1515    /// Additive DPS throughput in reference units: 1.0 = the reference
1516    /// single-ability caster (`BASE_SPELL_EFF`, cd→0) that `spawn_wave`'s
1517    /// mirror player is built from. Per-second form of the same
1518    /// `eff × FD/(FD+cd)` budget combat damage derives from.
1519    pub dps_add: f64,
1520    /// Heal throughput in the same units (multiply by `attack × DMG_K` for
1521    /// absolute HP/s — exactly what `spell_heal`/HoT apply in combat).
1522    pub heal_add: f64,
1523}
1524
1525/// Pure split of an [`crate::mechanics::content::AbilityInfo`] budget into the
1526/// (dps_add, heal_add) axes. Separated from the config lookup so the arithmetic
1527/// is unit-testable without prod ability UUIDs.
1528///
1529/// `damage`/`dot`/`hot` in `AbilityInfo` are per-cast budgets (the closures
1530/// split `ability_damage_from_rarity = eff·k·cd`), so dividing by `cd` yields
1531/// per-second throughput `eff·k·share`. `vampiric` heals a fraction of damage
1532/// dealt. `crit_chance_bonus`/`effect_duration` buff components are dropped
1533/// (conservative): their budget share was already subtracted from `damage` by
1534/// the content closures, so ignoring them under-counts slightly rather than
1535/// double-counting.
1536pub fn ability_profile_from_info(
1537    damage: Option<f64>,
1538    dot: Option<f64>,
1539    hot: Option<f64>,
1540    vampiric: Option<f64>,
1541    projectiles: Option<i64>,
1542    cooldown_sec: f64,
1543) -> AbilityCombatProfile {
1544    let cd = cooldown_sec.max(0.1);
1545    // Multi-projectile abilities report PER-PROJECTILE damage in their info
1546    // split (the closures divide the budget by the count for the tooltip);
1547    // combat fires all of them, so the throughput carries the full budget.
1548    let shots = projectiles.unwrap_or(1).max(1) as f64;
1549    let total_damage = damage.unwrap_or(0.0) * shots;
1550    let dmg_budget = total_damage + dot.unwrap_or(0.0);
1551    let heal_budget = hot.unwrap_or(0.0) + vampiric.unwrap_or(0.0) * total_damage;
1552    AbilityCombatProfile {
1553        dps_add: dmg_budget / cd / BASE_SPELL_EFF,
1554        heal_add: heal_budget / cd,
1555    }
1556}
1557
1558/// Combat-honest profile of a pet ult: the pet's ability carries the donor
1559/// gacha ability's per-cast budget (combat behaviors hardcode the donor uuid),
1560/// but fires at the charge-fill rate, not at the template cooldown — so the
1561/// throughput divides the per-cast budget by [`PET_ULT_EFFECTIVE_CD`].
1562pub fn pet_ult_combat_profile(
1563    config: &GameConfig,
1564    lookups: &ContentLookups,
1565    ability: &Ability,
1566) -> AbilityCombatProfile {
1567    let Some(tpl) = config.ability_template(ability.template_id) else {
1568        return AbilityCombatProfile::default();
1569    };
1570    match crate::mechanics::content::ability_info(
1571        config,
1572        lookups,
1573        ability.template_id,
1574        ability.level,
1575    ) {
1576        Ok(info) => ability_profile_from_info(
1577            info.damage,
1578            info.dot,
1579            info.hot,
1580            info.vampiric,
1581            info.projectiles,
1582            PET_ULT_EFFECTIVE_CD,
1583        ),
1584        Err(_) => {
1585            // No closure — price the whole per-cast budget as damage at the
1586            // charge-fill rate (same honest default as the regular path).
1587            let cd = (tpl.cooldown as f64 / 1000.0).max(0.1);
1588            let eff = ability_eff(lookups, tpl.rarity_id, ability.level);
1589            let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
1590            AbilityCombatProfile {
1591                dps_add: eff * k * cd / PET_ULT_EFFECTIVE_CD,
1592                heal_add: 0.0,
1593            }
1594        }
1595    }
1596}
1597
1598/// Combat-honest profile of one ability instance. Uses the per-ability
1599/// `content::ability_info` split where one exists; abilities without a bespoke
1600/// closure (some class/buff abilities) fall back to a pure-damage assumption at
1601/// their `eff × FD/(FD+cd)` throughput — the closest honest default.
1602pub fn ability_combat_profile(
1603    config: &GameConfig,
1604    lookups: &ContentLookups,
1605    ability: &Ability,
1606) -> AbilityCombatProfile {
1607    let Some(tpl) = config.ability_template(ability.template_id) else {
1608        return AbilityCombatProfile::default();
1609    };
1610    let cd = (tpl.cooldown as f64 / 1000.0).max(0.1);
1611    match crate::mechanics::content::ability_info(
1612        config,
1613        lookups,
1614        ability.template_id,
1615        ability.level,
1616    ) {
1617        Ok(info) => ability_profile_from_info(
1618            info.damage,
1619            info.dot,
1620            info.hot,
1621            info.vampiric,
1622            info.projectiles,
1623            cd,
1624        ),
1625        Err(_) => {
1626            // No bespoke split for this ability id — assume its whole budget is
1627            // damage (`eff × k`), the same throughput the plain-damage closures
1628            // produce.
1629            let eff = ability_eff(lookups, tpl.rarity_id, ability.level);
1630            let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
1631            AbilityCombatProfile {
1632                dps_add: eff * k,
1633                heal_add: 0.0,
1634            }
1635        }
1636    }
1637}
1638
1639/// Expected seconds between pet-ult casts for the honest scalar. The pet ult
1640/// is charge-gated, not cooldown-gated (`charge_rate_on_skill_use` /
1641/// `_on_damage_dealt` / `_on_damage_taken` fill `max_charge`), so its template
1642/// cooldown says nothing about throughput. With the shipped charge profiles
1643/// (max_charge 1000, rates 5-40 per trigger) a fight fills the bar roughly
1644/// once per ~12s of combat — a deliberate round number until a sim remeasure;
1645/// the pet's per-cast budget divided by this is the honest DPS/heal rate.
1646pub const PET_ULT_EFFECTIVE_CD: f64 = 12.0;
1647
1648/// Aggregate the equipped abilities into the two power-axis multipliers:
1649/// `(dps_mult, ehp_mult)`.
1650///
1651/// Membership matches combat exactly: `make_active_abilities_from_equipped`
1652/// sends BOTH `slotted` (gacha slots) and `unslotted` (the class kit and other
1653/// slotless grants) into the fight, so both count here — EXCEPT abilities with
1654/// an empty `start_behavior`: those never begin a cast cycle in combat, so a
1655/// scalar that counts them lies.
1656///
1657/// - `dps_mult` = Σ `dps_add` — additive throughput, exactly how per-ability
1658///   damage stacks in combat. Zero castable abilities ⇒ 0.0 (a character that
1659///   casts nothing deals nothing — "bare character power is 0" is the
1660///   established semantic).
1661/// - `ehp_mult` = capped heal-sustain factor `(hp + heal/fight) / hp`, the same
1662///   shape and the same `hot_tick_max_pct` cap the combat HoT tick obeys, and
1663///   the same treatment `power_from_attrs` already gives `regeneration_rate`.
1664pub fn ability_power_mults(
1665    config: &GameConfig,
1666    lookups: &ContentLookups,
1667    abilities: &EquippedAbilities,
1668    leader_pet_ability: Option<(Uuid, i64)>,
1669    attrs: &AttrMap,
1670) -> (f64, f64) {
1671    // The leader pet's active ability joins the fight exactly like an equipped
1672    // ability (`create_player_entity` pushes it at the pet's level), so it
1673    // counts here too — same membership rule, same dead-ability filter, but
1674    // priced at the charge-fill rate ([`PET_ULT_EFFECTIVE_CD`]), not at the
1675    // ability template's cooldown.
1676    let pet_ability = leader_pet_ability.map(|(template_id, level)| Ability {
1677        template_id,
1678        level: level.max(1),
1679        shards_amount: 0,
1680    });
1681
1682    let mut dps_mult = 0.0;
1683    let mut heal_add = 0.0;
1684    for (ability, is_pet) in abilities
1685        .unslotted
1686        .iter()
1687        .chain(abilities.slotted.values())
1688        .map(|a| (a, false))
1689        .chain(pet_ability.iter().map(|a| (a, true)))
1690    {
1691        // Dead abilities (no start behavior) never cast — no contribution.
1692        let castable = config
1693            .ability_template(ability.template_id)
1694            .and_then(|t| t.start_behavior.as_deref())
1695            .is_some_and(|s| !s.is_empty());
1696        if !castable {
1697            continue;
1698        }
1699        let p = if is_pet {
1700            pet_ult_combat_profile(config, lookups, ability)
1701        } else {
1702            ability_combat_profile(config, lookups, ability)
1703        };
1704        dps_mult += p.dps_add;
1705        heal_add += p.heal_add;
1706    }
1707
1708    let hp = get_attr_from_attrs(attrs, "hp");
1709    let attack = get_attr_from_attrs(attrs, "attack");
1710    let ehp_mult = if hp > 0.0 && heal_add > 0.0 {
1711        // Absolute heal rate as combat applies it (attack-scaled), capped per
1712        // tick like the combat HoT path so a mis-tuned heal can't credit
1713        // unbounded EHP.
1714        let heal_per_sec = (attack * DMG_K * heal_add).min(hp * tuning().hot_tick_max_pct);
1715        (hp + heal_per_sec * FIGHT_DURATION) / hp
1716    } else {
1717        1.0
1718    };
1719    (dps_mult, ehp_mult)
1720}
1721
1722pub fn ability_damage_from_rarity(
1723    lookups: &ContentLookups,
1724    rarity_id: Uuid,
1725    cooldown_ms: i64,
1726    level: i64,
1727) -> f64 {
1728    let eff = ability_eff(lookups, rarity_id, level);
1729    let cd = cooldown_ms as f64 / 1000.0;
1730    let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
1731    eff * k * BASE_SPELL_EFF * cd
1732}
1733
1734pub fn ability_damage_from_id(
1735    config: &GameConfig,
1736    lookups: &ContentLookups,
1737    ability_id: Uuid,
1738    level: i64,
1739) -> Result<f64, String> {
1740    let ability = config
1741        .ability_template(ability_id)
1742        .ok_or_else(|| format!("balance::ability_damage: unknown ability id {ability_id}"))?;
1743    Ok(ability_damage_from_rarity(
1744        lookups,
1745        ability.rarity_id,
1746        ability.cooldown as i64,
1747        level,
1748    ))
1749}
1750
1751pub fn eff_item_with_config(
1752    config: &GameConfig,
1753    lookups: &ContentLookups,
1754    item_template_id: Uuid,
1755    level: f64,
1756) -> f64 {
1757    let rarity_q = config
1758        .item_template(item_template_id)
1759        .and_then(|tpl| lookups.item_rarity_q.get(&tpl.rarity_id).copied())
1760        .unwrap_or(1.0);
1761    eff_by_level(level) * rarity_q
1762}
1763
1764/// A2-BAL-003 §3.3: how many optional stats a chest of this level rolls.
1765///
1766/// The count is a property of the CHEST, not of the item template. It used to
1767/// live on each template's `optional_attributes_count`, which meant the same
1768/// sword rolled the same number of extras out of a level-1 chest as out of a
1769/// level-40 one — and ten templates carried three, above the signed maximum of
1770/// two.
1771pub fn optional_attributes_for_chest_level(chest_level: i64) -> u64 {
1772    match chest_level {
1773        ..=1 => 0,
1774        2..=5 => 1,
1775        _ => 2,
1776    }
1777}
1778
1779pub fn attr_spread_for_item(
1780    // Kept in the signature: `eff_item_with_config` needed it for the removed
1781    // skewed roll, and the call sites read more clearly with the item's whole
1782    // context in one place. Dropping it would touch every caller for no gain.
1783    _config: &GameConfig,
1784    lookups: &ContentLookups,
1785    random: &GameRng,
1786    template_id: Uuid,
1787    level: f64,
1788) -> f64 {
1789    if let Some(fp) = lookups.item_fixed_power.get(&template_id) {
1790        return *fp;
1791    }
1792    if (level - 1.0).abs() < f64::EPSILON {
1793        return LEVEL_ONE_ATTR_MEAN
1794            * (1.0 + ATTR_DEVIATION_LEVEL_ONE * (2.0 * random.random_f64() - 1.0));
1795    }
1796    // A2-BAL-003 §3.3: every item above level 1 rolls the same uniform spread.
1797    //
1798    // The skewed fake-level roll that used to take over above item L25 is gone.
1799    // It made the spread's SHAPE change partway up the ladder, so an item's roll
1800    // distribution depended on where in the game it dropped — which is not
1801    // something the signed `0.9..1.1` band can describe, and made sale-EV drift
1802    // by level impossible to reason about.
1803    attr_spread_random(random)
1804}
1805
1806pub fn aux_attr_eff(base_eff: f64, random: &GameRng) -> f64 {
1807    let rand_mod = attr_spread_random(random);
1808    (base_eff * rand_mod).powf(AUX_ATTR_IMPACT)
1809}
1810
1811pub fn aux_attr_eff_for_item(
1812    config: &GameConfig,
1813    lookups: &ContentLookups,
1814    base_eff: f64,
1815    random: &GameRng,
1816    template_id: Uuid,
1817    level: f64,
1818) -> f64 {
1819    let rand_mod = attr_spread_for_item(config, lookups, random, template_id, level);
1820    (base_eff * rand_mod).powf(AUX_ATTR_IMPACT)
1821}
1822
1823/// Compute character power from a composed [`AttrMap`] as **P = DPS × EHP**
1824/// (balance v2). `DPS` is the offensive product (attack · rate · crit ·
1825/// multicast · bravery · counterattack); `EHP` the survivability product
1826/// (hp ÷ the damage that gets through), where armor and dodge use the
1827/// diminishing-returns curves [`armor_k`] and `ev/(ev+K_DODGE)`. Normalised by
1828/// [`POWER_NORM`] so a reference character (attack=`BASE_ATTACK`, hp=`BASE_HP`,
1829/// neutral elsewhere) anchors at [`BASE_POWER`].
1830///
1831/// **Float-accumulation order is load-bearing** (see [`character_attrs_power`]):
1832/// the per-attribute reads happen in a fixed order so the floored result is
1833/// deterministic.
1834pub fn power_from_attrs(attrs: &AttrMap) -> i64 {
1835    power_from_attrs_raw(attrs).floor() as i64
1836}
1837
1838/// [`power_from_attrs`] before the floor.
1839///
1840/// Ranking callers need the raw value: displayed power is an integer, but at
1841/// low character levels `DPS × EHP / power_norm` lands well under `1.0`, so the
1842/// floored score collapses every candidate to `0` and any sort over it silently
1843/// degrades to input order.
1844pub fn power_from_attrs_raw(attrs: &AttrMap) -> f64 {
1845    const BASE_POINT: f64 = 10000.0; // attribute basis points: 10000 == 1.0 / 100%
1846
1847    // ---- offensive: DPS ----
1848    let attack = get_attr_from_attrs(attrs, "attack");
1849    // speed → cast rate, mirroring combat's `scale_cooldown_for_speed` (cooldown ∝
1850    // baseline/speed ⇒ rate ∝ speed/baseline, baseline_speed = BASE_POINT). Combat treats
1851    // `speed ≤ 0` as the baseline (`speed_or_baseline`), so the scalar must too — else a
1852    // speed-less build reads as 0 DPS here while combat still attacks at the baseline rate.
1853    let speed = get_attr_from_attrs(attrs, "speed");
1854    let atk_rate = if speed > 0.0 { speed / BASE_POINT } else { 1.0 };
1855    // Probability stats are clamped to [0,1] — a chance can't exceed 100%.
1856    // Without this, large (e.g. class-level) grants overflow the basis-point
1857    // cap and break the formula (e.g. block>1 → `1−0.5·block` negative → power
1858    // goes negative). Combat's `stat_throw` already saturates a proc at 100%.
1859    // `crit_chance_scale` mirrors combat (`stat_throw_scaled` in the attack
1860    // path): the honest scalar must price crit at the REALIZED proc rate, not
1861    // the raw stat — otherwise crit builds are over-valued in displayed/
1862    // matchmaking power whenever the scale is below 1.
1863    let crit_chance = (get_attr_from_attrs(attrs, "crit_chance") / BASE_POINT
1864        * tuning().crit_chance_scale)
1865        .clamp(0.0, 1.0);
1866    let crit_damage_mod = BASE_CRIT_MOD + get_attr_from_attrs(attrs, "crit_modifier") / BASE_POINT;
1867    let crit_factor = 1.0 + crit_chance * (crit_damage_mod - 1.0);
1868    let multicast_factor =
1869        1.0 + (get_attr_from_attrs(attrs, "multicast_chance") / BASE_POINT).clamp(0.0, 1.0);
1870    let bravery_factor = buff_uptime_mult_branches(
1871        get_attr_from_attrs(attrs, "bravery") / BASE_POINT,
1872        BRAVERY_BUFF_DURATION,
1873    );
1874
1875    let mut dps = attack * atk_rate * crit_factor * multicast_factor * bravery_factor;
1876
1877    // counterattack: retaliatory damage relative to a fight's baseline output
1878    // (same additive shape as the legacy formula).
1879    let counterattack_chance =
1880        (get_attr_from_attrs(attrs, "counterattack_chance") / BASE_POINT).clamp(0.0, 1.0);
1881    let counterattack_dmg =
1882        FIGHT_DURATION * ATTACKS_PER_SEC * COUNTERATTACK_POWER * counterattack_chance;
1883    let baseline_dmg =
1884        FIGHT_DURATION * SPELL_QUANTITY as f64 / armor_k(get_attr_from_attrs(attrs, "armor"));
1885    if baseline_dmg > 0.0 {
1886        dps *= (baseline_dmg + counterattack_dmg) / baseline_dmg;
1887    }
1888
1889    // ---- defensive: EHP = hp ÷ (fraction of damage that gets through) ----
1890    // Via get_attr_from_attrs so hp.bonus/hp.mod count (consistent with combat).
1891    let hp = get_attr_from_attrs(attrs, "hp");
1892    let mut ehp = hp;
1893    // armor mitigation (DR via `armor_k`): divide by the damage-through fraction.
1894    ehp /= armor_k(get_attr_from_attrs(attrs, "armor"));
1895    // dodge (evasion rating) via DR: avoid prob = ev/(ev+K_DODGE), asymptote < 1.
1896    let evasion = get_attr_from_attrs(attrs, "evasion").max(0.0);
1897    ehp /= 1.0 - evasion / (evasion + tuning().k_dodge);
1898    // block: halves damage on proc → expected damage multiplier (1 − 0.5·p),
1899    // p clamped to [0,1] (max ×2 EHP at always-block).
1900    let block_chance = (get_attr_from_attrs(attrs, "block") / BASE_POINT).clamp(0.0, 1.0);
1901    ehp /= 1.0 - 0.5 * block_chance;
1902    // received_damage: a damage-taken multiplier (base BASE_POINT = 100% taken); combat
1903    // applies it in `damage_entity`, so the scalar must too or it under-counts mitigation
1904    // EHP (a build with persistent `received_damage` reduction reads weaker than it fights).
1905    // The scalar has no `lookups` for the base, so BASE_POINT is added explicitly here;
1906    // floored like combat (`MIN_RECEIVED_DAMAGE_K`) so it can't reach ≤0. Neutral → ×1.
1907    let received_damage =
1908        (BASE_POINT + get_attr_from_attrs(attrs, "received_damage")).max(MIN_RECEIVED_DAMAGE_K);
1909    ehp *= BASE_POINT / received_damage;
1910    // regen-as-EHP, derived to MATCH combat. Combat heals at most
1911    // `regen_tick_max_pct × max_hp` per ~1s tick (`regeneration_tick`), so over a fight it
1912    // delivers `FIGHT_DURATION × min(regen_rate, regen_tick_max_pct × hp)` — and the scalar
1913    // credits exactly that, using the SAME `tuning().regen_tick_max_pct` knob as combat, so
1914    // the two can never desync. This makes regen structurally weaker EHP than block — a real
1915    // combat-balance fact the scalar reports honestly; closing the Priest-competitiveness
1916    // gap is a sim/design decision, not a scalar tweak.
1917    let regen_per_sec = (get_attr_from_attrs(attrs, "regeneration_rate")
1918        + get_attr_from_attrs(attrs, REGEN_PERCENT_CODE) / BASE_POINT * hp.max(0.0))
1919    .min(hp.max(0.0) * tuning().regen_tick_max_pct);
1920    let regen_per_fight = regen_per_sec * FIGHT_DURATION;
1921    if hp != 0.0 {
1922        ehp *= (hp + regen_per_fight) / hp;
1923    }
1924    // deceit: debuffs the enemy → effective survivability gain (legacy shape).
1925    ehp *= buff_uptime_mult_branches(
1926        get_attr_from_attrs(attrs, "deceit") / BASE_POINT,
1927        DECEIT_DEBUFF_DURATION,
1928    );
1929
1930    (dps * ehp) / tuning().power_norm
1931}
1932
1933/// Character power over the items+pets subset (marginal gear-compare path and
1934/// arena-bot power): composed attrs → `power_from_attrs` → honest ability
1935/// multipliers ([`ability_power_mults`], DPS/EHP-split).
1936///
1937/// **Accumulation order is load-bearing** (per-key float sums): char-level
1938/// attributes, then inventory items (each item's attributes in order), then
1939/// pets (in the given slice order, each pet's stats in order).
1940pub fn character_power(
1941    config: &GameConfig,
1942    lookups: &ContentLookups,
1943    level: i64,
1944    inventory: &[Item],
1945    abilities: &EquippedAbilities,
1946    pets: &[Pet],
1947) -> Result<i64, String> {
1948    let (attrs, _total_power_bonus) = compose_character_attrs(config, level, inventory, pets)?;
1949    let attrs_power = power_from_attrs(&attrs);
1950
1951    // Honest ability multipliers: combat-membership set, DPS/EHP split (see
1952    // [`ability_power_mults`]). Pets cast nothing, so they contribute through
1953    // `attrs` only and add no ability multiplier.
1954    let (dps_mult, ehp_mult) = ability_power_mults(config, lookups, abilities, None, &attrs);
1955
1956    // BAL-030: the per-item `power_bonus` jitter is OUT of every power path —
1957    // display, matchmaking, and this gear-compare. It is a persisted-but-inert
1958    // display field; letting it into the compare would let a cosmetic roll
1959    // decide which item auto-equip keeps (matching
1960    // `behaviors::power::character_power`, which dropped it the same way).
1961    Ok((attrs_power as f64 * dps_mult * ehp_mult).floor() as i64)
1962}
1963
1964/// Combat-power scalar from an already-composed [`AttrMap`] plus equipped
1965/// abilities — `floor(power_from_attrs(attrs) × dps_mult × ehp_mult)` with the
1966/// honest ability multipliers ([`ability_power_mults`]).
1967///
1968/// Lets the display / matchmaking / gating path feed the **full** multi-source
1969/// aggregation (`attributes::calculate_player_entity_stats_with_zeroes` —
1970/// char-level, items, class, pets, talents, statue, class-levels) through the
1971/// same formula, so the power scalar reflects every combat source.
1972/// [`character_power`] keeps the items+pets subset for *marginal*
1973/// gear-compare, where the constant class/talent/statue baseline cancels in
1974/// the with-minus-without difference.
1975pub fn character_power_from_attrs(
1976    config: &GameConfig,
1977    lookups: &ContentLookups,
1978    attrs: &AttrMap,
1979    abilities: &EquippedAbilities,
1980    leader_pet_ability: Option<(Uuid, i64)>,
1981) -> i64 {
1982    character_power_from_attrs_raw(config, lookups, attrs, abilities, leader_pet_ability).floor()
1983        as i64
1984}
1985
1986/// [`character_power_from_attrs`] before the floor — the `P_static` term of
1987/// `P_ui = floor(P_static × Πq)` (BAL-030).
1988///
1989/// The dynamic multipliers are applied by the caller and the result is floored
1990/// ONCE at the very end, so a component worth a fraction of a point still moves
1991/// the number instead of being rounded away mid-formula.
1992pub fn character_power_from_attrs_raw(
1993    config: &GameConfig,
1994    lookups: &ContentLookups,
1995    attrs: &AttrMap,
1996    abilities: &EquippedAbilities,
1997    leader_pet_ability: Option<(Uuid, i64)>,
1998) -> f64 {
1999    let attrs_power = power_from_attrs_raw(attrs);
2000    // Honest ability multipliers: combat-membership set, DPS/EHP split — see
2001    // [`ability_power_mults`]. Display, matchmaking and gating all read this
2002    // path, so the scalar rates what combat actually delivers — including the
2003    // leader pet's active ability.
2004    let (dps_mult, ehp_mult) =
2005        ability_power_mults(config, lookups, abilities, leader_pet_ability, attrs);
2006    attrs_power * dps_mult * ehp_mult
2007}
2008
2009/// The `attrs_power` term of [`character_power`]: char-level attributes,
2010/// inventory items and pet stats composed into an [`AttrMap`] and run through
2011/// [`power_from_attrs`] — WITHOUT the trailing `* ability_eff_sum` multiplier.
2012///
2013/// **Accumulation order is load-bearing** (per-key float sums): char-level
2014/// attributes, then inventory items (each item's attributes in order), then
2015/// pets (in the given slice order, each pet's stats in order) — exactly the
2016/// order the original used; [`character_power`] delegates here so both paths
2017/// stay byte-identical.
2018///
2019/// For callers comparing loadouts that carry no abilities of their own (e.g.
2020/// ranking pets for fast-equip), where [`character_power`]'s ability factor
2021/// would multiply every score by 0.
2022pub fn character_attrs_power(
2023    config: &GameConfig,
2024    level: i64,
2025    inventory: &[Item],
2026    pets: &[Pet],
2027) -> Result<i64, String> {
2028    // BAL-030: `power_bonus` is display-inert and enters no power path.
2029    let (collected, _total_power_bonus) = compose_character_attrs(config, level, inventory, pets)?;
2030    Ok(power_from_attrs(&collected))
2031}
2032
2033/// [`character_attrs_power`] before the floor, for callers that RANK loadouts
2034/// rather than display a number. See [`power_from_attrs_raw`] for why the
2035/// integer is unusable as a sort key.
2036pub fn character_attrs_power_raw(
2037    config: &GameConfig,
2038    level: i64,
2039    inventory: &[Item],
2040    pets: &[Pet],
2041) -> Result<f64, String> {
2042    // BAL-030: `power_bonus` is display-inert and enters no power path.
2043    let (collected, _total_power_bonus) = compose_character_attrs(config, level, inventory, pets)?;
2044    Ok(power_from_attrs_raw(&collected))
2045}
2046
2047/// Compose the char-level + items + pets attribute map (the input to
2048/// [`power_from_attrs`]) plus the summed per-item `power_bonus` jitter.
2049/// Extracted from [`character_attrs_power`] so [`character_power`] can reuse
2050/// the composed attrs for the honest ability EHP cap (it needs `hp`/`attack`).
2051pub fn compose_character_attrs(
2052    config: &GameConfig,
2053    level: i64,
2054    inventory: &[Item],
2055    pets: &[Pet],
2056) -> Result<(AttrMap, i64), String> {
2057    let mut collected: AttrMap = AttrMap::new();
2058
2059    let Some(char_level_tpl) = config.character_level(level) else {
2060        return Err(format!(
2061            "balance::character_power: missing character_level {level}"
2062        ));
2063    };
2064
2065    for attr in &char_level_tpl.attributes {
2066        if let Some(a) = config.attribute(attr.attribute_id) {
2067            // Original: `collected.insert(code, value)` — an unconditional
2068            // overwrite (NOT accumulate). If two char-level entries share a
2069            // code, the later one wins. Preserve that exactly with `insert`.
2070            collected.insert(a.code.as_str().to_string(), attr.value as f64);
2071        }
2072    }
2073
2074    // Sum the per-item power jitter alongside attribute accumulation. Items that
2075    // were rolled before the jitter feature (or arena bots) carry power_bonus=0,
2076    // so they are unaffected by this sum.
2077    let mut total_power_bonus: i64 = 0;
2078
2079    for item in inventory {
2080        for item_attr in &item.attributes {
2081            if let Some(a) = config.attribute(item_attr.attr_id) {
2082                accumulate(&mut collected, a.code.as_str(), item_attr.value as f64);
2083            }
2084        }
2085        total_power_bonus += item.power_bonus as i64;
2086    }
2087
2088    for pet in pets {
2089        for stat in &pet.stats {
2090            if let Some(a) = config.attribute(stat.attribute_id) {
2091                accumulate(&mut collected, a.code.as_str(), stat.value as f64);
2092            }
2093        }
2094    }
2095
2096    // Deflate the composed ARMOR rating by character level, mirroring the full
2097    // aggregation path (`attributes::calculate_player_entity_stats_with_zeroes`)
2098    // so this marginal gear-compare / auto-equip scalar prices armor the same
2099    // way combat and displayed Combat-Power do. The deflator is a per-level
2100    // scalar independent of the armor amount, so applying it to the item+pet
2101    // subset here matches the full aggregate exactly on the armor axis (it
2102    // distributes over the sum, and the omitted class/talent/statue baseline
2103    // armor cancels in the with-minus-without difference). Without it the
2104    // marginal path over-values armor, increasingly with level, mis-ranking
2105    // armor items in auto-equip and gear-compare tooltips.
2106    let deflator = player_armor_rating_deflator(level);
2107    if deflator < 1.0
2108        && let Some(v) = collected.get_mut("armor")
2109    {
2110        *v *= deflator;
2111    }
2112
2113    Ok((collected, total_power_bonus))
2114}
2115
2116#[cfg(test)]
2117mod attr_spread_tests {
2118    //! Deterministic equivalence tests for the branchy `attr_spread_for_item`
2119    //! (the RNG-driven primitive behind every item-attribute calc). Driven by
2120    //! `GameRng::from_values` so they prove formula + *draw count* without
2121    //! any sim entropy. Draw count is load-bearing for determinism — the random
2122    //! seed advances per draw — so each branch asserts how many draws it consumes.
2123
2124    use super::*;
2125
2126    /// A template id that is NOT in `item_fixed_power`, so the fixed-power early
2127    /// return never fires.
2128    fn non_fixed_template() -> Uuid {
2129        Uuid::from_u128(0x0194d64e_2100_7569_91be_a3d34c967544)
2130    }
2131
2132    /// Count of `f64` draws a recording RNG sees after one `attr_spread_for_item`.
2133    fn draws_for(level: f64) -> usize {
2134        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2135        let lk = ContentLookups::default();
2136        let rng = GameRng::from_entropy_recording();
2137        let _ = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2138        rng.recorded().len()
2139    }
2140
2141    /// Level 1 rolls a WIDE spread around the weakened 0.65 mean — the old
2142    /// constant made every level-1 roll a clone of the scripted starters.
2143    #[test]
2144    fn level_one_rolls_wide_spread_around_065() {
2145        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2146        let lk = ContentLookups::default();
2147
2148        // r=0.5 lands exactly on the mean.
2149        let rng = GameRng::from_values(vec![0.5]);
2150        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
2151        assert!(
2152            (v - LEVEL_ONE_ATTR_MEAN).abs() < 1e-12,
2153            "expected the mean, got {v}"
2154        );
2155
2156        // r=0.0 / r=1.0 hit the band edges: 0.65 × (1 ∓ 0.2).
2157        let rng = GameRng::from_values(vec![0.0]);
2158        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
2159        let low = LEVEL_ONE_ATTR_MEAN * (1.0 - ATTR_DEVIATION_LEVEL_ONE);
2160        assert!((v - low).abs() < 1e-12, "expected {low}, got {v}");
2161
2162        let rng = GameRng::from_values(vec![1.0]);
2163        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
2164        let high = LEVEL_ONE_ATTR_MEAN * (1.0 + ATTR_DEVIATION_LEVEL_ONE);
2165        assert!((v - high).abs() < 1e-12, "expected {high}, got {v}");
2166
2167        assert_eq!(
2168            draws_for(1.0),
2169            1,
2170            "level==1 must consume exactly one RNG draw"
2171        );
2172    }
2173
2174    #[test]
2175    fn level_le_25_uses_one_draw_via_attr_spread_random() {
2176        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2177        let lk = ContentLookups::default();
2178        // attr_spread_random(r) = 1 + ATTR_DEVIATION*(2*r - 1). r=0.5 -> 1.0.
2179        let rng = GameRng::from_values(vec![0.5]);
2180        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 25.0);
2181        assert!((v - 1.0).abs() < 1e-12, "expected 1.0, got {v}");
2182        // r=0.0 -> 1 - ATTR_DEVIATION; r=1.0 -> 1 + ATTR_DEVIATION.
2183        let rng = GameRng::from_values(vec![0.0]);
2184        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 10.0);
2185        assert!((v - (1.0 - ATTR_DEVIATION)).abs() < 1e-12);
2186        assert_eq!(
2187            draws_for(25.0),
2188            1,
2189            "level<=25 must consume exactly one draw"
2190        );
2191    }
2192
2193    /// A2-BAL-003 §3.3: there is no longer a second regime above item L25.
2194    ///
2195    /// These replace `level_gt_25_uses_two_draws_{lower,upper}_branch`, which
2196    /// pinned the skewed fake-level roll. That roll changed the distribution's
2197    /// SHAPE partway up the ladder — the same item rolled a different spread
2198    /// depending on how deep it dropped, which the signed `0.9..1.1` band cannot
2199    /// describe. Draw count is asserted too: it dropped from two to one, and the
2200    /// session RNG advances per draw, so a change there shifts every later roll.
2201    #[test]
2202    fn every_level_above_one_uses_the_same_uniform_spread() {
2203        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2204        let lk = ContentLookups::default();
2205
2206        for level in [2.0, 25.0, 26.0, 100.0, 400.0] {
2207            let rng = GameRng::from_values(vec![0.5]);
2208            let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2209            assert!(
2210                (v - 1.0).abs() < 1e-12,
2211                "level {level}: r=0.5 must land on 1.0, got {v}"
2212            );
2213
2214            let rng = GameRng::from_values(vec![0.0]);
2215            let lo = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2216            assert!(
2217                (lo - (1.0 - ATTR_DEVIATION)).abs() < 1e-12,
2218                "level {level}: low edge"
2219            );
2220
2221            let rng = GameRng::from_values(vec![1.0]);
2222            let hi = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
2223            assert!(
2224                (hi - (1.0 + ATTR_DEVIATION)).abs() < 1e-12,
2225                "level {level}: high edge"
2226            );
2227
2228            assert_eq!(
2229                draws_for(level),
2230                1,
2231                "level {level} must consume exactly one draw — the old deep-level \
2232                 branch consumed two"
2233            );
2234        }
2235    }
2236
2237    #[test]
2238    fn fixed_power_short_circuits_and_draws_nothing() {
2239        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2240        let mut lk = ContentLookups::default();
2241        let tpl = non_fixed_template();
2242        lk.item_fixed_power.insert(tpl, 1.23);
2243        let rng = GameRng::from_entropy_recording();
2244        let v = attr_spread_for_item(&cfg, &lk, &rng, tpl, 100.0);
2245        assert_eq!(v, 1.23);
2246        assert_eq!(
2247            rng.recorded().len(),
2248            0,
2249            "fixed-power path must not draw RNG"
2250        );
2251    }
2252}
2253
2254#[cfg(test)]
2255mod power_formula_tests {
2256    //! Pins `power_from_attrs` to the **v2 P = DPS × EHP** formula with DR
2257    //! curves, so a regression to the legacy opaque `S²` formula FAILS here
2258    //! (the two give different numbers for the same loadout). This is the proof
2259    //! that the new combat-power formula is the one actually running.
2260    use super::*;
2261
2262    #[test]
2263    fn power_is_dps_times_ehp_with_dr() {
2264        let mut a = AttrMap::new();
2265        a.insert("hp".into(), 3000.0);
2266        a.insert("attack".into(), 600.0);
2267        a.insert("speed".into(), 10000.0); // attack rate 1.0
2268        a.insert("armor".into(), 2000.0); // armor_k = K/(armor+K) = 2000/4000 = 0.5
2269        a.insert("evasion".into(), 1500.0); // dodge = 1500/(1500+6000) = 0.2
2270        // crit / multicast / bravery / block / regen / deceit / counterattack absent → 0.
2271        //
2272        // DPS = attack(600) · rate(1.0) · crit(1) · multicast(1) · bravery(1) · counter(1) = 600
2273        // EHP = hp(3000) ÷ armor_k(0.5) ÷ (1 − dodge 0.2) = 3000 / 0.5 / 0.8 = 7500
2274        // power = floor(DPS·EHP / POWER_NORM) = floor(600·7500 / 1800) = 2500
2275        // (the legacy S² formula gives 1470 for this loadout — the assertion
2276        // proves the DPS×EHP path is the one running.)
2277        assert_eq!(power_from_attrs(&a), 2500);
2278    }
2279
2280    #[test]
2281    fn armor_k_is_dr_not_linear() {
2282        // DR curve: always in (0,1], never negative — the legacy `1 - armor/10000`
2283        // hit 0 at armor=10000 and went negative beyond (the shipped bug).
2284        assert!((armor_k(0.0) - 1.0).abs() < 1e-9);
2285        assert!((armor_k(K_ARMOR) - 0.5).abs() < 1e-9); // rating == K → 50% through
2286        assert!(armor_k(50_000.0) > 0.0); // huge armor: still positive (no heal-the-target bug)
2287        assert!(armor_k(50_000.0) < 0.05);
2288    }
2289
2290    #[test]
2291    fn probability_stats_clamp_at_100pct() {
2292        // crit / multicast / counterattack chances over the basis-point cap
2293        // (e.g. an over-generous class-level grant) must clamp to 100% — power
2294        // can't keep scaling past a certain chance. Guards the `.clamp(0,1)`s.
2295        let base = || {
2296            let mut a = AttrMap::new();
2297            a.insert("hp".into(), 3000.0);
2298            a.insert("attack".into(), 600.0);
2299            a.insert("speed".into(), 10000.0);
2300            a
2301        };
2302        let with = |stat: &str, v: f64| {
2303            let mut a = base();
2304            a.insert(stat.into(), v);
2305            power_from_attrs(&a)
2306        };
2307        // crit's clamp point sits at raw 10000/crit_chance_scale (the scalar
2308        // prices crit at the REALIZED proc rate, so a sub-1 scale moves the
2309        // clamp point up; both raw values below are past it either way).
2310        assert_eq!(
2311            with("crit_chance", 20_000.0),
2312            with("crit_chance", 5_000_000.0),
2313            "crit_chance must clamp at 100% effective (no power growth past the cap)"
2314        );
2315        for stat in ["multicast_chance", "counterattack_chance"] {
2316            assert_eq!(
2317                with(stat, 10000.0),
2318                with(stat, 5_000_000.0),
2319                "{stat} must clamp at 100% (no power growth past the cap)"
2320            );
2321        }
2322    }
2323
2324    #[test]
2325    fn scalar_matches_combat_on_received_damage_and_speed_baseline() {
2326        // received_damage: combat applies it; the scalar must too. A −5000 mod (10000−5000 =
2327        // 5000 = 50% damage taken) ⇒ ×2 EHP ⇒ double power; neutral (absent) ⇒ ×1 (no change).
2328        let mut neutral = AttrMap::new();
2329        neutral.insert("hp".into(), 3000.0);
2330        neutral.insert("attack".into(), 600.0);
2331        neutral.insert("speed".into(), 10000.0);
2332        assert_eq!(
2333            power_from_attrs(&neutral),
2334            1000,
2335            "neutral baseline = DPS·EHP/NORM"
2336        );
2337        let mut reduced = neutral.clone();
2338        reduced.insert("received_damage".into(), -5000.0);
2339        assert_eq!(
2340            power_from_attrs(&reduced),
2341            2000,
2342            "50% received_damage must double EHP/power (scalar matches combat)"
2343        );
2344
2345        // speed ≤ 0 falls back to the baseline cast rate (like combat's `speed_or_baseline`),
2346        // NOT 0 DPS — a build with no speed stat must still have positive power.
2347        let mut no_speed = AttrMap::new();
2348        no_speed.insert("hp".into(), 3000.0);
2349        no_speed.insert("attack".into(), 600.0); // no "speed" key ⇒ composed speed 0
2350        assert_eq!(
2351            power_from_attrs(&no_speed),
2352            1000,
2353            "speed≤0 must use baseline rate (1.0), not 0 DPS"
2354        );
2355    }
2356}
2357
2358#[cfg(test)]
2359mod stat_equivalence_audit {
2360    //! SimC-style equivalence-points audit: the marginal HONEST-POWER value of
2361    //! one ITEM ROLL of each optional stat must stay within a band — no dead
2362    //! stats, no dominant stat. Per-roll magnitudes mirror
2363    //! `behaviors/items.rs` (aux stats all ride
2364    //! `(eff·spread)^AUX_ATTR_IMPACT`, so they scale together by
2365    //! construction; this test pins that equivalence against regressions in
2366    //! either the item formulas or the scalar pricing). Marginals are
2367    //! evaluated at a smooth mid loadout, away from clamps.
2368    use super::*;
2369
2370    fn mid_loadout() -> AttrMap {
2371        let mut a = AttrMap::new();
2372        a.insert("hp".into(), 3000.0);
2373        a.insert("attack".into(), 600.0);
2374        a.insert("speed".into(), 10000.0);
2375        a.insert("armor".into(), 1000.0);
2376        a.insert("crit_chance".into(), 1000.0);
2377        a
2378    }
2379
2380    fn marginal(stat: &str, roll: f64) -> f64 {
2381        let base = power_from_attrs(&mid_loadout()) as f64;
2382        let mut b = mid_loadout();
2383        *b.entry(stat.to_string()).or_insert(0.0) += roll;
2384        (power_from_attrs(&b) as f64 - base) / base
2385    }
2386
2387    #[test]
2388    fn optional_item_roll_values_stay_in_band() {
2389        // Per-roll permyriad at eff=5 (aux = 5^0.05 ≈ 1.0838), mirroring
2390        // items.rs attr fns (spread = 1, /10 item scale included):
2391        let aux: f64 = 5.0_f64.powf(AUX_ATTR_IMPACT);
2392        let a2 = aux * aux;
2393        let rolls: &[(&str, f64)] = &[
2394            (
2395                "crit_chance",
2396                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 4.0) * 1000.0,
2397            ),
2398            (
2399                "crit_modifier",
2400                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 2.0) * 1000.0,
2401            ),
2402            ("evasion", (1.0 - 1.0 / aux) * 1000.0),
2403            ("speed", (aux - 1.0) * 1000.0),
2404            (
2405                "multicast_chance",
2406                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 4.0) * 1000.0,
2407            ),
2408            (
2409                "counterattack_chance",
2410                ((-1.0 + (8.0 * a2 - 7.0).sqrt()) / 4.0) * 1000.0,
2411            ),
2412            ("block", ((aux - 1.0) * 1000.0).min(200.0)),
2413        ];
2414        let mut vals = vec![];
2415        for (stat, roll) in rolls {
2416            let m = marginal(stat, *roll);
2417            println!("EP {stat:22} roll {roll:7.1} -> dP/P {:+.4}", m);
2418            assert!(m > 0.0, "{stat}: dead stat — a roll must buy SOME power");
2419            vals.push((stat, m));
2420        }
2421        let mut sorted: Vec<f64> = vals.iter().map(|(_, m)| *m).collect();
2422        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2423        let median = sorted[sorted.len() / 2];
2424        for (stat, m) in &vals {
2425            assert!(
2426                *m >= median / 5.0 && *m <= median * 5.0,
2427                "{stat}: per-roll value {m:.4} outside x5 band of median {median:.4}"
2428            );
2429        }
2430    }
2431}
2432
2433#[cfg(test)]
2434mod v2_helper_tests {
2435    //! Pins the v2 sim-tunable helpers: enemy-curve anchor+monotonicity (the
2436    //! progression gate), the sub-linear gold faucet (the cost-curve bind), and
2437    //! the per-tick heal/damage cap (anti out-heal / one-shot).
2438    use super::*;
2439
2440    #[test]
2441    fn enemy_curve_anchored_and_monotonic() {
2442        // The curve's PRODUCTION domain is ch ≥ HAND_AUTHORED_CHAPTER_MAX+1:
2443        // `enemy_power_scalar` uses the hand-authored YAML `base_power` for
2444        // ch0-9, so `enemy_power_for_chapter` is only ever spawn-consulted for
2445        // ch ≥ 10. Strictly increasing THERE — content must gate progression.
2446        // (stage2-bridge CYCLE 3: the ch10 entry was lowered to E(10)≈416, below
2447        // the phantom ch7-9 curve values (E(7)=686), so monotonicity is required
2448        // only in the production domain ch≥10, not from the legacy anchor ch7.)
2449        // BAL-037: the signed curve owns ch0…509, so monotonicity is required
2450        // from the very first chapter — and it is NON-DECREASING, because the
2451        // contract's single final `floor` may legitimately produce equal
2452        // adjacent integers. A strict-rise assertion here would push an
2453        // implementer toward the forbidden artificial `+1`.
2454        let mut prev = enemy_power_for_chapter(0);
2455        assert!(prev > 0.0);
2456        for ch in 1..=70 {
2457            let p = enemy_power_for_chapter(ch);
2458            assert!(
2459                p >= prev,
2460                "enemy power must not decrease: ch{ch} → {p} < {prev}"
2461            );
2462            prev = p;
2463        }
2464        // The ch9→10 seam is continuous by construction: E_base(10) ≈ E_base(9) ×
2465        // 1.03. This is the accidental ×9 jump the signed curve exists to remove,
2466        // so it is worth pinning rather than trusting the knots by eye.
2467        let seam = e_base(10) / e_base(9);
2468        assert!(
2469            (seam - 1.03).abs() < 0.02,
2470            "ch9→10 must stay continuous: seam ratio {seam} (legacy curve jumped ~×9)"
2471        );
2472    }
2473
2474    #[test]
2475    fn dungeon_uses_authored_base_power_not_chapter_curve() {
2476        // A dungeon fight is tagged CampaignBossFight but must use its authored
2477        // per-difficulty `base_power` (the difficulty ladder), NOT the campaign
2478        // chapter curve — otherwise every difficulty collapses to one power keyed
2479        // to the player's campaign chapter ("losing to the dungeon at unlock").
2480        let ch = 20;
2481        // Campaign boss at ch20 ignores base_power and rides the (large) curve.
2482        let campaign = enemy_power_scalar(1500.0, ch, "CampaignBossFight", false);
2483        assert!(
2484            campaign > 10_000.0,
2485            "campaign boss must use the chapter curve at ch20, got {campaign}"
2486        );
2487        // Same template AS A DUNGEON returns the authored base_power verbatim —
2488        // difficulty 1 (1500) is a trivial first clear vs a ch20 player (~20k).
2489        assert_eq!(
2490            enemy_power_scalar(1500.0, ch, "CampaignBossFight", true),
2491            1500.0
2492        );
2493        // The ladder is honored across difficulties and is chapter-independent:
2494        // diff 5 (50k) and diff 10 (8.5M) pass through unchanged at any chapter.
2495        assert_eq!(
2496            enemy_power_scalar(50_000.0, ch, "CampaignBossFight", true),
2497            50_000.0
2498        );
2499        assert_eq!(
2500            enemy_power_scalar(8_500_000.0, 80, "CampaignBossFight", true),
2501            8_500_000.0
2502        );
2503    }
2504
2505    #[test]
2506    fn whole_first_stage_rides_the_signed_ftue_curve() {
2507        // BAL-037 refits the whole FTUE block as one progression sequence: stage 1
2508        // no longer reads the authored `FightTemplate.power`, and its current
2509        // absolute rows `1…46` are explicitly NOT preserved. Waves are
2510        // `S_ref × (0.45 → 0.65)` and every boss — FTUE included — carries the one
2511        // total encounter coefficient `sqrt(1.30)`.
2512        for ch in 0..=9 {
2513            let wave = enemy_power_scalar(1234.0, ch, "CampaignFight", false);
2514            assert_ne!(wave, 1234.0, "stage-1 wave at {ch} must ignore base_power");
2515            assert_eq!(wave, enemy_wave_power(ch));
2516            assert_eq!(
2517                enemy_power_scalar(1234.0, ch, "CampaignBossFight", false),
2518                enemy_boss_power(ch),
2519                "stage-1 boss at {ch} takes sqrt(1.30), no separate mid-boss gate"
2520            );
2521        }
2522        // The signed endpoints of the FTUE ramp: 0.45 of S_ref at ch0, 0.65 at ch9.
2523        assert_eq!(enemy_wave_power(0), (30.0f64 * 0.45).floor());
2524        assert_eq!(enemy_wave_power(9), (4_100.0f64 * 0.65).floor());
2525        // ch10 (stage 2-1) and beyond ride the CURVE, not base_power. Post
2526        // stage2-bridge the ch10 boss carries NO mid-gate (the gate moved to the
2527        // ch12-14 window) — only the ×STEP^0.5 boss bump — so it equals
2528        // enemy_power_for_chapter(10)×STEP^0.5. We assert the curve regime is
2529        // engaged (result diverges from the passed base_power) and that the ch10
2530        // boss is the clean (un-gated) boss value.
2531        let boss10 = enemy_power_scalar(1234.0, 10, "CampaignBossFight", false);
2532        let expect10 = enemy_power_for_chapter(10) * CHAPTER_POWER_STEP.powf(0.5);
2533        assert!(
2534            (boss10 - expect10).abs() < 1.0 && (boss10 - 1234.0).abs() > 1.0,
2535            "ch10 boss must ride the curve with no mid-gate, got {boss10} vs {expect10}"
2536        );
2537        // The mid-boss gate now fires on the ch12-14 window, NOT the boundary:
2538        // ch10/ch11 bosses are un-gated, ch12 carries the peak bump.
2539        assert_eq!(
2540            enemy_mid_chapter_mult(10),
2541            1.0,
2542            "ch10 boss must be un-gated"
2543        );
2544        assert_eq!(
2545            enemy_mid_chapter_mult(11),
2546            1.0,
2547            "ch11 boss must be un-gated"
2548        );
2549        assert!(
2550            enemy_mid_chapter_mult(12) > 1.0,
2551            "ch12 boss must carry the mid-gate gear-check"
2552        );
2553        assert!(
2554            (enemy_power_scalar(1234.0, 10, "CampaignFight", false) - 1234.0).abs() > 1.0,
2555            "ch10 wave must ride the curve, not base_power"
2556        );
2557    }
2558
2559    #[test]
2560    fn sell_price_is_sublinear() {
2561        // Sub-linear (exp < 1): doubling `eff` less-than-doubles price, so the
2562        // geometric chest-upgrade sink can outgrow the gold faucet and bind
2563        // progression. The ratio check below fails if exp ever reaches ≥ 1.
2564        let p1 = sell_price(1000.0) as f64;
2565        let p2 = sell_price(2000.0) as f64;
2566        assert!(p2 > p1, "price must increase with eff");
2567        assert!(
2568            p2 < 2.0 * p1,
2569            "sub-linear: doubling eff must less-than-double price ({p1} → {p2})"
2570        );
2571        assert_eq!(sell_price(-5.0), 0, "negative eff floored to 0");
2572    }
2573
2574    #[test]
2575    fn sell_price_with_config_precedence() {
2576        // Per-knob precedence resolver: env (sim sweep) wins over the GameConfig
2577        // value, which wins over the compiled constant. Tested on the pure
2578        // resolver so no process-global env is mutated — race-free under
2579        // threaded `cargo test`.
2580        assert_eq!(
2581            resolve_sell_knob(Some(0.9), Some(0.6), SELL_PRICE_EXP),
2582            0.9,
2583            "env wins over config"
2584        );
2585        assert_eq!(
2586            resolve_sell_knob(None, Some(0.6), SELL_PRICE_EXP),
2587            0.6,
2588            "config wins over the constant when env is unset"
2589        );
2590        assert_eq!(
2591            resolve_sell_knob(None, None, SELL_PRICE_EXP),
2592            SELL_PRICE_EXP,
2593            "constant default when neither env nor config is set"
2594        );
2595
2596        // The full price fn reads `OVERLORD_BAL_SELL_*` live, so pin the
2597        // config/default cases only when those vars are absent (the standard
2598        // test env); an env sweep is covered by the resolver assertions above.
2599        let env_clean = std::env::var("OVERLORD_BAL_SELL_EXP").is_err()
2600            && std::env::var("OVERLORD_BAL_SELL_COEF").is_err();
2601        if env_clean {
2602            for &eff in &[0.0_f64, 1.0, 250.0, 1000.0, 9999.0] {
2603                let baseline = sell_price(eff);
2604                // Config absent ⇒ compiled constants ⇒ bit-identical to the
2605                // pre-config `sell_price` path.
2606                assert_eq!(
2607                    sell_price_with_config(eff, None, None),
2608                    baseline,
2609                    "config absent ⇒ constant default (bit-identical to sell_price)"
2610                );
2611                // Deploying the config with the current constant values is a
2612                // no-op on price.
2613                assert_eq!(
2614                    sell_price_with_config(eff, Some(SELL_PRICE_EXP), Some(SELL_PRICE_COEF)),
2615                    baseline,
2616                    "config = current constants ⇒ unchanged price"
2617                );
2618            }
2619            // A config override with different knobs is honored (env unset).
2620            let eff = 1000.0_f64;
2621            assert_eq!(
2622                sell_price_with_config(eff, Some(0.60), Some(50.0)),
2623                (eff.powf(0.60) * 50.0).floor() as i64,
2624                "config knobs used when present and env unset"
2625            );
2626        }
2627    }
2628
2629    #[test]
2630    fn eff_by_level_branch_join_is_continuous() {
2631        // The two eff branches must join at L=130 in value AND slope — the
2632        // A2/B2/C2 late-branch consts are a re-fit whenever B1/B2 move (see the
2633        // closed form in the const block). Guards against a future sweep
2634        // baking a discontinuous pair (a value step here is a hidden gear
2635        // cliff at item level 130).
2636        let left = eff_by_level(EFF_MIDGAME_LEVEL);
2637        let right = EFF_A2 * (EFF_MIDGAME_LEVEL + 1e-9).powf(EFF_B2) + EFF_C2;
2638        assert!(
2639            ((right - left) / left).abs() < 1e-3,
2640            "eff branch value step at L130: left {left}, right {right}"
2641        );
2642        let slope_left = EFF_A1 * EFF_B1 * (EFF_MIDGAME_LEVEL - 1.0).powf(EFF_B1 - 1.0);
2643        let slope_right = EFF_A2 * EFF_B2 * EFF_MIDGAME_LEVEL.powf(EFF_B2 - 1.0);
2644        assert!(
2645            ((slope_right - slope_left) / slope_left).abs() < 1e-2,
2646            "eff branch slope step at L130: left {slope_left}, right {slope_right}"
2647        );
2648    }
2649
2650    #[test]
2651    fn smooth_curve_has_one_universal_tail_and_no_authored_walls() {
2652        assert_eq!(e_base(45), 302_619.0);
2653        for ch in 46..=509 {
2654            let ratio = e_base(ch) / e_base(ch - 1);
2655            assert!(
2656                (ratio - E_BASE_TAIL_STEP).abs() < 1e-12,
2657                "ch{ch}: universal tail ratio {ratio} vs {E_BASE_TAIL_STEP}"
2658            );
2659        }
2660
2661        // Wave and boss curves read the same smooth base. The boss coefficient
2662        // is global, not a chapter spike, and final integer flooring is the only
2663        // permitted divergence from the analytic values.
2664        for ch in [10, 21, 25, 31, 35, 45, 65, 95, 165, 300, 509] {
2665            assert_eq!(enemy_wave_power(ch), e_base(ch).floor());
2666            let expected_boss = (e_base(ch) * BOSS_ENCOUNTER_COEF_SQ.sqrt()).floor();
2667            assert_eq!(enemy_boss_power(ch), expected_boss);
2668        }
2669
2670        let chapters_per_twenty_percent = 1.20f64.ln() / E_BASE_TAIL_STEP.ln();
2671        assert!(
2672            (2.0..=2.7).contains(&chapters_per_twenty_percent),
2673            "+20% strength should open several chapters, got {chapters_per_twenty_percent}"
2674        );
2675        let chapters_per_fifty_percent = 1.50f64.ln() / E_BASE_TAIL_STEP.ln();
2676        assert!(
2677            (5.0..=5.7).contains(&chapters_per_fifty_percent),
2678            "+50% strength should open a short chapter streak, got {chapters_per_fifty_percent}"
2679        );
2680    }
2681
2682    #[test]
2683    fn interp_geometric_knots_between_and_extrapolation() {
2684        let knots: &[(i64, f64)] = &[(10, 100.0), (20, 400.0), (30, 800.0)];
2685        // Exact at knots.
2686        assert_eq!(interp_geometric(knots, 10), 100.0);
2687        assert_eq!(interp_geometric(knots, 20), 400.0);
2688        // Constant before the first knot.
2689        assert_eq!(interp_geometric(knots, 0), 100.0);
2690        // Geometric midpoint between knots: sqrt(100·400) = 200.
2691        assert!((interp_geometric(knots, 15) - 200.0).abs() < 1e-9);
2692        // Past the end: final segment ratio (×2 per 10ch ⇒ ×2^(1/10) per ch).
2693        let per_ch = (800.0f64 / 400.0).powf(0.1);
2694        assert!((interp_geometric(knots, 35) - 800.0 * per_ch.powi(5)).abs() < 1e-6);
2695        // Degenerate shapes.
2696        assert_eq!(interp_geometric(&[], 5), 0.0);
2697        assert_eq!(interp_geometric(&[(1, 42.0)], 99), 42.0);
2698    }
2699
2700    #[test]
2701    fn ability_milestones_pin() {
2702        // L5 ×1.10 and L10 ×1.20 (cumulative) on top of the +5%/level line —
2703        // the proximate-milestone shape of the ability upgrade track.
2704        // Uses the default lookups (unknown rarity ⇒ rarity_eff 1.0).
2705        let lk = ContentLookups::default();
2706        let r = Uuid::nil();
2707        assert!((ability_eff(&lk, r, 1) - 1.0).abs() < 1e-12);
2708        assert!((ability_eff(&lk, r, 4) - 1.15).abs() < 1e-12);
2709        assert!((ability_eff(&lk, r, 5) - 1.20 * 1.10).abs() < 1e-12);
2710        assert!((ability_eff(&lk, r, 10) - 1.45 * 1.10 * 1.20).abs() < 1e-12);
2711    }
2712
2713    #[test]
2714    fn zone_correction_preserves_enemy_curve() {
2715        // The enemy curve is expressed in zone-corrected units ρ̂ = P/(z·E);
2716        // this test pins E = G/(ρ̂·z) at reference chapters so nobody edits ρ̂
2717        // or z WITHOUT re-deriving the pair (and G) together. Re-bake the pins
2718        // only as part of a legitimate re-derivation of all three tables.
2719        // stage2-bridge CYCLE 3: the WHOLE curve ch10+ re-derived from the
2720        // required chest-open cadence (designer-authorized full retirement of
2721        // the old funded curve). Every pin ch15+ re-baked. ch1/7 unchanged (E(7)
2722        // legacy anchor pin; ch0-9 hand-authored, phantom curve). ch80+ are in
2723        // the model-EXTRAPOLATED 12/min tail (unreachable in-test).
2724        let pins: &[(i64, f64)] = &[
2725            (1, 686.0),
2726            (7, 686.0),
2727            (15, 14979.0),
2728            (23, 1079676.0),
2729            (29, 6511267.0),
2730            (39, 17313684.0),
2731            (49, 35372775.0),
2732            (60, 67137418.0),
2733            (64, 77618278.0),
2734            (80, 114351114.0),
2735            (95, 199038697.0),
2736            (110, 409498525.0),
2737            (120, 662398186.0),
2738        ];
2739        for &(ch, expected) in pins {
2740            let e = enemy_power_derived(ch).unwrap();
2741            assert!(
2742                (e - expected).abs() / expected < 3e-4,
2743                "E({ch}) drifted: {e} vs ratified {expected}"
2744            );
2745        }
2746        // The corrected units themselves: uniform crossing ≈0.55 means the
2747        // on-schedule pressure ρ̂ stays within the sane band everywhere.
2748        for ch in 7..=120 {
2749            let rho_hat = interp_geometric(RHO_TARGET_KNOTS, ch);
2750            assert!(rho_hat > 0.2 && rho_hat < 20.0, "ρ̂({ch}) = {rho_hat}");
2751        }
2752    }
2753
2754    #[test]
2755    // Deliberate emptiness guard against the CURRENT const — see `zone_difficulty`.
2756    #[allow(clippy::const_is_empty)]
2757    fn derived_curve_unavailable_until_tables_baked() {
2758        // Safety: with empty calibration tables the derived mode must report
2759        // None so enemy_power_for_chapter falls back to legacy.
2760        if PLAYER_GROWTH_KNOTS.is_empty() || RHO_TARGET_KNOTS.is_empty() {
2761            assert_eq!(enemy_power_derived(40), None);
2762        } else {
2763            assert!(enemy_power_derived(40).unwrap() > 0.0);
2764        }
2765    }
2766
2767    #[test]
2768    fn wave_skew_defaults_pin_the_stat_check_package() {
2769        // The sub-1 magnitude lengthens fights (more RNG samples per outcome
2770        // = steeper sigmoid). DAMAGE below HP is deliberate (break-even
2771        // r ≈ 0.83): it compensates the slot-model fight shapes (streamed
2772        // waves, boss summons) whose damage pattern is less survivable than
2773        // the legacy decaying waves at the same budget — validated vs `main`
2774        // by matched n=6 7-day sims (2026-07). Changing either value moves
2775        // the game-wide difficulty; re-run the sim comparison first.
2776        assert_eq!(BALANCE_TUNING_DEFAULT.wave_damage_skew, 0.75);
2777        assert_eq!(BALANCE_TUNING_DEFAULT.wave_hp_skew, 0.9);
2778    }
2779
2780    #[test]
2781    fn cap_per_tick_binds_and_passes_through() {
2782        // Over-cap clamps to pct×max_hp; under-cap passes through unchanged.
2783        assert_eq!(cap_per_tick(1_000_000.0, 1000.0, 0.02), 20.0);
2784        assert_eq!(cap_per_tick(5.0, 1000.0, 0.02), 5.0);
2785        assert_eq!(cap_per_tick(0.0, 1000.0, 0.5), 0.0);
2786    }
2787}
2788
2789#[cfg(test)]
2790mod honest_ability_power_tests {
2791    //! The honest scalar's DPS/EHP-split ability contributions (see
2792    //! `ability_power_mults`). A regression back to a flat `Σ ability_eff`
2793    //! multiplier FAILS here.
2794    use super::*;
2795    use essences::abilities::EquippedAbilities;
2796
2797    #[test]
2798    fn profile_split_damage_vs_heal() {
2799        // Pure damage budget: everything lands on the DPS axis.
2800        let p = ability_profile_from_info(Some(10.0), None, None, None, None, 2.0);
2801        assert!((p.dps_add - 5.0).abs() < 1e-9); // 10 budget / 2s cd
2802        assert_eq!(p.heal_add, 0.0);
2803
2804        // Pure HoT budget: everything lands on the heal axis.
2805        let p = ability_profile_from_info(None, None, Some(10.0), None, None, 2.0);
2806        assert_eq!(p.dps_add, 0.0);
2807        assert!((p.heal_add - 5.0).abs() < 1e-9);
2808
2809        // Lifesteal: damage throughput + vampiric fraction of it as heal.
2810        let p = ability_profile_from_info(Some(10.0), None, None, Some(0.3), None, 2.0);
2811        assert!((p.dps_add - 5.0).abs() < 1e-9);
2812        assert!((p.heal_add - 1.5).abs() < 1e-9); // 0.3 × 10 / 2s
2813
2814        // DoT counts as damage throughput (its OT premium is already priced
2815        // into the budget split by the content closures).
2816        let p = ability_profile_from_info(Some(4.0), Some(6.0), None, None, None, 2.0);
2817        assert!((p.dps_add - 5.0).abs() < 1e-9);
2818
2819        // Multi-projectile: the info split reports per-projectile damage;
2820        // combat fires them all — throughput carries the full budget.
2821        let p = ability_profile_from_info(Some(4.0), None, None, None, Some(3), 2.0);
2822        assert!((p.dps_add - 6.0).abs() < 1e-9); // 4×3 / 2s
2823    }
2824
2825    fn test_ability(level: i64) -> Ability {
2826        // A template that EXISTS in the test config but has no bespoke
2827        // `ability_info` closure → the conservative pure-damage fallback
2828        // (eff × FD/(FD+cd)) applies. cd = 10000ms in the test config.
2829        Ability {
2830            template_id: Uuid::parse_str("da6c582b-7364-40bd-9b2d-946d8e20eaac").unwrap(),
2831            level,
2832            shards_amount: 0,
2833        }
2834    }
2835
2836    fn base_attrs() -> AttrMap {
2837        let mut a = AttrMap::new();
2838        a.insert("hp".into(), 3000.0);
2839        a.insert("attack".into(), 600.0);
2840        a.insert("speed".into(), 10000.0);
2841        a
2842    }
2843
2844    #[test]
2845    fn membership_matches_combat_unslotted_count_dead_do_not() {
2846        // Combat membership (`make_active_abilities_from_equipped`) sends BOTH
2847        // slotted and unslotted abilities into the fight — so unslotted (the
2848        // class kit) must add power. Abilities with an empty `start_behavior`
2849        // never begin a cast cycle — so they must add NOTHING.
2850        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2851        let lk = ContentLookups::default();
2852        let attrs = base_attrs();
2853
2854        let mut slotted_only = EquippedAbilities::new();
2855        slotted_only.slotted.insert(1, test_ability(1));
2856        let p_slotted = character_power_from_attrs(&cfg, &lk, &attrs, &slotted_only, None);
2857        assert!(p_slotted > 0);
2858
2859        // Unslotted castable ability adds throughput exactly like a slotted one.
2860        let mut with_kit = EquippedAbilities::new();
2861        with_kit.slotted.insert(1, test_ability(1));
2862        with_kit.unslotted.push(test_ability(1));
2863        let p_kit = character_power_from_attrs(&cfg, &lk, &attrs, &with_kit, None);
2864        assert!(
2865            (p_kit - p_slotted * 2).abs() <= 1, // floor rounding
2866            "an unslotted castable ability fights (combat membership) — it must add \
2867             power: got {p_kit}, expected ≈{}",
2868            p_slotted * 2
2869        );
2870
2871        // Dead ability (empty start_behavior) never casts — no power.
2872        let dead = Ability {
2873            template_id: Uuid::parse_str("00000000-dead-7000-8000-000000000001").unwrap(),
2874            level: 1,
2875            shards_amount: 0,
2876        };
2877        let mut with_dead = EquippedAbilities::new();
2878        with_dead.slotted.insert(1, test_ability(1));
2879        with_dead.unslotted.push(dead);
2880        let p_dead = character_power_from_attrs(&cfg, &lk, &attrs, &with_dead, None);
2881        assert_eq!(
2882            p_dead, p_slotted,
2883            "a dead (empty start_behavior) ability never casts — it must not add power"
2884        );
2885    }
2886
2887    #[test]
2888    fn fallback_throughput_carries_cooldown_factor() {
2889        // cd = 10s in the test config → k = FD/(FD+10) ≈ 0.4545. The legacy
2890        // formula would have used eff×1.0; the honest one must be scaled by k.
2891        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2892        let lk = ContentLookups::default();
2893        let mut eq = EquippedAbilities::new();
2894        eq.slotted.insert(1, test_ability(1));
2895        let (dps_mult, ehp_mult) = ability_power_mults(&cfg, &lk, &eq, None, &base_attrs());
2896        let k = FIGHT_DURATION / (FIGHT_DURATION + 10.0);
2897        // eff for the test rarity id is unknown to lookups → 1.0; level 1 → ×1.
2898        assert!(
2899            (dps_mult - k).abs() < 1e-9,
2900            "expected eff×k = {k}, got {dps_mult}"
2901        );
2902        assert_eq!(ehp_mult, 1.0, "pure damage ability must not credit EHP");
2903    }
2904
2905    #[test]
2906    fn no_slotted_abilities_means_zero_power() {
2907        // Legacy semantic preserved: a character that casts nothing deals
2908        // nothing — power 0 (see project_native_power_default).
2909        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2910        let lk = ContentLookups::default();
2911        let eq = EquippedAbilities::new();
2912        assert_eq!(
2913            character_power_from_attrs(&cfg, &lk, &base_attrs(), &eq, None),
2914            0
2915        );
2916    }
2917
2918    #[test]
2919    fn leader_pet_active_ability_adds_power() {
2920        // `create_player_entity` pushes the leader pet's active ability into
2921        // the fight set at the pet's level — the scalar must count it, priced
2922        // at the charge-fill rate (per-cast budget / PET_ULT_EFFECTIVE_CD),
2923        // not at the template cooldown.
2924        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2925        let lk = ContentLookups::default();
2926        let mut eq = EquippedAbilities::new();
2927        eq.slotted.insert(1, test_ability(1));
2928        let pet_ability_id = Uuid::parse_str("da6c582b-7364-40bd-9b2d-946d8e20eaac").unwrap();
2929        let (without, _) = ability_power_mults(&cfg, &lk, &eq, None, &base_attrs());
2930        let (with, _) =
2931            ability_power_mults(&cfg, &lk, &eq, Some((pet_ability_id, 1)), &base_attrs());
2932        assert!(
2933            with > without,
2934            "leader pet's castable active ability must add DPS throughput"
2935        );
2936        // Same template at the same level: slotted contributes eff·k, the pet
2937        // contributes the per-cast budget eff·k·cd spread over the fill time.
2938        let cd = (cfg.ability_template(pet_ability_id).unwrap().cooldown as f64 / 1000.0).max(0.1);
2939        let expected = without * (1.0 + cd / PET_ULT_EFFECTIVE_CD);
2940        assert!(
2941            (with - expected).abs() < 1e-9,
2942            "pet ult must be priced at the charge-fill rate: {with} vs {expected}"
2943        );
2944    }
2945
2946    #[test]
2947    fn power_bonus_is_inert_in_the_gear_compare() {
2948        // BAL-030: the per-item jitter is a persisted DISPLAY field and enters
2949        // no power path — display, matchmaking, or this gear-compare. A
2950        // cosmetic roll must not decide which item auto-equip keeps.
2951        let cfg = configs::tests_game_config::generate_game_config_for_tests();
2952        let lk = ContentLookups::default();
2953        let no_abilities = EquippedAbilities::new();
2954        let power_with = |bonus: i32| {
2955            let item = Item {
2956                power_bonus: bonus,
2957                ..Default::default()
2958            };
2959            character_power(&cfg, &lk, 1, &[item], &no_abilities, &[]).unwrap()
2960        };
2961        assert_eq!(
2962            power_with(0),
2963            power_with(7_777),
2964            "power_bonus must not move the gear-compare power"
2965        );
2966    }
2967
2968    #[test]
2969    fn heal_ability_credits_capped_ehp() {
2970        // Direct aggregation check on the heal axis: synthesize the profile
2971        // via the pure splitter, then verify the EHP factor shape + cap.
2972        let attrs = base_attrs(); // hp 3000, attack 600
2973        let heal_add: f64 = 5.0;
2974        let hp = 3000.0;
2975        let attack = 600.0;
2976        let uncapped = attack * DMG_K * heal_add; // 300 HP/s
2977        let cap = hp * BALANCE_TUNING_DEFAULT.hot_tick_max_pct; // 750 HP/s
2978        assert!(uncapped < cap, "test setup: below cap");
2979        let expected = (hp + uncapped * FIGHT_DURATION) / hp;
2980        // (hp + 300·25)/3000 = 3.5 at the measured FIGHT_DURATION of 25s.
2981        assert!((expected - 3.5).abs() < 1e-3);
2982        let _ = attrs; // attrs used above for provenance of the numbers
2983    }
2984}
2985
2986#[cfg(test)]
2987mod class_balance_tests {
2988    //! The four combat classes are differentiated by `class_levels` grants
2989    //! (Warrior→block, Rogue→crit, Mage→multicast, Priest→regen) but must remain
2990    //! POWER-balanced under `P=DPS×EHP`. Their grants overflow the basis-point
2991    //! cap, so this also guards the clamp/regen-cap robustness fixes: without
2992    //! them block>1 gave NEGATIVE power and regen exploded EHP ~40×.
2993    use super::*;
2994
2995    fn profile(stat: &str, value: f64) -> AttrMap {
2996        let mut a = AttrMap::new();
2997        a.insert("attack".into(), 600.0);
2998        a.insert("hp".into(), 3000.0);
2999        a.insert("speed".into(), 10000.0);
3000        a.insert(stat.into(), value);
3001        a
3002    }
3003
3004    #[test]
3005    fn class_power_offensive_trio_equal_regen_honestly_lower() {
3006        // The block class (Warrior, ×2 EHP) and the two ×2-DPS classes (Rogue crit,
3007        // Mage multicast) are power-balanced — symmetric ×2 mechanisms → equal power.
3008        let warrior = power_from_attrs(&profile("block", 38800.0)); // 3.88 → clamp 1.0 → ×2 EHP
3009        // The raw crit stat here overflows the realized-rate clamp regardless
3010        // of crit_chance_scale, so crit lands at 100% → the ×2 crit-damage
3011        // identity ⇒ ×2 DPS, symmetric with Mage's multicast.
3012        let rogue = power_from_attrs(&profile("crit_chance", 38800.0)); // clamp → ×2 DPS
3013        let mage = power_from_attrs(&profile("multicast_chance", 19400.0)); // → ×2 DPS
3014        let priest = power_from_attrs(&profile("regeneration_rate", 19400.0));
3015        assert!(
3016            warrior > 0,
3017            "block>1 must NOT produce negative power (clamp)"
3018        );
3019        assert_eq!(warrior, rogue, "Warrior vs Rogue imbalance");
3020        assert_eq!(rogue, mage, "Rogue vs Mage imbalance");
3021        // Priest's regen is honestly capped to what COMBAT delivers, which is
3022        // structurally below block's ×2 EHP — so the scalar shows Priest
3023        // LOWER. This is a real combat-balance fact, not a formula bug;
3024        // closing it (raise `regen_tick_max_pct` or compensate the Priest
3025        // grant) is a sim/design decision, not a scalar tweak.
3026        assert!(priest > 0, "Priest power must stay positive");
3027        assert!(
3028            priest < warrior,
3029            "honest regen EHP (≈×1.16) is below block's ×2 — Priest is structurally lower"
3030        );
3031    }
3032
3033    #[test]
3034    fn regen_is_bounded() {
3035        // Absurd regen must not explode EHP: the scalar applies the SAME per-tick cap as
3036        // combat (`regen_tick_max_pct × hp`), so regen above the cap adds no extra power.
3037        let huge = power_from_attrs(&profile("regeneration_rate", 1_000_000.0));
3038        let capped = power_from_attrs(&profile("regeneration_rate", 19400.0));
3039        assert_eq!(huge, capped, "regen beyond the cap must not increase power");
3040    }
3041}