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
21pub 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
56pub 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 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 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
167fn artifact_ownership_mods(
179 entity_state: &EntityState,
180 game_config: &game_config::GameConfig,
181) -> Vec<(String, i64)> {
182 let Some(artifacts) = entity_state.artifacts() else {
189 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
202fn 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
238fn 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 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
309fn 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
337fn 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
364fn 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
400fn 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 const PLACEHOLDER_CURRENCY: uuid::Uuid = uuid!("b59b33a2-4d19-4e2c-9cea-e03ea15882a0");
448 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 #[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 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 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 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 #[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 let mut cs = make_character_state(STARTER_CLASS_ID, 1);
553 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 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 #[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 cs.character_classes.clear();
599
600 let stats =
601 calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
602 .unwrap();
603
604 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 #[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 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}