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
37pub const FIGHT_DURATION: f64 = 8.333;
38pub const BASE_HP: f64 = 3000.0;
39pub const BASE_ATTACK: f64 = 600.0;
40pub const DMG_K: f64 = 0.1;
41pub const BASE_CRIT_CHANCE: f64 = 0.0;
42pub const BASE_CRIT_MOD: f64 = 2.0;
43pub const BASE_POWER: i64 = 1000;
44pub const BASE_SPEED: f64 = 1.0;
45pub const COUNTERATTACK_POWER: f64 = 2.0;
46pub const BASE_SPELL_EFF: f64 = 1.0;
47pub const MAIN_ATTRS_QUANTITY: i64 = 3;
48pub const SPELL_QUANTITY: i64 = 6;
49pub const ATTR_DEVIATION: f64 = 0.1;
50pub const AUX_ATTR_IMPACT: f64 = 0.05;
51pub const AOE_COEF: f64 = 0.6;
52pub const OT_COEF: f64 = 1.2;
53pub const HEAL_COEF: f64 = 1.2;
54pub const BUFF_EFF: f64 = 1.5;
55pub const BRAVERY_BUFF_QUANTITY: i64 = 2;
56pub const BRAVERY_BUFF_DURATION: f64 = 1.0;
57pub const DECEIT_DEBUFF_QUANTITY: i64 = 2;
58pub const DECEIT_DEBUFF_DURATION: f64 = 1.0;
59pub const CASTS_PER_SEC: f64 = 2.0;
60pub const ATTACKS_PER_SEC: f64 = CASTS_PER_SEC * 1.1;
61
62// --- Balance v2: P = DPS × EHP, diminishing-returns curves R/(R+K) ----------
63// Avoidance/mitigation use the DR curve `rating/(rating+K)`: it asymptotes
64// below 100%, so no single defensive stat dominates and there is no cliff.
65// These replace the legacy linear `1 - armor/10000` (which hit 0 mitigation at
66// armor=10000 and went NEGATIVE — healing the target — beyond it) and the
67// linear `evasion/10000` dodge. Seed values from the model (§1/§5); the final
68// numbers come from the bot-sim, not the formula.
69pub const K_ARMOR: f64 = 2000.0;
70pub const K_DODGE: f64 = 1500.0;
71/// Default cap on per-tick combat regen, as a fraction of max HP (seed; final
72/// from the bot-sim). The `Regeneration_rate` effect fires ~1×/s over an ~8.3s
73/// `FIGHT_DURATION` (~8 ticks), so this bounds a fight's combat regen to
74/// ≈0.16×HP ⇒ ≈×1.16 EHP. The power scalar's regen-as-EHP is **derived from this
75/// same value** (`power_from_attrs`, SC-1 fix) so display/matchmaking can't over-
76/// rate what combat delivers — there is no separate scalar regen cap any more.
77pub const REGEN_TICK_MAX_PCT: f64 = 0.02;
78/// Per-tick caps for HoT/DoT effects, as a fraction of max HP — safety so a
79/// mis-tuned ability effect can't out-heal all damage (HoT) or one-shot (DoT).
80/// Generous (these are ability-derived, already bounded by ability balance); the
81/// cap only catches pathological values. Seed; sim-calibrated.
82pub const HOT_TICK_MAX_PCT: f64 = 0.25;
83pub const DOT_TICK_MAX_PCT: f64 = 0.50;
84/// Minimum `received_damage` multiplier (in /10000 units) so heavy mitigation
85/// (e.g. stacked `protection`) can't reach ≤0 → unkillable. 100 ⇒ ≤99% reduction.
86pub const MIN_RECEIVED_DAMAGE_K: f64 = 100.0;
87/// Floor on a stat's `.mod` multiplier in `get_entity_stat`, so a stacking
88/// `.mod` debuff (e.g. `weakness` on attack, `protection` on received_damage)
89/// can't drive a stat to ≤0 (zero-damage / unkillable). 0.05 ⇒ ≤95% debuff.
90/// Only bites at mod ≤ −9500; no-mod / buff stats are unaffected.
91pub const MIN_STAT_MOD_MULT: f64 = 0.05;
92
93/// Power-scalar normalisation. Anchors a reference character
94/// (attack=`BASE_ATTACK`, hp=`BASE_HP`, neutral elsewhere) at `BASE_POWER`, so
95/// the displayed Combat-Power integer stays on the established scale while the
96/// formula underneath is `P = DPS × EHP`. = 600·3000/1000 = 1800.
97pub const POWER_NORM: f64 = BASE_ATTACK * BASE_HP / BASE_POWER as f64;
98
99// --- Balance v2: the sim-tunable knob surface --------------------------------
100// All the v2 balance "knobs" in one place so a balance change is a one-field
101// edit and the bot-sim can SWEEP them with **zero recompile / zero deploy**:
102// set the matching `OVERLORD_BAL_*` env var and restart the (cached) monolith.
103// Defaults equal the constants above/below, so behaviour is unchanged unless an
104// override is provided. Read everywhere via `tuning()`; initialised once at
105// process start by `init_tuning` (the binary calls
106// `init_tuning(BalanceTuning::from_env())`). Tests don't init → defaults.
107#[derive(Clone, Copy, Debug, PartialEq)]
108pub struct BalanceTuning {
109    /// DR knob for armor mitigation `K/(armor+K)`. Env: `OVERLORD_BAL_K_ARMOR`.
110    pub k_armor: f64,
111    /// DR knob for dodge/evasion `ev/(ev+K)`. Env: `OVERLORD_BAL_K_DODGE`.
112    pub k_dodge: f64,
113    /// Power-scalar normaliser. Env: `OVERLORD_BAL_POWER_NORM`.
114    pub power_norm: f64,
115    /// Last hand-made campaign chapter (formula curve starts above it). Env: `OVERLORD_BAL_ENEMY_ANCHOR_CHAPTER`.
116    pub enemy_curve_anchor_chapter: i64,
117    /// Enemy power at the anchor chapter. Env: `OVERLORD_BAL_ENEMY_ANCHOR_POWER`.
118    pub enemy_curve_anchor_power: f64,
119    /// Enemy power geometric step per chapter — the progression gate. Env: `OVERLORD_BAL_ENEMY_STEP`.
120    pub chapter_power_step: f64,
121    /// Late-game enemy step floor. Past `enemy_step_taper_start` the per-chapter
122    /// step decays smoothly from `chapter_power_step` toward this value, so the
123    /// difficulty gate tracks the player's DECELERATING late power growth
124    /// (~1.12×/ch) instead of staying at the early 1.30 — which out-paced late
125    /// power and produced multi-day chapter stalls / a hard wall. With `>=
126    /// chapter_power_step` the taper is OFF (closed-form, unchanged). Env:
127    /// `OVERLORD_BAL_ENEMY_STEP_LATE`.
128    pub enemy_step_late: f64,
129    /// Chapter at which the late-step taper begins. Env: `OVERLORD_BAL_ENEMY_STEP_TAPER_START`.
130    pub enemy_step_taper_start: i64,
131    /// Item sale-price exponent on `eff` (income growth shape). Env: `OVERLORD_BAL_SELL_EXP`.
132    pub sell_price_exp: f64,
133    /// Item sale-price coefficient. Env: `OVERLORD_BAL_SELL_COEF`.
134    pub sell_price_coef: f64,
135    /// Soft 3-cycle class-counter swing (PvP). Env: `OVERLORD_BAL_CLASS_SWING`.
136    pub class_counter_swing: f64,
137    /// Max per-tick combat regen heal as a fraction of max HP — bounds regen in
138    /// *combat* (the scalar bound is separate) so high regen can't out-heal all
139    /// damage / instant-full a tank. Env: `OVERLORD_BAL_REGEN_TICK_PCT`.
140    pub regen_tick_max_pct: f64,
141    /// Max per-tick HoT heal as a fraction of max HP. Env: `OVERLORD_BAL_HOT_TICK_PCT`.
142    pub hot_tick_max_pct: f64,
143    /// Max per-tick DoT damage as a fraction of max HP. Env: `OVERLORD_BAL_DOT_TICK_PCT`.
144    pub dot_tick_max_pct: f64,
145    /// Early-game stat-check bump strength applied at ch3 (tapering ch4/ch5),
146    /// so the first gear conversions are forced. ch2 is left untouched (it is
147    /// resource-capped by the 20 starter cookies — a bump there DEADLOCKS, sim-
148    /// confirmed). `1.0` = off. Env: `OVERLORD_BAL_ENEMY_EARLY_BUMP`.
149    pub enemy_early_bump: f64,
150    /// Mid-game boss-only bump applied at ch7–15 to enforce the gear-engagement gate.
151    /// Applied exclusively to `CampaignBossFight` in `enemy_power_scalar`; wave fights
152    /// are unaffected so the global `enemy_power_for_chapter` curve stays monotone.
153    /// At 8.0: a frozen player (stopped at ch5, power ~2121) faces P/E ≈ 0.34 at the
154    /// ch7 boss → ~7% first-try win rate; combined with `stop_on_lose=true` on bosses
155    /// and the lazy bot not retrying, this creates the hard wall. Engaged players
156    /// open more chests between attempts and eventually clear the boss. Tapers to 1.0
157    /// at ch16 (one past ability-slot unlock at ch15). `1.0` = off.
158    /// Env: `OVERLORD_BAL_ENEMY_MID_BUMP`.
159    pub enemy_mid_bump: f64,
160    /// `eff_by_level` early-branch (L ≤ 130) coefficient. Env: `OVERLORD_BAL_EFF_A1`.
161    pub eff_a1: f64,
162    /// `eff_by_level` early-branch exponent — THE early-power shape knob (lower =
163    /// flatter early growth, decouples power from level/clock). Env: `OVERLORD_BAL_EFF_B1`.
164    pub eff_b1: f64,
165    /// `eff_by_level` late-branch (L > 130) coefficient. Env: `OVERLORD_BAL_EFF_A2`.
166    pub eff_a2: f64,
167    /// `eff_by_level` late-branch exponent (raise above 0.1 so late power keeps
168    /// growing instead of plateauing). Env: `OVERLORD_BAL_EFF_B2`.
169    pub eff_b2: f64,
170    /// `eff_by_level` late-branch offset (re-fit for continuity at L=130). Env: `OVERLORD_BAL_EFF_C2`.
171    pub eff_c2: f64,
172}
173
174/// Tuning with every knob at its constant default.
175pub const BALANCE_TUNING_DEFAULT: BalanceTuning = BalanceTuning {
176    k_armor: K_ARMOR,
177    k_dodge: K_DODGE,
178    power_norm: POWER_NORM,
179    enemy_curve_anchor_chapter: ENEMY_CURVE_ANCHOR_CHAPTER,
180    enemy_curve_anchor_power: ENEMY_CURVE_ANCHOR_POWER,
181    chapter_power_step: CHAPTER_POWER_STEP,
182    enemy_step_late: ENEMY_STEP_LATE,
183    enemy_step_taper_start: ENEMY_STEP_TAPER_START,
184    sell_price_exp: SELL_PRICE_EXP,
185    sell_price_coef: SELL_PRICE_COEF,
186    class_counter_swing: CLASS_COUNTER_SWING,
187    regen_tick_max_pct: REGEN_TICK_MAX_PCT,
188    hot_tick_max_pct: HOT_TICK_MAX_PCT,
189    dot_tick_max_pct: DOT_TICK_MAX_PCT,
190    enemy_early_bump: ENEMY_EARLY_BUMP,
191    enemy_mid_bump: ENEMY_MID_BUMP,
192    eff_a1: EFF_A1,
193    eff_b1: EFF_B1,
194    eff_a2: EFF_A2,
195    eff_b2: EFF_B2,
196    eff_c2: EFF_C2,
197};
198
199static BALANCE_TUNING: std::sync::OnceLock<BalanceTuning> = std::sync::OnceLock::new();
200
201/// The process-wide balance tuning (defaults until [`init_tuning`] is called).
202/// Hot-path safe — an atomic load of a `&'static`.
203#[inline]
204pub fn tuning() -> &'static BalanceTuning {
205    BALANCE_TUNING.get().unwrap_or(&BALANCE_TUNING_DEFAULT)
206}
207
208/// Set the process-wide balance tuning once at startup (first call wins).
209pub fn init_tuning(t: BalanceTuning) {
210    let _ = BALANCE_TUNING.set(t);
211}
212
213impl BalanceTuning {
214    /// Defaults, with any `OVERLORD_BAL_*` env var that parses applied on top.
215    /// This is the sim-sweep entry point — no recompile, no deploy.
216    pub fn from_env() -> Self {
217        fn ovr_f(name: &str, cur: f64) -> f64 {
218            std::env::var(name)
219                .ok()
220                .and_then(|v| v.parse().ok())
221                .unwrap_or(cur)
222        }
223        fn ovr_i(name: &str, cur: i64) -> i64 {
224            std::env::var(name)
225                .ok()
226                .and_then(|v| v.parse().ok())
227                .unwrap_or(cur)
228        }
229        let d = BALANCE_TUNING_DEFAULT;
230        BalanceTuning {
231            // The DR denominators (`K/(R+K)`, `1 − ev/(ev+K)`) and the power
232            // normaliser are divisors: a swept value of 0 yields a divide-by-zero
233            // (+inf power, i64::MAX, 100%-dodge unkillable) or a 0/0 NaN. The
234            // model requires K > 0 for the DR curve to be well-defined, so floor
235            // these knobs at a small positive — a sweep then produces a measurable
236            // result, never inf/NaN. Defaults (1500/2000/1800) are unaffected.
237            k_armor: ovr_f("OVERLORD_BAL_K_ARMOR", d.k_armor).max(1.0),
238            k_dodge: ovr_f("OVERLORD_BAL_K_DODGE", d.k_dodge).max(1.0),
239            power_norm: ovr_f("OVERLORD_BAL_POWER_NORM", d.power_norm).max(1.0),
240            enemy_curve_anchor_chapter: ovr_i(
241                "OVERLORD_BAL_ENEMY_ANCHOR_CHAPTER",
242                d.enemy_curve_anchor_chapter,
243            ),
244            enemy_curve_anchor_power: ovr_f(
245                "OVERLORD_BAL_ENEMY_ANCHOR_POWER",
246                d.enemy_curve_anchor_power,
247            ),
248            chapter_power_step: ovr_f("OVERLORD_BAL_ENEMY_STEP", d.chapter_power_step),
249            enemy_step_late: ovr_f("OVERLORD_BAL_ENEMY_STEP_LATE", d.enemy_step_late),
250            enemy_step_taper_start: ovr_i(
251                "OVERLORD_BAL_ENEMY_STEP_TAPER_START",
252                d.enemy_step_taper_start,
253            ),
254            sell_price_exp: ovr_f("OVERLORD_BAL_SELL_EXP", d.sell_price_exp),
255            sell_price_coef: ovr_f("OVERLORD_BAL_SELL_COEF", d.sell_price_coef),
256            class_counter_swing: ovr_f("OVERLORD_BAL_CLASS_SWING", d.class_counter_swing),
257            regen_tick_max_pct: ovr_f("OVERLORD_BAL_REGEN_TICK_PCT", d.regen_tick_max_pct),
258            hot_tick_max_pct: ovr_f("OVERLORD_BAL_HOT_TICK_PCT", d.hot_tick_max_pct),
259            dot_tick_max_pct: ovr_f("OVERLORD_BAL_DOT_TICK_PCT", d.dot_tick_max_pct),
260            enemy_early_bump: ovr_f("OVERLORD_BAL_ENEMY_EARLY_BUMP", d.enemy_early_bump),
261            enemy_mid_bump: ovr_f("OVERLORD_BAL_ENEMY_MID_BUMP", d.enemy_mid_bump),
262            eff_a1: ovr_f("OVERLORD_BAL_EFF_A1", d.eff_a1),
263            eff_b1: ovr_f("OVERLORD_BAL_EFF_B1", d.eff_b1),
264            eff_a2: ovr_f("OVERLORD_BAL_EFF_A2", d.eff_a2),
265            eff_b2: ovr_f("OVERLORD_BAL_EFF_B2", d.eff_b2),
266            eff_c2: ovr_f("OVERLORD_BAL_EFF_C2", d.eff_c2),
267        }
268    }
269}
270
271// --- Balance v2: enemy power curve (replaces the inline ad-hoc curve) --------
272// Campaign enemy power past the hand-made chapters follows a clean geometric
273// curve. Constants live here so the bot-sim can calibrate difficulty (Phase 2)
274// without touching `spawn_wave`. The old code floored this and then did an
275// *integer* division by `BASE_POWER` for `CampaignFight` (truncating e.g.
276// 2268/1000 → 2 instead of 2.268) plus a hand cliff at chapter 44 — both
277// removed in v2; difficulty now comes from the curve alone.
278//
279// Early-game difficulty — smooth ramp redesign (2026-06-30):
280//
281// PROBLEM (baseline sim, run_1782816662_3050_19844):
282//   ch0 (1-1): player=28, enemy=35 (HANDMADE), P/E=0.81 → 50% win rate (must be trivial!)
283//   ch4-ch6 (1-5..1-7): P/E=3.5–5.0 → coast-through (inertia / no gear pressure)
284//   ch1→ch2 cliff: P/E drops from 2.15 to 0.93 (formula kicks in abruptly at ch2)
285//
286// FIX: extend the hand-authored region from ch1 to ch6 (chapters 1-1..1-7),
287// hand-authoring a SMOOTH taut ramp that:
288//   • keeps ch0 (1-1) trivially easy (P/E ~3.5 for a zero-gear fresh player),
289//   • brings P/E into the 1.4–2.2 taut band through ch1–ch6, forcing gear engagement,
290//   • stays byte-identical to the previous formula for ch7+ (ch10=1509, ch20=20803, ch43=8552484).
291//
292// ANCHOR MATH: moving the anchor from ch1 to ch6 multiplies anchor_power by 1.30^5.
293//   new_anchor_power = 142.3 × 1.30^5 = 528.35
294//   The formula for ch > anchor_ch is `anchor_power × STEP^(ch − anchor_ch)`.
295//   At ch7: 528.35 × 1.30 = 686 (identical to old formula(7) = 142.3 × 1.30^6 = 686). ✓
296//   ch10: 1509, ch20: 20803, ch43: 8552484 — all unchanged.
297//
298// HAND-AUTHORED RAMP (wave fight power / boss fight power — both set in YAML):
299//   ch0 (1-1): wave=8,  boss=10  — trivial (P/E wave≈3.5 / boss≈2.8)
300//   ch1 (1-2): wave=60, boss=75  — gentle  (P/E wave≈2.15/ boss≈1.72)
301//   ch2 (1-3): wave=100,boss=130 — taut    (P/E wave≈1.84/ boss≈1.42)
302//   ch3 (1-4): wave=240,boss=320 — taut    (P/E wave≈1.81/ boss≈1.36)
303//   ch4 (1-5): wave=700,boss=900 — taut    (P/E wave≈1.66/ boss≈1.29)
304//   ch5 (1-6): wave=1100,boss=1500— taut   (P/E wave≈1.79/ boss≈1.31)
305//   ch6 (1-7): wave=1600,boss=2200— taut   (P/E wave≈1.80/ boss≈1.31)
306//   ch7 (1-8): first formula chapter; wave=686, boss=6265 (mid-bump gate) — unchanged.
307//
308// EARLY_BUMP remains 1.0 (off). MID_BUMP at ch7-15 is unchanged at 8.0.
309pub const ENEMY_CURVE_ANCHOR_CHAPTER: i64 = 6;
310pub const ENEMY_CURVE_ANCHOR_POWER: f64 = 528.35;
311// Balance v2 / resource-gate rebalance (2026-06-23): enemy power per chapter —
312// the progression gate. It's a race between two geometric curves (enemy power vs
313// the bot's gear-driven power). At the old 1.40 step the enemy badly OUTPACED
314// the player's late power growth (~1.05–1.08×/ch past ch20), producing a HARD
315// wall at ch42-43 where progress stopped dead. The resource-gate model wants
316// progress to *never fully stop* — it should slow and become gold/cookie-gated
317// (run dry → idle funds the next case upgrade → push on), not slam a power wall.
318// Lowering the step to 1.30 keeps the enemy close enough to the (now flatter,
319// EFF_B1=1.40) player power that the player keeps creeping forward by upgrading,
320// while remaining a real gate (NOT trivial). Sim sweep (`free`, 7d, EFF_B1=1.40):
321//   step 1.40 → walls ~ch38   1.30 → reaches ch43-49 (still climbing)   1.28 → ch45-52
322// 1.30 + EARLY_BUMP=1.6 lands a firm early game (ch3-5 forced gear) and a smooth,
323// never-stuck deceleration. Tunable via `OVERLORD_BAL_ENEMY_STEP`. The free/paid
324// gradient now comes more from idle-wait time-skips than raw content depth (per
325// the resource-gate design). Re-tune if the player power curve changes.
326pub const CHAPTER_POWER_STEP: f64 = 1.30;
327// Late-game step taper (2026-06-24, "every returning day must progress"): a
328// CONSTANT 1.30 step still out-paces the player's decelerating late power growth
329// (~1.12×/ch measured 30-day), so chapters stall multiple days and eventually a
330// soft wall forms. Past ENEMY_STEP_TAPER_START the per-chapter step decays
331// smoothly toward ENEMY_STEP_LATE so the gate tracks late power → the player
332// advances ~daily (power still decelerates, but progress never stops). Defaults
333// keep the taper OFF (late == base) so existing curves/tests are unchanged; the
334// live values are calibrated by the 30-day sim sweep and baked below.
335// SWEEP (free, 30d): no-taper walls ch62 (item-case stuck L11, 6-day hard wall);
336// late=1.13→ch83 / 1.10→ch82 / 1.08→ch87, all power-growing EVERY day, item-case
337// L18-20, max stall ≤2-3 days (was 6+). Taper begins at ch42 (early game and the
338// firm honeymoon are untouched; the decay reaches the floor ~ch67).
339// SOFT-PLATEAU TUNE (2026-06-24): lowered 1.12 → 1.10. At 1.12 a CONTINUOUS all-day
340// session deep-plateaued — chapters held flat ~2h at the deep wall while only power
341// crept (+16%) and cookies drained to 0 (the late step just out-paced the in-session
342// power creep). 1.10 lets that slow power-creep keep clearing chapters at the deep
343// wall (~1 ch/15min crawl, cookies STILL scarce → "slow progress, few cookies") while
344// the real jump still comes next day from AFK (continuous A/B: deep tail 0 → ~5 ch/hr
345// nonzero, next-day AFK +79%). Normal 20-min-session cadence barely moves (30-d sweep
346// 1.12→ch81 vs 1.10→ch82). Tracks ≈ the player's late power growth ~1.10×/ch.
347pub const ENEMY_STEP_LATE: f64 = 1.10;
348pub const ENEMY_STEP_TAPER_START: i64 = 42;
349/// Mid-game enemy bump — **applied only to CampaignBossFight** (ch7–15).
350/// Applied via `OVERLORD_BAL_ENEMY_MID_BUMP`. At 8.0 the boss at ch7 has effective
351/// power `687 × STEP^0.5 × 8 ≈ 6265` vs a frozen player's ~2121 (P/E ≈ 0.34 →
352/// ~7% first-try win rate), while an engaged player (power ~3274) has P/E ≈ 0.52 —
353/// with `stop_on_lose=true` and boss-stop-on-loss retries, even a 50% single-shot
354/// rate means the free bot quickly clears it via quest-funded chest opens between attempts,
355/// while the lazy bot has no path to more power. Combined with `stop_on_lose=true` on
356/// campaign boss fights and the lazy-bot strategy not retrying boss fights, this creates
357/// the hard engagement gate: freeze at 1-6, wall at the ch7 boss.
358/// Wave fights are unaffected (the bump is applied in `enemy_power_scalar`, not in
359/// `enemy_power_for_chapter`), so the global power curve stays monotone.
360pub const ENEMY_MID_BUMP: f64 = 8.0;
361
362/// Early-game stat-check bump strength. `1.0` = OFF (current). Was 1.6 (2026-06-23,
363/// "too easy early, full HP") but playtest (2026-06-29) found the bump created the
364/// opposite problem: stages 1-3..1-5 felt hard and punishing ("cleared by luck",
365/// "barely scraped through"), while 1-7+ felt fine. The sim confirmed ch2 (=stage
366/// 1-3) already hits P/E=1.24 with min-HP 13% from the hand-made→formula transition
367/// alone, and the ch3=1.6x bump pushed ch3 (=stage 1-4) from the clean 1.30x step
368/// to a 2.09x step — creating an anomalous hump. Disabled (1.0) so the geometric
369/// 1.30x step runs cleanly through early chapters. Tune via `OVERLORD_BAL_ENEMY_EARLY_BUMP`.
370pub const ENEMY_EARLY_BUMP: f64 = 1.0;
371
372/// Early-game stat-check bump (playtester: "no pressure until 2-1"). The
373/// resource-capped early chapters (the player can only gear so far on the
374/// starter + first-quest cookies) get a MODEST lift on top of the geometric
375/// line so the first gear conversions are *forced* — a uniform lift instead
376/// DEADLOCKED ch2 (sim: enemy×2 → 0% win, the player can't gear past it with
377/// 20 starter cookies). Tapers to 1.0 by ch5 so ch6+/ch10+/the validated wall
378/// stay byte-identical to the 1.40 curve. Started conservative (the player
379/// scalar undercounts true combat power ~4-7×, so the ch2 ceiling is uncertain
380/// ~250-400 nominal); climb in-sim only while ch2 win-rate stays > 0.
381fn enemy_early_chapter_mult(chapter: i64) -> f64 {
382    let bump = tuning().enemy_early_bump;
383    if bump <= 1.0 {
384        return 1.0;
385    }
386    // First check at ch3 (quest cookies have unlocked headroom there), tapering
387    // to 1.0 by ch6. ch2 stays untouched — it is hard resource-capped by the 20
388    // starter cookies and a bump there deadlocks (sim-confirmed).
389    match chapter {
390        3 => bump,
391        4 => 1.0 + (bump - 1.0) * 0.66,
392        5 => 1.0 + (bump - 1.0) * 0.33,
393        _ => 1.0,
394    }
395}
396
397/// Mid-game **boss-only** gear-engagement gate (ch7–15). Applied in
398/// [`enemy_power_scalar`] only for `CampaignBossFight` (not wave fights), so
399/// normal wave progression is unaffected while the boss is the hard gate.
400///
401/// Why boss-only: applying the bump to wave fights too causes global monotonicity
402/// violations in `enemy_power_for_chapter` for any bump > STEP (1.30) — the taper
403/// endpoint has mult(last_ch) > 1.0 while the next chapter returns 1.0, creating a
404/// downstep. Restricting the bump to bosses avoids that problem entirely.
405///
406/// With `stop_on_lose=true` on campaign bosses and the lazy bot strategy not retrying
407/// (`_retry_boss_if_needed` removed from the frozen action list), one loss = permanent
408/// wall. The bump must be large enough that the first-try win probability is low for
409/// a frozen player (≤ 10-15%). At bump=8 with STEP=1.30 the boss power at ch7
410/// becomes `enemy(7) × STEP^0.5 × 8 = 687 × 1.14 × 8 = 6265` vs lazy power ~2121,
411/// P/E ≈ 0.34 → ~7% first-try win rate → ~93% of lazy bots wall on first encounter.
412///
413/// Tapers linearly from `enemy_mid_bump` at ch7 to 1.0 at ch16. ch6 and below:
414/// 1.0 (respects "don't make ch1-5 harder" hard constraint). ch16+: 1.0 (ability-slot
415/// system takes over as the engagement lever). 1.0 = off (mid_bump ≤ 1.0 = no gate).
416pub(crate) fn enemy_mid_chapter_mult(chapter: i64) -> f64 {
417    let bump = tuning().enemy_mid_bump;
418    if bump <= 1.0 {
419        return 1.0;
420    }
421    // boss-only bump, ch7-15 (exclusive upper bound ch16 so the taper step
422    // within the bumped range is (bump-1)/9, purely internal to boss fights;
423    // the monotonicity test for `enemy_power_for_chapter` is unaffected because
424    // this function is no longer called from there).
425    if !(7..16).contains(&chapter) {
426        return 1.0;
427    }
428    let frac = (16 - chapter) as f64 / (16 - 7) as f64;
429    1.0 + (bump - 1.0) * frac
430}
431
432/// Absolute enemy power at `chapter`: `ANCHOR · ∏ step(c)`, where the per-chapter
433/// step is the constant `chapter_power_step` until `enemy_step_taper_start`, then
434/// decays smoothly toward `enemy_step_late` so late-game difficulty tracks the
435/// player's decelerating power (no multi-day stalls). With the taper OFF
436/// (`enemy_step_late >= chapter_power_step`) this is the original closed form
437/// `ANCHOR · STEP^(chapter − ANCHOR_CH)`. A tapering early-chapter bump
438/// (see [`enemy_early_chapter_mult`]) multiplies the result.
439///
440/// Note: the mid-game bump ([`enemy_mid_chapter_mult`]) is NOT applied here — it
441/// is applied only to `CampaignBossFight` in [`enemy_power_scalar`], so wave
442/// fights and the global power curve remain unaffected (avoids monotonicity
443/// violations for large bump values).
444pub fn enemy_power_for_chapter(chapter: i64) -> f64 {
445    let t = tuning();
446    let exp = (chapter - t.enemy_curve_anchor_chapter).max(0);
447    let growth = if t.enemy_step_late >= t.chapter_power_step {
448        // Taper OFF — closed form (unchanged, preserves all pinned curves/tests).
449        t.chapter_power_step.powi(exp as i32)
450    } else {
451        // Taper ON — cumulative product. step(c) decays from the base step toward
452        // the late floor with a fixed half-life past `enemy_step_taper_start`.
453        const TAPER_DECAY: f64 = 0.9; // ~base→late over ~25 chapters
454        let mut g = 1.0_f64;
455        for i in 1..=exp {
456            let c = t.enemy_curve_anchor_chapter + i;
457            let over = (c - t.enemy_step_taper_start).max(0) as f64;
458            let step = t.enemy_step_late
459                + (t.chapter_power_step - t.enemy_step_late) * TAPER_DECAY.powf(over);
460            g *= step;
461        }
462        g
463    };
464    (t.enemy_curve_anchor_power * growth * enemy_early_chapter_mult(chapter)).floor()
465}
466
467// --- Balance v2 (Phase 2): gold faucet shaping ------------------------------
468// The dominant gold faucet is selling items, priced from an item's effectiveness
469// `eff` (which grows ~level^1.58). Left linear in `eff` (the legacy `eff·100`),
470// sale income outran the geometric chest-upgrade sink mid/late game, leaving
471// gold in runaway surplus so the cost curve never *bound* progression. v2 prices
472// items as `eff^SELL_PRICE_EXP · SELL_PRICE_COEF`, flattening the faucet so the
473// geometric chest-upgrade sink can bind — `COEF` anchors low-`eff` (early,
474// honeymoon) sales at ≈ the legacy value.
475// EXP calibrated by the bot-sim (Phase 20): for a `free` bot, gold sink/faucet
476// ratio (chest-upgrade spend ÷ sell income) vs EXP — 0.65→0.55, 0.55→0.65,
477// 0.45→0.79. 0.45 lands free in the 0.7–1.1 "sink binds" band (gold ~spent, not
478// hoarded) with progression intact (still walls ~ch40, ~4M power); lower risks
479// faucet < sink → starvation. The whale still front-loads (real-money gold), so
480// its surplus is benign per the model ("soft currency in surplus, engineer the
481// sink"). Tunable via `OVERLORD_BAL_SELL_EXP`.
482pub const SELL_PRICE_EXP: f64 = 0.45;
483pub const SELL_PRICE_COEF: f64 = 87.0;
484
485/// Item sale price (sell-currency units) from an item's effectiveness `eff`:
486/// `floor(eff^sell_price_exp · sell_price_coef)`. Sub-linear (`exp < 1`) so gold
487/// income grows ~linearly with item level and the geometric chest sink can bind
488/// (Phase 2). Pure + sim-tunable via [`tuning()`]; the call site is
489/// `behaviors::items::item_price`.
490pub fn sell_price(eff: f64) -> i64 {
491    let t = tuning();
492    (eff.max(0.0).powf(t.sell_price_exp) * t.sell_price_coef).floor() as i64
493}
494
495/// Per-tick heal/damage cap: `amount` bounded above by `pct × max_hp`. Used by
496/// the regen / HoT / DoT combat ticks so a mis-tuned effect can't out-heal all
497/// incoming damage (regen/HoT) or one-shot (DoT). Pure + testable; `pct` comes
498/// from the matching [`tuning()`] knob (`regen|hot|dot_tick_max_pct`).
499pub fn cap_per_tick(amount: f64, max_hp: f64, pct: f64) -> f64 {
500    amount.min(max_hp * pct)
501}
502
503/// Multiplicative buff/debuff factor from a proc chance `p` (0..1): a buff of
504/// strength [`BUFF_EFF`] held for a mean uptime, stacked `quantity` times.
505/// Shared by bravery (offensive) and deceit (defensive); preserves the legacy
506/// `(1 + (BUFF_EFF−1)·uptime)^quantity` shape exactly.
507pub fn buff_uptime_mult(p: f64, quantity: i64, duration: f64) -> f64 {
508    if p <= 0.0 {
509        return 1.0;
510    }
511    let uptime = (CASTS_PER_SEC * p * duration / quantity as f64).min(1.0);
512    (1.0 + (BUFF_EFF - 1.0) * uptime).powi(quantity as i32)
513}
514
515// --- Balance v2 (Phase 5): soft 3-cycle class counter -----------------------
516// A soft rock-paper-scissors among the three combat classes —
517// Warrior > Rogue > Mage > Warrior — with Priest and Citizen NEUTRAL (the
518// model's §11 design: don't force a 4-way cycle). The counter is a ±swing
519// damage multiplier in a matchup; it is PvP-only *by construction* because PvE
520// mobs carry no class (→ neutral). `±0.15` is a SEED ("soft" per the model,
521// vs ≥±0.25–0.40 "strong"); the final per-mode value comes from a per-class
522// bot-sim against the 45–55% win-rate corridor (which the current single-class
523// bot harness cannot yet measure). Roles are derived from the class's
524// `main_attribute` code, so no new config field is needed:
525//   block → Warrior, crit_chance → Rogue, multicast_chance → Mage,
526//   regeneration_rate (Priest) / hp (Citizen) / anything else → neutral.
527pub const CLASS_COUNTER_SWING: f64 = 0.15;
528
529/// 3-cycle combat role from a class's `main_attribute` code.
530/// 0 = Warrior, 1 = Rogue, 2 = Mage, -1 = neutral.
531pub fn class_counter_role_from_code(main_attr_code: &str) -> i8 {
532    match main_attr_code {
533        "block" => 0,
534        "crit_chance" => 1,
535        "multicast_chance" => 2,
536        _ => -1,
537    }
538}
539
540/// Soft 3-cycle damage multiplier for an `attacker` class hitting a `defender`
541/// class (both `Option`, resolved via `lookups.class_counter_role`). Returns
542/// `1 + CLASS_COUNTER_SWING` when the attacker's role beats the defender's,
543/// `1 - CLASS_COUNTER_SWING` when it is beaten, and `1.0` otherwise (same role,
544/// either neutral, or a classless PvE mob).
545pub fn class_counter_multiplier(
546    lookups: &ContentLookups,
547    attacker: Option<Uuid>,
548    defender: Option<Uuid>,
549) -> f64 {
550    let (Some(a), Some(d)) = (attacker, defender) else {
551        return 1.0;
552    };
553    let ra = lookups.class_counter_role.get(&a).copied().unwrap_or(-1);
554    let rd = lookups.class_counter_role.get(&d).copied().unwrap_or(-1);
555    if ra < 0 || rd < 0 || ra == rd {
556        return 1.0;
557    }
558    let swing = tuning().class_counter_swing;
559    if rd == (ra + 1) % 3 {
560        1.0 + swing // attacker beats defender
561    } else if ra == (rd + 1) % 3 {
562        1.0 - swing // attacker is countered
563    } else {
564        1.0
565    }
566}
567
568/// Pure: stochastic round of a non-integer value.
569pub fn rand_round_f64(value: f64, random: &GameRng) -> i64 {
570    let base = value.floor();
571    let prob = value - base;
572    if prob > 0.0 && random.random_f64() < prob {
573        return base as i64 + 1;
574    }
575    base as i64
576}
577
578pub fn effect_cost(duration: f64) -> f64 {
579    (BUFF_EFF - 1.0) * (SPELL_QUANTITY - 1) as f64 * duration * BASE_SPELL_EFF
580}
581
582pub fn effect_duration(cost: f64) -> f64 {
583    cost / ((BUFF_EFF - 1.0) * (SPELL_QUANTITY - 1) as f64 * BASE_SPELL_EFF)
584}
585
586fn buff_p_from_eff(eff: f64, cast_rate: f64, duration: f64, effects_quantity: i64) -> f64 {
587    let uptime_max = (cast_rate * duration / effects_quantity as f64).min(1.0);
588    let eff_max = (1.0 + (BUFF_EFF - 1.0) * uptime_max).powi(effects_quantity as i32);
589    if eff >= eff_max {
590        return 1.0;
591    }
592    let effect_eff = eff.powf(1.0 / effects_quantity as f64);
593    let effect_uptime = ((effect_eff - 1.0) / (BUFF_EFF - 1.0)).max(0.0);
594    let p = effect_uptime * effects_quantity as f64 / (cast_rate * duration);
595    p.clamp(0.0, 1.0)
596}
597
598pub fn bravery_p_from_eff(eff: f64) -> f64 {
599    buff_p_from_eff(
600        eff,
601        CASTS_PER_SEC,
602        BRAVERY_BUFF_DURATION,
603        BRAVERY_BUFF_QUANTITY,
604    )
605}
606
607pub fn deceit_p_from_eff(eff: f64) -> f64 {
608    buff_p_from_eff(
609        eff,
610        CASTS_PER_SEC,
611        DECEIT_DEBUFF_DURATION,
612        DECEIT_DEBUFF_QUANTITY,
613    )
614}
615
616pub fn attr_spread_random(random: &GameRng) -> f64 {
617    1.0 + ATTR_DEVIATION * (2.0 * random.random_f64() - 1.0)
618}
619
620pub fn hp_k_for_level(level: f64) -> f64 {
621    const ALL_SPELLS_LEVEL: f64 = 30.0;
622    if level > ALL_SPELLS_LEVEL {
623        return 1.0;
624    }
625    ((level - 1.0) / (ALL_SPELLS_LEVEL - 1.0) * (SPELL_QUANTITY - 1) as f64 + 1.0)
626        / SPELL_QUANTITY as f64
627}
628
629// `eff_by_level` curve constants (item effectiveness vs item level). Two
630// branches joined at L=130: a steep early power-law and a flat late power-law.
631// Promoted to module consts + the `tuning()` surface so the bot-sim can sweep
632// the early-power SHAPE (`EFF_B1`) without a recompile — the keystone of the
633// resource-gate rebalance (item level tracks the level/clock, so this curve,
634// not the economy, sets how fast power grows early). Defaults below reproduce
635// the legacy curve byte-for-byte. The L>130 branch must stay continuous with
636// the L≤130 branch at L=130 (value + slope) when these are re-tuned.
637// Resource-gate rebalance (2026-06-23): the early exponent B1 is lowered
638// 1.5833 → 1.40 to FLATTEN early power growth (item level tracks the level/clock,
639// so this curve — not the economy — sets how fast power grows early). With the
640// gear-eff→power exponent k≈1 (each item's attack & hp scale as eff^0.5, so
641// P=DPS·EHP ∝ eff^1; the quadratic S² is the separate SPELL axis), dropping B1
642// maps ~1:1 into power. Sim: ch10 power 37k → ~16k (target 12-18k), killing the
643// "tens of thousands of power in the first 10 min" complaint. A1 is UNCHANGED so
644// eff drops at every L>2 (no mid-curve bulge). The late branch (A2,B2,C2) is
645// re-fit for value+slope continuity at L=130 with B2 raised 0.1 → 0.40 so late
646// power keeps climbing (no far-late plateau / "fighting never stops"). Closed
647// form (verified): V=A1·129^B1+1=98.52, S=A1·B1·129^(B1-1)=1.0583,
648// A2=S·130^(1-B2)/B2=49.08, C2=V-A2·130^B2=-245.40. All sim-tunable via
649// OVERLORD_BAL_EFF_*; re-derive A2/C2 if B1 or B2 move.
650pub const EFF_MIDGAME_LEVEL: f64 = 130.0;
651pub const EFF_A1: f64 = 0.1082166549;
652pub const EFF_B1: f64 = 1.40;
653pub const EFF_C1: f64 = 1.0;
654pub const EFF_A2: f64 = 49.08;
655pub const EFF_B2: f64 = 0.40;
656pub const EFF_C2: f64 = -245.40;
657
658pub fn eff_by_level(level: f64) -> f64 {
659    let t = tuning();
660    if level <= EFF_MIDGAME_LEVEL {
661        t.eff_a1 * (level - 1.0).powf(t.eff_b1) + EFF_C1
662    } else {
663        t.eff_a2 * level.powf(t.eff_b2) + t.eff_c2
664    }
665}
666
667pub fn item_q_by_day(day: f64) -> f64 {
668    const A: f64 = 1.369248467;
669    const B: f64 = 1.986;
670    const C: f64 = 1.00;
671    A * (B * day + 1.0).ln() + C
672}
673
674pub fn day_by_level(level: f64) -> f64 {
675    const A1: f64 = 13.95171732;
676    const A2: f64 = 0.5784426942;
677    const A3: f64 = 1.00;
678    ((level - A3) / A1).powf(1.0 / A2)
679}
680
681pub fn level_by_day(day: f64) -> f64 {
682    const A1: f64 = 13.95171732;
683    const A2: f64 = 0.5784426942;
684    const A3: f64 = 1.00;
685    A1 * day.powf(A2) + A3
686}
687
688pub fn eff_spell_by_level(level: f64) -> f64 {
689    const S1: f64 = 0.36536535;
690    const S2: f64 = 0.525009219;
691    const S3: f64 = 1.0;
692    let day = day_by_level(level);
693    S1 * day.powf(S2) + S3
694}
695
696pub fn armor_k(armor: f64) -> f64 {
697    // Balance v2: fraction of incoming damage that PASSES the target's armor,
698    // via the DR curve `K_ARMOR/(armor+K_ARMOR)`. Always in (0, 1] for
699    // armor ≥ 0. Replaces the old linear `1 - armor/10000`, which reached 0 at
700    // armor=10000 and went negative beyond it (a real shipped bug).
701    let k = tuning().k_armor;
702    k / (armor.max(0.0) + k)
703}
704
705/// Enemy "power" scalar for a fight — the value `spawn_wave` feeds into the
706/// HP/attack curve, and the apples-to-apples counterpart of the player's
707/// `character.power` (both normalized to [`BASE_POWER`]). For campaign fights:
708/// at/below the anchor chapter it is the hand-authored `FightTemplate.power`
709/// (`base_power`); above it the rebalanced geometric curve
710/// ([`enemy_power_for_chapter`], which carries the sweepable anchor/step + the
711/// early-chapter bump), with a boss bump. For DUNGEON fights it is always the
712/// authored per-difficulty `base_power` (see below). Shared so the battle-end
713/// analytics report exactly the value the fight spawned.
714pub fn enemy_power_scalar(
715    base_power: f64,
716    current_chapter: i64,
717    fight_type: &str,
718    is_dungeon: bool,
719) -> f64 {
720    let t = tuning();
721    // Dungeons are a self-contained difficulty LADDER: enemy power is the
722    // authored per-difficulty `base_power` (the chosen difficulty's
723    // `FightTemplate.power`, ramping e.g. 1500 → 600M across the levels), NOT the
724    // campaign chapter curve. Dungeon fights are tagged `CampaignBossFight` (for
725    // boss talents/visuals), so without this guard they fall into the chapter-curve
726    // branch below — collapsing every difficulty to one power keyed to the player's
727    // campaign chapter (the regression where the ladder does nothing and difficulty
728    // 1 is a full-chapter boss → "losing to the dungeon at unlock"). Honor the
729    // authored ladder so low difficulties are an easy first clear and the player
730    // climbs as their power grows.
731    if is_dungeon {
732        return base_power;
733    }
734    if (fight_type == "CampaignFight" || fight_type == "CampaignBossFight")
735        && current_chapter > t.enemy_curve_anchor_chapter
736    {
737        let power = enemy_power_for_chapter(current_chapter);
738        if fight_type == "CampaignBossFight" {
739            // Boss bump: the standard ×STEP^0.5 difficulty lift, plus the mid-game
740            // gear-engagement gate (ch7–15) that forces frozen players to wall here.
741            // The mid-gate is BOSS-ONLY so wave fights stay unaffected and the global
742            // `enemy_power_for_chapter` curve stays monotone (no mid-mult there).
743            power * t.chapter_power_step.powf(0.5) * enemy_mid_chapter_mult(current_chapter)
744        } else {
745            power
746        }
747    } else {
748        base_power
749    }
750}
751
752pub fn hp_k_for_chapter(config: &GameConfig, chapter: i64) -> f64 {
753    let mut reached: Vec<_> = config
754        .ability_slots_levels
755        .iter()
756        .filter(|item| chapter >= item.from_chapter_level)
757        .collect();
758    reached.sort_by_key(|b| std::cmp::Reverse(b.from_chapter_level));
759
760    if reached.is_empty() {
761        return 1.0 / SPELL_QUANTITY as f64;
762    }
763    let current = reached[0];
764    if current.ability_slots == (SPELL_QUANTITY - 1) as u64 {
765        return 1.0;
766    }
767    let mut unreached: Vec<_> = config
768        .ability_slots_levels
769        .iter()
770        .filter(|item| chapter < item.from_chapter_level)
771        .collect();
772    unreached.sort_by_key(|a| a.from_chapter_level);
773    if unreached.is_empty() {
774        return 1.0;
775    }
776    let next = unreached[0];
777    let mean_ability_slots = current.ability_slots as f64
778        + (chapter - current.from_chapter_level) as f64
779            / (next.from_chapter_level - current.from_chapter_level) as f64
780        + 1.0;
781    mean_ability_slots / SPELL_QUANTITY as f64
782}
783
784/// `attrs.get_attr(name)`: read the composed attribute value from an attribute map.
785/// Returns `(base + bonus) * (1 + bonus / 10000.0)`.
786///
787/// of `mod` in the multiplier. We preserve the original behaviour byte-for-byte
788/// so power calculations stay consistent with the current production config.
789///
790/// `attrs.get_attr(...)` method (registered for scripts that still pass a
791pub fn get_attr_from_attrs(attrs: &AttrMap, attr: &str) -> f64 {
792    let base = attrs.get(attr).copied().unwrap_or(0.0);
793    let bonus = attrs.get(&format!("{attr}.bonus")).copied().unwrap_or(0.0);
794    // Balance v2: match combat's `get_entity_stat` — additive `.bonus`, then the
795    // `.mod` multiplier `(1 + mod/10000)`. The old code used `.bonus` in the
796    // multiplier too (a self-multiply bug) and ignored `.mod` entirely, so the
797    // power scalar disagreed with combat (e.g. `attack.mod`/`hp.mod` from
798    // class-levels never affected displayed/matchmaking power). (Base-value —
799    // e.g. received_damage's 10000 — is still combat-only: the scalar has no
800    // `lookups` here; tracked as a remaining scalar↔combat gap.)
801    let mod_v = attrs.get(&format!("{attr}.mod")).copied().unwrap_or(0.0) / 10000.0 + 1.0;
802    (base + bonus) * mod_v
803}
804
805pub fn ability_eff(lookups: &ContentLookups, rarity_id: Uuid, level: i64) -> f64 {
806    let rarity_eff = lookups
807        .ability_rarity_eff
808        .get(&rarity_id)
809        .copied()
810        .unwrap_or(1.0);
811    let level_eff = 1.0 + 0.05 * (level - 1) as f64;
812    rarity_eff * level_eff
813}
814
815pub fn ability_damage_from_rarity(
816    lookups: &ContentLookups,
817    rarity_id: Uuid,
818    cooldown_ms: i64,
819    level: i64,
820) -> f64 {
821    let eff = ability_eff(lookups, rarity_id, level);
822    let cd = cooldown_ms as f64 / 1000.0;
823    let k = FIGHT_DURATION / (FIGHT_DURATION + cd);
824    eff * k * BASE_SPELL_EFF * cd
825}
826
827pub fn ability_damage_from_id(
828    config: &GameConfig,
829    lookups: &ContentLookups,
830    ability_id: Uuid,
831    level: i64,
832) -> Result<f64, String> {
833    let ability = config
834        .ability_template(ability_id)
835        .ok_or_else(|| format!("balance::ability_damage: unknown ability id {ability_id}"))?;
836    Ok(ability_damage_from_rarity(
837        lookups,
838        ability.rarity_id,
839        ability.cooldown as i64,
840        level,
841    ))
842}
843
844pub fn mean_fight_duration(config: &GameConfig, ability_id: Uuid) -> Result<f64, String> {
845    let ability = config
846        .ability_template(ability_id)
847        .ok_or_else(|| format!("balance::mean_fight_duration: unknown ability id {ability_id}"))?;
848    let cooldown = ability.cooldown as f64 / 1000.0;
849    let mut time = cooldown;
850    let mut sum_remaining = 0.0;
851    let mut casts = 0.0;
852    while time <= FIGHT_DURATION {
853        sum_remaining += FIGHT_DURATION - time;
854        casts += 1.0;
855        time += cooldown;
856    }
857    if casts == 0.0 {
858        return Ok(FIGHT_DURATION);
859    }
860    Ok(sum_remaining / casts)
861}
862
863pub fn eff_item_with_config(
864    config: &GameConfig,
865    lookups: &ContentLookups,
866    item_template_id: Uuid,
867    level: f64,
868) -> f64 {
869    let rarity_q = config
870        .item_template(item_template_id)
871        .and_then(|tpl| lookups.item_rarity_q.get(&tpl.rarity_id).copied())
872        .unwrap_or(1.0);
873    eff_by_level(level) * rarity_q
874}
875
876pub fn attr_spread_for_item(
877    config: &GameConfig,
878    lookups: &ContentLookups,
879    random: &GameRng,
880    template_id: Uuid,
881    level: f64,
882) -> f64 {
883    if let Some(fp) = lookups.item_fixed_power.get(&template_id) {
884        return *fp;
885    }
886    if (level - 1.0).abs() < f64::EPSILON {
887        return 0.65;
888    }
889    if level <= 25.0 {
890        return attr_spread_random(random);
891    }
892
893    let side = random.random_f64();
894    let u = random.random_f64();
895    let fake_level = if side < 0.5625 {
896        level - u.powi(2)
897    } else {
898        level + 3.0 * u.powi(6)
899    };
900    let fake_eff = eff_item_with_config(config, lookups, template_id, fake_level);
901    let real_eff = eff_item_with_config(config, lookups, template_id, level);
902    if real_eff == 0.0 {
903        return 1.0;
904    }
905    fake_eff / real_eff
906}
907
908pub fn aux_attr_eff(base_eff: f64, random: &GameRng) -> f64 {
909    let rand_mod = attr_spread_random(random);
910    (base_eff * rand_mod).powf(AUX_ATTR_IMPACT)
911}
912
913pub fn aux_attr_eff_for_item(
914    config: &GameConfig,
915    lookups: &ContentLookups,
916    base_eff: f64,
917    random: &GameRng,
918    template_id: Uuid,
919    level: f64,
920) -> f64 {
921    let rand_mod = attr_spread_for_item(config, lookups, random, template_id, level);
922    (base_eff * rand_mod).powf(AUX_ATTR_IMPACT)
923}
924
925/// Compute character power from a composed [`AttrMap`] as **P = DPS × EHP**
926/// (balance v2). `DPS` is the offensive product (attack · rate · crit ·
927/// multicast · bravery · counterattack); `EHP` the survivability product
928/// (hp ÷ the damage that gets through), where armor and dodge use the
929/// diminishing-returns curves [`armor_k`] and `ev/(ev+K_DODGE)`. Normalised by
930/// [`POWER_NORM`] so a reference character (attack=`BASE_ATTACK`, hp=`BASE_HP`,
931/// neutral elsewhere) anchors at [`BASE_POWER`]. Replaces the legacy opaque
932/// `S²`-style formula; every factor maps 1:1 to the old one except armor and
933/// evasion, which move from a linear cap to a DR curve.
934///
935/// **Float-accumulation order is load-bearing** (see [`character_attrs_power`]):
936/// the per-attribute reads happen in a fixed order so the floored result is
937/// deterministic.
938pub fn power_from_attrs(attrs: &AttrMap) -> i64 {
939    const BASE_POINT: f64 = 10000.0; // attribute basis points: 10000 == 1.0 / 100%
940
941    // ---- offensive: DPS ----
942    let attack = get_attr_from_attrs(attrs, "attack");
943    // speed → cast rate, mirroring combat's `scale_cooldown_for_speed` (cooldown ∝
944    // baseline/speed ⇒ rate ∝ speed/baseline, baseline_speed = BASE_POINT). Combat treats
945    // `speed ≤ 0` as the baseline (`speed_or_baseline`), so the scalar must too — else a
946    // speed-less build reads as 0 DPS here while combat still attacks at the baseline rate.
947    let speed = get_attr_from_attrs(attrs, "speed");
948    let atk_rate = if speed > 0.0 { speed / BASE_POINT } else { 1.0 };
949    // Probability stats are clamped to [0,1] — a chance can't exceed 100%.
950    // Without this, large (e.g. class-level) grants overflow the basis-point
951    // cap and break the formula (e.g. block>1 → `1−0.5·block` negative → power
952    // goes negative). Combat's `stat_throw` already saturates a proc at 100%.
953    let crit_chance = (get_attr_from_attrs(attrs, "crit_chance") / BASE_POINT).clamp(0.0, 1.0);
954    let crit_damage_mod = BASE_CRIT_MOD + get_attr_from_attrs(attrs, "crit_modifier") / BASE_POINT;
955    let crit_factor = 1.0 + crit_chance * (crit_damage_mod - 1.0);
956    let multicast_factor =
957        1.0 + (get_attr_from_attrs(attrs, "multicast_chance") / BASE_POINT).clamp(0.0, 1.0);
958    let bravery_factor = buff_uptime_mult(
959        get_attr_from_attrs(attrs, "bravery") / BASE_POINT,
960        BRAVERY_BUFF_QUANTITY,
961        BRAVERY_BUFF_DURATION,
962    );
963
964    let mut dps = attack * atk_rate * crit_factor * multicast_factor * bravery_factor;
965
966    // counterattack: retaliatory damage relative to a fight's baseline output
967    // (same additive shape as the legacy formula).
968    let counterattack_chance =
969        (get_attr_from_attrs(attrs, "counterattack_chance") / BASE_POINT).clamp(0.0, 1.0);
970    let counterattack_dmg =
971        FIGHT_DURATION * ATTACKS_PER_SEC * COUNTERATTACK_POWER * counterattack_chance;
972    let baseline_dmg =
973        FIGHT_DURATION * SPELL_QUANTITY as f64 / armor_k(get_attr_from_attrs(attrs, "armor"));
974    if baseline_dmg > 0.0 {
975        dps *= (baseline_dmg + counterattack_dmg) / baseline_dmg;
976    }
977
978    // ---- defensive: EHP = hp ÷ (fraction of damage that gets through) ----
979    // Via get_attr_from_attrs so hp.bonus/hp.mod count (consistent with combat),
980    // not the raw value the old scalar read.
981    let hp = get_attr_from_attrs(attrs, "hp");
982    let mut ehp = hp;
983    // armor mitigation (DR via `armor_k`): divide by the damage-through fraction.
984    ehp /= armor_k(get_attr_from_attrs(attrs, "armor"));
985    // dodge (evasion rating) via DR: avoid prob = ev/(ev+K_DODGE), asymptote < 1.
986    let evasion = get_attr_from_attrs(attrs, "evasion").max(0.0);
987    ehp /= 1.0 - evasion / (evasion + tuning().k_dodge);
988    // block: halves damage on proc → expected damage multiplier (1 − 0.5·p),
989    // p clamped to [0,1] (max ×2 EHP at always-block).
990    let block_chance = (get_attr_from_attrs(attrs, "block") / BASE_POINT).clamp(0.0, 1.0);
991    ehp /= 1.0 - 0.5 * block_chance;
992    // received_damage: a damage-taken multiplier (base BASE_POINT = 100% taken); combat
993    // applies it in `damage_entity`, so the scalar must too or it under-counts mitigation
994    // EHP (a build with persistent `received_damage` reduction reads weaker than it fights).
995    // The scalar has no `lookups` for the base, so BASE_POINT is added explicitly here;
996    // floored like combat (`MIN_RECEIVED_DAMAGE_K`) so it can't reach ≤0. Neutral → ×1.
997    let received_damage =
998        (BASE_POINT + get_attr_from_attrs(attrs, "received_damage")).max(MIN_RECEIVED_DAMAGE_K);
999    ehp *= BASE_POINT / received_damage;
1000    // regen-as-EHP, derived to MATCH combat (SC-1 fix). Combat heals at most
1001    // `regen_tick_max_pct × max_hp` per ~1s tick (`regeneration_tick`), so over a fight it
1002    // delivers `FIGHT_DURATION × min(regen_rate, regen_tick_max_pct × hp)`. The old
1003    // independent `≤1×hp` cap let the scalar credit up to ×2 EHP while combat delivers only
1004    // ~×1.16 — over-rating high-regen builds' displayed/matchmaking power by ~70%. Uses the
1005    // SAME `tuning().regen_tick_max_pct` knob as combat, so the two can never desync.
1006    // (This also makes regen structurally weaker EHP than block — a real combat-balance fact
1007    // the old scalar hid; closing the Priest-competitiveness gap is a separate sim/design pass.)
1008    let regen_per_sec = get_attr_from_attrs(attrs, "regeneration_rate")
1009        .min(hp.max(0.0) * tuning().regen_tick_max_pct);
1010    let regen_per_fight = regen_per_sec * FIGHT_DURATION;
1011    if hp != 0.0 {
1012        ehp *= (hp + regen_per_fight) / hp;
1013    }
1014    // deceit: debuffs the enemy → effective survivability gain (legacy shape).
1015    ehp *= buff_uptime_mult(
1016        get_attr_from_attrs(attrs, "deceit") / BASE_POINT,
1017        DECEIT_DEBUFF_QUANTITY,
1018        DECEIT_DEBUFF_DURATION,
1019    );
1020
1021    ((dps * ehp) / tuning().power_norm).floor() as i64
1022}
1023
1024///
1025/// references. The previous version accepted either real essences structs (the
1026/// into these typed inputs and then calls this exact function — so results are
1027/// byte-identical on both paths.
1028///
1029/// **Accumulation order is load-bearing** (per-key float sums): char-level
1030/// attributes, then inventory items (each item's attributes in order), then
1031/// pets (in the given slice order, each pet's stats in order). Abilities are
1032/// summed slotted-then-unslotted, matching the original.
1033pub fn character_power(
1034    config: &GameConfig,
1035    lookups: &ContentLookups,
1036    level: i64,
1037    inventory: &[Item],
1038    abilities: &EquippedAbilities,
1039    pets: &[Pet],
1040) -> Result<i64, String> {
1041    let attrs_power = character_attrs_power(config, level, inventory, pets)?;
1042
1043    // Abilities: slotted (BTreeMap value order) then unslotted, matching the
1044    // original `.slotted.into_values()` / `.unslotted` iteration order.
1045    let mut ability_eff_sum = 0.0;
1046    for ability in abilities.slotted.values().chain(abilities.unslotted.iter()) {
1047        let Ability {
1048            template_id, level, ..
1049        } = ability;
1050        if let Some(tpl) = config.ability_template(*template_id) {
1051            ability_eff_sum += ability_eff(lookups, tpl.rarity_id, *level);
1052        }
1053    }
1054
1055    Ok((attrs_power as f64 * ability_eff_sum).floor() as i64)
1056}
1057
1058/// Combat-power scalar from an already-composed [`AttrMap`] plus equipped
1059/// abilities — `floor(power_from_attrs(attrs) × Σ ability_eff)`.
1060///
1061/// Balance v2 (Phase 4 fix): lets the display / matchmaking / gating path feed
1062/// the **full** multi-source aggregation
1063/// (`attributes::calculate_player_entity_stats_with_zeroes` — char-level, items,
1064/// class, pets, talents, statue, class-levels) through the same formula, so the
1065/// power scalar reflects every combat source. [`character_power`] keeps the
1066/// items+pets subset for *marginal* gear-compare, where the constant
1067/// class/talent/statue baseline cancels in the with-minus-without difference.
1068pub fn character_power_from_attrs(
1069    config: &GameConfig,
1070    lookups: &ContentLookups,
1071    attrs: &AttrMap,
1072    abilities: &EquippedAbilities,
1073) -> i64 {
1074    let attrs_power = power_from_attrs(attrs);
1075    let mut ability_eff_sum = 0.0;
1076    for ability in abilities.slotted.values().chain(abilities.unslotted.iter()) {
1077        if let Some(tpl) = config.ability_template(ability.template_id) {
1078            ability_eff_sum += ability_eff(lookups, tpl.rarity_id, ability.level);
1079        }
1080    }
1081    (attrs_power as f64 * ability_eff_sum).floor() as i64
1082}
1083
1084/// The `attrs_power` term of [`character_power`]: char-level attributes,
1085/// inventory items and pet stats composed into an [`AttrMap`] and run through
1086/// [`power_from_attrs`] — WITHOUT the trailing `* ability_eff_sum` multiplier.
1087///
1088/// **Accumulation order is load-bearing** (per-key float sums): char-level
1089/// attributes, then inventory items (each item's attributes in order), then
1090/// pets (in the given slice order, each pet's stats in order) — exactly the
1091/// order the original used; [`character_power`] delegates here so both paths
1092/// stay byte-identical.
1093///
1094/// For callers comparing loadouts that carry no abilities of their own (e.g.
1095/// ranking pets for fast-equip), where [`character_power`]'s ability factor
1096/// would multiply every score by 0.
1097pub fn character_attrs_power(
1098    config: &GameConfig,
1099    level: i64,
1100    inventory: &[Item],
1101    pets: &[Pet],
1102) -> Result<i64, String> {
1103    let mut collected: AttrMap = AttrMap::new();
1104
1105    let Some(char_level_tpl) = config.character_level(level) else {
1106        return Err(format!(
1107            "balance::character_power: missing character_level {level}"
1108        ));
1109    };
1110
1111    for attr in &char_level_tpl.attributes {
1112        if let Some(a) = config.attribute(attr.attribute_id) {
1113            // Original: `collected.insert(code, value)` — an unconditional
1114            // overwrite (NOT accumulate). If two char-level entries share a
1115            // code, the later one wins. Preserve that exactly with `insert`.
1116            collected.insert(a.code.as_str().to_string(), attr.value as f64);
1117        }
1118    }
1119
1120    // Sum the per-item power jitter alongside attribute accumulation. Items that
1121    // were rolled before the jitter feature (or arena bots) carry power_bonus=0,
1122    // so they are unaffected by this sum.
1123    let mut total_power_bonus: i64 = 0;
1124
1125    for item in inventory {
1126        for item_attr in &item.attributes {
1127            if let Some(a) = config.attribute(item_attr.attr_id) {
1128                accumulate(&mut collected, a.code.as_str(), item_attr.value as f64);
1129            }
1130        }
1131        total_power_bonus += item.power_bonus as i64;
1132    }
1133
1134    for pet in pets {
1135        for stat in &pet.stats {
1136            if let Some(a) = config.attribute(stat.attribute_id) {
1137                accumulate(&mut collected, a.code.as_str(), stat.value as f64);
1138            }
1139        }
1140    }
1141
1142    Ok(power_from_attrs(&collected) + total_power_bonus)
1143}
1144
1145#[cfg(test)]
1146mod attr_spread_tests {
1147    //! Deterministic equivalence tests for the branchy `attr_spread_for_item`
1148    //! (the RNG-driven primitive behind every item-attribute calc). Driven by
1149    //! `GameRng::from_values` so they prove formula + *draw count* without
1150    //! any sim entropy. Draw count is load-bearing for determinism — the random
1151    //! seed advances per draw — so each branch asserts how many draws it consumes.
1152
1153    use super::*;
1154
1155    /// A template id that is NOT in `item_fixed_power`, so the fixed-power early
1156    /// return never fires.
1157    fn non_fixed_template() -> Uuid {
1158        Uuid::from_u128(0x0194d64e_2100_7569_91be_a3d34c967544)
1159    }
1160
1161    /// Count of `f64` draws a recording RNG sees after one `attr_spread_for_item`.
1162    fn draws_for(level: f64) -> usize {
1163        let cfg = configs::tests_game_config::generate_game_config_for_tests();
1164        let lk = ContentLookups::default();
1165        let rng = GameRng::from_entropy_recording();
1166        let _ = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
1167        rng.recorded().len()
1168    }
1169
1170    #[test]
1171    fn level_one_is_constant_065_and_draws_nothing() {
1172        let cfg = configs::tests_game_config::generate_game_config_for_tests();
1173        let lk = ContentLookups::default();
1174        let rng = GameRng::from_values(vec![0.5]);
1175        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 1.0);
1176        assert_eq!(v, 0.65);
1177        assert_eq!(draws_for(1.0), 0, "level==1 must consume no RNG draws");
1178    }
1179
1180    #[test]
1181    fn level_le_25_uses_one_draw_via_attr_spread_random() {
1182        let cfg = configs::tests_game_config::generate_game_config_for_tests();
1183        let lk = ContentLookups::default();
1184        // attr_spread_random(r) = 1 + ATTR_DEVIATION*(2*r - 1). r=0.5 -> 1.0.
1185        let rng = GameRng::from_values(vec![0.5]);
1186        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 25.0);
1187        assert!((v - 1.0).abs() < 1e-12, "expected 1.0, got {v}");
1188        // r=0.0 -> 1 - ATTR_DEVIATION; r=1.0 -> 1 + ATTR_DEVIATION.
1189        let rng = GameRng::from_values(vec![0.0]);
1190        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), 10.0);
1191        assert!((v - (1.0 - ATTR_DEVIATION)).abs() < 1e-12);
1192        assert_eq!(
1193            draws_for(25.0),
1194            1,
1195            "level<=25 must consume exactly one draw"
1196        );
1197    }
1198
1199    #[test]
1200    fn level_gt_25_uses_two_draws_lower_branch() {
1201        // side < 0.5625 -> fake_level = level - u^2 (lower branch).
1202        let cfg = configs::tests_game_config::generate_game_config_for_tests();
1203        let lk = ContentLookups::default();
1204        let level = 100.0;
1205        let side = 0.1; // < 0.5625
1206        let u = 0.3;
1207        let rng = GameRng::from_values(vec![side, u]);
1208        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
1209
1210        // Reproduce the expected ratio with the same default config (rarity_q=1).
1211        let fake_level = level - u.powi(2);
1212        let fake_eff = eff_item_with_config(&cfg, &lk, non_fixed_template(), fake_level);
1213        let real_eff = eff_item_with_config(&cfg, &lk, non_fixed_template(), level);
1214        let expected = fake_eff / real_eff;
1215        assert!((v - expected).abs() < 1e-12, "expected {expected}, got {v}");
1216        assert_eq!(
1217            draws_for(level),
1218            2,
1219            "level>25 must consume exactly two draws"
1220        );
1221    }
1222
1223    #[test]
1224    fn level_gt_25_uses_two_draws_upper_branch() {
1225        // side >= 0.5625 -> fake_level = level + 3*u^6 (upper branch).
1226        let cfg = configs::tests_game_config::generate_game_config_for_tests();
1227        let lk = ContentLookups::default();
1228        let level = 100.0;
1229        let side = 0.9; // >= 0.5625
1230        let u = 0.4;
1231        let rng = GameRng::from_values(vec![side, u]);
1232        let v = attr_spread_for_item(&cfg, &lk, &rng, non_fixed_template(), level);
1233
1234        let fake_level = level + 3.0 * u.powi(6);
1235        let fake_eff = eff_item_with_config(&cfg, &lk, non_fixed_template(), fake_level);
1236        let real_eff = eff_item_with_config(&cfg, &lk, non_fixed_template(), level);
1237        let expected = fake_eff / real_eff;
1238        assert!((v - expected).abs() < 1e-12, "expected {expected}, got {v}");
1239    }
1240
1241    #[test]
1242    fn fixed_power_short_circuits_and_draws_nothing() {
1243        let cfg = configs::tests_game_config::generate_game_config_for_tests();
1244        let mut lk = ContentLookups::default();
1245        let tpl = non_fixed_template();
1246        lk.item_fixed_power.insert(tpl, 1.23);
1247        let rng = GameRng::from_entropy_recording();
1248        let v = attr_spread_for_item(&cfg, &lk, &rng, tpl, 100.0);
1249        assert_eq!(v, 1.23);
1250        assert_eq!(
1251            rng.recorded().len(),
1252            0,
1253            "fixed-power path must not draw RNG"
1254        );
1255    }
1256}
1257
1258#[cfg(test)]
1259mod class_counter_tests {
1260    //! The soft 3-cycle class counter (Warrior>Rogue>Mage>Warrior, Priest/Citizen
1261    //! neutral). Roles are stored on `ContentLookups`; here we build them by hand.
1262    use super::*;
1263    use std::collections::HashMap;
1264
1265    fn lookups_with_roles() -> (ContentLookups, Uuid, Uuid, Uuid, Uuid) {
1266        let (w, r, m, p) = (
1267            Uuid::from_u128(1),
1268            Uuid::from_u128(2),
1269            Uuid::from_u128(3),
1270            Uuid::from_u128(4),
1271        );
1272        let lk = ContentLookups {
1273            class_counter_role: HashMap::from([(w, 0i8), (r, 1), (m, 2), (p, -1)]),
1274            ..Default::default()
1275        };
1276        (lk, w, r, m, p)
1277    }
1278
1279    #[test]
1280    fn role_from_main_attribute_code() {
1281        assert_eq!(class_counter_role_from_code("block"), 0); // Warrior
1282        assert_eq!(class_counter_role_from_code("crit_chance"), 1); // Rogue
1283        assert_eq!(class_counter_role_from_code("multicast_chance"), 2); // Mage
1284        assert_eq!(class_counter_role_from_code("regeneration_rate"), -1); // Priest
1285        assert_eq!(class_counter_role_from_code("hp"), -1); // Citizen
1286    }
1287
1288    #[test]
1289    fn three_cycle_swings() {
1290        let (lk, w, r, m, _p) = lookups_with_roles();
1291        let up = 1.0 + CLASS_COUNTER_SWING;
1292        let down = 1.0 - CLASS_COUNTER_SWING;
1293        // W>R>M>W
1294        assert!((class_counter_multiplier(&lk, Some(w), Some(r)) - up).abs() < 1e-9);
1295        assert!((class_counter_multiplier(&lk, Some(r), Some(m)) - up).abs() < 1e-9);
1296        assert!((class_counter_multiplier(&lk, Some(m), Some(w)) - up).abs() < 1e-9);
1297        // reverse direction is countered
1298        assert!((class_counter_multiplier(&lk, Some(r), Some(w)) - down).abs() < 1e-9);
1299        assert!((class_counter_multiplier(&lk, Some(m), Some(r)) - down).abs() < 1e-9);
1300        assert!((class_counter_multiplier(&lk, Some(w), Some(m)) - down).abs() < 1e-9);
1301    }
1302
1303    #[test]
1304    fn neutral_and_pve_are_unity() {
1305        let (lk, w, _r, _m, p) = lookups_with_roles();
1306        // Priest is neutral both ways.
1307        assert_eq!(class_counter_multiplier(&lk, Some(p), Some(w)), 1.0);
1308        assert_eq!(class_counter_multiplier(&lk, Some(w), Some(p)), 1.0);
1309        // Same class → no swing.
1310        assert_eq!(class_counter_multiplier(&lk, Some(w), Some(w)), 1.0);
1311        // PvE: a classless mob (None) → no swing in either slot.
1312        assert_eq!(class_counter_multiplier(&lk, Some(w), None), 1.0);
1313        assert_eq!(class_counter_multiplier(&lk, None, Some(w)), 1.0);
1314        // Unknown class id (not in the map) → neutral.
1315        assert_eq!(
1316            class_counter_multiplier(&lk, Some(Uuid::from_u128(99)), Some(w)),
1317            1.0
1318        );
1319    }
1320}
1321
1322#[cfg(test)]
1323mod power_formula_tests {
1324    //! Pins `power_from_attrs` to the **v2 P = DPS × EHP** formula with DR
1325    //! curves, so a regression to the legacy opaque `S²` formula FAILS here
1326    //! (the two give different numbers for the same loadout). This is the proof
1327    //! that the new combat-power formula is the one actually running.
1328    use super::*;
1329
1330    #[test]
1331    fn power_is_dps_times_ehp_with_dr() {
1332        let mut a = AttrMap::new();
1333        a.insert("hp".into(), 3000.0);
1334        a.insert("attack".into(), 600.0);
1335        a.insert("speed".into(), 10000.0); // attack rate 1.0
1336        a.insert("armor".into(), 2000.0); // armor_k = K/(armor+K) = 2000/4000 = 0.5
1337        a.insert("evasion".into(), 1500.0); // dodge = 1500/(1500+1500) = 0.5
1338        // crit / multicast / bravery / block / regen / deceit / counterattack absent → 0.
1339        //
1340        // DPS = attack(600) · rate(1.0) · crit(1) · multicast(1) · bravery(1) · counter(1) = 600
1341        // EHP = hp(3000) ÷ armor_k(0.5) ÷ (1 − dodge 0.5) = 3000 / 0.5 / 0.5 = 12000
1342        // power = floor(DPS·EHP / POWER_NORM) = floor(600·12000 / 1800) = 4000
1343        //
1344        // The legacy S² formula gives 1470 for the same attrs (armor 1/0.8=1.25,
1345        // evasion 1/(1−0.15)=1.176, ×BASE_POWER 1000) — so this asserts the v2 path.
1346        assert_eq!(power_from_attrs(&a), 4000);
1347    }
1348
1349    #[test]
1350    fn armor_k_is_dr_not_linear() {
1351        // DR curve: always in (0,1], never negative — the legacy `1 - armor/10000`
1352        // hit 0 at armor=10000 and went negative beyond (the shipped bug).
1353        assert!((armor_k(0.0) - 1.0).abs() < 1e-9);
1354        assert!((armor_k(K_ARMOR) - 0.5).abs() < 1e-9); // rating == K → 50% through
1355        assert!(armor_k(50_000.0) > 0.0); // huge armor: still positive (no heal-the-target bug)
1356        assert!(armor_k(50_000.0) < 0.05);
1357    }
1358
1359    #[test]
1360    fn probability_stats_clamp_at_100pct() {
1361        // crit / multicast / counterattack chances over the basis-point cap
1362        // (e.g. an over-generous class-level grant) must clamp to 100% — power
1363        // can't keep scaling past a certain chance. Guards the `.clamp(0,1)`s.
1364        let base = || {
1365            let mut a = AttrMap::new();
1366            a.insert("hp".into(), 3000.0);
1367            a.insert("attack".into(), 600.0);
1368            a.insert("speed".into(), 10000.0);
1369            a
1370        };
1371        let with = |stat: &str, v: f64| {
1372            let mut a = base();
1373            a.insert(stat.into(), v);
1374            power_from_attrs(&a)
1375        };
1376        for stat in ["crit_chance", "multicast_chance", "counterattack_chance"] {
1377            assert_eq!(
1378                with(stat, 10000.0),
1379                with(stat, 5_000_000.0),
1380                "{stat} must clamp at 100% (no power growth past the cap)"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn scalar_matches_combat_on_received_damage_and_speed_baseline() {
1387        // received_damage: combat applies it; the scalar must too. A −5000 mod (10000−5000 =
1388        // 5000 = 50% damage taken) ⇒ ×2 EHP ⇒ double power; neutral (absent) ⇒ ×1 (no change).
1389        let mut neutral = AttrMap::new();
1390        neutral.insert("hp".into(), 3000.0);
1391        neutral.insert("attack".into(), 600.0);
1392        neutral.insert("speed".into(), 10000.0);
1393        assert_eq!(
1394            power_from_attrs(&neutral),
1395            1000,
1396            "neutral baseline = DPS·EHP/NORM"
1397        );
1398        let mut reduced = neutral.clone();
1399        reduced.insert("received_damage".into(), -5000.0);
1400        assert_eq!(
1401            power_from_attrs(&reduced),
1402            2000,
1403            "50% received_damage must double EHP/power (scalar matches combat)"
1404        );
1405
1406        // speed ≤ 0 falls back to the baseline cast rate (like combat's `speed_or_baseline`),
1407        // NOT 0 DPS — a build with no speed stat must still have positive power.
1408        let mut no_speed = AttrMap::new();
1409        no_speed.insert("hp".into(), 3000.0);
1410        no_speed.insert("attack".into(), 600.0); // no "speed" key ⇒ composed speed 0
1411        assert_eq!(
1412            power_from_attrs(&no_speed),
1413            1000,
1414            "speed≤0 must use baseline rate (1.0), not 0 DPS"
1415        );
1416    }
1417}
1418
1419#[cfg(test)]
1420mod v2_helper_tests {
1421    //! Pins the v2 sim-tunable helpers: enemy-curve anchor+monotonicity (the
1422    //! progression gate), the sub-linear gold faucet (the cost-curve bind), and
1423    //! the per-tick heal/damage cap (anti out-heal / one-shot).
1424    use super::*;
1425
1426    #[test]
1427    fn enemy_curve_anchored_and_monotonic() {
1428        // Anchored exactly at the last hand-made chapter.
1429        assert_eq!(
1430            enemy_power_for_chapter(ENEMY_CURVE_ANCHOR_CHAPTER),
1431            ENEMY_CURVE_ANCHOR_POWER.floor()
1432        );
1433        // Strictly increasing past the anchor — content must gate progression.
1434        let mut prev = enemy_power_for_chapter(ENEMY_CURVE_ANCHOR_CHAPTER);
1435        for ch in (ENEMY_CURVE_ANCHOR_CHAPTER + 1)..=70 {
1436            let p = enemy_power_for_chapter(ch);
1437            assert!(
1438                p > prev,
1439                "enemy power must strictly increase: ch{ch} → {p} ≤ {prev}"
1440            );
1441            prev = p;
1442        }
1443    }
1444
1445    #[test]
1446    fn dungeon_uses_authored_base_power_not_chapter_curve() {
1447        // A dungeon fight is tagged CampaignBossFight but must use its authored
1448        // per-difficulty `base_power` (the difficulty ladder), NOT the campaign
1449        // chapter curve — otherwise every difficulty collapses to one power keyed
1450        // to the player's campaign chapter ("losing to the dungeon at unlock").
1451        let ch = 20;
1452        // Campaign boss at ch20 ignores base_power and rides the (large) curve.
1453        let campaign = enemy_power_scalar(1500.0, ch, "CampaignBossFight", false);
1454        assert!(
1455            campaign > 10_000.0,
1456            "campaign boss must use the chapter curve at ch20, got {campaign}"
1457        );
1458        // Same template AS A DUNGEON returns the authored base_power verbatim —
1459        // difficulty 1 (1500) is a trivial first clear vs a ch20 player (~20k).
1460        assert_eq!(
1461            enemy_power_scalar(1500.0, ch, "CampaignBossFight", true),
1462            1500.0
1463        );
1464        // The ladder is honored across difficulties and is chapter-independent:
1465        // diff 5 (50k) and diff 10 (8.5M) pass through unchanged at any chapter.
1466        assert_eq!(
1467            enemy_power_scalar(50_000.0, ch, "CampaignBossFight", true),
1468            50_000.0
1469        );
1470        assert_eq!(
1471            enemy_power_scalar(8_500_000.0, 80, "CampaignBossFight", true),
1472            8_500_000.0
1473        );
1474    }
1475
1476    #[test]
1477    fn sell_price_is_sublinear() {
1478        // Sub-linear (exp < 1): doubling `eff` less-than-doubles price, so the
1479        // geometric chest-upgrade sink can outgrow the gold faucet and bind
1480        // progression. The ratio check below fails if exp ever reaches ≥ 1.
1481        let p1 = sell_price(1000.0) as f64;
1482        let p2 = sell_price(2000.0) as f64;
1483        assert!(p2 > p1, "price must increase with eff");
1484        assert!(
1485            p2 < 2.0 * p1,
1486            "sub-linear: doubling eff must less-than-double price ({p1} → {p2})"
1487        );
1488        assert_eq!(sell_price(-5.0), 0, "negative eff floored to 0");
1489    }
1490
1491    #[test]
1492    fn cap_per_tick_binds_and_passes_through() {
1493        // Over-cap clamps to pct×max_hp; under-cap passes through unchanged.
1494        assert_eq!(cap_per_tick(1_000_000.0, 1000.0, 0.02), 20.0);
1495        assert_eq!(cap_per_tick(5.0, 1000.0, 0.02), 5.0);
1496        assert_eq!(cap_per_tick(0.0, 1000.0, 0.5), 0.0);
1497    }
1498}
1499
1500#[cfg(test)]
1501mod class_balance_tests {
1502    //! The four combat classes are differentiated by `class_levels` grants
1503    //! (Warrior→block, Rogue→crit, Mage→multicast, Priest→regen) but must remain
1504    //! POWER-balanced under `P=DPS×EHP`. Their grants overflow the basis-point
1505    //! cap, so this also guards the clamp/regen-cap robustness fixes: without
1506    //! them block>1 gave NEGATIVE power and regen exploded EHP ~40×.
1507    use super::*;
1508
1509    fn profile(stat: &str, value: f64) -> AttrMap {
1510        let mut a = AttrMap::new();
1511        a.insert("attack".into(), 600.0);
1512        a.insert("hp".into(), 3000.0);
1513        a.insert("speed".into(), 10000.0);
1514        a.insert(stat.into(), value);
1515        a
1516    }
1517
1518    #[test]
1519    fn class_power_offensive_trio_equal_regen_honestly_lower() {
1520        // The block class (Warrior, ×2 EHP) and the two ×2-DPS classes (Rogue crit,
1521        // Mage multicast) are power-balanced — symmetric ×2 mechanisms → equal power.
1522        let warrior = power_from_attrs(&profile("block", 38800.0)); // 3.88 → clamp 1.0 → ×2 EHP
1523        let rogue = power_from_attrs(&profile("crit_chance", 19400.0)); // 1.94 → clamp → ×2 DPS
1524        let mage = power_from_attrs(&profile("multicast_chance", 19400.0)); // → ×2 DPS
1525        let priest = power_from_attrs(&profile("regeneration_rate", 19400.0));
1526        assert!(
1527            warrior > 0,
1528            "block>1 must NOT produce negative power (clamp)"
1529        );
1530        assert_eq!(warrior, rogue, "Warrior vs Rogue imbalance");
1531        assert_eq!(rogue, mage, "Rogue vs Mage imbalance");
1532        // SC-1: Priest's regen is now honestly capped to what COMBAT delivers
1533        // (≈×1.16 EHP), which is structurally below block's ×2 — so the scalar shows
1534        // Priest LOWER (the old scalar falsely showed parity by crediting ×2 EHP that
1535        // combat never delivers). This is a real combat-balance fact, not a formula
1536        // bug; closing it (raise `regen_tick_max_pct` or compensate the Priest grant)
1537        // is a sim/design pass, not a scalar tweak.
1538        assert!(priest > 0, "Priest power must stay positive");
1539        assert!(
1540            priest < warrior,
1541            "honest regen EHP (≈×1.16) is below block's ×2 — Priest is structurally lower"
1542        );
1543    }
1544
1545    #[test]
1546    fn regen_is_bounded() {
1547        // Absurd regen must not explode EHP: the scalar applies the SAME per-tick cap as
1548        // combat (`regen_tick_max_pct × hp`), so regen above the cap adds no extra power.
1549        let huge = power_from_attrs(&profile("regeneration_rate", 1_000_000.0));
1550        let capped = power_from_attrs(&profile("regeneration_rate", 19400.0));
1551        assert_eq!(huge, capped, "regen beyond the cap must not increase power");
1552    }
1553}