1use std::collections::{HashMap, VecDeque};
52
53use essences::entity::{ActionPriority, Entity, EntityId};
54use essences::fighting::{ActiveFight, EntityTeam};
55
56use crate::event::OverlordEvent;
57use crate::mechanics::content_lookups::ContentLookups;
58use crate::state::OverlordState;
59
60const WINDOW_TICKS: usize = 100;
62
63pub const DISPERSION_IDLE_SHARE_THRESHOLD_PCT: f64 = 40.0;
66
67pub const STUN_UNTIL_TICK_ATTR: &str = "stun_until_tick";
71
72#[derive(Debug, Clone, Default)]
74struct EntityCounters {
75 team: EntityTeam,
76 sampled_ticks: u64,
77 idle_in_contact_ticks: u64,
78 body_busy_ticks: u64,
79 whiffs: u64,
80 hits_while_casting: u64,
81 idle_window: VecDeque<bool>,
84 window_idle_count: usize,
85 max_window_idle_share: Option<f64>,
88 is_boss: bool,
92 stunned_ticks: u64,
94 damage_dealt: u64,
96 casts_started: u64,
97 casts_completed: u64,
98 stuns_applied: u64,
103 min_hp_fraction: Option<f64>,
105 healing_received: u64,
108 overheal: u64,
109}
110
111impl EntityCounters {
112 fn record_sample(
113 &mut self,
114 idle_in_contact: bool,
115 body_busy: bool,
116 stunned: bool,
117 hp_fraction: f64,
118 ) {
119 self.sampled_ticks += 1;
120 self.min_hp_fraction = Some(
121 self.min_hp_fraction
122 .map_or(hp_fraction, |m: f64| m.min(hp_fraction)),
123 );
124 if stunned {
125 self.stunned_ticks += 1;
126 }
127 if idle_in_contact {
128 self.idle_in_contact_ticks += 1;
129 }
130 if body_busy {
131 self.body_busy_ticks += 1;
132 }
133
134 self.idle_window.push_back(idle_in_contact);
135 if idle_in_contact {
136 self.window_idle_count += 1;
137 }
138 if self.idle_window.len() > WINDOW_TICKS
139 && let Some(true) = self.idle_window.pop_front()
140 {
141 self.window_idle_count -= 1;
142 }
143 if self.idle_window.len() == WINDOW_TICKS {
144 let share = self.window_idle_count as f64 / WINDOW_TICKS as f64;
145 self.max_window_idle_share =
146 Some(self.max_window_idle_share.map_or(share, |m| m.max(share)));
147 }
148 }
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct FightMetrics {
154 entities: HashMap<EntityId, EntityCounters>,
155 total_sampled_ticks: u64,
156}
157
158impl FightMetrics {
159 pub(super) fn sample(
163 &mut self,
164 state: &OverlordState,
165 lookups: &ContentLookups,
166 current_tick: u64,
167 ) {
168 let Some(fight) = state.active_fight.as_ref() else {
169 return;
170 };
171 self.total_sampled_ticks += 1;
172
173 for entity in &fight.entities {
174 if entity.hp == 0 {
177 continue;
178 }
179 let body_busy = is_body_busy(entity);
180 let idle_in_contact = !body_busy && has_valid_target_in_range(entity, fight, lookups);
181 let stunned = entity
185 .attributes
186 .0
187 .get(STUN_UNTIL_TICK_ATTR)
188 .is_some_and(|until| *until > current_tick as i64);
189
190 let counters = self.entities.entry(entity.id).or_default();
191 counters.team = entity.team.clone();
192 counters.is_boss = entity.has_big_hp_bar;
193 let hp_fraction = if entity.max_hp > 0 {
194 entity.hp as f64 / entity.max_hp as f64
195 } else {
196 0.0
197 };
198 counters.record_sample(idle_in_contact, body_busy, stunned, hp_fraction);
199 }
200 }
201
202 pub(super) fn observe_event(&mut self, event: &OverlordEvent, pre_state: &OverlordState) {
206 let Some(fight) = pre_state.active_fight.as_ref() else {
207 return;
208 };
209 match event {
210 OverlordEvent::CastAbility {
211 by_entity_id,
212 to_entity_id,
213 ..
214 } => {
215 let has_live_target = fight
216 .entities
217 .iter()
218 .any(|e| e.id == *to_entity_id && e.hp > 0);
219 if !has_live_target {
220 self.counters_for(*by_entity_id, fight).whiffs += 1;
221 }
222 self.counters_for(*by_entity_id, fight).casts_completed += 1;
223 }
224 OverlordEvent::Damage {
225 entity_id,
226 by_entity_id,
227 damage,
228 ..
229 } => {
230 if let Some(victim) = fight.entities.iter().find(|e| e.id == *entity_id)
231 && is_body_busy(victim)
232 {
233 let team = victim.team.clone();
234 let counters = self.entities.entry(*entity_id).or_default();
235 counters.team = team;
236 counters.hits_while_casting += 1;
237 }
238 if let Some(dealer) = by_entity_id {
242 self.counters_for(*dealer, fight).damage_dealt += damage;
243 }
244 }
245 OverlordEvent::StartCastAbility { by_entity_id, .. } => {
246 self.counters_for(*by_entity_id, fight).casts_started += 1;
247 }
248 OverlordEvent::EntityStun { entity_id, .. } => {
249 self.counters_for(*entity_id, fight).stuns_applied += 1;
250 }
251 OverlordEvent::Heal {
252 entity_id, heal, ..
253 } => {
254 let headroom = fight
258 .entities
259 .iter()
260 .find(|e| e.id == *entity_id)
261 .map(|e| e.max_hp.saturating_sub(e.hp))
262 .unwrap_or(0);
263 let counters = self.counters_for(*entity_id, fight);
264 counters.healing_received += heal;
265 counters.overheal += heal.saturating_sub(headroom);
266 }
267 _ => {}
268 }
269 }
270
271 fn counters_for(&mut self, id: EntityId, fight: &ActiveFight) -> &mut EntityCounters {
274 let team = fight
275 .entities
276 .iter()
277 .find(|e| e.id == id)
278 .map(|e| e.team.clone());
279 let counters = self.entities.entry(id).or_default();
280 if let Some(team) = team {
281 counters.team = team;
282 }
283 counters
284 }
285
286 pub fn summary(&self, player_id: Option<EntityId>) -> FightMetricsSummary {
289 let mut entities: Vec<EntityMetrics> = self
290 .entities
291 .iter()
292 .map(|(id, c)| EntityMetrics::new(*id, c))
293 .collect();
294 entities.sort_by_key(|e| *e.entity_id.as_bytes());
296
297 let hero =
298 player_id.and_then(|id| self.entities.get(&id).map(|c| EntityMetrics::new(id, c)));
299
300 let ally = self.team_metrics(EntityTeam::Ally);
301 let enemy = self.team_metrics(EntityTeam::Enemy);
302
303 let max_window_idle_share_pct = entities
304 .iter()
305 .filter_map(|e| e.max_window_idle_share_pct)
306 .fold(None, |acc: Option<f64>, v| {
307 Some(acc.map_or(v, |m| m.max(v)))
308 });
309 let dispersion_ok =
310 max_window_idle_share_pct.is_none_or(|m| m <= DISPERSION_IDLE_SHARE_THRESHOLD_PCT);
311
312 let bosses: Vec<EntityMetrics> = entities.iter().filter(|e| e.is_boss).cloned().collect();
313
314 FightMetricsSummary {
315 total_sampled_ticks: self.total_sampled_ticks,
316 total_whiffs: self.entities.values().map(|c| c.whiffs).sum(),
317 hero,
318 ally,
319 enemy,
320 bosses,
321 entities,
322 max_window_idle_share_pct,
323 dispersion_ok,
324 }
325 }
326
327 fn team_metrics(&self, team: EntityTeam) -> TeamMetrics {
328 let members: Vec<&EntityCounters> =
329 self.entities.values().filter(|c| c.team == team).collect();
330
331 let sampled_ticks: u64 = members.iter().map(|c| c.sampled_ticks).sum();
332 let idle_ticks: u64 = members.iter().map(|c| c.idle_in_contact_ticks).sum();
333 let body_ticks: u64 = members.iter().map(|c| c.body_busy_ticks).sum();
334 let max_window_idle_share_pct = members
335 .iter()
336 .filter_map(|c| c.max_window_idle_share.map(|s| s * 100.0))
337 .fold(None, |acc: Option<f64>, v| {
338 Some(acc.map_or(v, |m| m.max(v)))
339 });
340
341 TeamMetrics {
342 team,
343 sampled_ticks,
344 idle_share_pct: pct(idle_ticks, sampled_ticks),
345 body_busy_share_pct: pct(body_ticks, sampled_ticks),
346 whiffs: members.iter().map(|c| c.whiffs).sum(),
347 hits_while_casting: members.iter().map(|c| c.hits_while_casting).sum(),
348 max_window_idle_share_pct,
349 dispersion_ok: max_window_idle_share_pct
350 .is_none_or(|m| m <= DISPERSION_IDLE_SHARE_THRESHOLD_PCT),
351 }
352 }
353}
354
355fn is_body_busy(entity: &Entity) -> bool {
359 let queues = entity.actions_queue.view();
360 [ActionPriority::Second, ActionPriority::Third]
361 .iter()
362 .any(|p| queues.get(p).is_some_and(|q| !q.is_empty()))
363}
364
365fn has_valid_target_in_range(
369 entity: &Entity,
370 fight: &ActiveFight,
371 lookups: &ContentLookups,
372) -> bool {
373 for active in &entity.abilities {
374 let ability_id = active.ability.template_id;
375 let target_type = lookups
376 .ability_target_type
377 .get(&ability_id)
378 .map(|s| s.as_str())
379 .unwrap_or("");
380 let range = lookups.ability_range.get(&ability_id).copied().unwrap_or(0);
381 for other in &fight.entities {
382 if other.hp == 0 {
383 continue;
384 }
385 let by_team = match target_type {
386 "Enemy" => other.team != entity.team,
387 "Ally" => other.team == entity.team,
388 _ => false,
389 };
390 if by_team && (other.coordinates.x - entity.coordinates.x).abs() <= range {
391 return true;
392 }
393 }
394 }
395 false
396}
397
398fn pct(num: u64, den: u64) -> f64 {
399 if den == 0 {
400 0.0
401 } else {
402 num as f64 / den as f64 * 100.0
403 }
404}
405
406#[derive(Debug, Clone, serde::Serialize)]
408pub struct EntityMetrics {
409 pub entity_id: EntityId,
410 pub team: EntityTeam,
411 pub sampled_ticks: u64,
412 pub idle_in_contact_ticks: u64,
413 pub body_busy_ticks: u64,
414 pub whiffs: u64,
415 pub hits_while_casting: u64,
416 pub idle_share_pct: f64,
417 pub body_busy_share_pct: f64,
418 pub max_window_idle_share_pct: Option<f64>,
419 pub is_boss: bool,
423 pub stunned_ticks: u64,
424 pub stun_share_pct: f64,
425 pub damage_dealt: u64,
426 pub casts_started: u64,
427 pub casts_completed: u64,
428 pub casts_cancelled: u64,
431 pub stuns_applied: u64,
432 pub min_hp_fraction: Option<f64>,
434 pub healing_received: u64,
435 pub overheal: u64,
436}
437
438impl EntityMetrics {
439 fn new(entity_id: EntityId, c: &EntityCounters) -> Self {
440 Self {
441 entity_id,
442 team: c.team.clone(),
443 sampled_ticks: c.sampled_ticks,
444 idle_in_contact_ticks: c.idle_in_contact_ticks,
445 body_busy_ticks: c.body_busy_ticks,
446 whiffs: c.whiffs,
447 hits_while_casting: c.hits_while_casting,
448 idle_share_pct: pct(c.idle_in_contact_ticks, c.sampled_ticks),
449 body_busy_share_pct: pct(c.body_busy_ticks, c.sampled_ticks),
450 max_window_idle_share_pct: c.max_window_idle_share.map(|s| s * 100.0),
451 is_boss: c.is_boss,
452 stunned_ticks: c.stunned_ticks,
453 stun_share_pct: pct(c.stunned_ticks, c.sampled_ticks),
454 damage_dealt: c.damage_dealt,
455 casts_started: c.casts_started,
456 casts_completed: c.casts_completed,
457 casts_cancelled: c.casts_started.saturating_sub(c.casts_completed),
458 stuns_applied: c.stuns_applied,
459 min_hp_fraction: c.min_hp_fraction,
460 healing_received: c.healing_received,
461 overheal: c.overheal,
462 }
463 }
464}
465
466#[derive(Debug, Clone, serde::Serialize)]
468pub struct TeamMetrics {
469 pub team: EntityTeam,
470 pub sampled_ticks: u64,
471 pub idle_share_pct: f64,
472 pub body_busy_share_pct: f64,
473 pub whiffs: u64,
474 pub hits_while_casting: u64,
475 pub max_window_idle_share_pct: Option<f64>,
476 pub dispersion_ok: bool,
477}
478
479#[derive(Debug, Clone, serde::Serialize)]
482pub struct FightMetricsSummary {
483 pub total_sampled_ticks: u64,
484 pub total_whiffs: u64,
485 pub hero: Option<EntityMetrics>,
486 pub ally: TeamMetrics,
487 pub enemy: TeamMetrics,
488 pub entities: Vec<EntityMetrics>,
489 pub max_window_idle_share_pct: Option<f64>,
491 pub dispersion_ok: bool,
493 pub bosses: Vec<EntityMetrics>,
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 use essences::abilities::{Ability, ActiveAbility};
505 use essences::combat_origin::CombatEventOrigin;
506 use essences::entity::{ActionWithDeadline, Coordinates, EntityAction};
507 use essences::fight_breakdown::CombatSource;
508 use uuid::Uuid;
509
510 fn ability(id: Uuid) -> ActiveAbility {
511 ActiveAbility {
512 ability: Ability {
513 template_id: id,
514 level: 1,
515 shards_amount: 0,
516 },
517 deadline: None,
518 slot_id: None,
519 }
520 }
521
522 fn entity(id: Uuid, team: EntityTeam, x: i64, ability_id: Uuid) -> Entity {
523 Entity {
524 id,
525 hp: 100,
526 max_hp: 100,
527 team,
528 coordinates: Coordinates { x, y: 0 },
529 abilities: vec![ability(ability_id)],
530 ..Default::default()
531 }
532 }
533
534 fn lookups(id: Uuid, range: i64) -> ContentLookups {
536 let mut l = ContentLookups::default();
537 l.ability_target_type.insert(id, "Enemy".to_string());
538 l.ability_range.insert(id, range);
539 l
540 }
541
542 fn state_with(fight: ActiveFight) -> OverlordState {
543 OverlordState {
544 active_fight: Some(fight),
545 ..Default::default()
546 }
547 }
548
549 fn push_cast(e: &mut Entity, ability_id: Uuid, target: Uuid) {
550 e.actions_queue.push(&ActionWithDeadline {
551 action: EntityAction::CastAbility {
552 ability_id,
553 target_entity_id: target,
554 },
555 deadline_tick: 0,
556 origin: CombatEventOrigin::Core,
557 });
558 }
559
560 #[test]
563 fn idle_and_body_busy_sampling() {
564 let (hero_id, enemy_id) = (Uuid::new_v4(), Uuid::new_v4());
565 let ab = Uuid::new_v4();
566 let hero = entity(hero_id, EntityTeam::Ally, 0, ab);
567 let enemy = entity(enemy_id, EntityTeam::Enemy, 1, ab); let fight = ActiveFight {
569 player_id: hero_id,
570 entities: vec![hero, enemy],
571 ..Default::default()
572 };
573 let lookups = lookups(ab, 1);
574
575 let mut m = FightMetrics::default();
576
577 m.sample(&state_with(fight.clone()), &lookups, 0);
579
580 let mut fight2 = fight.clone();
582 push_cast(&mut fight2.entities[0], ab, enemy_id);
583 m.sample(&state_with(fight2), &lookups, 0);
584
585 let s = m.summary(Some(hero_id));
586 let hero = s.hero.expect("hero present");
587 assert_eq!(hero.sampled_ticks, 2);
588 assert_eq!(
589 hero.idle_in_contact_ticks, 1,
590 "only tick 1 was idle-in-contact"
591 );
592 assert_eq!(hero.body_busy_ticks, 1, "only tick 2 had a queued cast");
593 assert_eq!(hero.idle_share_pct, 50.0);
594 assert_eq!(hero.body_busy_share_pct, 50.0);
595 assert_eq!(s.total_sampled_ticks, 2);
596 }
597
598 #[test]
600 fn out_of_range_is_not_idle_in_contact() {
601 let (hero_id, enemy_id) = (Uuid::new_v4(), Uuid::new_v4());
602 let ab = Uuid::new_v4();
603 let hero = entity(hero_id, EntityTeam::Ally, 0, ab);
604 let enemy = entity(enemy_id, EntityTeam::Enemy, 5, ab); let fight = ActiveFight {
606 player_id: hero_id,
607 entities: vec![hero, enemy],
608 ..Default::default()
609 };
610 m_sample_once_and_assert_not_idle(fight, hero_id, ab);
611 }
612
613 fn m_sample_once_and_assert_not_idle(fight: ActiveFight, hero_id: Uuid, ab: Uuid) {
614 let mut m = FightMetrics::default();
615 m.sample(&state_with(fight), &lookups(ab, 1), 0);
616 let hero = m.summary(Some(hero_id)).hero.expect("hero");
617 assert_eq!(hero.sampled_ticks, 1);
618 assert_eq!(hero.idle_in_contact_ticks, 0);
619 }
620
621 #[test]
623 fn whiff_counting() {
624 let (hero_id, enemy_id) = (Uuid::new_v4(), Uuid::new_v4());
625 let ab = Uuid::new_v4();
626 let hero = entity(hero_id, EntityTeam::Ally, 0, ab);
627 let enemy = entity(enemy_id, EntityTeam::Enemy, 1, ab);
628 let fight = ActiveFight {
629 player_id: hero_id,
630 entities: vec![hero, enemy],
631 ..Default::default()
632 };
633 let state = state_with(fight);
634 let mut m = FightMetrics::default();
635
636 m.observe_event(
638 &OverlordEvent::CastAbility {
639 by_entity_id: hero_id,
640 to_entity_id: enemy_id,
641 ability_id: ab,
642 origin: CombatEventOrigin::Core,
643 },
644 &state,
645 );
646 m.observe_event(
648 &OverlordEvent::CastAbility {
649 by_entity_id: hero_id,
650 to_entity_id: Uuid::new_v4(),
651 ability_id: ab,
652 origin: CombatEventOrigin::Core,
653 },
654 &state,
655 );
656
657 let s = m.summary(Some(hero_id));
658 assert_eq!(s.total_whiffs, 1);
659 assert_eq!(s.hero.unwrap().whiffs, 1);
660 }
661
662 #[test]
665 fn hit_while_casting_counting() {
666 let (hero_id, enemy_id) = (Uuid::new_v4(), Uuid::new_v4());
667 let ab = Uuid::new_v4();
668 let mut hero = entity(hero_id, EntityTeam::Ally, 0, ab);
669 let enemy = entity(enemy_id, EntityTeam::Enemy, 1, ab);
670
671 let idle_state = state_with(ActiveFight {
673 player_id: hero_id,
674 entities: vec![hero.clone(), enemy.clone()],
675 ..Default::default()
676 });
677 let mut m = FightMetrics::default();
678 m.observe_event(
679 &OverlordEvent::Damage {
680 by_entity_id: None,
681 entity_id: hero_id,
682 damage: 5,
683 damage_data: Default::default(),
684 origin: CombatEventOrigin::Core,
685 source: essences::fight_breakdown::CombatSource::Other,
686 },
687 &idle_state,
688 );
689 assert!(m.summary(Some(hero_id)).hero.is_none());
691
692 push_cast(&mut hero, ab, enemy_id);
694 let casting_state = state_with(ActiveFight {
695 player_id: hero_id,
696 entities: vec![hero, enemy],
697 ..Default::default()
698 });
699 m.observe_event(
700 &OverlordEvent::Damage {
701 by_entity_id: None,
702 entity_id: hero_id,
703 damage: 5,
704 damage_data: Default::default(),
705 origin: CombatEventOrigin::Core,
706 source: essences::fight_breakdown::CombatSource::Other,
707 },
708 &casting_state,
709 );
710 assert_eq!(m.summary(Some(hero_id)).hero.unwrap().hits_while_casting, 1);
711 }
712
713 #[test]
716 fn dispersion_window_flags_a_cold_stretch() {
717 let (hero_id, enemy_id) = (Uuid::new_v4(), Uuid::new_v4());
718 let ab = Uuid::new_v4();
719 let hero = entity(hero_id, EntityTeam::Ally, 0, ab);
720 let enemy = entity(enemy_id, EntityTeam::Enemy, 1, ab);
721 let state = state_with(ActiveFight {
722 player_id: hero_id,
723 entities: vec![hero, enemy],
724 ..Default::default()
725 });
726 let lookups = lookups(ab, 1);
727 let mut m = FightMetrics::default();
728
729 for _ in 0..(WINDOW_TICKS - 1) {
731 m.sample(&state, &lookups, 0);
732 }
733 assert!(m.summary(Some(hero_id)).max_window_idle_share_pct.is_none());
734 assert!(m.summary(Some(hero_id)).dispersion_ok);
735
736 m.sample(&state, &lookups, 0);
738 let s = m.summary(Some(hero_id));
739 assert_eq!(s.max_window_idle_share_pct, Some(100.0));
740 assert!(!s.dispersion_ok, "an all-idle window must fail dispersion");
741 assert!(!s.ally.dispersion_ok);
742 }
743
744 #[test]
749 fn stun_ticks_count_real_frozen_time_not_nominal_duration() {
750 let hero_id = Uuid::now_v7();
751 let boss_id = Uuid::now_v7();
752 let ab = Uuid::now_v7();
753 let hero = entity(hero_id, EntityTeam::Ally, 0, ab);
754 let mut boss = entity(boss_id, EntityTeam::Enemy, 1, ab);
755 boss.has_big_hp_bar = true;
756 boss.attributes.set(STUN_UNTIL_TICK_ATTR, 250);
758
759 let fight = ActiveFight {
760 player_id: hero_id,
761 entities: vec![hero, boss],
762 ..Default::default()
763 };
764 let state = state_with(fight);
765 let lk = lookups(ab, 1);
766
767 let mut m = FightMetrics::default();
768 for tick in [0, 100, 300] {
769 m.sample(&state, &lk, tick);
770 }
771
772 let s = m.summary(Some(hero_id));
773 let boss_row = s
774 .bosses
775 .first()
776 .expect("the big-HP-bar entity is a boss row");
777 assert_eq!(boss_row.entity_id, boss_id);
778 assert_eq!(boss_row.sampled_ticks, 3, "F counts every living tick");
779 assert_eq!(boss_row.stunned_ticks, 2, "only ticks before the deadline");
780 assert!((boss_row.stun_share_pct - 200.0 / 3.0).abs() < 1e-9);
781 }
782
783 #[test]
787 fn a_fight_without_a_boss_flagged_entity_reports_no_boss_rows() {
788 let hero_id = Uuid::now_v7();
789 let enemy_id = Uuid::now_v7();
790 let ab = Uuid::now_v7();
791 let fight = ActiveFight {
792 player_id: hero_id,
793 entities: vec![
794 entity(hero_id, EntityTeam::Ally, 0, ab),
795 entity(enemy_id, EntityTeam::Enemy, 1, ab),
796 ],
797 ..Default::default()
798 };
799 let mut m = FightMetrics::default();
800 m.sample(&state_with(fight), &lookups(ab, 1), 0);
801 assert!(m.summary(Some(hero_id)).bosses.is_empty());
802 }
803
804 #[test]
808 fn damage_is_credited_to_its_dealer_and_ownerless_damage_to_nobody() {
809 let hero_id = Uuid::now_v7();
810 let boss_id = Uuid::now_v7();
811 let ab = Uuid::now_v7();
812 let mut boss = entity(boss_id, EntityTeam::Enemy, 1, ab);
813 boss.has_big_hp_bar = true;
814 let fight = ActiveFight {
815 player_id: hero_id,
816 entities: vec![entity(hero_id, EntityTeam::Ally, 0, ab), boss],
817 ..Default::default()
818 };
819 let state = state_with(fight);
820
821 let mut m = FightMetrics::default();
822 let hit = |by: Option<Uuid>, damage: u64| OverlordEvent::Damage {
823 by_entity_id: by,
824 entity_id: hero_id,
825 damage,
826 damage_data: Default::default(),
827 origin: CombatEventOrigin::default(),
828 source: CombatSource::ArmedBonus,
829 };
830 m.observe_event(&hit(Some(boss_id), 30), &state);
831 m.observe_event(&hit(Some(boss_id), 12), &state);
832 m.observe_event(&hit(None, 999), &state);
833
834 let by_id: HashMap<_, _> = m
835 .summary(Some(hero_id))
836 .entities
837 .into_iter()
838 .map(|e| (e.entity_id, e))
839 .collect();
840 assert_eq!(by_id[&boss_id].damage_dealt, 42);
841 assert_eq!(by_id.get(&hero_id).map_or(0, |e| e.damage_dealt), 0);
842 }
843
844 #[test]
848 fn min_hp_fraction_records_the_dip_not_the_recovery() {
849 let hero_id = Uuid::now_v7();
850 let ab = Uuid::now_v7();
851 let lk = lookups(ab, 1);
852
853 let mut m = FightMetrics::default();
854 for hp in [100u64, 30, 100] {
855 let mut hero = entity(hero_id, EntityTeam::Ally, 0, ab);
856 hero.hp = hp;
857 let fight = ActiveFight {
858 player_id: hero_id,
859 entities: vec![hero],
860 ..Default::default()
861 };
862 m.sample(&state_with(fight), &lk, 0);
863 }
864
865 let hero = m.summary(Some(hero_id)).hero.expect("hero");
866 assert_eq!(hero.min_hp_fraction, Some(0.3));
867 }
868
869 #[test]
872 fn overheal_is_the_part_of_a_heal_with_no_headroom_left() {
873 let hero_id = Uuid::now_v7();
874 let ab = Uuid::now_v7();
875 let mut hero = entity(hero_id, EntityTeam::Ally, 0, ab);
876 hero.hp = 90; let fight = ActiveFight {
878 player_id: hero_id,
879 entities: vec![hero],
880 ..Default::default()
881 };
882 let state = state_with(fight);
883
884 let mut m = FightMetrics::default();
885 m.observe_event(
886 &OverlordEvent::Heal {
887 by_entity_id: None,
888 entity_id: hero_id,
889 heal: 25,
890 origin: CombatEventOrigin::default(),
891 source: CombatSource::ArmedBonus,
892 },
893 &state,
894 );
895
896 let hero = m.summary(Some(hero_id)).hero.expect("hero");
897 assert_eq!(hero.healing_received, 25);
898 assert_eq!(hero.overheal, 15, "25 healed into 10 headroom wastes 15");
899 }
900}