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
86fn 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 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
143fn 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
167fn 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 const PLACEHOLDER_CURRENCY: uuid::Uuid = uuid!("b59b33a2-4d19-4e2c-9cea-e03ea15882a0");
226 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 #[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 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 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 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 #[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 let mut cs = make_character_state(STARTER_CLASS_ID, 1);
330 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 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 #[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 cs.character_classes.clear();
376
377 let stats =
378 calculate_player_entity_stats_with_zeroes(&EntityState::Character(&cs), &config)
379 .unwrap();
380
381 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}