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