overlord_event_system/
attributes.rs

1use configs::game_config;
2use essences::{class, entity, entity::EntityState, items, talent_tree::TalentTemplate};
3
4use crate::game_config_helpers::GameConfigLookup;
5
6#[derive(Default)]
7pub struct EntityStats {
8    pub attributes: entity::EntityAttributes,
9    pub max_hp: u64,
10}
11
12pub type AttributeDeltas = std::collections::HashMap<items::AttributeId, i64>;
13
14pub fn calculate_entity_stats(
15    game_config: &game_config::GameConfig,
16    attributes_deltas: AttributeDeltas,
17) -> anyhow::Result<EntityStats> {
18    calculate_entity_stats_with_mods(game_config, attributes_deltas, &[])
19}
20
21/// Same as [`calculate_entity_stats`], plus `.mod` multipliers addressed by
22/// attribute **code** rather than by config id.
23///
24/// The artifact ownership bonus is a percentage on hp / attack / armor, and
25/// `<code>.mod` is read generically by both combat (`get_entity_stat`) and the
26/// honest power scalar (`balance::get_attr_from_attrs`) — so it needs no
27/// attribute of its own in the catalog, which is what lets `armor.mod` work
28/// without one existing. Folded in **before** `compose_max_hp`, so an artifact's
29/// HP percentage reaches the fight's `max_hp` and not just the displayed number.
30pub fn calculate_entity_stats_with_mods(
31    game_config: &game_config::GameConfig,
32    attributes_deltas: AttributeDeltas,
33    extra_mods: &[(&str, i64)],
34) -> anyhow::Result<EntityStats> {
35    let mut attributes = entity::EntityAttributes::default();
36
37    for attribute_delta in attributes_deltas {
38        let Some(config_attribute) = game_config.attribute(attribute_delta.0) else {
39            anyhow::bail!("Couldn't find attribute with id = {}", attribute_delta.0);
40        };
41
42        attributes.add(&config_attribute.code.clone(), attribute_delta.1);
43    }
44
45    for (code, value) in extra_mods {
46        if *value != 0 {
47            attributes.add(code, *value);
48        }
49    }
50
51    let max_hp = compose_max_hp(&attributes, game_config)?;
52
53    Ok(EntityStats { attributes, max_hp })
54}
55
56/// Compose the fight `max_hp` from an aggregated attribute map the same way
57/// combat composes every other stat (`fight::get_entity_stat`): raw hp, plus
58/// additive `hp.bonus`, times the `hp.mod` multiplier (`+10000 == +1.0`,
59/// floored at `MIN_STAT_MOD_MULT`). This keeps `hp.mod`/`hp.bonus` grants
60/// (class levels, the Endurance talent) real in combat and consistent with
61/// the power scalar's `get_attr_from_attrs` composition.
62pub fn compose_max_hp(
63    attributes: &entity::EntityAttributes,
64    game_config: &game_config::GameConfig,
65) -> anyhow::Result<u64> {
66    let hp_attribute_id = game_config.game_settings.hp_attribute_id;
67    let Some(hp_attribute) = game_config.attribute(hp_attribute_id) else {
68        anyhow::bail!("Couldn't find hp attribute with id = {hp_attribute_id}");
69    };
70    let code = &hp_attribute.code;
71    let get = |key: String| attributes.0.get(&key).copied().unwrap_or(0) as f64;
72    let base = get(code.clone());
73    let bonus = get(format!("{code}.bonus"));
74    let mod_v = (get(format!("{code}.mod")) / 10000.0 + 1.0)
75        .max(crate::mechanics::balance::MIN_STAT_MOD_MULT);
76    Ok(((base + bonus) * mod_v).max(0.0).floor() as u64)
77}
78
79pub fn calculate_player_entity_stats_with_zeroes(
80    entity_state: &EntityState,
81    game_config: &game_config::GameConfig,
82) -> anyhow::Result<EntityStats> {
83    let mut attributes_deltas = AttributeDeltas::new();
84
85    for attribute in &game_config.attributes {
86        attributes_deltas.insert(attribute.id, 0);
87    }
88
89    let Some(level_attributes) = game_config.character_level(entity_state.level()) else {
90        anyhow::bail!(
91            "Couldn't find character level attributes for level={}",
92            entity_state.level()
93        );
94    };
95
96    for attribute in &level_attributes.attributes {
97        *attributes_deltas.entry(attribute.attribute_id).or_insert(0) += attribute.value as i64;
98    }
99
100    for item in entity_state.inventory() {
101        if !item.is_equipped {
102            continue;
103        }
104
105        for attribute in &item.attributes {
106            *attributes_deltas.entry(attribute.attr_id).or_insert(0) += attribute.value as i64;
107        }
108    }
109
110    let Some(class) = game_config.class(entity_state.class()) else {
111        anyhow::bail!("Couldn't find class with id={}", entity_state.class());
112    };
113
114    for class_attribute in &class.attributes {
115        let class::ClassAttribute::EntityAttribute(entity_attribute) = class_attribute;
116        *attributes_deltas
117            .entry(entity_attribute.attribute_id)
118            .or_insert(0) += entity_attribute.value as i64;
119    }
120
121    aggregate_pet_stats(entity_state, game_config, &mut attributes_deltas);
122    aggregate_talent_attribute_bonuses(entity_state, &game_config.talents, &mut attributes_deltas);
123    aggregate_statue_attribute_bonuses(entity_state, game_config, &mut attributes_deltas);
124    aggregate_plinko_pin_attribute_bonuses(entity_state, &mut attributes_deltas);
125    aggregate_class_level_attribute_bonuses(entity_state, game_config, &mut attributes_deltas);
126    aggregate_stone_attribute_bonuses(entity_state, game_config, &mut attributes_deltas);
127
128    // Deflate the aggregated ARMOR
129    // rating by character level (WoW-style rating→percent conversion indexed
130    // by level). Item armor saturates the DR curve from band 1 (64%→78.5%
131    // mitigation across the game, marginal decay ×1.66) — deflation keeps an
132    // armor point buying a comparable mitigation slice at every level.
133    // Applied here, in ONE place, so combat, the honest power scalar and the
134    // UI all see the same effective rating. Dodge needs no deflation
135    // (measured circulation 236-474 vs K_DODGE 6000 — far from saturation).
136    let deflator = crate::mechanics::balance::player_armor_rating_deflator(entity_state.level());
137    if deflator < 1.0
138        && let Some(armor_id) = game_config
139            .attributes
140            .iter()
141            .find(|a| a.code == "armor")
142            .map(|a| a.id)
143        && let Some(v) = attributes_deltas.get_mut(&armor_id)
144    {
145        *v = (*v as f64 * deflator).round() as i64;
146    }
147
148    apply_core_multiplier(entity_state, game_config, &mut attributes_deltas);
149
150    // Two ownership sources, both `<code>.mod`, both summed here rather than
151    // in two places: artifacts pay their own per-template bonus (BAL-011) and
152    // Collection Power pays the universal one for every OTHER catalog. They
153    // never overlap — artifacts are deliberately outside Collection Power — so
154    // adding them is not a double count.
155    let mut ownership_mods = artifact_ownership_mods(entity_state, game_config);
156    ownership_mods.extend(crate::mechanics::collection_power::collection_stat_mods(
157        game_config,
158        entity_state,
159    ));
160    let ownership_mods: Vec<(&str, i64)> = ownership_mods
161        .iter()
162        .map(|(code, value)| (code.as_str(), *value))
163        .collect();
164    calculate_entity_stats_with_mods(game_config, attributes_deltas, &ownership_mods)
165}
166
167/// The artifact ownership bonus, as `("<code>.mod", permyriad)` pairs.
168///
169/// Summed over the **whole collection**, worn or not: an artifact pays for being
170/// owned, which is the one thing that makes a second artifact worth having once
171/// only one can be worn. Which attributes it pays in is per-template
172/// (`ArtifactTemplate::ownership_bonuses`) — an artifact with an empty list pays
173/// nothing without needing a flag of its own.
174///
175/// Applied here, in the same aggregation as items, pets, talents and the statue,
176/// so the bonus reaches combat, the displayed stats, the power scalar and
177/// therefore PvE gating and PvP matchmaking through one path.
178fn artifact_ownership_mods(
179    entity_state: &EntityState,
180    game_config: &game_config::GameConfig,
181) -> Vec<(String, i64)> {
182    // Ownership is allowed to exist before the feature opens (the starter
183    // Empty Frame is granted at account creation), but it must be combat-inert
184    // until the artifact chapter gate. This choke point feeds raw combat
185    // attributes, displayed stats, honest power, PvE and PvP for both the local
186    // character and a human opponent, so gating here prevents every ownership
187    // stat contribution from leaking early.
188    let Some(artifacts) = entity_state.artifacts() else {
189        // Arena filler bots have no collection and, crucially, no campaign
190        // chapter. Do not reinterpret their bot level as a feature gate.
191        return Vec::new();
192    };
193    if entity_state.current_chapter_level() < game_config.artifacts_settings.unlock_chapter {
194        return Vec::new();
195    }
196    if artifacts.artifacts.is_empty() {
197        return Vec::new();
198    }
199    crate::mechanics::artifacts::ownership_stat_mods(game_config, artifacts)
200}
201
202/// Scale HP / attack / defense by the twin-core PER-STAT multiplier `M_stat`
203/// (BAL-010) — the product of the two core levels through
204/// `configs::cores::core_stat_multiplier`, so total POWER moves by `M_power`.
205///
206/// Applied HERE, last, for two reasons. First, "поверх статов от предметов":
207/// every other source (items, class, pets, talents, statue, class levels) is
208/// already summed in, so the multiplier genuinely compounds with gear rather
209/// than with a bare base. Second, this function is the single choke point that
210/// BOTH the displayed power scalar (`behaviors::power::character_power`) and
211/// the combat entity (`entities::create_player_entity`) go through, so the two
212/// cannot disagree — plan §7.1 holds by construction, not by two call sites
213/// being kept in sync by hand.
214///
215/// Keyed on whoever the combatant is, not on whose session this is: a human PvP
216/// opponent carries a full `CharacterState` and gets their own multiplier, so
217/// two identical builds meet as equals in the arena. Mobs and arena filler bots
218/// have no cores and are untouched.
219fn apply_core_multiplier(
220    entity_state: &EntityState,
221    game_config: &game_config::GameConfig,
222    attributes_deltas: &mut AttributeDeltas,
223) {
224    let Some(cores) = entity_state.cores() else {
225        return;
226    };
227    let multiplier = crate::mechanics::cores::core_multiplier(game_config, cores);
228    if multiplier == 1.0 {
229        return;
230    }
231    for attribute_id in &game_config.cores_settings.multiplied_attribute_ids {
232        if let Some(value) = attributes_deltas.get_mut(attribute_id) {
233            *value = (*value as f64 * multiplier).round() as i64;
234        }
235    }
236}
237
238/// Apply the attribute row of the character's active class at its current
239/// level.
240///
241/// The rows are TOTAL SNAPSHOTS, not increments (BAL-033): the row of the
242/// highest level the character has reached is applied whole, and the rows below
243/// it are not added on top. Summing them — which is what this used to do — made
244/// every authored row an increment by accident and inflated a max-level class
245/// far past the numbers the table shows.
246///
247/// The active class is read from `character.class`, the level from the matching
248/// `character_classes` entry. Missing entry → no bonuses.
249///
250/// Crucially these bonuses are scoped to the active class, so a switch drops
251/// them and a switch back restores them — class progression is not "permanent
252/// character growth."
253///
254/// Keyed on `EntityState::character_classes()`, not on the enum variant, so a
255/// human PvP opponent gets their own class levels — matchmaking already prices
256/// them in through the stored `character.power`. Arena filler bots have no
257/// class-level rows and contribute nothing.
258fn aggregate_class_level_attribute_bonuses(
259    entity_state: &EntityState,
260    game_config: &game_config::GameConfig,
261    attributes_deltas: &mut AttributeDeltas,
262) {
263    let Some(character_classes) = entity_state.character_classes() else {
264        return;
265    };
266    let active_class_id = entity_state.class();
267    let Some(active) = character_classes
268        .iter()
269        .find(|cc| cc.class_id == active_class_id)
270    else {
271        return;
272    };
273    let Some(row) = game_config
274        .class_levels
275        .iter()
276        .filter(|row| row.class_id == active_class_id && row.level <= active.level)
277        .max_by_key(|row| row.level)
278    else {
279        return;
280    };
281    for attr in &row.attrs {
282        *attributes_deltas.entry(attr.attribute_id).or_insert(0) += attr.value as i64;
283    }
284}
285
286fn aggregate_pet_stats(
287    entity_state: &EntityState,
288    game_config: &game_config::GameConfig,
289    attributes_deltas: &mut AttributeDeltas,
290) {
291    let Some(equipped_pets) = entity_state.equipped_pets() else {
292        return;
293    };
294
295    for pet in equipped_pets.slotted.values() {
296        // TODO fix this shit
297        let template = match game_config.pet_template(pet.template_id) {
298            Some(t) => t,
299            None => continue,
300        };
301
302        for stat in &template.stats {
303            let value = stat.base_value + stat.per_level_value * (pet.level - 1);
304            *attributes_deltas.entry(stat.attribute_id).or_insert(0) += value;
305        }
306    }
307}
308
309/// Accumulate flat attribute bonuses from completed talent levels.
310///
311/// Keyed on `EntityState::talent_levels()`, so a human PvP opponent brings their own
312/// researched talents into the arena; arena filler bots have no talent tree and
313/// contribute nothing.
314fn aggregate_talent_attribute_bonuses(
315    entity_state: &EntityState,
316    talents: &[TalentTemplate],
317    attributes_deltas: &mut AttributeDeltas,
318) {
319    let Some(talent_levels) = entity_state.talent_levels() else {
320        return;
321    };
322    for talent in talents {
323        let Some(&level) = talent_levels.get(&talent.id) else {
324            continue;
325        };
326        for level_config in &talent.levels {
327            if level_config.level > level {
328                break;
329            }
330            for bonus in &level_config.attribute_bonuses {
331                *attributes_deltas.entry(bonus.attribute_id).or_insert(0) += bonus.value;
332            }
333        }
334    }
335}
336
337/// Accumulate the flat passive stats of every **socketed** Trigger/Effect
338/// Stone.
339///
340/// Folded in here, alongside items and the statue, so stone stats reach combat,
341/// the displayed attribute list and the power scalar through the one
342/// aggregation everything already shares — which is also why they count toward
343/// Power, and therefore toward PvE gating and PvP matchmaking, exactly like
344/// item stats.
345///
346/// `active_side` is deliberately not consulted: both effect stones of a slot
347/// contribute at all times, so a flip changes which effect *fires* and nothing
348/// about the character's stats or Power.
349fn aggregate_stone_attribute_bonuses(
350    entity_state: &EntityState,
351    game_config: &game_config::GameConfig,
352    attributes_deltas: &mut AttributeDeltas,
353) {
354    let Some(stones) = entity_state.stones() else {
355        return;
356    };
357    for (attribute_id, value) in
358        crate::mechanics::stones::socketed_stat_bonuses(game_config, stones)
359    {
360        *attributes_deltas.entry(attribute_id).or_insert(0) += value;
361    }
362}
363
364/// Accumulate flat attribute bonuses from the active statue set.
365///
366/// Keyed on `EntityState::statue_state()`, so a human PvP opponent brings their own
367/// carved statue into the arena; arena filler bots have no statue and contribute
368/// nothing.
369fn aggregate_statue_attribute_bonuses(
370    entity_state: &EntityState,
371    game_config: &game_config::GameConfig,
372    attributes_deltas: &mut AttributeDeltas,
373) {
374    let Some(statue) = entity_state.statue_state() else {
375        return;
376    };
377    let active_index = statue.active_set_index as usize;
378    let Some(active_set) = statue.sets.get(active_index) else {
379        return;
380    };
381    for slot in active_set.slots.0.values() {
382        let Some(bonus_type) = game_config
383            .statue_bonus_type_configs
384            .iter()
385            .find(|bt| bt.attribute_id == slot.attribute_id)
386        else {
387            continue;
388        };
389        let Some(grade_value) = bonus_type
390            .grade_values
391            .iter()
392            .find(|gv| gv.grade_id == slot.grade_id)
393        else {
394            continue;
395        };
396        *attributes_deltas.entry(slot.attribute_id).or_insert(0) += grade_value.value.get() as i64;
397    }
398}
399
400/// Accumulate the permanent Plinko pin bonuses the player has banked so far.
401///
402/// Stored per attribute in `character_state.plinko_pin_bonuses` in MICRO-UNITS
403/// and already clamped to the axis's current tranche cap at grant time
404/// (BAL-021). Combat and the displayed stats read whole stat points, so the
405/// fractional tail of a late pin accumulates across opens instead of being
406/// rounded away on each one. Otherwise the same shape as talents and the
407/// statue, which is what puts the bonus in combat, in the displayed stats and
408/// in the power scalar through one path.
409fn aggregate_plinko_pin_attribute_bonuses(
410    entity_state: &EntityState,
411    attributes_deltas: &mut AttributeDeltas,
412) {
413    let Some(bonuses) = entity_state.plinko_pin_bonuses() else {
414        return;
415    };
416    for (attribute_id, micro) in bonuses.iter() {
417        *attributes_deltas.entry(*attribute_id).or_insert(0) +=
418            configs::plinko::PlinkoSettings::whole_from_micro(*micro);
419    }
420}
421
422pub fn calculate_player_entity_stats_without_zeroes(
423    entity_state: &EntityState,
424    game_config: &game_config::GameConfig,
425) -> anyhow::Result<EntityStats> {
426    let mut stats = calculate_player_entity_stats_with_zeroes(entity_state, game_config)?;
427    stats.attributes.remove_zeroes();
428
429    Ok(stats)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use ::essences::character_state::CharacterState;
436    use ::essences::characters::CharacterBuilder;
437    use ::essences::class::{CharacterClass, ClassLevels};
438    use ::essences::currency::CurrencyUnit;
439    use ::essences::game::EntityAttribute;
440    use configs::tests_game_config::generate_game_config_for_tests;
441    use uuid::uuid;
442
443    const STARTER_CLASS_ID: uuid::Uuid = uuid!("5956e37c-ca7f-45cf-8bfb-49601dc9aca3");
444    const ALT_CLASS_ID: uuid::Uuid = uuid!("95d314ee-ce2c-4b10-a8bc-596b0f03ab8a");
445    // Currency id used as a placeholder price for ClassLevels rows. We never charge; this is just
446    // shape-valid config data.
447    const PLACEHOLDER_CURRENCY: uuid::Uuid = uuid!("b59b33a2-4d19-4e2c-9cea-e03ea15882a0");
448    // Some attribute id that exists in tests_game_config and isn't already affected by the
449    // base class/character/level bonuses we'd accidentally collide with.
450    const TEST_ATTRIBUTE_ID: uuid::Uuid = uuid!("3a6ec1c8-7494-43df-a345-d23e297b892d");
451
452    fn make_class_levels_row(class_id: uuid::Uuid, level: u64, value: u64) -> ClassLevels {
453        ClassLevels {
454            class_id,
455            level,
456            attrs: vec![EntityAttribute {
457                attribute_id: TEST_ATTRIBUTE_ID,
458                value,
459            }],
460            price: CurrencyUnit {
461                currency_id: PLACEHOLDER_CURRENCY,
462                amount: 0,
463            },
464            ability_levels: vec![],
465        }
466    }
467
468    fn make_character_state(class_id: uuid::Uuid, character_class_level: u64) -> CharacterState {
469        let character = CharacterBuilder::new()
470            .with_class(class_id)
471            .with_character_level(2)
472            .build();
473        let character_id = character.id;
474        CharacterState {
475            character,
476            character_classes: vec![CharacterClass {
477                character_id,
478                class_id,
479                level: character_class_level,
480                xp: 0,
481            }],
482            ..Default::default()
483        }
484    }
485
486    fn attr_value(stats: &EntityStats, attribute_code: &str) -> i64 {
487        stats.attributes.0.get(attribute_code).copied().unwrap_or(0)
488    }
489
490    /// BAL-033: the rows are TOTAL snapshots, so the character wears the row of
491    /// the level they reached — not that row plus every row below it.
492    #[test]
493    fn class_level_attrs_apply_up_to_current_level() {
494        let mut config = generate_game_config_for_tests();
495        config.class_levels = vec![
496            make_class_levels_row(STARTER_CLASS_ID, 1, 5),
497            make_class_levels_row(STARTER_CLASS_ID, 2, 7),
498            make_class_levels_row(STARTER_CLASS_ID, 3, 11),
499        ];
500
501        let attribute_code = config
502            .attributes
503            .iter()
504            .find(|a| a.id == TEST_ATTRIBUTE_ID)
505            .expect("test attribute id must exist in test config")
506            .code
507            .clone();
508
509        // Level 1: only the L1 row contributes (5).
510        let cs_l1 = make_character_state(STARTER_CLASS_ID, 1);
511        let stats_l1 =
512            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs_l1), &config)
513                .unwrap();
514        let baseline = attr_value(&stats_l1, &attribute_code);
515
516        // Level 2 wears the L2 row alone: +7 instead of the L1 row's +5, i.e.
517        // two more than the baseline, not seven more.
518        let cs_l2 = make_character_state(STARTER_CLASS_ID, 2);
519        let stats_l2 =
520            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs_l2), &config)
521                .unwrap();
522        assert_eq!(attr_value(&stats_l2, &attribute_code), baseline + 2);
523
524        // Level 3 wears the L3 row alone: 11 against the L1 row's 5.
525        let cs_l3 = make_character_state(STARTER_CLASS_ID, 3);
526        let stats_l3 =
527            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs_l3), &config)
528                .unwrap();
529        assert_eq!(attr_value(&stats_l3, &attribute_code), baseline + 6);
530    }
531
532    /// Class-level attrs are scoped to the active class — bonuses from rows belonging to a
533    /// different class id are not included.
534    #[test]
535    fn class_level_attrs_only_apply_for_active_class() {
536        let mut config = generate_game_config_for_tests();
537        config.class_levels = vec![
538            make_class_levels_row(STARTER_CLASS_ID, 1, 5),
539            make_class_levels_row(ALT_CLASS_ID, 1, 99),
540            make_class_levels_row(ALT_CLASS_ID, 2, 99),
541        ];
542
543        let attribute_code = config
544            .attributes
545            .iter()
546            .find(|a| a.id == TEST_ATTRIBUTE_ID)
547            .unwrap()
548            .code
549            .clone();
550
551        // Active class STARTER_CLASS_ID at L1; ALT class also has rows but those must not apply.
552        let mut cs = make_character_state(STARTER_CLASS_ID, 1);
553        // Even if the player has a high-level entry for ALT in their character_classes, it
554        // doesn't contribute while STARTER is active.
555        cs.character_classes.push(CharacterClass {
556            character_id: cs.character.id,
557            class_id: ALT_CLASS_ID,
558            level: 99,
559            xp: 0,
560        });
561        let stats =
562            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
563                .unwrap();
564        let starter_only = attr_value(&stats, &attribute_code);
565
566        // Compare against a control with only the STARTER row.
567        let mut control = generate_game_config_for_tests();
568        control.class_levels = vec![make_class_levels_row(STARTER_CLASS_ID, 1, 5)];
569        let cs_control = make_character_state(STARTER_CLASS_ID, 1);
570        let stats_control = calculate_player_entity_stats_with_zeroes(
571            &EntityState::Character(&cs_control),
572            &control,
573        )
574        .unwrap();
575        assert_eq!(starter_only, attr_value(&stats_control, &attribute_code));
576    }
577
578    /// Character with no `character_classes` entry for the active class: no class-level bonuses
579    /// apply (treated as no progression yet). Other sources still contribute normally.
580    #[test]
581    fn class_level_attrs_missing_character_class_entry_is_noop() {
582        let mut config = generate_game_config_for_tests();
583        config.class_levels = vec![
584            make_class_levels_row(STARTER_CLASS_ID, 1, 5),
585            make_class_levels_row(STARTER_CLASS_ID, 2, 7),
586        ];
587
588        let attribute_code = config
589            .attributes
590            .iter()
591            .find(|a| a.id == TEST_ATTRIBUTE_ID)
592            .unwrap()
593            .code
594            .clone();
595
596        let mut cs = make_character_state(STARTER_CLASS_ID, 1);
597        // Simulate a not-yet-migrated character: no character_classes rows at all.
598        cs.character_classes.clear();
599
600        let stats =
601            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
602                .unwrap();
603
604        // Compare against a config with no class_levels at all — should match exactly.
605        let mut control = generate_game_config_for_tests();
606        control.class_levels = vec![];
607        let stats_control =
608            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &control)
609                .unwrap();
610        assert_eq!(
611            attr_value(&stats, &attribute_code),
612            attr_value(&stats_control, &attribute_code)
613        );
614    }
615
616    /// `hp.mod` / `hp.bonus` grants compose into the fight `max_hp` the same
617    /// way `.mod`/`.bonus` compose for every combat stat:
618    /// `(hp + hp.bonus) × (1 + mod/10000)`.
619    #[test]
620    fn hp_mod_and_bonus_scale_fight_max_hp() {
621        let mut config = generate_game_config_for_tests();
622        let hp_attribute = config
623            .attributes
624            .iter()
625            .find(|a| a.id == config.game_settings.hp_attribute_id)
626            .expect("hp attribute must exist in test config")
627            .clone();
628
629        // Register `hp.mod` / `hp.bonus` attributes and grant +50% mod and
630        // +40 flat bonus via class levels.
631        let hp_mod_id = uuid!("6f0a48f2-52aa-4a56-9d40-1de35bb2dd01");
632        let mut hp_mod_attribute = hp_attribute.clone();
633        hp_mod_attribute.id = hp_mod_id;
634        hp_mod_attribute.code = "hp.mod".to_string();
635        config.attributes.push(hp_mod_attribute);
636
637        let hp_bonus_id = uuid!("6f0a48f2-52aa-4a56-9d40-1de35bb2dd02");
638        let mut hp_bonus_attribute = hp_attribute.clone();
639        hp_bonus_attribute.id = hp_bonus_id;
640        hp_bonus_attribute.code = "hp.bonus".to_string();
641        config.attributes.push(hp_bonus_attribute);
642
643        config.class_levels = vec![ClassLevels {
644            class_id: STARTER_CLASS_ID,
645            level: 1,
646            attrs: vec![
647                EntityAttribute {
648                    attribute_id: hp_mod_id,
649                    value: 5000,
650                },
651                EntityAttribute {
652                    attribute_id: hp_bonus_id,
653                    value: 40,
654                },
655            ],
656            price: CurrencyUnit {
657                currency_id: PLACEHOLDER_CURRENCY,
658                amount: 0,
659            },
660            ability_levels: vec![],
661        }];
662
663        let baseline_config = {
664            let mut control = generate_game_config_for_tests();
665            control.class_levels = vec![];
666            control
667        };
668        let cs = make_character_state(STARTER_CLASS_ID, 1);
669
670        let baseline = calculate_player_entity_stats_with_zeroes(
671            &EntityState::Character(&cs),
672            &baseline_config,
673        )
674        .unwrap();
675        let raw_hp = attr_value(&baseline, "hp");
676        assert!(raw_hp > 0, "the test character must have base hp");
677        assert_eq!(baseline.max_hp, raw_hp as u64);
678
679        let stats =
680            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
681                .unwrap();
682        assert_eq!(stats.max_hp, (((raw_hp + 40) as f64) * 1.5).floor() as u64);
683    }
684}