overlord_event_system/fight/
breakdown.rs

1//! Live accumulator behind [`essences::fight_breakdown::FightBreakdown`].
2//!
3//! A pure observer, in the same sense as [`crate::fight::FightMetrics`]: it is
4//! fed from the two HP-mutation points and never mutates `OverlordState`, emits
5//! an event, or touches the RNG — so a fight runs identically with and without
6//! it, and the PvP precalculation still replays byte-for-byte.
7//!
8//! It lives on `OverlordLogic` rather than in `OverlordState` for one reason:
9//! `active_fight` is the dominant per-tick patch cost, and a counter that
10//! changed on every hit would be paid for on every combat tick. The summary is
11//! needed exactly once, at `EndFight`, so that is the only moment it reaches
12//! state.
13
14use essences::entity::{Entity, EntityId};
15use essences::fight_breakdown::{ActorBreakdown, BreakdownEntry, CombatSource, FightBreakdown};
16use essences::fighting::{EntityTeam, FightTemplateId};
17use essences::game::EntityTemplateId;
18use uuid::Uuid;
19
20/// Who a recorded amount is credited to. Resolved at record time, because the
21/// entity may be dead and gone from `active_fight.entities` by fight end.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct BreakdownActor {
24    pub entity_id: Option<EntityId>,
25    pub entity_template_id: Option<EntityTemplateId>,
26    pub team: EntityTeam,
27}
28
29impl BreakdownActor {
30    /// The actor an ownerless amount is credited to (a DoT tick, a fight-start
31    /// script). Kept as its own row rather than dropped, so the rows of a fight
32    /// still add up to the HP that changed hands. There is no neutral team, so
33    /// the row is `Ally` with a `None` id — the `None` is what identifies it.
34    pub fn unowned() -> Self {
35        Self {
36            entity_id: None,
37            entity_template_id: None,
38            team: EntityTeam::default(),
39        }
40    }
41
42    pub fn from_entity(entity: &Entity) -> Self {
43        Self {
44            entity_id: Some(entity.id),
45            entity_template_id: entity.entity_template_id,
46            team: entity.team.clone(),
47        }
48    }
49}
50
51/// Per-fight totals, keyed by the fight INSTANCE so a delayed event that
52/// outlives its fight cannot land in the next one.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct BreakdownAccumulator {
55    fight_instance_id: Uuid,
56    fight_id: FightTemplateId,
57    actors: Vec<ActorBreakdown>,
58}
59
60impl BreakdownAccumulator {
61    pub fn new(fight_instance_id: Uuid, fight_id: FightTemplateId) -> Self {
62        Self {
63            fight_instance_id,
64            fight_id,
65            actors: Vec::new(),
66        }
67    }
68
69    pub fn fight_instance_id(&self) -> Uuid {
70        self.fight_instance_id
71    }
72
73    /// Add one applied amount. `damage` and `heal` are what the HP actually
74    /// moved by, so a fully absorbed hit or a fully wasted overheal records
75    /// nothing at all.
76    pub fn record(
77        &mut self,
78        actor: BreakdownActor,
79        source: CombatSource,
80        damage: u64,
81        heal: u64,
82        crit: bool,
83    ) {
84        if damage == 0 && heal == 0 {
85            return;
86        }
87        // Linear scans: an actor has a handful of sources and a fight a handful
88        // of actors, and insertion order is what keeps the output deterministic
89        // (a hash map's iteration order is not).
90        let actor_row = match self
91            .actors
92            .iter_mut()
93            .position(|row| row.entity_id == actor.entity_id)
94        {
95            Some(index) => &mut self.actors[index],
96            None => {
97                self.actors.push(ActorBreakdown {
98                    entity_id: actor.entity_id,
99                    entity_template_id: actor.entity_template_id,
100                    team: actor.team,
101                    entries: Vec::new(),
102                });
103                self.actors.last_mut().expect("just pushed an actor row")
104            }
105        };
106        let entry = match actor_row
107            .entries
108            .iter_mut()
109            .position(|entry| entry.source == source)
110        {
111            Some(index) => &mut actor_row.entries[index],
112            None => {
113                actor_row.entries.push(BreakdownEntry {
114                    source,
115                    ..Default::default()
116                });
117                actor_row
118                    .entries
119                    .last_mut()
120                    .expect("just pushed an entry row")
121            }
122        };
123        entry.damage = entry.damage.saturating_add(damage);
124        entry.heal = entry.heal.saturating_add(heal);
125        entry.hits = entry.hits.saturating_add(1);
126        if crit {
127            entry.crits = entry.crits.saturating_add(1);
128        }
129    }
130
131    /// The retrievable summary. Takes the two facts only the fight boundary
132    /// knows.
133    pub fn finish(&self, is_win: bool, duration_ticks: u64) -> FightBreakdown {
134        FightBreakdown {
135            fight_instance_id: self.fight_instance_id,
136            fight_id: self.fight_id,
137            is_win,
138            duration_ticks,
139            actors: self.actors.clone(),
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn actor(id: u128, team: EntityTeam) -> BreakdownActor {
149        BreakdownActor {
150            entity_id: Some(Uuid::from_u128(id)),
151            entity_template_id: None,
152            team,
153        }
154    }
155
156    fn ability(id: u128) -> CombatSource {
157        CombatSource::AbilityCast {
158            ability_id: Uuid::from_u128(id),
159        }
160    }
161
162    #[test]
163    fn same_source_accumulates_into_one_row() {
164        let mut acc = BreakdownAccumulator::new(Uuid::from_u128(1), Uuid::from_u128(2));
165        acc.record(actor(10, EntityTeam::Ally), ability(100), 5, 0, true);
166        acc.record(actor(10, EntityTeam::Ally), ability(100), 7, 0, false);
167
168        let out = acc.finish(true, 300);
169        assert_eq!(out.actors.len(), 1);
170        assert_eq!(out.actors[0].entries.len(), 1);
171        assert_eq!(out.actors[0].entries[0].damage, 12);
172        assert_eq!(out.actors[0].entries[0].hits, 2);
173        assert_eq!(out.actors[0].entries[0].crits, 1);
174        assert_eq!(out.actors[0].total_damage(), 12);
175    }
176
177    #[test]
178    fn distinct_sources_and_actors_stay_separate() {
179        let mut acc = BreakdownAccumulator::new(Uuid::from_u128(1), Uuid::from_u128(2));
180        acc.record(actor(10, EntityTeam::Ally), ability(100), 5, 0, false);
181        acc.record(actor(10, EntityTeam::Ally), CombatSource::Dot, 3, 0, false);
182        acc.record(actor(20, EntityTeam::Enemy), ability(100), 4, 0, false);
183        acc.record(actor(10, EntityTeam::Ally), CombatSource::Hot, 0, 9, false);
184
185        let out = acc.finish(false, 100);
186        assert_eq!(out.actors.len(), 2);
187        assert_eq!(out.actors[0].entries.len(), 3);
188        assert_eq!(out.actors[0].total_damage(), 8);
189        assert_eq!(out.actors[0].total_heal(), 9);
190        assert_eq!(out.actors[1].total_damage(), 4);
191    }
192
193    #[test]
194    fn a_zero_amount_creates_no_row() {
195        let mut acc = BreakdownAccumulator::new(Uuid::from_u128(1), Uuid::from_u128(2));
196        acc.record(actor(10, EntityTeam::Ally), ability(100), 0, 0, true);
197        assert!(acc.finish(true, 0).actors.is_empty());
198    }
199}