1use configs::game_config;
2use essences::combat_origin::CombatEventOrigin;
3use essences::{
4 abilities, character_state,
5 entity::{self, EntityAction, EntityActionsQueue, EntityAttributes, EntityId, EntityState},
6 fighting,
7 flip::FlipState,
8};
9use event_system::event::EventPluginized;
10
11use std::collections::BTreeMap;
12
13use crate::attributes::{
14 AttributeDeltas, EntityStats, calculate_entity_stats,
15 calculate_player_entity_stats_without_zeroes, compose_max_hp,
16};
17use crate::game_config_helpers::GameConfigLookup;
18
19use super::{TICKER_UNIT_DURATION_MS, event::OverlordEvent, state::OverlordState};
20
21fn full_mana_pool(game_config: &game_config::GameConfig) -> essences::mana::ManaPool {
25 essences::mana::ManaPool::full(
26 game_config.mana_settings.pool,
27 game_config.mana_settings.regen_per_second,
28 0,
29 )
30}
31
32fn make_ability_deadline(cooldown: u64) -> chrono::DateTime<chrono::Utc> {
33 ::time::utc_now()
34 + chrono::TimeDelta::milliseconds((cooldown as u128 * TICKER_UNIT_DURATION_MS) as i64)
35}
36
37pub fn sort_active_abilities(abilities: &mut [abilities::ActiveAbility]) {
38 abilities.sort_by(|a, b| {
39 a.slot_id
40 .cmp(&b.slot_id)
41 .then(a.ability.template_id.cmp(&b.ability.template_id))
42 });
43}
44
45pub fn make_active_abilities_from_equipped(
46 equipped_abilities: &abilities::EquippedAbilities,
47 game_config: &game_config::GameConfig,
48 begin_in_cooldown: bool,
49) -> Vec<abilities::ActiveAbility> {
50 let deadline_for = |template_id| {
51 if begin_in_cooldown {
52 let cooldown = game_config
53 .ability_template(template_id)
54 .map(|t| t.cooldown)
55 .unwrap_or(0);
56 Some(make_ability_deadline(cooldown))
57 } else {
58 None
59 }
60 };
61
62 let mut abilities: Vec<abilities::ActiveAbility> =
63 equipped_abilities
64 .unslotted
65 .iter()
66 .map(|a| abilities::ActiveAbility {
67 deadline: deadline_for(a.template_id),
68 ability: a.clone(),
69 slot_id: None,
70 })
71 .chain(equipped_abilities.slotted.iter().map(|(&slot_id, a)| {
72 abilities::ActiveAbility {
73 deadline: deadline_for(a.template_id),
74 ability: a.clone(),
75 slot_id: Some(slot_id),
76 }
77 }))
78 .collect();
79
80 sort_active_abilities(&mut abilities);
81
82 abilities
83}
84
85pub fn create_player_entity(
89 character_state: &character_state::CharacterState,
90 flip_state: FlipState,
91 entity_id: uuid::Uuid,
92 game_config: &game_config::GameConfig,
93) -> anyhow::Result<entity::Entity> {
94 let entity_stats = calculate_player_entity_stats_without_zeroes(
95 &EntityState::Character(character_state),
96 game_config,
97 )?;
98
99 let entity_abilities = make_active_abilities_from_equipped(
100 &character_state.equipped_abilities,
101 game_config,
102 false,
103 );
104
105 let mut entity = entity::Entity {
106 id: entity_id,
107 max_hp: entity_stats.max_hp,
108 hp: entity_stats.max_hp,
109 abilities: entity_abilities,
110 actions_queue: EntityActionsQueue::new(entity_id),
111 attributes: entity_stats.attributes,
112 effect_ids: Default::default(),
113 coordinates: game_config.fight_settings.player_start_position.clone(),
114 move_target: None,
115 width: 1, rewards: None,
117 class_id: Some(character_state.character.class),
118 team: fighting::EntityTeam::Ally,
119 has_big_hp_bar: false,
120 entity_template_id: None,
121 flip_state: game_config
125 .flip_settings
126 .is_unlocked(character_state.character.current_chapter_level)
127 .then_some(FlipState {
128 progress: 0.0,
129 ..flip_state
130 }),
131 mana: Some(full_mana_pool(game_config)),
134 proc_entropy: Default::default(),
135 law_bridges: Default::default(),
136 law_cores: Default::default(),
137 };
138
139 seed_law_state(
144 &mut entity,
145 &character_state.cores,
146 character_state.character.current_chapter_level,
147 flip_state.active_side,
148 game_config,
149 &crate::mechanics::artifacts::law_column_mods(game_config, &character_state.artifacts),
150 );
151 seed_class_passive(
152 &mut entity,
153 game_config,
154 character_state.character.class,
155 &character_state.character_classes,
156 );
157
158 Ok(entity)
159}
160
161fn seed_class_passive(
166 entity: &mut entity::Entity,
167 game_config: &game_config::GameConfig,
168 class_id: essences::class::ClassId,
169 character_classes: &[essences::class::CharacterClass],
170) {
171 let Some(class) = game_config.classes.iter().find(|c| c.id == class_id) else {
172 return;
173 };
174 let class_level = character_classes
177 .iter()
178 .map(|row| row.level as i64)
179 .max()
180 .unwrap_or(1);
181 crate::mechanics::class_passives::seed(entity, class, class_level);
182}
183
184pub fn seed_law_state(
198 entity: &mut entity::Entity,
199 cores: &essences::cores::CoresState,
200 chapter_level: i64,
201 active_side: essences::flip::WorldSide,
202 game_config: &game_config::GameConfig,
203 mods: &crate::mechanics::artifacts::LawColumnMods,
204) {
205 if !game_config.cores_settings.is_unlocked(chapter_level) {
206 return;
207 }
208 let was_full_hp = entity.hp >= entity.max_hp;
214
215 entity.law_bridges =
216 crate::mechanics::cores::build_law_bridge_charges(game_config, cores, mods);
217 entity.law_cores = cores.clone();
218 let next = law_attribute_snapshot(entity, active_side, game_config);
219 apply_law_attribute_deltas(entity, &BTreeMap::new(), &next, game_config);
220
221 if was_full_hp {
222 entity.hp = entity.max_hp;
223 }
224}
225
226pub fn law_attribute_snapshot(
242 entity: &entity::Entity,
243 active_side: essences::flip::WorldSide,
244 game_config: &game_config::GameConfig,
245) -> BTreeMap<String, i64> {
246 crate::mechanics::cores::active_law_attribute_deltas(
247 game_config,
248 &entity.law_cores,
249 active_side,
250 &entity.law_bridges,
251 )
252}
253
254pub fn apply_law_attribute_change(
258 entity: &mut entity::Entity,
259 previous: &BTreeMap<String, i64>,
260 next: &BTreeMap<String, i64>,
261 game_config: &game_config::GameConfig,
262) {
263 apply_law_attribute_deltas(entity, previous, next, game_config);
264}
265
266pub fn refresh_law_attributes(
275 entity: &mut entity::Entity,
276 previous_side: essences::flip::WorldSide,
277 active_side: essences::flip::WorldSide,
278 previous_charges: &essences::cores::LawBridgeCharges,
279 game_config: &game_config::GameConfig,
280) {
281 let previous = crate::mechanics::cores::active_law_attribute_deltas(
282 game_config,
283 &entity.law_cores,
284 previous_side,
285 previous_charges,
286 );
287 let next = crate::mechanics::cores::active_law_attribute_deltas(
288 game_config,
289 &entity.law_cores,
290 active_side,
291 &entity.law_bridges,
292 );
293 apply_law_attribute_deltas(entity, &previous, &next, game_config);
294}
295
296fn apply_law_attribute_deltas(
301 entity: &mut entity::Entity,
302 previous: &BTreeMap<String, i64>,
303 next: &BTreeMap<String, i64>,
304 game_config: &game_config::GameConfig,
305) {
306 let mut touched_hp = false;
307 let hp_code = game_config
308 .attribute(game_config.game_settings.hp_attribute_id)
309 .map(|attribute| attribute.code.clone())
310 .unwrap_or_else(|| "hp".to_string());
311
312 for code in previous
313 .keys()
314 .chain(next.keys())
315 .cloned()
316 .collect::<std::collections::BTreeSet<_>>()
317 {
318 let delta =
319 next.get(&code).copied().unwrap_or(0) - previous.get(&code).copied().unwrap_or(0);
320 if delta == 0 {
321 continue;
322 }
323 if code == hp_code || code.starts_with(&format!("{hp_code}.")) {
324 touched_hp = true;
325 }
326 entity.attributes.add(&code, delta);
327 }
328
329 if touched_hp && let Ok(max_hp) = compose_max_hp(&entity.attributes, game_config) {
330 entity.max_hp = max_hp;
331 entity.hp = entity.hp.min(max_hp);
332 }
333}
334
335pub fn create_party_entity(
336 character_state: &character_state::CharacterState,
337 flip_state: FlipState,
338 entity_id: uuid::Uuid,
339 game_config: &game_config::GameConfig,
340) -> anyhow::Result<entity::Entity> {
341 let mut entity = create_player_entity(character_state, flip_state, entity_id, game_config)?;
342 entity.coordinates = game_config.fight_settings.party_start_position.clone();
343 Ok(entity)
344}
345
346pub fn create_pve_entity(
347 entity_id: uuid::Uuid,
348 fight_entity: &fighting::FightEntity,
349 game_config: &game_config::GameConfig,
350 entity_attributes: Option<EntityAttributes>,
351) -> anyhow::Result<entity::Entity> {
352 let entity_template_id = match fight_entity.entity_type {
353 fighting::EntityType::PVEEntity { entity_template_id } => entity_template_id,
354 fighting::EntityType::PVPEntity => {
355 anyhow::bail!(
356 "Wanted to create a PVE entity, but got a PVP entity = {:?}",
357 fight_entity
358 );
359 }
360 };
361
362 let Some(entity) = game_config.entity_template(entity_template_id).cloned() else {
363 anyhow::bail!(
364 "Failed to find entity_template with id={}",
365 entity_template_id
366 )
367 };
368
369 let entity_abilities = entity
370 .ability_ids
371 .iter()
372 .filter_map(|&ability_id| {
373 let Some(entity_ability_template) = game_config.ability_template(ability_id) else {
374 tracing::error!("Failed to get ability with ability_id={}", ability_id);
375 return None;
376 };
377
378 let enemy_ability = abilities::Ability::from_template(
379 entity_ability_template,
380 None,
381 None,
382 );
383
384 Some(abilities::ActiveAbility {
385 ability: enemy_ability,
386 deadline: None,
387 slot_id: None,
388 })
389 })
390 .collect();
391
392 let entity_stats = if let Some(attributes) = entity_attributes {
393 let max_hp = compose_max_hp(&attributes, game_config)?;
394 EntityStats { attributes, max_hp }
395 } else {
396 let mut attributes_deltas = AttributeDeltas::new();
397 for attribute in entity.attributes {
398 *attributes_deltas.entry(attribute.attribute_id).or_insert(0) += attribute.value as i64;
399 }
400
401 calculate_entity_stats(game_config, attributes_deltas)?
402 };
403
404 Ok(entity::Entity {
405 id: entity_id,
406 max_hp: entity_stats.max_hp,
407 hp: entity_stats.max_hp,
408 abilities: entity_abilities,
409 actions_queue: EntityActionsQueue::new(entity_id),
410 attributes: entity_stats.attributes,
411 effect_ids: Default::default(),
412 coordinates: fight_entity.position.clone(),
413 move_target: None,
414 width: entity.width,
415 rewards: Some(entity.rewards),
416 class_id: None,
417 team: fight_entity.team.clone(),
418 has_big_hp_bar: fight_entity.has_big_hp_bar,
419 entity_template_id: Some(entity_template_id),
420 flip_state: None,
421 mana: None,
423 proc_entropy: Default::default(),
424 law_bridges: Default::default(),
425 law_cores: Default::default(),
426 })
427}
428
429pub fn create_pvp_entity(
430 entity_state: &EntityState,
431 flip_state: FlipState,
432 fight_entity: &fighting::FightEntity,
433 game_config: &game_config::GameConfig,
434) -> anyhow::Result<entity::Entity> {
435 if fight_entity.entity_type != fighting::EntityType::PVPEntity {
436 anyhow::bail!(
437 "Wanted to create a PVP entity, but got a PVE entity = {:?}",
438 fight_entity
439 );
440 };
441
442 let entity_stats = calculate_player_entity_stats_without_zeroes(entity_state, game_config)?;
443
444 let abilities =
445 make_active_abilities_from_equipped(entity_state.equipped_abilities(), game_config, false);
446
447 let mut entity = entity::Entity {
448 id: entity_state.id(),
449 max_hp: entity_stats.max_hp,
450 hp: entity_stats.max_hp,
451 abilities,
452 actions_queue: EntityActionsQueue::new(entity_state.id()),
453 attributes: entity_stats.attributes,
454 effect_ids: Default::default(),
455 coordinates: fight_entity.position.clone(),
456 move_target: None,
457 width: 1, rewards: None,
459 class_id: Some(entity_state.class()),
460 team: fight_entity.team.clone(),
461 has_big_hp_bar: fight_entity.has_big_hp_bar,
462 entity_template_id: None,
463 flip_state: game_config
466 .flip_settings
467 .is_unlocked(entity_state.current_chapter_level())
468 .then_some(FlipState {
469 progress: 0.0,
470 ..flip_state
471 }),
472 mana: Some(full_mana_pool(game_config)),
474 proc_entropy: Default::default(),
475 law_bridges: Default::default(),
476 law_cores: Default::default(),
477 };
478
479 if let Some(cores) = entity_state.cores() {
489 let mods = entity_state
494 .artifacts()
495 .map(|artifacts| crate::mechanics::artifacts::law_column_mods(game_config, artifacts))
496 .unwrap_or(crate::mechanics::artifacts::LawColumnMods::NONE);
497 seed_law_state(
498 &mut entity,
499 cores,
500 entity_state.current_chapter_level(),
501 flip_state.active_side,
502 game_config,
503 &mods,
504 );
505 }
506 seed_class_passive(
509 &mut entity,
510 game_config,
511 entity_state.class(),
512 entity_state.character_classes().unwrap_or_default(),
513 );
514
515 Ok(entity)
516}
517
518pub fn combatant_character_state<'a>(
525 character_state: &'a character_state::CharacterState,
526 party: &'a crate::party::Party,
527 pvp_state: &'a Option<essences::pvp::PVPState>,
528 fight: &fighting::ActiveFight,
529 entity_id: EntityId,
530) -> Option<&'a character_state::CharacterState> {
531 if fight.player_id == entity_id {
532 return Some(character_state);
533 }
534 if fight.party_player_id == Some(entity_id) {
535 return party.party_state.as_ref();
536 }
537 if let Some(pvp) = pvp_state
538 && pvp.opponent_state.id() == entity_id
539 {
540 return pvp.opponent_state.character_state();
541 }
542 None
543}
544
545pub fn combatant_character_state_of(
549 state: &OverlordState,
550 entity_id: EntityId,
551) -> Option<&character_state::CharacterState> {
552 let fight = state.active_fight.as_ref()?;
553 combatant_character_state(
554 &state.character_state,
555 &state.party,
556 &state.pvp_state,
557 fight,
558 entity_id,
559 )
560}
561
562pub fn mirror_combatant_flip_state(
566 state: &mut OverlordState,
567 entity_id: EntityId,
568 snapshot: FlipState,
569) {
570 let Some(fight) = state.active_fight.as_ref() else {
571 return;
572 };
573 if fight.player_id == entity_id {
574 state.flip_state = snapshot;
575 } else if fight.party_player_id == Some(entity_id) {
576 state.party.party_flip_state = Some(snapshot);
577 } else if let Some(pvp) = state.pvp_state.as_mut()
578 && pvp.opponent_state.id() == entity_id
579 {
580 pvp.opponent_flip_state = snapshot;
581 }
582}
583
584pub fn combatant_flip_threshold(
589 state: &OverlordState,
590 entity_id: EntityId,
591 game_config: &game_config::GameConfig,
592) -> f64 {
593 match combatant_character_state_of(state, entity_id) {
594 Some(build) => crate::mechanics::artifacts::flip_threshold(game_config, &build.artifacts),
595 None => game_config.flip_settings.progress_threshold,
596 }
597}
598
599pub fn event_from_entity_action(
606 action: EntityAction,
607 entity_id: EntityId,
608 origin: CombatEventOrigin,
609) -> EventPluginized<OverlordEvent, OverlordState> {
610 match action {
611 EntityAction::CastEffect {
612 entity_id,
613 effect_id,
614 } => EventPluginized::now(OverlordEvent::CastEffect {
615 origin,
616 entity_id,
617 effect_id,
618 }),
619 EntityAction::CastAbility {
620 ability_id,
621 target_entity_id,
622 } => EventPluginized::now(OverlordEvent::CastAbility {
623 origin,
624 by_entity_id: entity_id,
625 to_entity_id: target_entity_id,
626 ability_id,
627 }),
628 EntityAction::CastBasicAbility {
629 ability_id,
630 target_entity_id,
631 } => EventPluginized::now(OverlordEvent::CastAbility {
632 origin,
633 by_entity_id: entity_id,
634 to_entity_id: target_entity_id,
635 ability_id,
636 }),
637 EntityAction::StartCastAbility {
638 ability_id,
639 by_entity_id,
640 pet_id,
641 } => EventPluginized::now(OverlordEvent::StartCastAbility {
642 origin,
643 by_entity_id,
644 ability_id,
645 pet_id,
646 }),
647 }
648}
649
650pub fn stamp_gauge_hp_shares(entities: &mut [entity::Entity]) {
658 for team in [fighting::EntityTeam::Ally, fighting::EntityTeam::Enemy] {
659 let total: u64 = entities
660 .iter()
661 .filter(|e| e.team == team && !e.attributes.is_summoned())
662 .map(|e| e.max_hp)
663 .sum();
664 if total == 0 {
665 let mut stamped_any = false;
670 for entity in entities
671 .iter_mut()
672 .filter(|e| e.team == team && !e.attributes.is_summoned())
673 {
674 entity.attributes.set("gauge_hp_share", -1);
677 stamped_any = true;
678 }
679 if stamped_any {
680 tracing::error!(
681 ?team,
682 "gauge_hp_share: eligible initial roster has zero total max_hp; \
683 outgoing gauge for this side is disabled"
684 );
685 }
686 continue;
687 }
688 for entity in entities
689 .iter_mut()
690 .filter(|e| e.team == team && !e.attributes.is_summoned())
691 {
692 let share = (entity.max_hp as f64 / total as f64 * 10_000.0).round() as i64;
693 entity.attributes.add("gauge_hp_share", share);
694 }
695 }
696}