overlord_event_system/fight/
metrics.rs

1//! Combat-feel metrics for the fight simulation.
2//!
3//! A pure observer. The collector samples fight state once per game tick and
4//! inspects the event stream as it flows through the engine, but it never
5//! mutates [`OverlordState`], never emits events, and never touches the RNG
6//! stream. The metrics live on [`FightSim`](super::FightSim), *outside* the
7//! simulated state, so a fight run with metrics on is byte-identical to one
8//! run with them off — determinism of the sim and of live resume is preserved.
9//!
10//! What it measures (per entity, aggregated per team + for the hero):
11//! - **idle-in-contact**: ticks where the entity's body is free (no
12//!   `CastAbility`/`CastBasicAbility` queued) *and* it has a valid target in
13//!   range — i.e. it could be attacking but isn't. Target metric `< 15%`.
14//! - **body busy**: ticks where the entity has a `CastAbility`/`CastBasicAbility`
15//!   in its queue. Target for the hero `40..=80%`.
16//! - **whiffs**: `CastAbility` events whose target was already dead/absent.
17//!   Target `0` (see plan §4 п.8, retarget-instead-of-whiff).
18//! - **hits-while-casting**: `Damage` events landed on an entity while it was
19//!   mid-cast.
20//! - **dispersion**: a sliding 10 s window of idle-in-contact share; the max
21//!   over all full windows must stay `<= 40%` — the primary criterion, since
22//!   the residual bad feel correlated with bunched "cold" stretches that a
23//!   fight-average hides.
24//!
25//! A2-BAL-001 calibration additions (per entity, boss rows are the ones that
26//! matter):
27//! - **alive ticks** (`sampled_ticks`): the card's `F`, the entity's lifetime
28//!   from spawn to death. Dead entities are skipped by `sample`, so the count
29//!   stops at death without needing a death hook.
30//! - **stunned ticks**: the card's `S_actual`. Read off the `stun_until_tick`
31//!   attribute the `EntityStun` handler stamps, compared against the sampled
32//!   tick — so it counts real frozen time, not the nominal stun duration, and
33//!   a stun truncated by death is counted only for the ticks it actually ran.
34//! - **damage dealt**: the card's `D_old` when measured on the boss row.
35//!   Credited from `Damage.by_entity_id`, the same attribution the end-of-fight
36//!   breakdown uses.
37//! - **casts started / completed**: `StartCastAbility` and `CastAbility`. Their
38//!   difference is the cancelled count the card requires to be recorded before
39//!   and after the change.
40//!
41//! A2-BAL-002 §2.5 survival-pressure additions:
42//! - **min HP fraction**: the LOWEST `hp / max_hp` the entity reached at any
43//!   sampled tick. The card mandates this instead of final HP because
44//!   regeneration can return final HP to 100 % and hide every point of damage
45//!   taken — a boss fight that looked comfortable and one that nearly killed the
46//!   player are indistinguishable by final HP.
47//! - **healing received and overheal**: without these, a sustain build's real
48//!   incoming damage is invisible — it shows as "never dropped low" when in
49//!   fact it absorbed and healed through a great deal.
50
51use 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
60/// Game ticks per dispersion window: 10 s at 100 ms/tick.
61const WINDOW_TICKS: usize = 100;
62
63/// Dispersion criterion: no full 10 s window's idle-in-contact share may
64/// exceed this percentage.
65pub const DISPERSION_IDLE_SHARE_THRESHOLD_PCT: f64 = 40.0;
66
67/// Absolute tick deadline stamped by the `EntityStun` handler. The single
68/// source of the name — the handler reads it from here, so the observer can
69/// never drift from the mechanic it observes.
70pub const STUN_UNTIL_TICK_ATTR: &str = "stun_until_tick";
71
72/// Per-entity accumulators. Kept out of `OverlordState`, so purely observational.
73#[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    /// Rolling last-`WINDOW_TICKS` idle-in-contact flags for the dispersion
82    /// window, plus a running true-count so the share is O(1) per sample.
83    idle_window: VecDeque<bool>,
84    window_idle_count: usize,
85    /// Max idle-in-contact share (0..=1) over any *full* window seen so far.
86    /// `None` until the entity has been sampled for a whole window.
87    max_window_idle_share: Option<f64>,
88    /// A2-BAL-001: does this entity carry the big HP bar (`is_boss` on its
89    /// template)? The stagger mechanic keys off exactly this flag, so it is
90    /// also what selects the boss row for calibration.
91    is_boss: bool,
92    /// A2-BAL-001 `S_actual`: sampled ticks where the entity was frozen.
93    stunned_ticks: u64,
94    /// A2-BAL-001 `D_old` on the boss row: damage this entity dealt.
95    damage_dealt: u64,
96    casts_started: u64,
97    casts_completed: u64,
98    /// `EntityStun` events applied to this entity. The card's acceptance check
99    /// is that ordinary damage produces strictly zero of these, so it needs a
100    /// direct count and not an inference from `stunned_ticks` (a sub-tick stun
101    /// would leave no sampled tick behind).
102    stuns_applied: u64,
103    /// Lowest `hp / max_hp` seen at any sampled tick. `None` until first sample.
104    min_hp_fraction: Option<f64>,
105    /// Healing that landed on this entity, and the part of it that was wasted
106    /// because the entity was already at full HP.
107    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/// The collector. One instance per [`FightSim`](super::FightSim).
152#[derive(Debug, Clone, Default)]
153pub struct FightMetrics {
154    entities: HashMap<EntityId, EntityCounters>,
155    total_sampled_ticks: u64,
156}
157
158impl FightMetrics {
159    /// Sample the live fight once, at a game-tick boundary. Called by the
160    /// engine after a tick's events have settled, only while the fight is
161    /// ongoing. Read-only over `state`.
162    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            // Dead entities are removed from the fight on death; guard anyway so
175            // a marked-dead-but-not-yet-swept unit never counts as idle.
176            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            // Real frozen time, not nominal stun duration: the handler stamps
182            // an absolute deadline, so a stun cut short by death contributes
183            // only the ticks it actually covered.
184            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    /// Inspect one event against the pre-handler state. Counts whiffs (a
203    /// `CastAbility` with no live target) and hits landed on a mid-cast body.
204    /// Read-only over `pre_state`.
205    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                // A2-BAL-001 `D_old`: credited to the dealer, matching the
239                // end-of-fight breakdown's attribution. Ownerless environment
240                // damage (`None`) belongs to no combatant and is skipped.
241                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                // Overheal is computed against the PRE-handler state, which is
255                // what `observe_event` is handed: the headroom the target still
256                // had when the heal landed.
257                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    /// Fetch (or create) the counters for `id`, keeping the team label in sync
272    /// with the fight if the entity is present.
273    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    /// Structured, serde-serializable snapshot for the sim harness / tests.
287    /// `player_id` selects the hero breakout.
288    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        // Deterministic ordering for stable logs/snapshots.
295        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
355/// A body is "busy" when a `CastAbility` (Second) or `CastBasicAbility` (Third)
356/// action is queued — the same notion the queue uses to block a new
357/// `StartCastAbility` (`is_casting_spell`).
358fn 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
365/// Mirrors `mechanics::fight::is_valid_target` over every equipped ability:
366/// does the entity have at least one ability whose target-type + range yields a
367/// live opposing/allied target in range? Read-only; uses only public lookups.
368fn 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/// Per-entity summary line.
407#[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    /// A2-BAL-001. `sampled_ticks` is the entity's lifetime `F` in game ticks;
420    /// `stunned_ticks` is `S_actual` in the same unit, so `stun_share_pct` is
421    /// `100 × S_actual / F` directly.
422    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    /// Started but never completed — the casts the stun ate. Saturating: a cast
429    /// still in flight when the fight ends would otherwise underflow.
430    pub casts_cancelled: u64,
431    pub stuns_applied: u64,
432    /// A2-BAL-002 §2.5. `None` if the entity was never sampled alive.
433    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/// Per-team aggregate.
467#[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/// Whole-fight structured summary. Serialize to JSON for the sim harness, or
480/// read the fields directly in tests.
481#[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    /// Max idle-in-contact share over any full 10 s window, any entity.
490    pub max_window_idle_share_pct: Option<f64>,
491    /// The primary criterion: no full window exceeded the dispersion threshold.
492    pub dispersion_ok: bool,
493    /// A2-BAL-001 calibration row: the big-HP-bar entities in this fight, in
494    /// the same deterministic order as `entities`. A campaign or dungeon boss
495    /// fight has exactly one; every other production mode has none, which is
496    /// the card's negative contract.
497    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    /// Enemy-targeting ability `id` with the given melee range.
535    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    /// Idle-in-contact: an empty body with a valid target in range counts as
561    /// idle; a busy body does not; and body-busy is tracked independently.
562    #[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); // distance 1 <= range 1
568        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        // Tick 1: hero body empty, enemy in range -> hero idle-in-contact.
578        m.sample(&state_with(fight.clone()), &lookups, 0);
579
580        // Tick 2: hero mid-cast -> body busy, not idle.
581        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    /// No valid target in range -> not idle-in-contact (legitimate waiting).
599    #[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); // distance 5 > range 1
605        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    /// A `CastAbility` at a missing/dead target is a whiff; a live target is not.
622    #[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        // Live target -> no whiff.
637        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        // Absent target -> whiff.
647        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    /// `Damage` on a mid-cast body counts as a hit-while-casting; on an idle
663    /// body it does not.
664    #[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        // Idle hero takes a hit -> not counted.
672        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        // An idle-body hit records nothing, so no hero counter exists yet.
690        assert!(m.summary(Some(hero_id)).hero.is_none());
691
692        // Casting hero takes a hit -> counted.
693        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    /// The dispersion window: a full 10 s window of idle-in-contact ticks trips
714    /// the criterion (max window share 100% > 40%).
715    #[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        // Fewer than a full window -> no window verdict yet.
730        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        // Complete the window: 100 idle-in-contact ticks -> 100% share.
737        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    /// A2-BAL-001: `S_actual` counts only the ticks the boss was *actually*
745    /// frozen. The handler stamps an absolute deadline, so a stun that death or
746    /// fight end cuts short must contribute the ticks it covered and no more —
747    /// the nominal 3 s duration is not what the calibration divides by.
748    #[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        // Frozen until tick 250: covers samples at 0 and 100, not 300.
757        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    /// The hero has no big HP bar, so it never appears as a boss row. This is
784    /// the negative half of the card's contract: every mode without a
785    /// boss-flagged template must report zero boss rows.
786    #[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    /// `D_old` is credited to the dealer, and ownerless environment damage
805    /// (`by_entity_id: None`) belongs to nobody — counting it against a
806    /// combatant would inflate the boss's outgoing total.
807    #[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    /// A2-BAL-002 §2.5: the card mandates `min_hp_fraction` over final HP
845    /// precisely because regeneration can hide the whole fight. A run that dips
846    /// to 30 % and heals back to full must not read as untouched.
847    #[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    /// Healing past full is wasted, and the waste is what tells a sustain build
870    /// apart from one that simply was not hit.
871    #[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; // 10 headroom against max_hp 100
877        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}