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    let mut attributes = entity::EntityAttributes::default();
19    let mut max_hp = 0;
20
21    for attribute_delta in attributes_deltas {
22        let Some(config_attribute) = game_config.attribute(attribute_delta.0) else {
23            anyhow::bail!("Couldn't find attribute with id = {}", attribute_delta.0);
24        };
25
26        if attribute_delta.0 == game_config.game_settings.hp_attribute_id {
27            max_hp += attribute_delta.1 as u64;
28        }
29
30        attributes.add(&config_attribute.code.clone(), attribute_delta.1);
31    }
32
33    Ok(EntityStats { attributes, max_hp })
34}
35
36pub fn calculate_player_entity_stats_with_zeroes(
37    entity_state: &EntityState,
38    game_config: &game_config::GameConfig,
39) -> anyhow::Result<EntityStats> {
40    let mut attributes_deltas = AttributeDeltas::new();
41
42    for attribute in &game_config.attributes {
43        attributes_deltas.insert(attribute.id, 0);
44    }
45
46    let Some(level_attributes) = game_config.character_level(entity_state.level()) else {
47        anyhow::bail!(
48            "Couldn't find character level attributes for level={}",
49            entity_state.level()
50        );
51    };
52
53    for attribute in &level_attributes.attributes {
54        *attributes_deltas.entry(attribute.attribute_id).or_insert(0) += attribute.value as i64;
55    }
56
57    for item in entity_state.inventory() {
58        if !item.is_equipped {
59            continue;
60        }
61
62        for attribute in &item.attributes {
63            *attributes_deltas.entry(attribute.attr_id).or_insert(0) += attribute.value as i64;
64        }
65    }
66
67    let Some(class) = game_config.class(entity_state.class()) else {
68        anyhow::bail!("Couldn't find class with id={}", entity_state.class());
69    };
70
71    for class_attribute in &class.attributes {
72        let class::ClassAttribute::EntityAttribute(entity_attribute) = class_attribute;
73        *attributes_deltas
74            .entry(entity_attribute.attribute_id)
75            .or_insert(0) += entity_attribute.value as i64;
76    }
77
78    aggregate_pet_stats(entity_state, game_config, &mut attributes_deltas);
79    aggregate_talent_attribute_bonuses(entity_state, &game_config.talents, &mut attributes_deltas);
80    aggregate_statue_attribute_bonuses(entity_state, game_config, &mut attributes_deltas);
81    aggregate_class_level_attribute_bonuses(entity_state, game_config, &mut attributes_deltas);
82
83    calculate_entity_stats(game_config, attributes_deltas)
84}
85
86/// Accumulate per-level attribute bonuses for the character's active class. Walks every
87/// `ClassLevels` row for the active class with `level <= character_class.level` and sums the
88/// `attrs` values. The active class is read from `character.class`, the level from the matching
89/// `character_classes` entry. Missing entry → no bonuses (treated as L1 with no level rows
90/// applied yet).
91///
92/// Crucially these bonuses are scoped to the active class, so a respec drops them and a
93/// switch back restores them — class-level progression is not "permanent character growth."
94fn aggregate_class_level_attribute_bonuses(
95    entity_state: &EntityState,
96    game_config: &game_config::GameConfig,
97    attributes_deltas: &mut AttributeDeltas,
98) {
99    let EntityState::Character(character_state) = entity_state else {
100        return;
101    };
102    let active_class_id = character_state.character.class;
103    let Some(active) = character_state
104        .character_classes
105        .iter()
106        .find(|cc| cc.class_id == active_class_id)
107    else {
108        return;
109    };
110    for row in &game_config.class_levels {
111        if row.class_id != active_class_id || row.level > active.level {
112            continue;
113        }
114        for attr in &row.attrs {
115            *attributes_deltas.entry(attr.attribute_id).or_insert(0) += attr.value as i64;
116        }
117    }
118}
119
120fn aggregate_pet_stats(
121    entity_state: &EntityState,
122    game_config: &game_config::GameConfig,
123    attributes_deltas: &mut AttributeDeltas,
124) {
125    let Some(equipped_pets) = entity_state.equipped_pets() else {
126        return;
127    };
128
129    for pet in equipped_pets.slotted.values() {
130        // TODO fix this shit
131        let template = match game_config.pet_template(pet.template_id) {
132            Some(t) => t,
133            None => continue,
134        };
135
136        for stat in &template.stats {
137            let value = stat.base_value + stat.per_level_value * (pet.level - 1);
138            *attributes_deltas.entry(stat.attribute_id).or_insert(0) += value;
139        }
140    }
141}
142
143/// Accumulate flat attribute bonuses from completed talent levels.
144fn aggregate_talent_attribute_bonuses(
145    entity_state: &EntityState,
146    talents: &[TalentTemplate],
147    attributes_deltas: &mut AttributeDeltas,
148) {
149    let EntityState::Character(character_state) = entity_state else {
150        return;
151    };
152    for talent in talents {
153        let Some(&level) = character_state.talent_levels.get(&talent.id) else {
154            continue;
155        };
156        for level_config in &talent.levels {
157            if level_config.level > level {
158                break;
159            }
160            for bonus in &level_config.attribute_bonuses {
161                *attributes_deltas.entry(bonus.attribute_id).or_insert(0) += bonus.value;
162            }
163        }
164    }
165}
166
167/// Accumulate flat attribute bonuses from the active statue set.
168fn aggregate_statue_attribute_bonuses(
169    entity_state: &EntityState,
170    game_config: &game_config::GameConfig,
171    attributes_deltas: &mut AttributeDeltas,
172) {
173    let EntityState::Character(character_state) = entity_state else {
174        return;
175    };
176    let statue = &character_state.statue_state;
177    let active_index = statue.active_set_index as usize;
178    let Some(active_set) = statue.sets.get(active_index) else {
179        return;
180    };
181    for slot in active_set.slots.0.values() {
182        let Some(bonus_type) = game_config
183            .statue_bonus_type_configs
184            .iter()
185            .find(|bt| bt.attribute_id == slot.attribute_id)
186        else {
187            continue;
188        };
189        let Some(grade_value) = bonus_type
190            .grade_values
191            .iter()
192            .find(|gv| gv.grade_id == slot.grade_id)
193        else {
194            continue;
195        };
196        *attributes_deltas.entry(slot.attribute_id).or_insert(0) += grade_value.value.get() as i64;
197    }
198}
199
200pub fn calculate_player_entity_stats_without_zeroes(
201    entity_state: &EntityState,
202    game_config: &game_config::GameConfig,
203) -> anyhow::Result<EntityStats> {
204    let mut stats = calculate_player_entity_stats_with_zeroes(entity_state, game_config)?;
205    stats.attributes.remove_zeroes();
206
207    Ok(stats)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use ::essences::character_state::CharacterState;
214    use ::essences::characters::CharacterBuilder;
215    use ::essences::class::{CharacterClass, ClassLevels};
216    use ::essences::currency::CurrencyUnit;
217    use ::essences::game::EntityAttribute;
218    use configs::tests_game_config::generate_game_config_for_tests;
219    use uuid::uuid;
220
221    const STARTER_CLASS_ID: uuid::Uuid = uuid!("5956e37c-ca7f-45cf-8bfb-49601dc9aca3");
222    const ALT_CLASS_ID: uuid::Uuid = uuid!("95d314ee-ce2c-4b10-a8bc-596b0f03ab8a");
223    // Currency id used as a placeholder price for ClassLevels rows. We never charge; this is just
224    // shape-valid config data.
225    const PLACEHOLDER_CURRENCY: uuid::Uuid = uuid!("b59b33a2-4d19-4e2c-9cea-e03ea15882a0");
226    // Some attribute id that exists in tests_game_config and isn't already affected by the
227    // base class/character/level bonuses we'd accidentally collide with.
228    const TEST_ATTRIBUTE_ID: uuid::Uuid = uuid!("3a6ec1c8-7494-43df-a345-d23e297b892d");
229
230    fn make_class_levels_row(class_id: uuid::Uuid, level: u64, value: u64) -> ClassLevels {
231        ClassLevels {
232            class_id,
233            level,
234            attrs: vec![EntityAttribute {
235                attribute_id: TEST_ATTRIBUTE_ID,
236                value,
237            }],
238            price: CurrencyUnit {
239                currency_id: PLACEHOLDER_CURRENCY,
240                amount: 0,
241            },
242            ability_levels: vec![],
243        }
244    }
245
246    fn make_character_state(class_id: uuid::Uuid, character_class_level: u64) -> CharacterState {
247        let character = CharacterBuilder::new()
248            .with_class(class_id)
249            .with_character_level(2)
250            .build();
251        let character_id = character.id;
252        CharacterState {
253            character,
254            character_classes: vec![CharacterClass {
255                character_id,
256                class_id,
257                level: character_class_level,
258                xp: 0,
259            }],
260            ..Default::default()
261        }
262    }
263
264    fn attr_value(stats: &EntityStats, attribute_code: &str) -> i64 {
265        stats.attributes.0.get(attribute_code).copied().unwrap_or(0)
266    }
267
268    /// Walks `class_levels` rows up to the active class's level and sums their attrs into the
269    /// computed player attributes.
270    #[test]
271    fn class_level_attrs_apply_up_to_current_level() {
272        let mut config = generate_game_config_for_tests();
273        config.class_levels = vec![
274            make_class_levels_row(STARTER_CLASS_ID, 1, 5),
275            make_class_levels_row(STARTER_CLASS_ID, 2, 7),
276            make_class_levels_row(STARTER_CLASS_ID, 3, 11),
277        ];
278
279        let attribute_code = config
280            .attributes
281            .iter()
282            .find(|a| a.id == TEST_ATTRIBUTE_ID)
283            .expect("test attribute id must exist in test config")
284            .code
285            .clone();
286
287        // Level 1: only the L1 row contributes (5).
288        let cs_l1 = make_character_state(STARTER_CLASS_ID, 1);
289        let stats_l1 =
290            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs_l1), &config)
291                .unwrap();
292        let baseline = attr_value(&stats_l1, &attribute_code);
293
294        // Level 2: L1 + L2 (5 + 7 = +7 over baseline).
295        let cs_l2 = make_character_state(STARTER_CLASS_ID, 2);
296        let stats_l2 =
297            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs_l2), &config)
298                .unwrap();
299        assert_eq!(attr_value(&stats_l2, &attribute_code), baseline + 7);
300
301        // Level 3: L1 + L2 + L3 (5 + 7 + 11 = +18 over baseline).
302        let cs_l3 = make_character_state(STARTER_CLASS_ID, 3);
303        let stats_l3 =
304            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs_l3), &config)
305                .unwrap();
306        assert_eq!(attr_value(&stats_l3, &attribute_code), baseline + 7 + 11);
307    }
308
309    /// Class-level attrs are scoped to the active class — bonuses from rows belonging to a
310    /// different class id are not included.
311    #[test]
312    fn class_level_attrs_only_apply_for_active_class() {
313        let mut config = generate_game_config_for_tests();
314        config.class_levels = vec![
315            make_class_levels_row(STARTER_CLASS_ID, 1, 5),
316            make_class_levels_row(ALT_CLASS_ID, 1, 99),
317            make_class_levels_row(ALT_CLASS_ID, 2, 99),
318        ];
319
320        let attribute_code = config
321            .attributes
322            .iter()
323            .find(|a| a.id == TEST_ATTRIBUTE_ID)
324            .unwrap()
325            .code
326            .clone();
327
328        // Active class STARTER_CLASS_ID at L1; ALT class also has rows but those must not apply.
329        let mut cs = make_character_state(STARTER_CLASS_ID, 1);
330        // Even if the player has a high-level entry for ALT in their character_classes, it
331        // doesn't contribute while STARTER is active.
332        cs.character_classes.push(CharacterClass {
333            character_id: cs.character.id,
334            class_id: ALT_CLASS_ID,
335            level: 99,
336            xp: 0,
337        });
338        let stats =
339            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
340                .unwrap();
341        let starter_only = attr_value(&stats, &attribute_code);
342
343        // Compare against a control with only the STARTER row.
344        let mut control = generate_game_config_for_tests();
345        control.class_levels = vec![make_class_levels_row(STARTER_CLASS_ID, 1, 5)];
346        let cs_control = make_character_state(STARTER_CLASS_ID, 1);
347        let stats_control = calculate_player_entity_stats_with_zeroes(
348            &EntityState::Character(&cs_control),
349            &control,
350        )
351        .unwrap();
352        assert_eq!(starter_only, attr_value(&stats_control, &attribute_code));
353    }
354
355    /// Character with no `character_classes` entry for the active class: no class-level bonuses
356    /// apply (treated as no progression yet). Other sources still contribute normally.
357    #[test]
358    fn class_level_attrs_missing_character_class_entry_is_noop() {
359        let mut config = generate_game_config_for_tests();
360        config.class_levels = vec![
361            make_class_levels_row(STARTER_CLASS_ID, 1, 5),
362            make_class_levels_row(STARTER_CLASS_ID, 2, 7),
363        ];
364
365        let attribute_code = config
366            .attributes
367            .iter()
368            .find(|a| a.id == TEST_ATTRIBUTE_ID)
369            .unwrap()
370            .code
371            .clone();
372
373        let mut cs = make_character_state(STARTER_CLASS_ID, 1);
374        // Simulate a not-yet-migrated character: no character_classes rows at all.
375        cs.character_classes.clear();
376
377        let stats =
378            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
379                .unwrap();
380
381        // Compare against a config with no class_levels at all — should match exactly.
382        let mut control = generate_game_config_for_tests();
383        control.class_levels = vec![];
384        let stats_control =
385            calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &control)
386                .unwrap();
387        assert_eq!(
388            attr_value(&stats, &attribute_code),
389            attr_value(&stats_control, &attribute_code)
390        );
391    }
392}