overlord_event_system/logic/
combat_facts.rs

1//! One vocabulary for what a dispatched combat event established.
2//!
3//! Laws ([`crate::logic::laws`]) and Trigger Stones ([`crate::logic::stones`])
4//! react to the same handful of Core outcomes, and each used to read them off
5//! the raw event itself. Two readings of one design phrase drift apart
6//! silently, and they had: "a hit on the hero" counted a poison tick for a
7//! stone and not for a law, while "killed an enemy" was derived from `Damage`
8//! on one side and from `EntityDeath` on the other.
9//!
10//! The reading now lives here once. Both hooks consume [`CombatFact`]s and
11//! neither parses [`OverlordEvent`] for combat meaning any more, so a
12//! refinement to one definition reaches both systems or neither.
13//!
14//! # The definitions
15//!
16//! * **A hit needs an attacker.** Only `Damage` carrying `by_entity_id` is a
17//!   hit. A poison tick, a bleed and environment damage are damage taken, but
18//!   they are nobody's hit, so they open no condition on either side.
19//! * **A kill is attributed to the hit that made it**, not to the corpse:
20//!   `EntityDeath` names who died and never who did it, and it fires for
21//!   allies too. Keying off the killing `Damage` gives an explicit killer and
22//!   an explicit pair of teams.
23//! * **Only Core counts.** Everything a law, a stone, a bridge, an echo or a
24//!   pet produced arrives `Proc` and is refused here — the single anti-loop
25//!   boundary both systems rest on.
26
27use configs::game_config::GameConfig;
28use essences::abilities::AbilityId;
29use essences::entity::{Entity, EntityId};
30
31use crate::event::OverlordEvent;
32use crate::game_config_helpers::GameConfigLookup;
33
34/// Which family a cast belongs to. A Basic Attack and an original Skill are the
35/// same wire event (`CastAbility`); the caster's class kit is what separates
36/// them, which is why this cannot be read off the event alone.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum CastKind {
39    Basic,
40    Skill,
41}
42
43/// Records the family of the cast about to resolve, on the caster itself.
44///
45/// Written BEFORE the cast resolves (`handle_cast_ability`), so everything
46/// downstream — the damage primitives, the law arms, the stone effects — reads
47/// one answer instead of each deciding again. The stones runtime used to write
48/// this from its post-handler hook, which was one cast too late for
49/// [`crate::mechanics::fight::attack`] to see the cast it was resolving.
50pub fn record_cast_kind(entity: &mut Entity, kind: CastKind) {
51    let value = match kind {
52        CastKind::Basic => crate::mechanics::stones::CAST_KIND_BASIC,
53        CastKind::Skill => crate::mechanics::stones::CAST_KIND_SKILL,
54    };
55    entity
56        .attributes
57        .set(crate::mechanics::stones::CAST_KIND, value);
58}
59
60/// The family of the cast this entity is currently resolving, or `None` before
61/// its first cast of the fight.
62pub fn current_cast_kind(entity: &Entity) -> Option<CastKind> {
63    match crate::mechanics::stones::attr(entity, crate::mechanics::stones::CAST_KIND) {
64        crate::mechanics::stones::CAST_KIND_BASIC => Some(CastKind::Basic),
65        crate::mechanics::stones::CAST_KIND_SKILL => Some(CastKind::Skill),
66        _ => None,
67    }
68}
69
70/// Records what the cast about to resolve actually paid at the mana gate, in
71/// x100 fixed point. Same contract as [`record_cast_kind`]: written before the
72/// cast resolves, read once by each condition that prices it.
73pub fn record_paid_mana(entity: &mut Entity, paid_x100: i64) {
74    entity.attributes.set(
75        crate::mechanics::stones::PAID_MANA,
76        paid_x100.max(0).saturating_add(1),
77    );
78}
79
80/// Drops the recorded payment for a cast that skips the mana gate (a pet ult,
81/// an entity with no pool), so its resolution cannot read the previous cast's
82/// price. A no-op when nothing is recorded, keeping unused entities free of
83/// bookkeeping.
84pub fn clear_paid_mana(entity: &mut Entity) {
85    if crate::mechanics::stones::attr(entity, crate::mechanics::stones::PAID_MANA) != 0 {
86        entity
87            .attributes
88            .0
89            .remove(crate::mechanics::stones::PAID_MANA);
90    }
91}
92
93/// Mana the resolving cast paid, in x100 fixed point — `Some(0)` for a cast the
94/// pool let through for free, `None` for one that never paid (no pool, a pet
95/// ult, or nothing cast yet).
96pub fn paid_mana_x100(entity: &Entity) -> Option<i64> {
97    let recorded = crate::mechanics::stones::attr(entity, crate::mechanics::stones::PAID_MANA);
98    (recorded > 0).then(|| recorded - 1)
99}
100
101/// One fact a dispatched Core event established about one combatant.
102///
103/// Deliberately raw: it carries what the event itself settled and nothing
104/// derived from it. "Share of Max HP", "which equipped slot", "how many
105/// distinct targets this cast has reached" are each one system's presentation
106/// of a fact, computed at that system's call site, not stored here.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum CombatFact {
109    /// This entity made a Core cast of the given family.
110    Cast {
111        kind: CastKind,
112        ability_id: AbilityId,
113    },
114    /// A Core hit of this entity's landed on an enemy.
115    HitLanded {
116        crit: bool,
117        damage: u64,
118        victim: EntityId,
119    },
120    /// That same hit left the enemy at zero HP. Always accompanies the
121    /// [`CombatFact::HitLanded`] it came from.
122    Kill { victim: EntityId },
123    /// A Core hit from an attacker landed on this entity.
124    HitTaken { damage: u64 },
125    /// This entity dodged a Core hit.
126    Dodged,
127}
128
129/// Which family `ability_id` belongs to for `entity`.
130///
131/// Read off the entity's own class rather than off the player's character
132/// state, so a party ally and a PvP opponent are classified by the same rule as
133/// the hero.
134pub fn cast_kind(game_config: &GameConfig, entity: &Entity, ability_id: AbilityId) -> CastKind {
135    let is_basic = entity
136        .class_id
137        .and_then(|class_id| game_config.class(class_id))
138        .is_some_and(|class| class.basic_abilities.contains(&ability_id));
139    if is_basic {
140        CastKind::Basic
141    } else {
142        CastKind::Skill
143    }
144}
145
146/// Everything `event` established, grouped by the combatant it is about.
147///
148/// One hit implicates two combatants — the attacker (crit, kill) and the victim
149/// (hit taken) — so both are returned; a PvP fight has a law owner on each side
150/// of that pair.
151pub fn classify(
152    game_config: &GameConfig,
153    fight: &essences::fighting::ActiveFight,
154    event: &OverlordEvent,
155) -> Vec<(EntityId, Vec<CombatFact>)> {
156    let mut out: Vec<(EntityId, Vec<CombatFact>)> = Vec::new();
157
158    // `CastAbility` is a provenance carrier rather than a combat outcome, so it
159    // answers `is_core_combat_event()` with `false` and its origin is read
160    // directly. The swing is the fact — a dodged swing is still a swing.
161    if let OverlordEvent::CastAbility {
162        by_entity_id,
163        ability_id,
164        origin,
165        ..
166    } = event
167    {
168        if !origin.is_core() {
169            return out;
170        }
171        let Some(caster) = fight.entities.iter().find(|e| e.id == *by_entity_id) else {
172            return out;
173        };
174        out.push((
175            *by_entity_id,
176            vec![CombatFact::Cast {
177                kind: cast_kind(game_config, caster, *ability_id),
178                ability_id: *ability_id,
179            }],
180        ));
181        return out;
182    }
183
184    // The gate that makes the loop impossible.
185    if !event.is_core_combat_event() {
186        return out;
187    }
188
189    match event {
190        // A hit needs an attacker, and self-damage is not a hit on anyone.
191        OverlordEvent::Damage {
192            by_entity_id: Some(by_entity_id),
193            entity_id,
194            damage,
195            damage_data,
196            ..
197        } if by_entity_id != entity_id && *damage > 0 => {
198            let mut attacker = vec![CombatFact::HitLanded {
199                crit: damage_data.0.contains_key("crit"),
200                damage: *damage,
201                victim: *entity_id,
202            }];
203            // The hook runs after the handler, so the victim's HP already
204            // reflects this hit and is still readable: removal from the fight
205            // happens on the follow-up `EntityDeath`, not here.
206            if let Some(victim) = fight.entities.iter().find(|e| e.id == *entity_id)
207                && victim.hp == 0
208                && fight
209                    .entities
210                    .iter()
211                    .any(|e| e.id == *by_entity_id && e.team != victim.team)
212            {
213                attacker.push(CombatFact::Kill { victim: *entity_id });
214            }
215            out.push((*by_entity_id, attacker));
216            out.push((*entity_id, vec![CombatFact::HitTaken { damage: *damage }]));
217        }
218        OverlordEvent::Evasion { entity_id, .. } => {
219            out.push((*entity_id, vec![CombatFact::Dodged]));
220        }
221        _ => {}
222    }
223    out
224}
225
226/// The facts about one specific combatant, flattened.
227pub fn facts_for(
228    classified: &[(EntityId, Vec<CombatFact>)],
229    entity_id: EntityId,
230) -> impl Iterator<Item = CombatFact> + '_ {
231    classified
232        .iter()
233        .filter(move |(id, _)| *id == entity_id)
234        .flat_map(|(_, facts)| facts.iter().copied())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use essences::combat_origin::CombatEventOrigin;
241    use essences::fighting::{ActiveFight, EntityTeam};
242    use uuid::Uuid;
243
244    const HERO: Uuid = Uuid::from_u128(0x8E20);
245    const FOE: Uuid = Uuid::from_u128(0xF0E);
246
247    fn fight(hero_hp: u64, foe_hp: u64) -> ActiveFight {
248        let combatant = |id: Uuid, team: EntityTeam, hp: u64| Entity {
249            id,
250            team,
251            hp,
252            max_hp: 100,
253            ..Default::default()
254        };
255        ActiveFight {
256            id: Uuid::from_u128(0xF16),
257            fight_id: Uuid::from_u128(0x7),
258            current_wave: 1,
259            player_id: HERO,
260            party_player_id: None,
261            entities: vec![
262                combatant(HERO, EntityTeam::Ally, hero_hp),
263                combatant(FOE, EntityTeam::Enemy, foe_hp),
264            ],
265            fight_stopped: false,
266            fight_ended: false,
267            max_duration_ticks: 1_000,
268            dungeon: None,
269            paused: false,
270            pending_wave_spawns: Vec::new(),
271            summoned_entity_ids: Vec::new(),
272        }
273    }
274
275    fn damage(by: Option<Uuid>, to: Uuid, damage: u64) -> OverlordEvent {
276        OverlordEvent::Damage {
277            by_entity_id: by,
278            entity_id: to,
279            damage,
280            damage_data: Default::default(),
281            origin: CombatEventOrigin::Core,
282            source: essences::fight_breakdown::CombatSource::Other,
283        }
284    }
285
286    fn facts(fight: &ActiveFight, event: &OverlordEvent, about: Uuid) -> Vec<CombatFact> {
287        let config = configs::tests_game_config::generate_game_config_for_tests();
288        facts_for(&classify(&config, fight, event), about).collect()
289    }
290
291    /// The decision this module exists to hold in one place: a hit needs an
292    /// attacker. A poison tick, a bleed and environment damage all arrive
293    /// without one, and they used to be a hit for the stones and not for the
294    /// laws — the same design phrase meaning two things.
295    #[test]
296    fn damage_without_an_attacker_is_nobodys_hit() {
297        let fight = fight(40, 100);
298        assert_eq!(
299            facts(&fight, &damage(Some(FOE), HERO, 10), HERO),
300            vec![CombatFact::HitTaken { damage: 10 }],
301        );
302        assert!(
303            facts(&fight, &damage(None, HERO, 10), HERO).is_empty(),
304            "a sourceless tick opens no condition on either side"
305        );
306    }
307
308    /// Self-damage is not a hit on anyone, and a zero-damage hit is not a hit.
309    #[test]
310    fn a_hit_needs_a_victim_other_than_its_source_and_a_number() {
311        let fight = fight(40, 100);
312        assert!(facts(&fight, &damage(Some(HERO), HERO, 10), HERO).is_empty());
313        assert!(facts(&fight, &damage(Some(FOE), HERO, 0), HERO).is_empty());
314    }
315
316    /// The other unified phrase: a kill belongs to the hit that made it. Both
317    /// facts come out of the same event, so a reader can pay a kill and still
318    /// know what landed it.
319    #[test]
320    fn a_kill_is_attributed_to_the_killing_hit() {
321        let killed = fight(100, 0);
322        assert_eq!(
323            facts(&killed, &damage(Some(HERO), FOE, 25), HERO),
324            vec![
325                CombatFact::HitLanded {
326                    crit: false,
327                    damage: 25,
328                    victim: FOE,
329                },
330                CombatFact::Kill { victim: FOE },
331            ],
332        );
333
334        // Still standing: the same hit, no kill.
335        let survived = fight(100, 5);
336        assert_eq!(
337            facts(&survived, &damage(Some(HERO), FOE, 25), HERO),
338            vec![CombatFact::HitLanded {
339                crit: false,
340                damage: 25,
341                victim: FOE,
342            }],
343        );
344    }
345
346    /// Killing something on your own side is not a kill — the team check, not
347    /// the corpse, is what says so.
348    #[test]
349    fn a_downed_ally_is_not_a_kill() {
350        let mut fight = fight(100, 0);
351        fight.entities[1].team = EntityTeam::Ally;
352        assert_eq!(
353            facts(&fight, &damage(Some(HERO), FOE, 25), HERO),
354            vec![CombatFact::HitLanded {
355                crit: false,
356                damage: 25,
357                victim: FOE,
358            }],
359        );
360    }
361
362    /// Anything a law, a stone or any other modifier produced is refused, which
363    /// is the single anti-loop boundary both systems rest on.
364    #[test]
365    fn a_proc_outcome_establishes_nothing() {
366        let fight = fight(40, 100);
367        let proc = OverlordEvent::Damage {
368            by_entity_id: Some(FOE),
369            entity_id: HERO,
370            damage: 10,
371            damage_data: Default::default(),
372            origin: CombatEventOrigin::Proc,
373            source: essences::fight_breakdown::CombatSource::Other,
374        };
375        assert!(facts(&fight, &proc, HERO).is_empty());
376    }
377
378    /// One hit, two implicated combatants — the attacker and the victim.
379    #[test]
380    fn a_hit_is_reported_to_both_sides_of_it() {
381        let fight = fight(40, 100);
382        let config = configs::tests_game_config::generate_game_config_for_tests();
383        let classified = classify(&config, &fight, &damage(Some(HERO), FOE, 7));
384        assert_eq!(
385            facts_for(&classified, HERO).collect::<Vec<_>>(),
386            vec![CombatFact::HitLanded {
387                crit: false,
388                damage: 7,
389                victim: FOE,
390            }],
391        );
392        assert_eq!(
393            facts_for(&classified, FOE).collect::<Vec<_>>(),
394            vec![CombatFact::HitTaken { damage: 7 }],
395        );
396    }
397
398    /// The cast kind survives a round trip through the caster's attributes,
399    /// which is how `mechanics::fight::attack` learns which law arm this cast
400    /// may spend.
401    #[test]
402    fn the_recorded_cast_kind_reads_back() {
403        let mut entity = Entity::default();
404        assert_eq!(current_cast_kind(&entity), None, "nothing cast yet");
405
406        record_cast_kind(&mut entity, CastKind::Basic);
407        assert_eq!(current_cast_kind(&entity), Some(CastKind::Basic));
408
409        record_cast_kind(&mut entity, CastKind::Skill);
410        assert_eq!(current_cast_kind(&entity), Some(CastKind::Skill));
411    }
412}