overlord_event_system/mechanics/
power_q.rs

1//! The dynamic half of displayed / matchmaking Power (BAL-030).
2//!
3//! `P_ui = floor(P_static × Πq)`. `P_static` is combat-effective stats run
4//! through [`crate::mechanics::balance::power_from_attrs`]; everything that
5//! cannot be folded honestly into a stat — a law, an artifact rule, a socketed
6//! effect, a facet — enters here as an approximate multiplier `q` authored on
7//! its own template.
8//!
9//! Three rules hold the whole module together:
10//!
11//! * **A bad combination never lowers Power.** Every `q` is `>= 1`, so a
12//!   no-op stone reads as "no contribution", not as a penalty.
13//! * **The estimate is STABLE out of combat.** Nothing here reads the live
14//!   flip side, gauge, law stacks or the rolled facet: a build is worth the
15//!   same number before the fight and during it. The two-sided systems are
16//!   averaged instead (`50/50` sides, the expected value of the facet die).
17//! * **Nothing is counted twice.** Raw stats — Twin Core multipliers, artifact
18//!   ownership bonuses, pet base stats, passive stone stats, ability payloads —
19//!   already moved `P_static`, so they contribute no `q`.
20//!
21//! The coefficients are approximate by construction and are allowed to be
22//! wrong about a specific matchup; `S_real` (the combat outcome) stays the
23//! balance truth.
24
25use configs::game_config::GameConfig;
26use essences::ability_stones::AbilityStoneId;
27use essences::character_state::CharacterState;
28use essences::items::ItemType;
29use essences::stones::{Stone, StoneSocketSlot};
30use essences::{artifacts::ArtifactCollection, cores::CoresState};
31
32use crate::game_config_helpers::GameConfigLookup;
33use crate::mechanics::cores as cores_mech;
34
35/// One coefficient interpolated between its first and last rank.
36///
37/// Interpolation is linear in `ln(q)`, so a rank ladder compounds evenly
38/// instead of front-loading. A single-rank component (`max_rank <= 1`) is worth
39/// its first-rank value.
40pub fn interpolated_q(first_rank: f64, max_rank_value: f64, rank: i64, max_rank: i64) -> f64 {
41    let first = first_rank.max(1.0);
42    let last = max_rank_value.max(first);
43    if max_rank <= 1 {
44        return first;
45    }
46    let t = ((rank.max(1) - 1) as f64 / (max_rank - 1) as f64).clamp(0.0, 1.0);
47    (first.ln() + (last.ln() - first.ln()) * t).exp()
48}
49
50/// Πq over every dynamic system of `character`'s CURRENT loadout.
51///
52/// Multiplies — an equivalent implementation could sum the logarithms. There is
53/// no global cap and no combo-correction table: interactions are deliberately
54/// left uncalibrated rather than patched with exceptions.
55pub fn dynamic_power_multiplier(config: &GameConfig, character: &CharacterState) -> f64 {
56    let mut q = 1.0;
57    q *= laws_and_bridges_q(config, &character.cores);
58    q *= artifacts_q(config, &character.artifacts);
59    q *= ability_stones_q(config, character);
60    q *= pet_facets_q(config, character);
61    q *= equipment_q(config, character);
62    q *= class_q(config, character);
63    q.max(1.0)
64}
65
66/// The active class's passive plus whichever of its kit the character actually
67/// has equipped.
68///
69/// The abilities' own payloads are already in `P_static` (the analytic ability
70/// multipliers price them), so what is counted here is only the utility a
71/// closed form cannot express: a damage window, a cooldown cut, a team buff.
72/// An unequipped class ability is worth nothing, exactly like an unslotted
73/// law.
74fn class_q(config: &GameConfig, character: &CharacterState) -> f64 {
75    const MAX_CLASS_LEVEL: i64 = 20;
76
77    let active = character.character.class;
78    let Some(class) = config.classes.iter().find(|c| c.id == active) else {
79        return 1.0;
80    };
81
82    // The shared account level makes every row equal in a consistent state, but
83    // legacy rows may diverge — price the ACTIVE class's own ladder position.
84    let class_level = character
85        .character_classes
86        .iter()
87        .find(|row| row.class_id == active)
88        .map(|row| row.level as i64)
89        .unwrap_or(1);
90
91    let mut q = interpolated_q(
92        class.passive_power_q_l1,
93        class.passive_power_q_l20,
94        class_level,
95        MAX_CLASS_LEVEL,
96    );
97
98    for row in &class.ability_power_q {
99        let equipped = character
100            .equipped_abilities
101            .slotted
102            .values()
103            .any(|ability| ability.template_id == row.ability_id);
104        if equipped {
105            q *= row.q.max(1.0);
106        }
107    }
108    q
109}
110
111/// Slotted laws and the bridges between them.
112///
113/// Only SLOTTED laws pay: owning a law is not power, using it is. The seeds
114/// already carry the `50/50` share of a law's own side, so a Real and a Fantasy
115/// law in the same build simply multiply.
116fn laws_and_bridges_q(config: &GameConfig, cores: &CoresState) -> f64 {
117    let max_rank = config
118        .cores_settings
119        .law_upgrade_ladder
120        .iter()
121        .map(|step| step.level)
122        .max()
123        .unwrap_or(1);
124
125    let law_q = |law_id| -> Option<f64> {
126        let owned = cores
127            .laws
128            .iter()
129            .find(|law| law.template_id == law_id && law.slot_index.is_some())?;
130        let template = cores_mech::law_template(config, law_id)?;
131        Some(interpolated_q(
132            template.power_q_first_rank,
133            template.power_q_max_rank,
134            owned.level,
135            max_rank,
136        ))
137    };
138
139    let mut q = 1.0;
140    for law in cores.laws.iter().filter(|law| law.slot_index.is_some()) {
141        if let Some(law_value) = law_q(law.template_id) {
142            q *= law_value;
143        }
144    }
145
146    // A bridge is worth the amplification it actually delivers to the law on
147    // the far end, in both directions, at the expected fill.
148    let fill = config
149        .game_settings
150        .power_estimator
151        .bridge_expected_fill
152        .clamp(0.0, 1.0);
153    for bridge in &cores.bridges {
154        let (Some(real_q), Some(fantasy_q)) =
155            (law_q(bridge.real_law_id), law_q(bridge.fantasy_law_id))
156        else {
157            continue;
158        };
159        q *= (0.5 * fill * fantasy_q.ln() + 0.5 * fill * real_q.ln()).exp();
160    }
161    q
162}
163
164/// The worn artifact's World Law plus the stones in its sockets. Ownership
165/// bonuses are stats and already moved `P_static`, so they add nothing here.
166fn artifacts_q(config: &GameConfig, collection: &ArtifactCollection) -> f64 {
167    let mut q = 1.0;
168
169    if let Some(equipped) = collection.equipped
170        && let Some(template) = config.artifacts.iter().find(|a| a.id == equipped)
171    {
172        q *= template.power_q.max(1.0);
173    }
174
175    let max_rank = config.artifacts_settings.max_stone_level;
176    for stone in collection.stones.iter().filter(|s| s.socket.is_some()) {
177        let Some(template) = config
178            .artifact_stones
179            .iter()
180            .find(|t| t.id == stone.template_id)
181        else {
182            continue;
183        };
184        if !template.active {
185            continue;
186        }
187        q *= interpolated_q(
188            template.power_q_first_rank,
189            template.power_q_max_rank,
190            stone.level,
191            max_rank,
192        );
193    }
194    q
195}
196
197/// Every ability stone sitting in an equipped ability's socket. Several
198/// modifiers on one ability multiply; the ability's own payload is already in
199/// `P_static`, so only the modifiers count.
200fn ability_stones_q(config: &GameConfig, character: &CharacterState) -> f64 {
201    let max_rank = config.ability_stone_settings.upgrade_copies.len() as i64 + 1;
202    let level_of = |stone_id: AbilityStoneId| {
203        character
204            .ability_stones
205            .iter()
206            .find(|owned| owned.template_id == stone_id)
207            .map_or(1, |owned| owned.level)
208    };
209
210    let mut q = 1.0;
211    for (ability_id, sockets) in &character.ability_stone_sockets.0 {
212        if !character
213            .equipped_abilities
214            .slotted
215            .values()
216            .any(|ability| ability.template_id == *ability_id)
217        {
218            continue;
219        }
220        for stone_id in sockets.values() {
221            let Some(template) = config.ability_stone_template(*stone_id) else {
222                continue;
223            };
224            q *= interpolated_q(
225                template.power_q_first_rank,
226                template.power_q_max_rank,
227                level_of(*stone_id),
228                max_rank,
229            );
230        }
231    }
232    q
233}
234
235/// The expected value of the facet die: each side's equipped faces averaged,
236/// then the two sides averaged `50/50`. An empty side is worth `1`, and the
237/// face that happens to be up right now is deliberately ignored.
238fn pet_facets_q(config: &GameConfig, character: &CharacterState) -> f64 {
239    const MAX_PET_LEVEL: i64 = 10;
240
241    let mut real: Vec<f64> = Vec::new();
242    let mut fantasy: Vec<f64> = Vec::new();
243
244    for pet in character.equipped_pets.slotted.values() {
245        let Some(template) = config.pet_template(pet.template_id) else {
246            continue;
247        };
248        let face_q = |facet| {
249            config
250                .pet_facet_settings
251                .power_q_row(facet)
252                .map(|row| {
253                    interpolated_q(row.q_first_level, row.q_max_level, pet.level, MAX_PET_LEVEL)
254                })
255                .unwrap_or(1.0)
256        };
257        real.push(face_q(template.real_facet));
258        fantasy.push(face_q(template.fantasy_facet));
259    }
260
261    let mean = |faces: &[f64]| {
262        if faces.is_empty() {
263            1.0
264        } else {
265            faces.iter().sum::<f64>() / faces.len() as f64
266        }
267    };
268    0.5 * mean(&real) + 0.5 * mean(&fantasy)
269}
270
271/// One `q` per equipment item, from the package in its sockets.
272///
273/// The trigger sets how OFTEN the package runs and the effects set what it
274/// does, so the two are priced together rather than multiplied as three
275/// independent stones — that would double-count the synergy the package is.
276/// A legal Trigger with no Effect yet is worth the authored floor: the gauge
277/// alone produces no measurable DPS or EHP, but the player must still see the
278/// number move.
279fn equipment_q(config: &GameConfig, character: &CharacterState) -> f64 {
280    use strum::IntoEnumIterator;
281
282    let settings = &config.game_settings.power_estimator;
283    let reference_rate = settings.reference_trigger_proc_rate.max(f64::MIN_POSITIVE);
284    let max_rank = config.stones_settings.max_stone_level;
285
286    let socketed = |item_type: ItemType, slot: StoneSocketSlot, stones: &[Stone]| {
287        stones
288            .iter()
289            .find(|stone| {
290                stone
291                    .socket
292                    .is_some_and(|key| key.item_type == item_type && key.socket == slot)
293            })
294            .cloned()
295    };
296
297    let mut q = 1.0;
298    for item_type in ItemType::iter() {
299        let trigger = socketed(
300            item_type,
301            StoneSocketSlot::Trigger,
302            &character.stones.trigger_stones,
303        );
304        let Some(trigger) = trigger else {
305            continue;
306        };
307        let Some(trigger_template) = config.trigger_stone_template(trigger.template_id) else {
308            continue;
309        };
310        if !trigger_template.active {
311            continue;
312        }
313
314        let rate_scale = (trigger_template.power_proc_rate / reference_rate).max(0.0);
315
316        let side_q = |slot: StoneSocketSlot| -> f64 {
317            let Some(effect) = socketed(item_type, slot, &character.stones.effect_stones) else {
318                return 1.0;
319            };
320            let Some(template) = config.effect_stone_template(effect.template_id) else {
321                return 1.0;
322            };
323            let base = interpolated_q(
324                template.power_q_base_first_rank,
325                template.power_q_base_max_rank,
326                effect.level,
327                max_rank,
328            );
329            // The same tier matrix combat prices the effect with: a mismatched
330            // pair is worth less here exactly as it hits for less there.
331            let tier_scale = config
332                .stones_settings
333                .tier_coefficients
334                .iter()
335                .find(|row| row.trigger_tier == trigger.tier && row.effect_tier == effect.tier)
336                .map_or(1.0, |row| row.multiplier);
337            1.0 + rate_scale * tier_scale * (base - 1.0)
338        };
339
340        let real = side_q(StoneSocketSlot::RealEffect);
341        let fantasy = side_q(StoneSocketSlot::FantasyEffect);
342        let package = ((real.ln() + fantasy.ln()) / 2.0).exp();
343        q *= package.max(settings.trigger_only_q);
344    }
345    q
346}