1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum CastKind {
39 Basic,
40 Skill,
41}
42
43pub 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
60pub 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
70pub 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
80pub 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
93pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum CombatFact {
109 Cast {
111 kind: CastKind,
112 ability_id: AbilityId,
113 },
114 HitLanded {
116 crit: bool,
117 damage: u64,
118 victim: EntityId,
119 },
120 Kill { victim: EntityId },
123 HitTaken { damage: u64 },
125 Dodged,
127}
128
129pub 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
146pub 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 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 if !event.is_core_combat_event() {
186 return out;
187 }
188
189 match event {
190 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 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
226pub 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}