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}