1use std::sync::Arc;
11
12use configs::game_config::GameConfig;
13use essences::abilities::Ability;
14use essences::entity::{Coordinates, Entity, EntityAttributes};
15use essences::fighting::{ActiveFight, EntityTeam};
16use event_system::script::random::GameRng;
17use uuid::Uuid;
18
19use crate::event::CustomEventData;
20use crate::event::*;
21use crate::game_config_helpers::GameConfigLookup;
22use crate::mechanics::balance;
23use crate::mechanics::content_lookups::{ContentLookups, EffectTpl};
24use crate::state::OverlordState;
25
26const BATTLEFIELD_HEIGHT: i64 = 3;
27const DOT_EFFECT_ID: &str = "019589cb-7adf-7466-8c46-92ac45032880";
28const HOT_EFFECT_ID: &str = "01958c0a-329e-744d-a5eb-0369e3f4c024";
29const REGEN_EFFECT_ID: &str = "01978d0c-5a8a-7b50-aaa8-e4cd7782f8bc";
30const LOW_CHAPTER_EFFECT_ID: &str = "019e27f4-f16b-7881-87cf-bc55605cb80b";
32const LOW_CHAPTER_EFFECT_MAX_CHAPTER: i64 = 16;
35const SLEEP_EFFECT_ID: &str = "0198f78c-70d9-7962-b34d-4222f5afa6a4";
36const DUNGEON_TALENT_ID: &str = "019d1c47-5a66-76e5-bb18-6d115e5c6942";
37const BOSS_TALENT_ID: &str = "019d1c47-8cc2-7136-b95b-68e15d7efd0c";
38const COUNTERATTACK_PROJECTILE: &str = "019aeeed-5bcc-7dcc-a74f-589031a6b8f2";
39
40pub trait FightSink {
46 fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error>;
47 fn push_attack(&mut self, delay: u64, duration: u64, target: Uuid)
48 -> Result<(), anyhow::Error>;
49 fn push_run(&mut self, coords: Coordinates, duration: u64) -> Result<(), anyhow::Error>;
50}
51
52#[derive(Default)]
55pub struct NativeSink {
56 pub events: Vec<OverlordEvent>,
57 pub casts: Vec<crate::behaviors::combat::start_cast::StartCastAbilityScriptResult>,
58}
59
60impl FightSink for NativeSink {
61 fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error> {
62 self.events.push(event);
63 Ok(())
64 }
65
66 fn push_attack(
67 &mut self,
68 delay: u64,
69 duration: u64,
70 target: Uuid,
71 ) -> Result<(), anyhow::Error> {
72 self.casts.push(
73 crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
74 delay_ticks: Some(delay),
75 animation_duration_ticks: Some(duration),
76 target_entity_id: Some(target),
77 ..Default::default()
78 },
79 );
80 Ok(())
81 }
82
83 fn push_run(&mut self, coords: Coordinates, duration: u64) -> Result<(), anyhow::Error> {
84 self.casts.push(
85 crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
86 coordinates: Some(coords),
87 run_duration_ticks: Some(duration),
88 ..Default::default()
89 },
90 );
91 Ok(())
92 }
93}
94
95fn push_event<ES>(sink: &mut dyn FightSink, event: ES) -> Result<(), anyhow::Error>
98where
99 ES: event_system::event::EventStruct<OverlordEvent> + 'static,
100{
101 sink.push_event(event.to_enum())
102}
103
104pub trait EffectCb {
116 fn on_apply(
119 &mut self,
120 sink: &mut dyn FightSink,
121 effect: &EffectTpl,
122 target: &Entity,
123 ) -> Result<(), anyhow::Error>;
124
125 fn on_change(
128 &mut self,
129 sink: &mut dyn FightSink,
130 effect: &EffectTpl,
131 target: &Entity,
132 new_stacks: i64,
133 old_stacks: i64,
134 ) -> Result<(), anyhow::Error>;
135}
136
137pub struct NoopEffectCb;
140
141impl EffectCb for NoopEffectCb {
142 fn on_apply(
143 &mut self,
144 _sink: &mut dyn FightSink,
145 _effect: &EffectTpl,
146 _target: &Entity,
147 ) -> Result<(), anyhow::Error> {
148 Ok(())
149 }
150
151 fn on_change(
152 &mut self,
153 _sink: &mut dyn FightSink,
154 _effect: &EffectTpl,
155 _target: &Entity,
156 _new_stacks: i64,
157 _old_stacks: i64,
158 ) -> Result<(), anyhow::Error> {
159 Ok(())
160 }
161}
162
163fn attr_get(entity: &Entity, attr_code: &str) -> f64 {
168 entity.attributes.0.get(attr_code).copied().unwrap_or(0) as f64
169}
170
171fn attr_present(entity: &Entity, attr_code: &str) -> bool {
172 entity.attributes.0.contains_key(attr_code)
173}
174
175fn incr_attr(
177 sink: &mut dyn FightSink,
178 entity_id: Uuid,
179 attr: &str,
180 delta: i64,
181) -> Result<(), anyhow::Error> {
182 push_event(
183 sink,
184 OverlordEventEntityIncrAttribute {
185 entity_id,
186 attribute: attr.to_string(),
187 delta,
188 },
189 )
190}
191
192pub fn get_entity_stat(lookups: &ContentLookups, entity: &Entity, stat_code: &str) -> f64 {
195 let mut stat = attr_get(entity, stat_code);
196 if let Some(attr_id) = lookups.attribute_by_code.get(stat_code)
197 && let Some(base) = lookups.attribute_base_value.get(attr_id)
198 {
199 stat += base;
200 }
201 let bonus = attr_get(entity, &format!("{stat_code}.bonus"));
202 let mod_v = (attr_get(entity, &format!("{stat_code}.mod")) / 10000.0 + 1.0)
206 .max(balance::MIN_STAT_MOD_MULT);
207 (stat + bonus) * mod_v
208}
209
210fn has_attribute_base(lookups: &ContentLookups, stat_code: &str) -> bool {
215 lookups
216 .attribute_by_code
217 .get(stat_code)
218 .is_some_and(|id| lookups.attribute_base_value.contains_key(id))
219}
220
221pub fn stat_throw(
222 random: &GameRng,
223 lookups: &ContentLookups,
224 entity: &Entity,
225 stat_code: &str,
226 bonus: f64,
227) -> bool {
228 let stat_value = get_entity_stat(lookups, entity, stat_code) + bonus;
229 if stat_value > 0.0 {
230 let success = stat_value / 10000.0;
231 return random.random_f64() < success;
232 }
233 false
234}
235
236fn cell_exists_and_free(
244 c: &Coordinates,
245 entities: &[Entity],
246 mover_id: Uuid,
247 width: i64,
248 direction: i64,
249) -> bool {
250 if c.y < 0 || c.y >= BATTLEFIELD_HEIGHT {
251 return false;
252 }
253 let w = width.max(1);
254 let (lo, hi) = if direction >= 0 {
256 (c.x - (w - 1), c.x)
257 } else {
258 (c.x, c.x + (w - 1))
259 };
260 let covers = |p: &Coordinates| p.y == c.y && p.x >= lo && p.x <= hi;
261 for entity in entities {
262 if entity.id == mover_id {
263 continue;
264 }
265 if covers(&entity.coordinates) {
266 return false;
267 }
268 if let Some(target) = &entity.move_target
269 && covers(target)
270 {
271 return false;
272 }
273 }
274 true
275}
276
277fn entity_get_tpl_width(config: &GameConfig, entity: &Entity) -> i64 {
278 if let Some(tpl_id) = entity.entity_template_id
279 && let Some(tpl) = config.entity_template(tpl_id)
280 {
281 return tpl.width as i64;
282 }
283 1
284}
285
286fn entity_get_tpl_cast_time(config: &GameConfig, entity: &Entity) -> i64 {
287 if let Some(tpl_id) = entity.entity_template_id
288 && let Some(tpl) = config.entity_template(tpl_id)
289 {
290 return tpl.cast_time as i64;
291 }
292 if let Some(class_id) = entity.class_id
293 && let Some(class) = config.class(class_id)
294 {
295 return class.cast_time as i64;
296 }
297 0
298}
299
300fn find_advance_coordinates(
301 entity: &Entity,
302 entities: &[Entity],
303 config: &GameConfig,
304) -> Option<Coordinates> {
305 let pos = &entity.coordinates;
306 let width = entity_get_tpl_width(config, entity);
307 let direction: i64 = if entity.team == EntityTeam::Ally {
308 1
309 } else {
310 -1
311 };
312 let x = pos.x + direction;
313
314 let cell_up = Coordinates { x, y: pos.y + 1 };
315 let cell_forward = Coordinates { x, y: pos.y };
316 let cell_down = Coordinates { x, y: pos.y - 1 };
317
318 let mid = (BATTLEFIELD_HEIGHT - 1) / 2;
319 let order: [&Coordinates; 3] = if pos.y > mid {
320 [&cell_up, &cell_forward, &cell_down]
321 } else if pos.y < mid {
322 [&cell_down, &cell_forward, &cell_up]
323 } else {
324 [&cell_forward, &cell_up, &cell_down]
325 };
326
327 for c in order {
328 if cell_exists_and_free(c, entities, entity.id, width, direction) {
329 return Some(c.clone());
330 }
331 }
332 None
333}
334
335fn get_distance_to(a: &Entity, b: &Entity) -> i64 {
336 (b.coordinates.x - a.coordinates.x).abs()
337}
338
339fn get_target_with_lowest_hp(targets: &[Entity]) -> Option<&Entity> {
340 targets.iter().min_by_key(|e| e.hp)
341}
342
343fn get_closest_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
344 targets.iter().min_by_key(|t| {
345 (t.coordinates.x - caster.coordinates.x).abs()
346 + (t.coordinates.y - caster.coordinates.y).abs()
347 })
348}
349
350fn get_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
351 if caster.entity_template_id.is_some() {
352 get_closest_target(caster, targets)
353 } else {
354 get_target_with_lowest_hp(targets)
355 }
356}
357
358pub fn add_entity_attr(
364 sink: &mut dyn FightSink,
365 entity: &Entity,
366 attr_code: &str,
367 value: i64,
368) -> Result<(), anyhow::Error> {
369 incr_attr(sink, entity.id, attr_code, value)
370}
371
372pub fn add_entity_stat_mod(
377 sink: &mut dyn FightSink,
378 entity: &Entity,
379 stat: &str,
380 value: i64,
381) -> Result<(), anyhow::Error> {
382 add_entity_attr(sink, entity, &format!("{stat}.mod"), value)
383}
384
385pub fn set_entity_attr(
388 sink: &mut dyn FightSink,
389 entity: &Entity,
390 attr_code: &str,
391 value: f64,
392) -> Result<(), anyhow::Error> {
393 let current = attr_get(entity, attr_code);
394 incr_attr(sink, entity.id, attr_code, (value - current).round() as i64)
395}
396
397pub fn touch_enemy(rng: &GameRng, lookups: &ContentLookups, target: &Entity) -> bool {
403 let evasion = get_entity_stat(lookups, target, "evasion");
404 if evasion <= 0.0 {
405 return true;
406 }
407 let dodge = evasion / (evasion + balance::tuning().k_dodge);
408 rng.random_f64() >= dodge
410}
411
412pub fn screen_shake(sink: &mut dyn FightSink, power: i64) -> Result<(), anyhow::Error> {
414 let mut data = CustomEventData::default();
415 data.add("screen_shake_power", power);
416 push_event(
417 sink,
418 OverlordEventFightVisualEvent {
419 effect_type: "screen_shake".to_string(),
420 effect_data: data,
421 },
422 )
423}
424
425pub fn heal_entity(
428 sink: &mut dyn FightSink,
429 entity: &Entity,
430 amount: f64,
431) -> Result<Option<i64>, anyhow::Error> {
432 let max_heal = entity.max_hp as i64 - entity.hp as i64;
433 let heal = max_heal.min(amount.floor() as i64);
434 if heal <= 0 {
435 return Ok(None);
436 }
437 push_event(
438 sink,
439 OverlordEventHeal {
440 entity_id: entity.id,
441 heal: heal as u64,
442 },
443 )?;
444 Ok(Some(heal))
445}
446
447pub fn damage_entity(
457 sink: &mut dyn FightSink,
458 rng: &GameRng,
459 lookups: &ContentLookups,
460 entity: &Entity,
461 raw_dmg: f64,
462 mut custom_data: CustomEventData,
463) -> Result<Option<i64>, anyhow::Error> {
464 if attr_present(entity, "godmode") {
465 return Ok(None);
466 }
467 let armor = get_entity_stat(lookups, entity, "armor");
468 let shield = attr_get(entity, "shield");
469 if !has_attribute_base(lookups, "received_damage") {
476 return Ok(None);
477 }
478 let mut received_damage_k =
484 get_entity_stat(lookups, entity, "received_damage").max(balance::MIN_RECEIVED_DAMAGE_K);
485
486 if stat_throw(rng, lookups, entity, "block", 0.0) {
487 received_damage_k *= 0.5;
488 custom_data.add("block", 1);
489 }
490
491 let dmg_k = balance::armor_k(armor) * received_damage_k / 10000.0;
492 let dmg = raw_dmg * dmg_k;
493 let res = dmg.floor() as i64;
494 if shield == 0.0 {
495 push_event(
496 sink,
497 OverlordEventDamage {
498 entity_id: entity.id,
499 damage: res.max(0) as u64,
500 damage_data: custom_data,
501 },
502 )?;
503 } else if dmg > shield {
504 incr_attr(sink, entity.id, "shield", -(shield as i64))?;
505 let dmg_hp = (dmg - shield).floor() as i64;
506 custom_data.add("shield_damage", shield as i64);
507 push_event(
508 sink,
509 OverlordEventDamage {
510 entity_id: entity.id,
511 damage: dmg_hp.max(0) as u64,
512 damage_data: custom_data,
513 },
514 )?;
515 } else {
516 incr_attr(sink, entity.id, "shield", -(dmg.round() as i64))?;
517 }
518 Ok(Some(res))
519}
520
521#[derive(Clone, Debug, Default)]
523pub struct AttackParams {
524 pub no_counterattack: bool,
525 pub crit_chance_bonus: f64,
526 pub is_crit: Option<bool>,
528 pub power: Option<f64>,
529 pub dot_power: Option<f64>,
530}
531
532impl AttackParams {
533 pub fn default_power() -> Self {
535 Self {
536 power: Some(1.0),
537 ..Default::default()
538 }
539 }
540}
541
542#[allow(clippy::too_many_arguments)]
548pub fn attack(
549 sink: &mut dyn FightSink,
550 rng: &GameRng,
551 lookups: &ContentLookups,
552 effects: &mut dyn EffectCb,
553 player_id: Uuid,
554 caster: &Entity,
555 target: &Entity,
556 params: &AttackParams,
557) -> Result<Option<i64>, anyhow::Error> {
558 if !touch_enemy(rng, lookups, target) {
559 push_event(
560 sink,
561 OverlordEventEvasion {
562 entity_id: target.id,
563 },
564 )?;
565 return Ok(None);
566 }
567 if !params.no_counterattack && stat_throw(rng, lookups, target, "counterattack_chance", 0.0) {
568 push_event(
569 sink,
570 OverlordEventCounterAttack {
571 by_entity_id: target.id,
572 to_entity_id: caster.id,
573 duration_ticks: 200,
574 },
575 )?;
576 push_event(
577 sink,
578 OverlordEventStartCastProjectile {
579 by_entity_id: target.id,
580 to_entity_id: caster.id,
581 projectile_id: Uuid::parse_str(COUNTERATTACK_PROJECTILE).unwrap(),
582 level: 1,
583 delay: 0,
584 },
585 )?;
586 }
587
588 if stat_throw(rng, lookups, caster, "deceit", 0.0) {
589 let pool = ["vulnerability", "weakness"];
590 let idx = rng.random_index(pool.len());
591 let code = pool[idx];
592 change_entity_effect_duration(
593 sink,
594 lookups,
595 effects,
596 target,
597 code,
598 (balance::DECEIT_DEBUFF_DURATION * 1000.0).floor() as i64,
599 )?;
600 }
601
602 let attack = get_entity_stat(lookups, caster, "attack");
603
604 let mut dmg_mod = 1.0;
605 let is_crit = match params.is_crit {
606 Some(v) => v,
607 None => stat_throw(
608 rng,
609 lookups,
610 caster,
611 "crit_chance",
612 params.crit_chance_bonus,
613 ),
614 };
615 if is_crit {
616 dmg_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
617 }
618
619 let counter = balance::class_counter_multiplier(lookups, caster.class_id, target.class_id);
623
624 let mut dmg_dealt = None;
625 if let Some(power) = params.power {
626 let dmg = attack * power * dmg_mod * counter;
627 let mut data = CustomEventData::default();
628 if is_crit {
629 data.add("crit", 1);
630 }
631 dmg_dealt = damage_entity(sink, rng, lookups, target, dmg * balance::DMG_K, data)?;
632 }
633 if let Some(dot_power) = params.dot_power {
634 apply_entity_over_time_effect(
635 sink,
636 target,
637 attack * dot_power * dmg_mod * counter * balance::DMG_K,
638 "dot",
639 )?;
640 }
641
642 if caster.id == player_id {
643 let power = params.power.unwrap_or(0.0);
644 if is_crit || power > 3.5 {
645 let _ = screen_shake(sink, 25);
646 }
647 }
648 Ok(dmg_dealt)
649}
650
651#[derive(Clone, Debug, Default)]
653pub struct SpellHealParams {
654 pub is_crit: Option<bool>,
655 pub power: Option<f64>,
656 pub hot_power: Option<f64>,
657}
658
659impl SpellHealParams {
660 pub fn default_power() -> Self {
661 Self {
662 power: Some(1.0),
663 ..Default::default()
664 }
665 }
666}
667
668pub fn spell_heal(
672 sink: &mut dyn FightSink,
673 rng: &GameRng,
674 lookups: &ContentLookups,
675 caster: &Entity,
676 target: &Entity,
677 params: &SpellHealParams,
678) -> Result<Option<i64>, anyhow::Error> {
679 let attack = get_entity_stat(lookups, caster, "attack");
680 let mut heal_mod = 1.0;
681 let is_crit = match params.is_crit {
682 Some(v) => v,
683 None => stat_throw(rng, lookups, caster, "crit_chance", 0.0),
684 };
685 if is_crit {
686 heal_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
687 }
688 let mut heal_event = None;
689 if let Some(power) = params.power {
690 heal_event = heal_entity(sink, target, attack * power * heal_mod * balance::DMG_K)?;
691 }
692 if let Some(hot_power) = params.hot_power {
693 apply_entity_over_time_effect(
694 sink,
695 target,
696 attack * hot_power * heal_mod * balance::DMG_K,
697 "hot",
698 )?;
699 }
700 Ok(heal_event)
701}
702
703pub fn apply_entity_effect(
706 sink: &mut dyn FightSink,
707 lookups: &ContentLookups,
708 effects: &mut dyn EffectCb,
709 target: &Entity,
710 effect_code: &str,
711 duration_seconds: f64,
712) -> Result<(), anyhow::Error> {
713 change_entity_effect_duration(
714 sink,
715 lookups,
716 effects,
717 target,
718 effect_code,
719 (duration_seconds * 1000.0).floor() as i64,
720 )
721}
722
723pub fn change_entity_effect_duration(
727 sink: &mut dyn FightSink,
728 lookups: &ContentLookups,
729 effects: &mut dyn EffectCb,
730 target: &Entity,
731 effect_code: &str,
732 duration: i64,
733) -> Result<(), anyhow::Error> {
734 let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
735 Some(t) => t.clone(),
736 None => {
737 return Err(anyhow::anyhow!(
738 "apply_entity_effect: unknown effect code {effect_code}"
739 ));
740 }
741 };
742
743 let duration_attr = format!("effect.{effect_code}.duration");
744 let current_duration_ticks = attr_get(target, &duration_attr) as i64;
745 let max_duration = effect_tpl.max_duration_ticks.unwrap_or(5000);
746 let new_duration = (current_duration_ticks + duration).min(max_duration);
747 incr_attr(
748 sink,
749 target.id,
750 &duration_attr,
751 new_duration - current_duration_ticks,
752 )?;
753 if current_duration_ticks == 0 {
754 push_event(
755 sink,
756 OverlordEventEntityApplyEffect {
757 entity_id: target.id,
758 effect_id: effect_tpl.id,
759 },
760 )?;
761 }
762 effects.on_apply(sink, &effect_tpl, target)?;
763 Ok(())
764}
765
766pub fn apply_entity_over_time_effect(
769 sink: &mut dyn FightSink,
770 target: &Entity,
771 amount: f64,
772 kind: &str,
773) -> Result<(), anyhow::Error> {
774 let effect_id = if kind == "dot" {
775 Uuid::parse_str(DOT_EFFECT_ID).unwrap()
776 } else {
777 Uuid::parse_str(HOT_EFFECT_ID).unwrap()
778 };
779 let per_tick = (amount / 5.0).floor() as i64;
780 for i in 1..=5 {
781 let tick_attr = format!("effect.{kind}.tick.{i}");
782 incr_attr(sink, target.id, &tick_attr, per_tick)?;
783 }
784 let next_attr = format!("effect.{kind}.next");
785 if !attr_present(target, &next_attr) {
786 incr_attr(sink, target.id, &next_attr, 1)?;
788 push_event(
789 sink,
790 OverlordEventEntityApplyEffect {
791 entity_id: target.id,
792 effect_id,
793 },
794 )?;
795 }
796 Ok(())
797}
798
799pub fn remove_entity_effect(
802 sink: &mut dyn FightSink,
803 lookups: &ContentLookups,
804 effects: &mut dyn EffectCb,
805 target: &Entity,
806 effect_code: &str,
807) -> Result<(), anyhow::Error> {
808 let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
809 Some(t) => t.clone(),
810 None => {
811 return Err(anyhow::anyhow!(
812 "remove_entity_effect: unknown effect code {effect_code}"
813 ));
814 }
815 };
816 let stacks_attr = format!("effect.{effect_code}.stacks");
817 let current_stacks = attr_get(target, &stacks_attr) as i64;
818 incr_attr(sink, target.id, &stacks_attr, -current_stacks)?;
819 effects.on_change(sink, &effect_tpl, target, 0, current_stacks)?;
820 Ok(())
821}
822
823#[allow(clippy::too_many_arguments)]
828pub fn init_fight(
829 sink: &mut dyn FightSink,
830 lookups: &ContentLookups,
831 fight: &ActiveFight,
832 state: &OverlordState,
833 fight_template_id: Uuid,
834) -> Result<(), anyhow::Error> {
835 let is_dungeon = lookups
836 .fight_template_is_dungeon
837 .get(&fight_template_id)
838 .copied()
839 .unwrap_or(false);
840 let is_bossfight = lookups
841 .fight_template_is_bossfight
842 .get(&fight_template_id)
843 .copied()
844 .unwrap_or(false);
845
846 let player_id = fight.player_id;
847 let party_player_id = fight.party_player_id;
848 let entities = fight.entities.clone();
849 let regen_effect = Uuid::parse_str(REGEN_EFFECT_ID).unwrap();
850 let low_chapter_effect = Uuid::parse_str(LOW_CHAPTER_EFFECT_ID).unwrap();
851 let dungeon_talent = Uuid::parse_str(DUNGEON_TALENT_ID).unwrap();
852 let boss_talent = Uuid::parse_str(BOSS_TALENT_ID).unwrap();
853
854 for entity in &entities {
855 if attr_present(entity, "regeneration_rate") {
856 push_event(
857 sink,
858 OverlordEventEntityApplyEffect {
859 entity_id: entity.id,
860 effect_id: regen_effect,
861 },
862 )?;
863 }
864
865 let mut dungeon_level: Option<i64> = None;
866 let mut boss_level: Option<i64> = None;
867
868 if entity.id == player_id {
869 dungeon_level = state
870 .character_state
871 .talent_levels
872 .get(&dungeon_talent)
873 .copied();
874 boss_level = state
875 .character_state
876 .talent_levels
877 .get(&boss_talent)
878 .copied();
879 if state.character_state.character.current_chapter_level
880 <= LOW_CHAPTER_EFFECT_MAX_CHAPTER
881 {
882 push_event(
883 sink,
884 OverlordEventEntityApplyEffect {
885 entity_id: entity.id,
886 effect_id: low_chapter_effect,
887 },
888 )?;
889 }
890 }
891 if Some(entity.id) == party_player_id {
892 let party = &state.party;
893 if let Some(party_state) = &party.party_state {
894 dungeon_level = party_state.talent_levels.get(&dungeon_talent).copied();
895 boss_level = party_state.talent_levels.get(&boss_talent).copied();
896 if let Some(adjusted) = party.party_adjusted_power {
897 let raw = party_state.character.power as f64;
898 if raw > 0.0 {
899 let adjust_k = adjusted as f64 / raw;
900 let stat_k = adjust_k.sqrt();
901 let current_attack = attr_get(entity, "attack");
902 let delta =
903 (current_attack * stat_k).floor() as i64 - current_attack as i64;
904 incr_attr(sink, entity.id, "attack", delta)?;
905 let new_max_hp = ((entity.hp as f64) * stat_k).floor() as u64;
906 push_event(
907 sink,
908 OverlordEventSetMaxHp {
909 entity_id: entity.id,
910 new_max_hp,
911 new_hp: new_max_hp,
912 },
913 )?;
914 }
915 }
916 }
917 }
918
919 if is_dungeon && let Some(level) = dungeon_level {
920 let current = attr_get(entity, "attack.mod") as i64;
921 incr_attr(sink, entity.id, "attack.mod", 200 * level - current)?;
922 }
923 if is_bossfight {
924 if let Some(level) = boss_level {
928 let current = attr_get(entity, "attack.mod") as i64;
929 incr_attr(sink, entity.id, "attack.mod", 200 * level - current)?;
930 }
931 }
932 }
933 Ok(())
934}
935
936#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
942pub struct WaveFightData {
943 pub entities: Vec<WaveEntityPower>,
945 pub waves: Vec<Vec<WaveSpawn>>,
947 pub time: f64,
949 pub power: Option<f64>,
952}
953
954#[derive(Clone, Debug, PartialEq, serde::Serialize)]
955pub struct WaveEntityPower {
956 pub entity_id: Option<String>,
961 pub power: Option<f64>,
964}
965
966#[derive(Clone, Debug, PartialEq, serde::Serialize)]
967pub struct WaveSpawn {
968 pub entity_id: String,
969 pub delay: Option<f64>,
972 pub position: Option<Coordinates>,
977}
978
979#[derive(Clone, Debug)]
980struct SimEnemy {
981 eff_damage: f64,
982 ttk: f64,
983 delay: f64,
984}
985
986fn sim_wave_choose_next_target(enemies: &[SimEnemy]) -> Option<usize> {
987 let mut fastest = 1800.0f64;
988 let mut idx = None;
989 for (i, e) in enemies.iter().enumerate() {
990 if e.delay <= 0.0 && e.ttk < fastest {
991 idx = Some(i);
992 fastest = e.ttk;
998 }
999 }
1000 idx
1001}
1002
1003fn sim_wave_choose_sim_time(enemies: &[SimEnemy], current_target: Option<usize>) -> f64 {
1004 let mut next_step = 1800.0f64;
1005 for e in enemies {
1006 if e.delay > 0.0 && e.delay < next_step {
1007 next_step = e.delay;
1008 }
1009 }
1010 if let Some(idx) = current_target
1011 && let Some(t) = enemies.get(idx)
1012 && t.ttk < next_step
1013 {
1014 next_step = t.ttk;
1015 }
1016 next_step
1017}
1018
1019fn sim_wave_advance(enemies: &mut Vec<SimEnemy>, mob_damage: &mut f64) {
1020 let target = sim_wave_choose_next_target(enemies);
1021 let time = sim_wave_choose_sim_time(enemies, target);
1022 if let Some(t) = target {
1023 let dmg: f64 = enemies
1029 .iter()
1030 .filter(|e| e.delay <= 0.0)
1031 .map(|e| e.eff_damage)
1032 .sum();
1033 *mob_damage += dmg * time;
1034 enemies[t].ttk -= time;
1035 if enemies[t].ttk <= 0.0 {
1036 enemies.remove(t);
1037 }
1038 }
1039 for e in enemies.iter_mut() {
1040 if e.delay > 0.0 {
1041 e.delay = (e.delay - time).max(0.0);
1042 }
1043 }
1044}
1045
1046#[allow(clippy::too_many_arguments)]
1052pub fn spawn_wave(
1053 sink: &mut dyn FightSink,
1054 rng: &GameRng,
1055 config: &GameConfig,
1056 lookups: &ContentLookups,
1057 fight: &ActiveFight,
1058 fight_data: &WaveFightData,
1059 base_power: f64,
1060 current_chapter: i64,
1061 fight_type: &str,
1062) -> Result<(), anyhow::Error> {
1063 const POWER_EFF_HP: f64 = 0.75;
1064 const POWER_ATTACK: f64 = 1.0 - POWER_EFF_HP;
1065
1066 let is_dungeon = lookups
1071 .fight_template_is_dungeon
1072 .get(&fight.fight_id)
1073 .copied()
1074 .unwrap_or(false);
1075 let power = balance::enemy_power_scalar(base_power, current_chapter, fight_type, is_dungeon);
1076
1077 let hp_k = balance::hp_k_for_chapter(config, current_chapter);
1078 let eff = power / balance::BASE_POWER as f64;
1079 let mut eff_hp = balance::BASE_HP * (eff * hp_k).powf(0.5);
1080 let mut eff_attack = balance::BASE_ATTACK * (eff / hp_k).powf(0.5);
1081
1082 eff_hp *= 2.0_f64.powf(0.5);
1083 eff_attack /= 2.0_f64.powf(0.5);
1084
1085 let player_dps = eff_attack * balance::BASE_SPELL_EFF * balance::DMG_K;
1086
1087 let mut min_power: Option<f64> = None;
1090 for ent in &fight_data.entities {
1091 if let Some(p) = ent.power {
1092 min_power = Some(min_power.map(|mp| mp.min(p)).unwrap_or(p));
1093 }
1094 }
1095 let min_power = min_power.unwrap_or(1.0);
1096
1097 let mut entity_powers: std::collections::HashMap<String, f64> =
1100 std::collections::HashMap::new();
1101 for ent in &fight_data.entities {
1102 let Some(id) = &ent.entity_id else {
1103 continue;
1104 };
1105 let p = ent.power.unwrap_or(1.0);
1106 entity_powers.insert(id.clone(), p / min_power);
1107 }
1108
1109 let mut mobs_eff_hp = 0.0f64;
1110 for wave in &fight_data.waves {
1111 for spawn in wave {
1112 let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
1113 mobs_eff_hp += enemy_power.powf(POWER_EFF_HP);
1114 }
1115 }
1116
1117 let time = fight_data.time;
1118 let player_damage = player_dps * time;
1119 let mob_hp_norm = if mobs_eff_hp > 0.0 {
1120 player_damage / mobs_eff_hp
1121 } else {
1122 0.0
1123 };
1124 let mob_norm_ttk = if player_dps > 0.0 {
1125 mob_hp_norm / player_dps
1126 } else {
1127 0.0
1128 };
1129
1130 let mut overall_damage = 0.0f64;
1131 for wave in &fight_data.waves {
1132 let mut sim_enemies: Vec<SimEnemy> = Vec::new();
1133 for spawn in wave {
1134 let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
1135 let delay = spawn.delay.unwrap_or(0.0);
1136 sim_enemies.push(SimEnemy {
1137 eff_damage: enemy_power.powf(POWER_ATTACK),
1138 ttk: mob_norm_ttk * enemy_power.powf(POWER_EFF_HP),
1139 delay,
1140 });
1141 }
1142 let mut mob_damage = 0.0f64;
1143 let mut iter = 0;
1144 while !sim_enemies.is_empty() && iter < 200 {
1145 iter += 1;
1146 sim_wave_advance(&mut sim_enemies, &mut mob_damage);
1147 }
1148 overall_damage += mob_damage;
1149 }
1150
1151 let mob_dps_norm = if overall_damage > 0.0 {
1152 eff_hp / overall_damage
1153 } else {
1154 0.0
1155 };
1156 let mob_attack_norm = mob_dps_norm / balance::BASE_SPELL_EFF / balance::DMG_K;
1157
1158 let wave_idx = (fight.current_wave - 1) as usize;
1159 let mut offset_x = 0i64;
1160 if wave_idx > 0 {
1161 let mut max_ally_x: Option<i64> = None;
1162 for e in &fight.entities {
1163 if e.team == EntityTeam::Ally && e.hp > 0 {
1164 max_ally_x = Some(
1165 max_ally_x
1166 .map(|m| m.max(e.coordinates.x))
1167 .unwrap_or(e.coordinates.x),
1168 );
1169 }
1170 }
1171 if let Some(m) = max_ally_x {
1172 offset_x = m - 1 + 6;
1173 }
1174 }
1175
1176 let Some(current_wave) = fight_data.waves.get(wave_idx) else {
1177 return Ok(());
1178 };
1179
1180 for spawn in current_wave {
1181 let enemy_tpl_id = match Uuid::parse_str(&spawn.entity_id) {
1182 Ok(u) => u,
1183 Err(_) => continue,
1184 };
1185 let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
1186 let enemy_hp_norm = enemy_power.powf(POWER_EFF_HP);
1187 let enemy_attack_norm = enemy_power.powf(POWER_ATTACK);
1188 let enemy_hp = enemy_hp_norm * mob_hp_norm;
1189 let enemy_attack = enemy_attack_norm * mob_attack_norm;
1190 let delay = spawn.delay.unwrap_or(0.0) as i64;
1191
1192 let mut attrs = EntityAttributes::default();
1193 attrs.add("attack", enemy_attack.floor() as i64);
1194 attrs.add("hp", enemy_hp.floor() as i64);
1195 attrs.add("speed", 10000);
1196 if delay > 0 {
1197 attrs.add("wake_up_delay", delay);
1198 }
1199
1200 let base_position = spawn
1201 .position
1202 .clone()
1203 .ok_or_else(|| anyhow::anyhow!("spawn_wave: spawn entry has no readable position"))?;
1204 let position = Coordinates {
1205 x: base_position.x + offset_x,
1206 y: base_position.y,
1207 };
1208
1209 let has_big_hp_bar = lookups
1210 .entity_template_is_boss
1211 .get(&enemy_tpl_id)
1212 .copied()
1213 .unwrap_or(false);
1214
1215 let spawn_evt = OverlordEventSpawnEntity {
1216 id: uuid::Builder::from_random_bytes(rng.random_bytes()).into_uuid(),
1217 entity_template_id: enemy_tpl_id,
1218 position,
1219 entity_team: EntityTeam::Enemy,
1220 has_big_hp_bar,
1221 entity_attributes: attrs,
1222 };
1223 let entity_id = spawn_evt.id;
1224 push_event(sink, spawn_evt)?;
1225
1226 if delay > 0 {
1227 incr_attr(sink, entity_id, "sleep", 1)?;
1233 push_event(
1234 sink,
1235 OverlordEventEntityApplyEffect {
1236 entity_id,
1237 effect_id: Uuid::parse_str(SLEEP_EFFECT_ID).unwrap(),
1238 },
1239 )?;
1240 }
1241 }
1242 Ok(())
1243}
1244
1245pub fn wave_data_from_config(cfg: &essences::fighting::PrepareFightWaves) -> WaveFightData {
1249 WaveFightData {
1250 entities: cfg
1251 .entities
1252 .iter()
1253 .map(|e| WaveEntityPower {
1254 entity_id: e.entity_id.clone(),
1255 power: e.power,
1256 })
1257 .collect(),
1258 waves: cfg
1259 .waves
1260 .iter()
1261 .map(|wave| {
1262 wave.iter()
1263 .map(|s| WaveSpawn {
1264 entity_id: s.entity_id.clone(),
1265 delay: Some(s.delay.unwrap_or(0.0)),
1269 position: s.position.clone(),
1270 })
1271 .collect()
1272 })
1273 .collect(),
1274 time: cfg.time,
1275 power: Some(cfg.power),
1276 }
1277}
1278
1279fn is_valid_target(
1284 lookups: &ContentLookups,
1285 target: &Entity,
1286 ability: &Ability,
1287 caster: &Entity,
1288) -> bool {
1289 let target_type = lookups
1290 .ability_target_type
1291 .get(&ability.template_id)
1292 .map(|s| s.as_str())
1293 .unwrap_or("");
1294 let by_team = match target_type {
1295 "Enemy" => target.team != caster.team,
1296 "Ally" => target.team == caster.team,
1297 _ => false,
1298 };
1299 let range = lookups
1300 .ability_range
1301 .get(&ability.template_id)
1302 .copied()
1303 .unwrap_or(0);
1304 by_team && get_distance_to(caster, target) <= range
1305}
1306
1307#[allow(clippy::too_many_arguments)]
1312pub fn cast(
1313 sink: &mut dyn FightSink,
1314 rng: &GameRng,
1315 config: &GameConfig,
1316 lookups: &ContentLookups,
1317 caster: &Entity,
1318 _ability: &Ability,
1319 valid_targets: &[Entity],
1320 natural_casts_number: i64,
1321) -> Result<(), anyhow::Error> {
1322 let mut casts_number = natural_casts_number;
1323 let is_multicast = stat_throw(rng, lookups, caster, "multicast_chance", 0.0);
1324 if is_multicast {
1325 casts_number *= 2;
1326 }
1327 let base_cast_time = entity_get_tpl_cast_time(config, caster) as f64;
1328 let cast_time = (base_cast_time / casts_number as f64).floor() as i64;
1329 for i in 0..casts_number {
1330 let target = if valid_targets.len() > 1 {
1331 if i == 0 {
1332 get_target(caster, valid_targets)
1333 .cloned()
1334 .unwrap_or_else(|| valid_targets[0].clone())
1335 } else {
1336 let idx = rng.random_index(valid_targets.len());
1337 valid_targets[idx].clone()
1338 }
1339 } else {
1340 valid_targets[0].clone()
1341 };
1342 sink.push_attack(
1343 (i * cast_time).max(0) as u64,
1344 cast_time.max(0) as u64,
1345 target.id,
1346 )?;
1347 }
1348 Ok(())
1349}
1350
1351pub fn on_cast(
1356 sink: &mut dyn FightSink,
1357 rng: &GameRng,
1358 lookups: &ContentLookups,
1359 effects: &mut dyn EffectCb,
1360 caster: &Entity,
1361) -> Result<(), anyhow::Error> {
1362 if stat_throw(rng, lookups, caster, "bravery", 0.0) {
1363 let pool = ["protection", "empower"];
1364 let idx = rng.random_index(pool.len());
1365 let code = pool[idx];
1366 apply_entity_effect(
1367 sink,
1368 lookups,
1369 effects,
1370 caster,
1371 code,
1372 balance::BRAVERY_BUFF_DURATION,
1373 )?;
1374 }
1375 Ok(())
1376}
1377
1378pub fn advance_entity(
1416 sink: &mut dyn FightSink,
1417 config: &GameConfig,
1418 lookups: &ContentLookups,
1419 fight: &ActiveFight,
1420 entity: &Entity,
1421 ability: &Ability,
1422) -> Result<(), anyhow::Error> {
1423 let direction: i64 = if entity.team == EntityTeam::Ally {
1424 1
1425 } else {
1426 -1
1427 };
1428
1429 let forward = |opp: &Entity| (opp.coordinates.x - entity.coordinates.x) * direction;
1431
1432 let Some(opponent) = fight
1433 .entities
1434 .iter()
1435 .filter(|ent| ent.team != entity.team)
1436 .filter(|ent| forward(ent) > 0)
1437 .min_by_key(|ent| forward(ent))
1438 else {
1439 return Ok(());
1440 };
1441
1442 let gap = forward(opponent);
1443 let max_steps = if attr_present(opponent, "static") {
1451 (gap - 1).max(0)
1452 } else {
1453 gap / 2
1454 };
1455
1456 let opp_team = match entity.team {
1462 EntityTeam::Ally => EntityTeam::Enemy,
1463 EntityTeam::Enemy => EntityTeam::Ally,
1464 };
1465 let mut opp_front: Option<i64> = None;
1466 for e in &fight.entities {
1467 if e.team != opp_team || e.hp == 0 {
1468 continue;
1469 }
1470 for x in std::iter::once(e.coordinates.x).chain(e.move_target.as_ref().map(|t| t.x)) {
1471 opp_front = Some(match (opp_front, direction > 0) {
1472 (Some(cur), true) => cur.min(x),
1473 (Some(cur), false) => cur.max(x),
1474 (None, _) => x,
1475 });
1476 }
1477 }
1478 let front_limit = opp_front.map(|front| front - direction);
1479
1480 let mut probe = entity.clone();
1481 for _ in 0..max_steps {
1482 let Some(next) = find_advance_coordinates(&probe, &fight.entities, config) else {
1483 break;
1484 };
1485 if let Some(limit) = front_limit
1487 && (next.x - limit) * direction > 0
1488 {
1489 break;
1490 }
1491 probe.coordinates = next;
1492 if fight
1493 .entities
1494 .iter()
1495 .any(|ent| is_valid_target(lookups, ent, ability, &probe))
1496 {
1497 break;
1498 }
1499 }
1500
1501 if probe.coordinates == entity.coordinates {
1502 return Ok(());
1503 }
1504 entity_run(sink, lookups, entity, probe.coordinates)
1505}
1506
1507pub fn entity_run(
1510 sink: &mut dyn FightSink,
1511 lookups: &ContentLookups,
1512 entity: &Entity,
1513 to: Coordinates,
1514) -> Result<(), anyhow::Error> {
1515 const DEFAULT_TIME_PER_CELL: f64 = 500.0;
1516 let speed = get_entity_stat(lookups, entity, "speed");
1517 if speed == 0.0 {
1518 return Ok(());
1519 }
1520 let speed_norm = speed / 10000.0;
1521 let dx = (to.x - entity.coordinates.x) as f64;
1524 let dy = (to.y - entity.coordinates.y) as f64;
1525 let distance = (dx * dx + dy * dy).sqrt();
1526 if distance == 0.0 {
1527 return Ok(());
1528 }
1529 let time_per_cell = DEFAULT_TIME_PER_CELL / speed_norm;
1530 let duration = (time_per_cell * distance).floor() as u64;
1531 sink.push_run(to, duration)
1532}
1533
1534#[allow(clippy::too_many_arguments)]
1539pub fn try_cast(
1540 sink: &mut dyn FightSink,
1541 rng: &GameRng,
1542 config: &GameConfig,
1543 lookups: &ContentLookups,
1544 fight: &ActiveFight,
1545 caster: &Entity,
1546 ability_id: Uuid,
1547 casts: i64,
1548) -> Result<(), anyhow::Error> {
1549 if attr_present(caster, "sleep") {
1550 return Ok(());
1551 }
1552 if config.ability_template(ability_id).is_none() {
1553 return Err(anyhow::anyhow!("try_cast: unknown ability {ability_id}"));
1554 }
1555 let target_type = lookups
1556 .ability_target_type
1557 .get(&ability_id)
1558 .cloned()
1559 .unwrap_or_default();
1560 let ability_val = Ability {
1561 template_id: ability_id,
1562 level: 1,
1563 shards_amount: 0,
1564 };
1565 if target_type == "Self" {
1566 cast(
1567 sink,
1568 rng,
1569 config,
1570 lookups,
1571 caster,
1572 &ability_val,
1573 std::slice::from_ref(caster),
1574 casts,
1575 )?;
1576 return Ok(());
1577 }
1578 let mut valid_targets = Vec::new();
1579 for ent in &fight.entities {
1580 if is_valid_target(lookups, ent, &ability_val, caster) {
1581 valid_targets.push(ent.clone());
1582 }
1583 }
1584 if !valid_targets.is_empty() {
1585 cast(
1586 sink,
1587 rng,
1588 config,
1589 lookups,
1590 caster,
1591 &ability_val,
1592 &valid_targets,
1593 casts,
1594 )?;
1595 return Ok(());
1596 }
1597 if !attr_present(caster, "static") {
1598 advance_entity(sink, config, lookups, fight, caster, &ability_val)?;
1599 }
1600 Ok(())
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use super::*;
1606
1607 fn entity_at(team: EntityTeam, x: i64) -> Entity {
1608 Entity {
1609 id: uuid::Uuid::new_v4(),
1610 team,
1611 coordinates: Coordinates { x, y: 1 },
1612 ..Default::default()
1613 }
1614 }
1615
1616 #[test]
1621 fn is_valid_target_requires_populated_target_type_and_range() {
1622 let ability_id = uuid::Uuid::new_v4();
1623 let ability = Ability {
1624 template_id: ability_id,
1625 level: 1,
1626 shards_amount: 0,
1627 };
1628 let caster = entity_at(EntityTeam::Ally, 0);
1629 let enemy_in_range = entity_at(EntityTeam::Enemy, 1);
1630 let enemy_out_of_range = entity_at(EntityTeam::Enemy, 5);
1631 let ally = entity_at(EntityTeam::Ally, 1);
1632
1633 let empty = ContentLookups::default();
1635 assert!(
1636 !is_valid_target(&empty, &enemy_in_range, &ability, &caster),
1637 "empty lookups must yield no valid target (this was the run-through bug)"
1638 );
1639
1640 let mut populated = ContentLookups::default();
1642 populated
1643 .ability_target_type
1644 .insert(ability_id, "Enemy".to_string());
1645 populated.ability_range.insert(ability_id, 1);
1646
1647 assert!(
1648 is_valid_target(&populated, &enemy_in_range, &ability, &caster),
1649 "an in-range enemy must be a valid target once lookups are populated"
1650 );
1651 assert!(
1652 !is_valid_target(&populated, &enemy_out_of_range, &ability, &caster),
1653 "an out-of-range enemy must not be targetable (caster should advance)"
1654 );
1655 assert!(
1656 !is_valid_target(&populated, &ally, &ability, &caster),
1657 "an ally must not be a valid target for an Enemy-typed ability"
1658 );
1659 }
1660
1661 fn lookups_with_speed(speed: f64) -> ContentLookups {
1662 let speed_id = uuid::Uuid::new_v4();
1663 let mut lookups = ContentLookups::default();
1664 lookups
1665 .attribute_by_code
1666 .insert("speed".to_string(), speed_id);
1667 lookups.attribute_base_value.insert(speed_id, speed);
1668 lookups
1669 }
1670
1671 fn advance_lookups(ability_id: uuid::Uuid, range: i64) -> ContentLookups {
1672 let mut lookups = lookups_with_speed(10000.0);
1673 lookups
1674 .ability_target_type
1675 .insert(ability_id, "Enemy".to_string());
1676 lookups.ability_range.insert(ability_id, range);
1677 lookups
1678 }
1679
1680 fn advance_against_enemy_at(enemy_x: i64, range: i64) -> NativeSink {
1681 let ability_id = uuid::Uuid::new_v4();
1682 let ability = Ability {
1683 template_id: ability_id,
1684 level: 1,
1685 shards_amount: 0,
1686 };
1687 let caster = entity_at(EntityTeam::Ally, 0);
1688 let enemy = entity_at(EntityTeam::Enemy, enemy_x);
1689 let lookups = advance_lookups(ability_id, range);
1690 let fight = ActiveFight {
1691 entities: vec![caster.clone(), enemy],
1692 ..Default::default()
1693 };
1694 let config = configs::tests_game_config::generate_game_config_for_tests();
1695
1696 let mut sink = NativeSink::default();
1697 advance_entity(&mut sink, &config, &lookups, &fight, &caster, &ability).unwrap();
1698 sink
1699 }
1700
1701 #[test]
1707 fn advance_entity_meets_a_mobile_opponent_in_the_middle() {
1708 let sink = advance_against_enemy_at(5, 1);
1709 assert_eq!(sink.casts.len(), 1, "the advance must be a single run");
1710 let run = &sink.casts[0];
1711 assert_eq!(
1712 run.coordinates,
1713 Some(Coordinates { x: 2, y: 1 }),
1714 "must cover half the 5-cell gap (meet in the middle)"
1715 );
1716 assert_eq!(
1717 run.run_duration_ticks,
1718 Some(1000),
1719 "2 cells × 500ms at baseline speed"
1720 );
1721 }
1722
1723 #[test]
1728 fn advance_entity_full_approach_against_static_opponent() {
1729 let caster = entity_at(EntityTeam::Ally, 0);
1730 let mut enemy = entity_at(EntityTeam::Enemy, 5);
1731 enemy.attributes.0.insert("static".to_string(), 1);
1732
1733 let sink = advance_in_fight(&caster, vec![enemy], 1);
1734 assert_eq!(sink.casts.len(), 1);
1735 assert_eq!(
1736 sink.casts[0].coordinates,
1737 Some(Coordinates { x: 4, y: 1 }),
1738 "must approach a static enemy all the way to x=4 in one run"
1739 );
1740 }
1741
1742 #[test]
1745 fn advance_entity_stops_when_target_comes_into_range() {
1746 let sink = advance_against_enemy_at(4, 3);
1747 assert_eq!(sink.casts.len(), 1);
1748 assert_eq!(
1749 sink.casts[0].coordinates,
1750 Some(Coordinates { x: 1, y: 1 }),
1751 "one step puts the enemy within range 3 — stop there"
1752 );
1753 }
1754
1755 #[test]
1759 fn advance_entity_never_enters_the_opponent_column() {
1760 let sink = advance_against_enemy_at(1, 1);
1761 assert!(
1762 sink.casts.is_empty(),
1763 "gap 1 must produce no run (entity would land on the enemy)"
1764 );
1765 }
1766
1767 fn entity_at_xy(team: EntityTeam, x: i64, y: i64) -> Entity {
1768 Entity {
1769 id: uuid::Uuid::new_v4(),
1770 team,
1771 coordinates: Coordinates { x, y },
1772 ..Default::default()
1773 }
1774 }
1775
1776 fn advance_in_fight(caster: &Entity, others: Vec<Entity>, range: i64) -> NativeSink {
1777 let ability_id = uuid::Uuid::new_v4();
1778 let ability = Ability {
1779 template_id: ability_id,
1780 level: 1,
1781 shards_amount: 0,
1782 };
1783 let lookups = advance_lookups(ability_id, range);
1784 let mut entities = vec![caster.clone()];
1785 entities.extend(others);
1786 let fight = ActiveFight {
1787 entities,
1788 ..Default::default()
1789 };
1790 let config = configs::tests_game_config::generate_game_config_for_tests();
1791 let mut sink = NativeSink::default();
1792 advance_entity(&mut sink, &config, &lookups, &fight, caster, &ability).unwrap();
1793 sink
1794 }
1795
1796 #[test]
1801 fn advance_entity_does_not_land_on_a_reserved_midpoint() {
1802 let caster = entity_at(EntityTeam::Ally, 0); let mut enemy = entity_at_xy(EntityTeam::Enemy, 4, 1);
1804 enemy.move_target = Some(Coordinates { x: 2, y: 1 });
1805
1806 let sink = advance_in_fight(&caster, vec![enemy], 1);
1807 assert_eq!(sink.casts.len(), 1);
1808 assert_ne!(
1809 sink.casts[0].coordinates,
1810 Some(Coordinates { x: 2, y: 1 }),
1811 "must not land on the cell the enemy reserved for its run"
1812 );
1813 }
1814
1815 #[test]
1824 fn advance_entity_second_mid_meeter_runs_short_not_stand() {
1825 let caster = entity_at(EntityTeam::Ally, 0); let mut enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
1827 enemy.move_target = Some(Coordinates { x: 3, y: 1 });
1828
1829 let sink = advance_in_fight(&caster, vec![enemy], 1);
1830 assert_eq!(sink.casts.len(), 1, "the second planner must emit one run");
1831 let dest = sink.casts[0].coordinates.clone().unwrap();
1832 assert_ne!(
1834 dest,
1835 Coordinates { x: 0, y: 1 },
1836 "second planner must move, not stand"
1837 );
1838 assert_ne!(
1840 dest,
1841 Coordinates { x: 3, y: 1 },
1842 "must not land on the reserved cell"
1843 );
1844 assert!(
1846 dest.x < 6,
1847 "must not reach or cross the enemy's column (x=6)"
1848 );
1849 }
1850
1851 #[test]
1856 fn advance_entity_even_gap_no_shared_cell() {
1857 let caster = entity_at(EntityTeam::Ally, 0); let mut enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
1859 enemy.move_target = Some(Coordinates { x: 1, y: 1 });
1862
1863 let sink = advance_in_fight(&caster, vec![enemy], 1);
1864 assert_eq!(sink.casts.len(), 1);
1865 let dest = sink.casts[0].coordinates.clone().unwrap();
1866 assert_ne!(
1867 dest,
1868 Coordinates { x: 1, y: 1 },
1869 "ally must not land on the enemy's reserved cell (no same-cell on even gaps)"
1870 );
1871 }
1872
1873 #[test]
1877 fn advance_entity_emits_no_run_when_boxed_in() {
1878 let caster = entity_at(EntityTeam::Ally, 0); let block_fwd = entity_at_xy(EntityTeam::Ally, 1, 1);
1880 let block_up = entity_at_xy(EntityTeam::Ally, 1, 2);
1881 let block_down = entity_at_xy(EntityTeam::Ally, 1, 0);
1882 let enemy = entity_at_xy(EntityTeam::Enemy, 4, 1); let sink = advance_in_fight(&caster, vec![block_fwd, block_up, block_down, enemy], 1);
1885 assert!(
1886 sink.casts.is_empty(),
1887 "no free forward cell → no run (must not overlap)"
1888 );
1889 }
1890
1891 #[test]
1896 fn advance_entity_ally_respects_occupied_forward_cell() {
1897 let caster = entity_at(EntityTeam::Ally, 0); let blocker = entity_at_xy(EntityTeam::Ally, 1, 1); let enemy = entity_at_xy(EntityTeam::Enemy, 2, 1); let sink = advance_in_fight(&caster, vec![blocker, enemy], 1);
1902 assert_eq!(sink.casts.len(), 1);
1903 assert_eq!(
1904 sink.casts[0].coordinates,
1905 Some(Coordinates { x: 1, y: 2 }),
1906 "must sidestep the occupied (1,1), not stack onto it"
1907 );
1908 }
1909
1910 #[test]
1916 fn advance_entity_half_gap_stays_on_its_own_side() {
1917 let caster = entity_at_xy(EntityTeam::Enemy, 8, 1);
1918 let mut ally = entity_at_xy(EntityTeam::Ally, 1, 1);
1919 ally.hp = 100;
1923
1924 let sink = advance_in_fight(&caster, vec![ally], 1);
1925 assert_eq!(sink.casts.len(), 1);
1926 let dest = sink.casts[0].coordinates.clone().unwrap();
1927 assert_eq!(
1928 dest,
1929 Coordinates { x: 5, y: 1 },
1930 "enemy covers half the 7-cell gap (8 → 5)"
1931 );
1932 assert!(dest.x > 1, "must stay on its own side of the ally");
1933 }
1934
1935 #[test]
1940 fn advance_entity_does_not_chase_an_opponent_behind_it() {
1941 let caster = entity_at_xy(EntityTeam::Ally, 5, 1); let enemy = entity_at_xy(EntityTeam::Enemy, 2, 1); let sink = advance_in_fight(&caster, vec![enemy], 1);
1945 assert!(
1946 sink.casts.is_empty(),
1947 "ally must not advance further away from an opponent behind it"
1948 );
1949 }
1950
1951 #[test]
1956 fn advance_entity_enemy_stops_one_column_in_front_of_player() {
1957 let enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
1958 let mut hero = entity_at_xy(EntityTeam::Ally, 1, 1);
1959 hero.hp = 100; hero.attributes.0.insert("static".to_string(), 1);
1961
1962 let sink = advance_in_fight(&enemy, vec![hero], 1);
1963 assert_eq!(sink.casts.len(), 1);
1964 assert_eq!(
1965 sink.casts[0].coordinates,
1966 Some(Coordinates { x: 2, y: 1 }),
1967 "enemy stops one column in front of the player (x=2), never on/past x=1"
1968 );
1969 }
1970
1971 #[test]
1981 fn advance_entity_enemy_does_not_dive_past_a_forward_ally() {
1982 let enemy = entity_at_xy(EntityTeam::Enemy, 3, 1);
1983 let mut summon = entity_at_xy(EntityTeam::Ally, 5, 0);
1984 summon.hp = 100;
1985 let mut hero = entity_at_xy(EntityTeam::Ally, 1, 1);
1986 hero.hp = 100;
1987 hero.attributes.0.insert("static".to_string(), 1);
1988
1989 let sink = advance_in_fight(&enemy, vec![summon, hero], 1);
1990 assert!(
1991 sink.casts.is_empty(),
1992 "enemy must not dive past the frontmost ally (x=5) into the formation"
1993 );
1994 }
1995
1996 #[test]
2001 fn advance_entity_front_line_ignores_dead_allies() {
2002 let enemy = entity_at_xy(EntityTeam::Enemy, 3, 1);
2003 let summon = entity_at_xy(EntityTeam::Ally, 5, 0); let mut hero = entity_at_xy(EntityTeam::Ally, 1, 1);
2005 hero.hp = 100;
2006 hero.attributes.0.insert("static".to_string(), 1);
2007
2008 let sink = advance_in_fight(&enemy, vec![summon, hero], 1);
2009 assert_eq!(sink.casts.len(), 1);
2010 assert_eq!(
2011 sink.casts[0].coordinates,
2012 Some(Coordinates { x: 2, y: 1 }),
2013 "dead forward ally is ignored; enemy stops one column in front of the live hero"
2014 );
2015 }
2016
2017 #[test]
2021 fn advance_entity_ally_stops_one_column_in_front_of_enemy() {
2022 let caster = entity_at_xy(EntityTeam::Ally, 0, 1);
2023 let mut enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
2024 enemy.hp = 100;
2025 enemy.attributes.0.insert("static".to_string(), 1);
2026
2027 let sink = advance_in_fight(&caster, vec![enemy], 1);
2028 assert_eq!(sink.casts.len(), 1);
2029 assert_eq!(
2030 sink.casts[0].coordinates,
2031 Some(Coordinates { x: 5, y: 1 }),
2032 "ally stops one column in front of the enemy (x=5), never on/past x=6"
2033 );
2034 }
2035
2036 #[test]
2043 fn advance_entity_mutual_meet_keeps_one_column_gap_enemy_second() {
2044 let enemy = entity_at_xy(EntityTeam::Enemy, 5, 1);
2045 let mut ally = entity_at_xy(EntityTeam::Ally, 1, 1);
2046 ally.hp = 100;
2047 ally.move_target = Some(Coordinates { x: 3, y: 1 });
2048
2049 let sink = advance_in_fight(&enemy, vec![ally], 1);
2050 assert_eq!(sink.casts.len(), 1);
2051 let dest = sink.casts[0].coordinates.clone().unwrap();
2052 assert_eq!(
2053 dest.x, 4,
2054 "enemy must stop one column in front of the ally's reserved x=3, not on it"
2055 );
2056 }
2057
2058 #[test]
2062 fn advance_entity_mutual_meet_keeps_one_column_gap_ally_second() {
2063 let ally = entity_at_xy(EntityTeam::Ally, 1, 1);
2064 let mut enemy = entity_at_xy(EntityTeam::Enemy, 5, 1);
2065 enemy.hp = 100;
2066 enemy.move_target = Some(Coordinates { x: 3, y: 1 });
2067
2068 let sink = advance_in_fight(&ally, vec![enemy], 1);
2069 assert_eq!(sink.casts.len(), 1);
2070 let dest = sink.casts[0].coordinates.clone().unwrap();
2071 assert_eq!(
2072 dest.x, 2,
2073 "ally must stop one column in front of the enemy's reserved x=3, not on it"
2074 );
2075 }
2076
2077 #[test]
2080 fn entity_run_duration_uses_euclidean_distance() {
2081 let entity = entity_at(EntityTeam::Ally, 0); let lookups = lookups_with_speed(10000.0);
2083
2084 let mut sink = NativeSink::default();
2085 entity_run(&mut sink, &lookups, &entity, Coordinates { x: 1, y: 2 }).unwrap();
2086
2087 assert_eq!(sink.casts.len(), 1);
2088 assert_eq!(
2089 sink.casts[0].run_duration_ticks,
2090 Some(707),
2091 "√2 cells × 500ms, floored"
2092 );
2093 }
2094
2095 #[test]
2099 fn get_entity_stat_mod_floor_prevents_zeroing() {
2100 let attack_id = uuid::Uuid::new_v4();
2101 let mut lookups = ContentLookups::default();
2102 lookups
2103 .attribute_by_code
2104 .insert("attack".to_string(), attack_id);
2105 lookups.attribute_base_value.insert(attack_id, 600.0);
2106
2107 let mut weak = Entity {
2109 id: uuid::Uuid::new_v4(),
2110 ..Default::default()
2111 };
2112 weak.attributes.add("attack.mod", -50_000);
2113 let v = get_entity_stat(&lookups, &weak, "attack");
2114 assert!(v > 0.0, "floored stat must stay positive, got {v}");
2115 assert!(
2116 (v - 600.0 * balance::MIN_STAT_MOD_MULT).abs() < 1e-9,
2117 "attack.mod must floor at MIN_STAT_MOD_MULT (×0.05), got {v}"
2118 );
2119
2120 let neutral = Entity {
2122 id: uuid::Uuid::new_v4(),
2123 ..Default::default()
2124 };
2125 assert!((get_entity_stat(&lookups, &neutral, "attack") - 600.0).abs() < 1e-9);
2126 }
2127
2128 #[test]
2132 fn touch_enemy_dodge_direction_and_asymptote() {
2133 let k = balance::tuning().k_dodge;
2134 let mut target = Entity {
2136 id: uuid::Uuid::new_v4(),
2137 ..Default::default()
2138 };
2139 target.attributes.add("evasion", k as i64);
2140
2141 let rng = GameRng::from_values(vec![0.4]);
2142 assert!(
2143 !touch_enemy(&rng, &ContentLookups::default(), &target),
2144 "roll 0.4 < dodge 0.5 ⇒ dodged (not touched)"
2145 );
2146 let rng = GameRng::from_values(vec![0.6]);
2147 assert!(
2148 touch_enemy(&rng, &ContentLookups::default(), &target),
2149 "roll 0.6 ≥ dodge 0.5 ⇒ touched (direction must not be inverted)"
2150 );
2151
2152 let mut glass = Entity {
2155 id: uuid::Uuid::new_v4(),
2156 ..Default::default()
2157 };
2158 glass.attributes.add("evasion", 10_000_000);
2159 let rng = GameRng::from_values(vec![0.999_999]);
2160 assert!(
2161 touch_enemy(&rng, &ContentLookups::default(), &glass),
2162 "dodge asymptotes below 100% — a 0.999999 roll still touches"
2163 );
2164
2165 let no_ev = Entity {
2167 id: uuid::Uuid::new_v4(),
2168 ..Default::default()
2169 };
2170 let rng = GameRng::from_values(vec![0.0]);
2171 assert!(
2172 touch_enemy(&rng, &ContentLookups::default(), &no_ev),
2173 "no evasion ⇒ always touched"
2174 );
2175 }
2176
2177 #[test]
2187 fn damage_requires_received_damage_base_value() {
2188 use rand::SeedableRng;
2189
2190 let target = Entity {
2191 id: uuid::Uuid::new_v4(),
2192 hp: 1000,
2193 max_hp: 1000,
2194 ..Default::default()
2195 };
2196 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
2197
2198 let empty = ContentLookups::default();
2201 let mut sink = NativeSink::default();
2202 let res = damage_entity(
2203 &mut sink,
2204 &rng,
2205 &empty,
2206 &target,
2207 100.0,
2208 CustomEventData::default(),
2209 )
2210 .unwrap();
2211 assert!(
2212 res.is_none(),
2213 "missing attribute_base_value cancels all damage (loud no-damage guard)"
2214 );
2215
2216 let rd_id = uuid::Uuid::new_v4();
2218 let mut lookups = ContentLookups::default();
2219 lookups
2220 .attribute_by_code
2221 .insert("received_damage".to_string(), rd_id);
2222 lookups.attribute_base_value.insert(rd_id, 10000.0);
2223
2224 let mut sink = NativeSink::default();
2225 let res = damage_entity(
2226 &mut sink,
2227 &rng,
2228 &lookups,
2229 &target,
2230 100.0,
2231 CustomEventData::default(),
2232 )
2233 .unwrap();
2234 assert!(
2235 res.is_some_and(|d| d > 0),
2236 "with received_damage base populated, a hit must deal damage"
2237 );
2238
2239 let mut tank = target.clone();
2243 tank.attributes.add("received_damage.mod", -50_000); let mut sink = NativeSink::default();
2245 let res = damage_entity(
2246 &mut sink,
2247 &rng,
2248 &lookups,
2249 &tank,
2250 100.0,
2251 CustomEventData::default(),
2252 )
2253 .unwrap();
2254 assert!(
2255 res.is_some_and(|d| d > 0),
2256 "stacked protection must not make an entity unkillable (received_damage floor)"
2257 );
2258 }
2259
2260 fn spawn_wave_enemy_hp(power: Option<f64>) -> i64 {
2264 use rand::SeedableRng;
2265
2266 let enemy_id = uuid::Uuid::new_v4();
2267 let fight_data = WaveFightData {
2268 entities: vec![WaveEntityPower {
2269 entity_id: Some(enemy_id.to_string()),
2270 power: Some(1.0),
2271 }],
2272 waves: vec![vec![WaveSpawn {
2273 entity_id: enemy_id.to_string(),
2274 delay: Some(0.0),
2275 position: Some(Coordinates { x: 3, y: 3 }),
2276 }]],
2277 time: 1.0,
2278 power,
2279 };
2280
2281 let fight = ActiveFight {
2284 current_wave: 1,
2285 ..Default::default()
2286 };
2287
2288 let config = configs::tests_game_config::generate_game_config_for_tests();
2289 let lookups = ContentLookups::default();
2290 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
2291 let mut sink = NativeSink::default();
2292
2293 spawn_wave(
2294 &mut sink,
2295 &rng,
2296 &config,
2297 &lookups,
2298 &fight,
2299 &fight_data,
2300 fight_data.power.unwrap_or(0.0),
2301 1,
2302 "CampaignFight",
2303 )
2304 .unwrap();
2305
2306 let spawn = sink
2307 .events
2308 .iter()
2309 .find_map(|ev| match ev {
2310 OverlordEvent::SpawnEntity {
2311 entity_attributes, ..
2312 } => Some(entity_attributes),
2313 _ => None,
2314 })
2315 .expect("spawn_wave must emit a SpawnEntity for the current wave");
2316
2317 spawn.0.get("hp").copied().unwrap_or(0)
2319 }
2320
2321 #[test]
2328 fn spawn_wave_enemy_hp_requires_nonzero_base_power() {
2329 assert_eq!(
2331 spawn_wave_enemy_hp(Some(0.0)),
2332 0,
2333 "base_power 0 must reproduce the zero-HP bug"
2334 );
2335
2336 assert!(
2338 spawn_wave_enemy_hp(Some(100.0)) > 0,
2339 "a non-zero reference power must produce wave enemies with positive HP"
2340 );
2341 }
2342
2343 #[test]
2349 fn delayed_spawn_sets_wake_up_delay_once() {
2350 use rand::SeedableRng;
2351
2352 let enemy_id = uuid::Uuid::new_v4();
2353 let delay = 5i64;
2354 let fight_data = WaveFightData {
2355 entities: vec![WaveEntityPower {
2356 entity_id: Some(enemy_id.to_string()),
2357 power: Some(1.0),
2358 }],
2359 waves: vec![vec![WaveSpawn {
2360 entity_id: enemy_id.to_string(),
2361 delay: Some(delay as f64),
2362 position: Some(Coordinates { x: 3, y: 3 }),
2363 }]],
2364 time: 1.0,
2365 power: Some(100.0),
2366 };
2367 let fight = ActiveFight {
2368 current_wave: 1,
2369 ..Default::default()
2370 };
2371 let config = configs::tests_game_config::generate_game_config_for_tests();
2372 let lookups = ContentLookups::default();
2373 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
2374 let mut sink = NativeSink::default();
2375 spawn_wave(
2376 &mut sink,
2377 &rng,
2378 &config,
2379 &lookups,
2380 &fight,
2381 &fight_data,
2382 100.0,
2383 1,
2384 "CampaignFight",
2385 )
2386 .unwrap();
2387
2388 let attrs = sink
2390 .events
2391 .iter()
2392 .find_map(|ev| match ev {
2393 OverlordEvent::SpawnEntity {
2394 entity_attributes, ..
2395 } => Some(entity_attributes),
2396 _ => None,
2397 })
2398 .expect("must emit a SpawnEntity");
2399 assert_eq!(attrs.0.get("wake_up_delay").copied(), Some(delay));
2400
2401 let wake_incrs = sink
2403 .events
2404 .iter()
2405 .filter(|ev| {
2406 matches!(ev, OverlordEvent::EntityIncrAttribute { attribute, .. } if attribute == "wake_up_delay")
2407 })
2408 .count();
2409 assert_eq!(
2410 wake_incrs, 0,
2411 "wake_up_delay must not be incremented a second time (doubles sleep duration)"
2412 );
2413 }
2414}