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}