1use std::sync::Arc;
11
12use configs::game_config::GameConfig;
13use essences::abilities::Ability;
14use essences::combat_origin::CombatEventOrigin;
15use essences::entity::{Coordinates, Entity, EntityAttributes, EntityId};
16use essences::fight_breakdown::CombatSource;
17use essences::fighting::{ActiveFight, EntityTeam};
18use event_system::script::random::GameRng;
19use uuid::Uuid;
20
21use crate::event::CustomEventData;
22use crate::event::*;
23use crate::game_config_helpers::GameConfigLookup;
24use crate::mechanics::balance;
25use crate::mechanics::content_lookups::{ContentLookups, EffectTpl};
26use crate::state::OverlordState;
27
28pub const BATTLEFIELD_HEIGHT: i64 = 3;
29const DOT_EFFECT_ID: &str = "019589cb-7adf-7466-8c46-92ac45032880";
30const HOT_EFFECT_ID: &str = "01958c0a-329e-744d-a5eb-0369e3f4c024";
31const REGEN_EFFECT_ID: &str = "01978d0c-5a8a-7b50-aaa8-e4cd7782f8bc";
32const LOW_CHAPTER_EFFECT_ID: &str = "019e27f4-f16b-7881-87cf-bc55605cb80b";
34const LOW_CHAPTER_EFFECT_MAX_CHAPTER: i64 = 16;
37const DUNGEON_TALENT_ID: &str = "019d1c47-5a66-76e5-bb18-6d115e5c6942";
38const BOSS_TALENT_ID: &str = "019d1c47-8cc2-7136-b95b-68e15d7efd0c";
39pub const COUNTERATTACK_PROJECTILE: &str = "019aeeed-5bcc-7dcc-a74f-589031a6b8f2";
40
41pub const MELEE_COL_OFFSET: i64 = 1;
59pub const RANGED_COL_OFFSET: i64 = 2;
60
61pub const DERIVED_DAMAGE_KEY: &str = "derived";
66
67pub const GUARD_CHARGES_ATTR: &str = "guard_charges";
72pub const GUARD_REDUCTION_ATTR: &str = "guard_reduction";
73pub const BOSS_OUTGOING_EQUIVALENCE_PPM_ATTR: &str = "boss_outgoing_equivalence_ppm";
77pub const FORMATION_ADVANCE_CELLS: i64 = 4;
80const DEFAULT_BETWEEN_WAVE_SPAWN_DELAY_TICKS: u64 = 500;
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct BetweenWaveBehavior {
86 pub spawn_delay_ticks: u64,
87 pub advance_formation: bool,
88}
89
90pub fn between_wave_behavior(config: &GameConfig, fight: &ActiveFight) -> BetweenWaveBehavior {
94 let dungeon_template = fight
95 .dungeon
96 .as_ref()
97 .and_then(|dungeon| config.dungeon_template(dungeon.id));
98
99 BetweenWaveBehavior {
100 spawn_delay_ticks: dungeon_template
101 .and_then(|dungeon| dungeon.between_wave_spawn_delay_ticks)
102 .unwrap_or(DEFAULT_BETWEEN_WAVE_SPAWN_DELAY_TICKS),
103 advance_formation: dungeon_template
104 .and_then(|dungeon| dungeon.advance_formation_between_waves)
105 .unwrap_or(true),
106 }
107}
108
109pub fn later_wave_entrance_floor_ticks(config: &GameConfig, fight: &ActiveFight) -> u64 {
112 if between_wave_behavior(config, fight).advance_formation {
113 config.fight_settings.formation_advance_ticks
114 } else {
115 0
116 }
117}
118
119const DEFAULT_TIME_PER_CELL: f64 = 500.0;
121
122pub fn formation_step_duration_ticks(lookups: &ContentLookups, cells: f64) -> u64 {
125 let mult = if lookups.ally_run_speed_mult > 0.0 {
126 lookups.ally_run_speed_mult
127 } else {
128 1.0
129 };
130 ((DEFAULT_TIME_PER_CELL / mult) * cells).floor() as u64
131}
132
133pub trait FightSink {
140 fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error>;
141 fn push_attack(
142 &mut self,
143 delay: u64,
144 duration: u64,
145 target: Uuid,
146 origin: CombatEventOrigin,
147 ) -> Result<(), anyhow::Error>;
148 fn push_run(
149 &mut self,
150 coords: Coordinates,
151 duration: u64,
152 origin: CombatEventOrigin,
153 ) -> Result<(), anyhow::Error>;
154
155 fn claim_once(&mut self, slot: OnceSlot, armed: i64) -> i64;
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum OnceSlot {
174 CritChance,
176 DamageBonus,
178 LawArm,
180 LawThisArm,
186}
187
188impl OnceSlot {
189 const COUNT: usize = 4;
190
191 fn index(self) -> usize {
192 match self {
193 Self::CritChance => 0,
194 Self::DamageBonus => 1,
195 Self::LawArm => 2,
196 Self::LawThisArm => 3,
197 }
198 }
199}
200
201#[derive(Default)]
204pub struct NativeSink {
205 pub events: Vec<OverlordEvent>,
206 pub casts: Vec<crate::behaviors::combat::start_cast::StartCastAbilityScriptResult>,
207 once_claimed: [bool; OnceSlot::COUNT],
210}
211
212impl FightSink for NativeSink {
213 fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error> {
214 self.events.push(event);
215 Ok(())
216 }
217
218 fn push_attack(
219 &mut self,
220 delay: u64,
221 duration: u64,
222 target: Uuid,
223 origin: CombatEventOrigin,
224 ) -> Result<(), anyhow::Error> {
225 self.casts.push(
226 crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
227 delay_ticks: Some(delay),
228 animation_duration_ticks: Some(duration),
229 target_entity_id: Some(target),
230 origin,
231 ..Default::default()
232 },
233 );
234 Ok(())
235 }
236
237 fn push_run(
238 &mut self,
239 coords: Coordinates,
240 duration: u64,
241 origin: CombatEventOrigin,
242 ) -> Result<(), anyhow::Error> {
243 self.casts.push(
244 crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
245 coordinates: Some(coords),
246 run_duration_ticks: Some(duration),
247 origin,
248 ..Default::default()
249 },
250 );
251 Ok(())
252 }
253
254 fn claim_once(&mut self, slot: OnceSlot, armed: i64) -> i64 {
255 let claimed = &mut self.once_claimed[slot.index()];
256 if *claimed {
257 return 0;
258 }
259 *claimed = true;
260 armed
261 }
262}
263
264fn push_event<ES>(sink: &mut dyn FightSink, event: ES) -> Result<(), anyhow::Error>
267where
268 ES: event_system::event::EventStruct<OverlordEvent> + 'static,
269{
270 sink.push_event(event.to_enum())
271}
272
273pub struct OriginSink<'a> {
293 inner: &'a mut dyn FightSink,
294 origin: CombatEventOrigin,
295}
296
297impl<'a> OriginSink<'a> {
298 pub fn new(inner: &'a mut dyn FightSink, origin: CombatEventOrigin) -> Self {
300 Self { inner, origin }
301 }
302
303 pub fn proc(inner: &'a mut dyn FightSink) -> Self {
305 Self::new(inner, CombatEventOrigin::Proc)
306 }
307
308 fn unrestricted(&self) -> bool {
310 self.origin.is_core()
311 }
312}
313
314impl FightSink for OriginSink<'_> {
315 fn push_event(&mut self, mut event: OverlordEvent) -> Result<(), anyhow::Error> {
316 if self.unrestricted() {
321 return self.inner.push_event(event);
322 }
323 if !event.set_origin(self.origin) {
324 anyhow::bail!(
325 "a {} producer may not emit {event}: it carries no provenance of its own, \
326 so its combat output would surface as Core",
327 self.origin
328 );
329 }
330 self.inner.push_event(event)
331 }
332
333 fn push_attack(
334 &mut self,
335 delay: u64,
336 duration: u64,
337 target: Uuid,
338 origin: CombatEventOrigin,
339 ) -> Result<(), anyhow::Error> {
340 self.inner
341 .push_attack(delay, duration, target, origin.merge(self.origin))
342 }
343
344 fn push_run(
345 &mut self,
346 coords: Coordinates,
347 duration: u64,
348 origin: CombatEventOrigin,
349 ) -> Result<(), anyhow::Error> {
350 self.inner
351 .push_run(coords, duration, origin.merge(self.origin))
352 }
353
354 fn claim_once(&mut self, slot: OnceSlot, armed: i64) -> i64 {
357 self.inner.claim_once(slot, armed)
358 }
359}
360
361pub trait EffectCb {
373 fn on_apply(
376 &mut self,
377 sink: &mut dyn FightSink,
378 effect: &EffectTpl,
379 target: &Entity,
380 ) -> Result<(), anyhow::Error>;
381
382 fn on_change(
385 &mut self,
386 sink: &mut dyn FightSink,
387 effect: &EffectTpl,
388 target: &Entity,
389 new_stacks: i64,
390 old_stacks: i64,
391 ) -> Result<(), anyhow::Error>;
392}
393
394pub struct NoopEffectCb;
397
398impl EffectCb for NoopEffectCb {
399 fn on_apply(
400 &mut self,
401 _sink: &mut dyn FightSink,
402 _effect: &EffectTpl,
403 _target: &Entity,
404 ) -> Result<(), anyhow::Error> {
405 Ok(())
406 }
407
408 fn on_change(
409 &mut self,
410 _sink: &mut dyn FightSink,
411 _effect: &EffectTpl,
412 _target: &Entity,
413 _new_stacks: i64,
414 _old_stacks: i64,
415 ) -> Result<(), anyhow::Error> {
416 Ok(())
417 }
418}
419
420fn attr_get(entity: &Entity, attr_code: &str) -> f64 {
425 entity.attributes.0.get(attr_code).copied().unwrap_or(0) as f64
426}
427
428fn attr_present(entity: &Entity, attr_code: &str) -> bool {
429 entity.attributes.0.contains_key(attr_code)
430}
431
432fn incr_attr(
434 sink: &mut dyn FightSink,
435 entity_id: Uuid,
436 attr: &str,
437 delta: i64,
438) -> Result<(), anyhow::Error> {
439 push_event(
440 sink,
441 OverlordEventEntityIncrAttribute {
442 entity_id,
443 attribute: attr.to_string(),
444 delta,
445 },
446 )
447}
448
449pub fn get_entity_stat(lookups: &ContentLookups, entity: &Entity, stat_code: &str) -> f64 {
452 let mut stat = attr_get(entity, stat_code);
453 if let Some(attr_id) = lookups.attribute_by_code.get(stat_code)
454 && let Some(base) = lookups.attribute_base_value.get(attr_id)
455 {
456 stat += base;
457 }
458 let bonus = attr_get(entity, &format!("{stat_code}.bonus"));
459 let mod_v = (attr_get(entity, &format!("{stat_code}.mod")) / 10000.0 + 1.0)
463 .max(balance::MIN_STAT_MOD_MULT);
464 (stat + bonus) * mod_v
465}
466
467fn has_attribute_base(lookups: &ContentLookups, stat_code: &str) -> bool {
472 lookups
473 .attribute_by_code
474 .get(stat_code)
475 .is_some_and(|id| lookups.attribute_base_value.contains_key(id))
476}
477
478pub fn stat_throw(
479 random: &GameRng,
480 lookups: &ContentLookups,
481 entity: &Entity,
482 stat_code: &str,
483 bonus: f64,
484) -> bool {
485 stat_throw_scaled(random, lookups, entity, stat_code, bonus, 1.0)
486}
487
488pub fn entropy_throw(
498 random: &GameRng,
499 lookups: &ContentLookups,
500 entity: &Entity,
501 stat_code: &str,
502 bonus: f64,
503 scale: f64,
504) -> bool {
505 let stat_value = (get_entity_stat(lookups, entity, stat_code) + bonus) * scale;
506 if stat_value <= 0.0 {
507 return false;
508 }
509 let add = stat_value.min(10000.0) as i64;
510 if add >= 10000 {
511 return true;
512 }
513 entity
514 .proc_entropy
515 .bump(stat_code, add, || (random.random_f64() * 10000.0) as i64)
516}
517
518pub fn entropy_throw_p(random: &GameRng, entity: &Entity, key: &str, p: f64) -> bool {
521 if p <= 0.0 {
522 return false;
523 }
524 if p >= 1.0 {
525 return true;
526 }
527 entity.proc_entropy.bump(key, (p * 10000.0) as i64, || {
528 (random.random_f64() * 10000.0) as i64
529 })
530}
531
532pub fn stat_throw_scaled(
537 random: &GameRng,
538 lookups: &ContentLookups,
539 entity: &Entity,
540 stat_code: &str,
541 bonus: f64,
542 scale: f64,
543) -> bool {
544 let stat_value = get_entity_stat(lookups, entity, stat_code) + bonus;
545 if stat_value > 0.0 {
546 let success = stat_value / 10000.0 * scale;
547 return random.random_f64() < success;
548 }
549 false
550}
551
552fn entity_get_tpl_cast_time(config: &GameConfig, entity: &Entity) -> i64 {
560 if let Some(tpl_id) = entity.entity_template_id
561 && let Some(tpl) = config.entity_template(tpl_id)
562 {
563 return tpl.cast_time as i64;
564 }
565 if let Some(class_id) = entity.class_id
566 && let Some(class) = config.class(class_id)
567 {
568 return class.cast_time as i64;
569 }
570 0
571}
572
573fn get_distance_to(a: &Entity, b: &Entity) -> i64 {
574 (b.coordinates.x - a.coordinates.x).abs()
575}
576
577fn get_target_with_lowest_hp(targets: &[Entity]) -> Option<&Entity> {
578 targets.iter().min_by_key(|e| e.hp)
579}
580
581fn get_closest_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
582 targets.iter().min_by_key(|t| {
583 (t.coordinates.x - caster.coordinates.x).abs()
584 + (t.coordinates.y - caster.coordinates.y).abs()
585 })
586}
587
588fn get_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
589 if caster.entity_template_id.is_some() {
590 get_closest_target(caster, targets)
591 } else {
592 get_target_with_lowest_hp(targets)
593 }
594}
595
596pub fn add_entity_attr(
602 sink: &mut dyn FightSink,
603 entity: &Entity,
604 attr_code: &str,
605 value: i64,
606) -> Result<(), anyhow::Error> {
607 incr_attr(sink, entity.id, attr_code, value)
608}
609
610pub fn add_entity_stat_mod(
615 sink: &mut dyn FightSink,
616 entity: &Entity,
617 stat: &str,
618 value: i64,
619) -> Result<(), anyhow::Error> {
620 add_entity_attr(sink, entity, &format!("{stat}.mod"), value)
621}
622
623pub fn set_entity_attr(
626 sink: &mut dyn FightSink,
627 entity: &Entity,
628 attr_code: &str,
629 value: f64,
630) -> Result<(), anyhow::Error> {
631 let current = attr_get(entity, attr_code);
632 incr_attr(sink, entity.id, attr_code, (value - current).round() as i64)
633}
634
635pub fn touch_enemy(rng: &GameRng, lookups: &ContentLookups, target: &Entity) -> bool {
642 let evasion = get_entity_stat(lookups, target, "evasion");
643 if evasion <= 0.0 {
644 return true;
645 }
646 let dodge = evasion / (evasion + balance::tuning().k_dodge);
647 !entropy_throw_p(rng, target, "evasion.dodge", dodge)
648}
649
650pub fn screen_shake(sink: &mut dyn FightSink, power: i64) -> Result<(), anyhow::Error> {
652 let mut data = CustomEventData::default();
653 data.add("screen_shake_power", power);
654 push_event(
655 sink,
656 OverlordEventFightVisualEvent {
657 effect_type: "screen_shake".to_string(),
658 effect_data: data,
659 },
660 )
661}
662
663pub fn heal_entity(
667 sink: &mut dyn FightSink,
668 by_entity_id: Option<EntityId>,
669 entity: &Entity,
670 amount: f64,
671 source: CombatSource,
672) -> Result<Option<i64>, anyhow::Error> {
673 let max_heal = entity.max_hp as i64 - entity.hp as i64;
674 let heal = max_heal.min(amount.floor() as i64);
675 if heal <= 0 {
676 return Ok(None);
677 }
678 push_event(
679 sink,
680 OverlordEventHeal {
681 by_entity_id,
682 entity_id: entity.id,
683 heal: heal as u64,
684 origin: CombatEventOrigin::Core,
685 source,
686 },
687 )?;
688 Ok(Some(heal))
689}
690
691#[allow(clippy::too_many_arguments)]
701pub fn damage_entity(
702 sink: &mut dyn FightSink,
703 rng: &GameRng,
704 lookups: &ContentLookups,
705 by_entity_id: Option<EntityId>,
706 entity: &Entity,
707 raw_dmg: f64,
708 mut custom_data: CustomEventData,
709 source: CombatSource,
710) -> Result<Option<i64>, anyhow::Error> {
711 if attr_present(entity, "godmode") {
712 return Ok(None);
713 }
714 let armor = get_entity_stat(lookups, entity, "armor");
715 let shield = attr_get(entity, "shield");
716 if !has_attribute_base(lookups, "received_damage") {
723 return Ok(None);
724 }
725
726 let mut raw_dmg = raw_dmg;
734 {
735 use crate::mechanics::stones as stone;
736
737 if stone::attr(entity, stone::GUARD_CHARGES) > 0 {
739 let charges = stone::attr(entity, stone::GUARD_CHARGES);
740 incr_attr(sink, entity.id, stone::GUARD_CHARGES, -1)?;
741 let retaliation = stone::attr(entity, stone::GUARD_RETALIATION);
742 if charges <= 1 {
743 incr_attr(sink, entity.id, stone::GUARD_RETALIATION, -retaliation)?;
744 }
745 if let (Some(attacker), true) = (by_entity_id, retaliation > 0) {
746 push_event(
749 sink,
750 OverlordEventDamage {
751 by_entity_id: Some(entity.id),
752 entity_id: attacker,
753 damage: retaliation as u64,
754 damage_data: CustomEventData::default(),
755 origin: CombatEventOrigin::Proc,
756 source: CombatSource::Retaliation,
757 },
758 )?;
759 }
760 return Ok(Some(0));
761 }
762
763 let mut reduction = stone::attr(entity, stone::INCOMING_REDUCTION).clamp(0, 10_000);
767 let charged = stone::attr(entity, stone::HIT_REDUCTION_CHARGES);
768 if charged > 0 {
769 let one_shot = stone::attr(entity, stone::HIT_REDUCTION).clamp(0, 10_000);
770 incr_attr(sink, entity.id, stone::HIT_REDUCTION_CHARGES, -1)?;
771 if charged <= 1 {
772 incr_attr(
773 sink,
774 entity.id,
775 stone::HIT_REDUCTION,
776 -stone::attr(entity, stone::HIT_REDUCTION),
777 )?;
778 }
779 reduction = 10_000 - ((10_000 - reduction) * (10_000 - one_shot)) / 10_000;
780 }
781 if reduction > 0 {
782 raw_dmg *= (10_000 - reduction) as f64 / 10_000.0;
783 }
784 }
785 let mut received_damage_k =
791 get_entity_stat(lookups, entity, "received_damage").max(balance::MIN_RECEIVED_DAMAGE_K);
792
793 if entropy_throw(rng, lookups, entity, "block", 0.0, 1.0) {
794 received_damage_k *= 0.5;
795 custom_data.add("block", 1);
796 let heal = crate::mechanics::class_passives::block_heal(entity);
800 if heal > 0.0 {
801 heal_entity(
804 sink,
805 Some(entity.id),
806 entity,
807 heal,
808 CombatSource::Regeneration,
809 )?;
810 }
811 }
812
813 if attr_get(entity, GUARD_CHARGES_ATTR) >= 1.0 {
816 let reduction = (attr_get(entity, GUARD_REDUCTION_ATTR) / 10000.0).clamp(0.0, 0.95);
817 if reduction > 0.0 {
818 received_damage_k *= 1.0 - reduction;
819 custom_data.add("guard", 1);
820 }
821 incr_attr(sink, entity.id, GUARD_CHARGES_ATTR, -1)?;
822 }
823
824 let dmg_k = balance::armor_k(armor) * received_damage_k / 10000.0;
825 let mut dmg = raw_dmg * dmg_k;
826
827 if crate::mechanics::stones::attr(entity, crate::mechanics::stones::DAMAGE_FLOOR) > 0 {
831 let survivable = entity.hp.saturating_sub(1) as f64;
832 dmg = dmg.min(survivable);
833 if dmg <= 0.0 {
838 return Ok(Some(0));
839 }
840 }
841 let res = (dmg.floor() as i64).max(1);
846 if shield == 0.0 {
847 push_event(
848 sink,
849 OverlordEventDamage {
850 by_entity_id,
851 entity_id: entity.id,
852 damage: res.max(0) as u64,
853 damage_data: custom_data,
854 origin: CombatEventOrigin::Core,
855 source,
856 },
857 )?;
858 } else if dmg > shield {
859 incr_attr(sink, entity.id, "shield", -(shield as i64))?;
860 let dmg_hp = (dmg - shield).floor() as i64;
861 custom_data.add("shield_damage", shield as i64);
862 push_event(
863 sink,
864 OverlordEventDamage {
865 by_entity_id,
866 entity_id: entity.id,
867 damage: dmg_hp.max(0) as u64,
868 damage_data: custom_data,
869 origin: CombatEventOrigin::Core,
870 source,
871 },
872 )?;
873 } else {
874 incr_attr(sink, entity.id, "shield", -(dmg.round() as i64))?;
875 }
876 Ok(Some(res))
877}
878
879#[derive(Clone, Debug, Default)]
881pub struct AttackParams {
882 pub no_counterattack: bool,
883 pub crit_chance_bonus: f64,
884 pub is_crit: Option<bool>,
886 pub power: Option<f64>,
887 pub dot_power: Option<f64>,
888 pub derived: bool,
893}
894
895impl AttackParams {
896 pub fn default_power() -> Self {
898 Self {
899 power: Some(1.0),
900 ..Default::default()
901 }
902 }
903}
904
905#[allow(clippy::too_many_arguments)]
911pub fn attack(
912 sink: &mut dyn FightSink,
913 rng: &GameRng,
914 lookups: &ContentLookups,
915 effects: &mut dyn EffectCb,
916 player_id: Uuid,
917 caster: &Entity,
918 target: &Entity,
919 params: &AttackParams,
920 source: CombatSource,
921) -> Result<Option<i64>, anyhow::Error> {
922 if !touch_enemy(rng, lookups, target) {
923 push_event(
924 sink,
925 OverlordEventEvasion {
926 entity_id: target.id,
927 origin: CombatEventOrigin::Core,
928 },
929 )?;
930 return Ok(None);
931 }
932 if !params.no_counterattack
937 && entropy_throw(
938 rng,
939 lookups,
940 target,
941 "counterattack_chance",
942 0.0,
943 caster.attributes.wave_share(),
944 )
945 {
946 push_event(
947 sink,
948 OverlordEventCounterAttack {
949 by_entity_id: target.id,
950 to_entity_id: caster.id,
951 duration_ticks: 200,
952 origin: CombatEventOrigin::Core,
953 },
954 )?;
955 push_event(
956 sink,
957 OverlordEventStartCastProjectile {
958 by_entity_id: target.id,
959 to_entity_id: caster.id,
960 projectile_id: Uuid::parse_str(COUNTERATTACK_PROJECTILE).unwrap(),
961 level: 1,
962 delay: 0,
963 origin: CombatEventOrigin::Core,
964 source: CombatSource::Counterattack,
965 },
966 )?;
967 }
968
969 if entropy_throw(rng, lookups, caster, "deceit", 0.0, 1.0) {
970 let pool = ["vulnerability", "weakness"];
971 let idx = rng.random_index(pool.len());
972 let code = pool[idx];
973 change_entity_effect_duration(
974 sink,
975 lookups,
976 effects,
977 target,
978 code,
979 (balance::DECEIT_DEBUFF_DURATION * 1000.0).floor() as i64,
980 )?;
981 }
982
983 let attack = get_entity_stat(lookups, caster, "attack");
984
985 let mut crit_chance_bonus = params.crit_chance_bonus;
991 if params.is_crit.is_none() {
992 let armed = crate::mechanics::stones::armed_magnitude(
993 caster,
994 crate::mechanics::stones::NEXT_ATTACK_CRIT,
995 crate::mechanics::stones::NEXT_ATTACK_CRIT_CHARGES,
996 )
997 .unwrap_or(0);
998 let bonus = if armed > 0 {
999 sink.claim_once(OnceSlot::CritChance, armed)
1000 } else {
1001 0
1002 };
1003 if bonus > 0 {
1004 crit_chance_bonus += bonus as f64 / 10_000.0;
1005 incr_attr(
1006 sink,
1007 caster.id,
1008 crate::mechanics::stones::NEXT_ATTACK_CRIT_CHARGES,
1009 -1,
1010 )?;
1011 if attr_get(caster, crate::mechanics::stones::NEXT_ATTACK_CRIT_CHARGES) <= 1.0 {
1012 incr_attr(
1013 sink,
1014 caster.id,
1015 crate::mechanics::stones::NEXT_ATTACK_CRIT,
1016 -bonus,
1017 )?;
1018 }
1019 }
1020 }
1021
1022 let mut dmg_mod = 1.0;
1023 let is_crit = match params.is_crit {
1024 Some(v) => v,
1025 None => entropy_throw(
1029 rng,
1030 lookups,
1031 caster,
1032 "crit_chance",
1033 crit_chance_bonus,
1034 balance::tuning().crit_chance_scale,
1035 ),
1036 };
1037 if is_crit {
1038 dmg_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
1039 }
1040
1041 if params.power.is_some() {
1058 let stone_armed = crate::mechanics::stones::armed_magnitude(
1060 caster,
1061 crate::mechanics::stones::NEXT_ATTACK_BONUS,
1062 crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES,
1063 )
1064 .unwrap_or(0)
1065 .max(0);
1066 let stone_bonus = if stone_armed > 0 {
1067 sink.claim_once(OnceSlot::DamageBonus, stone_armed)
1068 } else {
1069 0
1070 };
1071 if stone_bonus > 0 {
1072 incr_attr(
1073 sink,
1074 caster.id,
1075 crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES,
1076 -1,
1077 )?;
1078 if attr_get(caster, crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES) <= 1.0 {
1081 incr_attr(
1082 sink,
1083 caster.id,
1084 crate::mechanics::stones::NEXT_ATTACK_BONUS,
1085 -stone_bonus,
1086 )?;
1087 }
1088 }
1089
1090 let law_arm_key = crate::logic::combat_facts::current_cast_kind(caster)
1100 .map(crate::mechanics::cores::law_arm_key);
1101 let law_armed = law_arm_key
1102 .map(|key| attr_get(caster, key) as i64)
1103 .unwrap_or(0)
1104 .max(0);
1105 let law_bonus = if law_armed > 0 {
1106 sink.claim_once(OnceSlot::LawArm, law_armed)
1107 } else {
1108 0
1109 };
1110 if let Some(key) = law_arm_key
1111 && law_bonus > 0
1112 {
1113 incr_attr(sink, caster.id, key, -law_bonus)?;
1114 }
1115
1116 let this_armed = attr_get(caster, crate::mechanics::cores::ARM_THIS) as i64;
1121 let this_bonus = if this_armed > 0 {
1122 sink.claim_once(OnceSlot::LawThisArm, this_armed)
1123 } else {
1124 0
1125 };
1126 if this_bonus > 0 {
1127 incr_attr(
1128 sink,
1129 caster.id,
1130 crate::mechanics::cores::ARM_THIS,
1131 -this_bonus,
1132 )?;
1133 }
1134
1135 let bonus = stone_bonus + law_bonus + this_bonus;
1138 if bonus > 0 {
1139 dmg_mod *= 1.0 + bonus as f64 / 10000.0;
1140 }
1141 }
1142
1143 let mut dmg_dealt = None;
1149 let outgoing_equivalence = match attr_get(caster, BOSS_OUTGOING_EQUIVALENCE_PPM_ATTR) {
1155 ppm if ppm > 0.0 => ppm / 1_000_000.0,
1156 _ => 1.0,
1157 };
1158
1159 if let Some(power) = params.power {
1160 let dmg = attack * power * dmg_mod * outgoing_equivalence;
1161 let mut data = CustomEventData::default();
1162 if is_crit {
1163 data.add("crit", 1);
1164 }
1165 if params.derived {
1166 data.add(DERIVED_DAMAGE_KEY, 1);
1167 }
1168 dmg_dealt = damage_entity(
1169 sink,
1170 rng,
1171 lookups,
1172 Some(caster.id),
1173 target,
1174 dmg * balance::DMG_K,
1175 data,
1176 source,
1177 )?;
1178 }
1179 if let Some(dot_power) = params.dot_power {
1180 apply_entity_over_time_effect(
1181 sink,
1182 target,
1183 attack * dot_power * dmg_mod * outgoing_equivalence * balance::DMG_K,
1184 "dot",
1185 source,
1186 )?;
1187 }
1188
1189 if caster.id == player_id && !params.derived {
1190 let power = params.power.unwrap_or(0.0);
1191 if is_crit || power > 3.5 {
1192 let _ = screen_shake(sink, 25);
1193 }
1194 }
1195 Ok(dmg_dealt)
1196}
1197
1198#[derive(Clone, Debug, Default)]
1200pub struct SpellHealParams {
1201 pub is_crit: Option<bool>,
1202 pub power: Option<f64>,
1203 pub hot_power: Option<f64>,
1204}
1205
1206impl SpellHealParams {
1207 pub fn default_power() -> Self {
1208 Self {
1209 power: Some(1.0),
1210 ..Default::default()
1211 }
1212 }
1213}
1214
1215pub fn spell_heal(
1219 sink: &mut dyn FightSink,
1220 rng: &GameRng,
1221 lookups: &ContentLookups,
1222 caster: &Entity,
1223 target: &Entity,
1224 params: &SpellHealParams,
1225 source: CombatSource,
1226) -> Result<Option<i64>, anyhow::Error> {
1227 let attack = get_entity_stat(lookups, caster, "attack");
1228 let mut heal_mod = 1.0;
1229 let is_crit = match params.is_crit {
1230 Some(v) => v,
1231 None => entropy_throw(
1232 rng,
1233 lookups,
1234 caster,
1235 "crit_chance",
1236 0.0,
1237 balance::tuning().crit_chance_scale,
1238 ),
1239 };
1240 if is_crit {
1241 heal_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
1242 }
1243 let mut heal_event = None;
1244 if let Some(power) = params.power {
1245 heal_event = heal_entity(
1246 sink,
1247 Some(caster.id),
1248 target,
1249 attack * power * heal_mod * balance::DMG_K,
1250 source,
1251 )?;
1252 }
1253 if let Some(hot_power) = params.hot_power {
1254 apply_entity_over_time_effect(
1255 sink,
1256 target,
1257 attack * hot_power * heal_mod * balance::DMG_K,
1258 "hot",
1259 source,
1260 )?;
1261 }
1262 Ok(heal_event)
1263}
1264
1265pub fn apply_entity_effect(
1268 sink: &mut dyn FightSink,
1269 lookups: &ContentLookups,
1270 effects: &mut dyn EffectCb,
1271 target: &Entity,
1272 effect_code: &str,
1273 duration_seconds: f64,
1274) -> Result<(), anyhow::Error> {
1275 change_entity_effect_duration(
1276 sink,
1277 lookups,
1278 effects,
1279 target,
1280 effect_code,
1281 (duration_seconds * 1000.0).floor() as i64,
1282 )
1283}
1284
1285pub fn change_entity_effect_duration(
1289 sink: &mut dyn FightSink,
1290 lookups: &ContentLookups,
1291 effects: &mut dyn EffectCb,
1292 target: &Entity,
1293 effect_code: &str,
1294 duration: i64,
1295) -> Result<(), anyhow::Error> {
1296 let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
1297 Some(t) => t.clone(),
1298 None => {
1299 return Err(anyhow::anyhow!(
1300 "apply_entity_effect: unknown effect code {effect_code}"
1301 ));
1302 }
1303 };
1304
1305 let duration_attr = format!("effect.{effect_code}.duration");
1306 let current_duration_ticks = attr_get(target, &duration_attr) as i64;
1307 let max_duration = effect_tpl.max_duration_ticks.unwrap_or(5000);
1308 let new_duration = (current_duration_ticks + duration).min(max_duration);
1309 incr_attr(
1310 sink,
1311 target.id,
1312 &duration_attr,
1313 new_duration - current_duration_ticks,
1314 )?;
1315 if current_duration_ticks == 0 {
1316 push_event(
1317 sink,
1318 OverlordEventEntityApplyEffect {
1319 entity_id: target.id,
1320 effect_id: effect_tpl.id,
1321 origin: CombatEventOrigin::Core,
1322 },
1323 )?;
1324 }
1325 effects.on_apply(sink, &effect_tpl, target)?;
1326 Ok(())
1327}
1328
1329fn source_ability(source: CombatSource) -> Option<Uuid> {
1331 match source {
1332 CombatSource::AbilityCast { ability_id }
1333 | CombatSource::AbilityDerived { ability_id }
1334 | CombatSource::PetUlt { ability_id } => Some(ability_id),
1335 _ => None,
1336 }
1337}
1338
1339const OVER_TIME_SRC_BITS: u32 = 43;
1346const OVER_TIME_SRC_CHUNKS: u32 = 3;
1347
1348fn over_time_source_attr(kind: &str, chunk: u32) -> String {
1356 format!("effect.{kind}.src.{chunk}")
1357}
1358
1359fn over_time_source_chunk(chunk: u32) -> (u32, u128) {
1363 let shift = OVER_TIME_SRC_BITS * chunk;
1364 let width = OVER_TIME_SRC_BITS.min(128 - shift);
1365 (shift, (1u128 << width) - 1)
1366}
1367
1368fn set_over_time_source(
1371 sink: &mut dyn FightSink,
1372 target: &Entity,
1373 kind: &str,
1374 source: CombatSource,
1375) -> Result<(), anyhow::Error> {
1376 let bits = source_ability(source).map_or(0u128, |id| id.as_u128());
1377 for chunk in 0..OVER_TIME_SRC_CHUNKS {
1378 let (shift, mask) = over_time_source_chunk(chunk);
1379 let attr = over_time_source_attr(kind, chunk);
1380 let value = ((bits >> shift) & mask) as i64;
1381 let current = target.attributes.0.get(&attr).copied().unwrap_or(0);
1382 if current != value {
1383 incr_attr(sink, target.id, &attr, value - current)?;
1384 }
1385 }
1386 Ok(())
1387}
1388
1389pub fn over_time_tick_source(entity: &Entity, kind: &str, fallback: CombatSource) -> CombatSource {
1392 let mut bits = 0u128;
1393 for chunk in 0..OVER_TIME_SRC_CHUNKS {
1394 let (shift, mask) = over_time_source_chunk(chunk);
1395 let attr = over_time_source_attr(kind, chunk);
1396 let value = entity.attributes.0.get(&attr).copied().unwrap_or(0).max(0) as u128 & mask;
1398 bits |= value << shift;
1399 }
1400 if bits == 0 {
1401 return fallback;
1402 }
1403 CombatSource::AbilityDerived {
1407 ability_id: Uuid::from_u128(bits),
1408 }
1409}
1410
1411pub fn apply_entity_over_time_effect(
1414 sink: &mut dyn FightSink,
1415 target: &Entity,
1416 amount: f64,
1417 kind: &str,
1418 source: CombatSource,
1419) -> Result<(), anyhow::Error> {
1420 let effect_id = if kind == "dot" {
1421 Uuid::parse_str(DOT_EFFECT_ID).unwrap()
1422 } else {
1423 Uuid::parse_str(HOT_EFFECT_ID).unwrap()
1424 };
1425 let per_tick = (amount / 5.0).floor() as i64;
1426 for i in 1..=5 {
1427 let tick_attr = format!("effect.{kind}.tick.{i}");
1428 incr_attr(sink, target.id, &tick_attr, per_tick)?;
1429 }
1430 set_over_time_source(sink, target, kind, source)?;
1431 let next_attr = format!("effect.{kind}.next");
1432 if !attr_present(target, &next_attr) {
1433 incr_attr(sink, target.id, &next_attr, 1)?;
1435 push_event(
1436 sink,
1437 OverlordEventEntityApplyEffect {
1438 entity_id: target.id,
1439 effect_id,
1440 origin: CombatEventOrigin::Core,
1441 },
1442 )?;
1443 }
1444 Ok(())
1445}
1446
1447pub fn remove_entity_effect(
1450 sink: &mut dyn FightSink,
1451 lookups: &ContentLookups,
1452 effects: &mut dyn EffectCb,
1453 target: &Entity,
1454 effect_code: &str,
1455) -> Result<(), anyhow::Error> {
1456 let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
1457 Some(t) => t.clone(),
1458 None => {
1459 return Err(anyhow::anyhow!(
1460 "remove_entity_effect: unknown effect code {effect_code}"
1461 ));
1462 }
1463 };
1464 let stacks_attr = format!("effect.{effect_code}.stacks");
1465 let current_stacks = attr_get(target, &stacks_attr) as i64;
1466 incr_attr(sink, target.id, &stacks_attr, -current_stacks)?;
1467 effects.on_change(sink, &effect_tpl, target, 0, current_stacks)?;
1468 Ok(())
1469}
1470
1471#[allow(clippy::too_many_arguments)]
1476pub fn init_fight(
1477 sink: &mut dyn FightSink,
1478 lookups: &ContentLookups,
1479 fight: &ActiveFight,
1480 state: &OverlordState,
1481 fight_template_id: Uuid,
1482) -> Result<(), anyhow::Error> {
1483 let is_dungeon = lookups
1484 .fight_template_is_dungeon
1485 .get(&fight_template_id)
1486 .copied()
1487 .unwrap_or(false);
1488 let is_bossfight = lookups
1489 .fight_template_is_bossfight
1490 .get(&fight_template_id)
1491 .copied()
1492 .unwrap_or(false);
1493
1494 let player_id = fight.player_id;
1495 let party_player_id = fight.party_player_id;
1496 let entities = fight.entities.clone();
1497 let regen_effect = Uuid::parse_str(REGEN_EFFECT_ID).unwrap();
1498 let low_chapter_effect = Uuid::parse_str(LOW_CHAPTER_EFFECT_ID).unwrap();
1499 let dungeon_talent = Uuid::parse_str(DUNGEON_TALENT_ID).unwrap();
1500 let boss_talent = Uuid::parse_str(BOSS_TALENT_ID).unwrap();
1501
1502 for entity in &entities {
1503 if attr_present(entity, "regeneration_rate") {
1504 push_event(
1505 sink,
1506 OverlordEventEntityApplyEffect {
1507 entity_id: entity.id,
1508 effect_id: regen_effect,
1509 origin: CombatEventOrigin::Core,
1510 },
1511 )?;
1512 }
1513
1514 let mut dungeon_level: Option<i64> = None;
1515 let mut boss_level: Option<i64> = None;
1516
1517 if entity.id == player_id {
1518 dungeon_level = state
1519 .character_state
1520 .talent_levels
1521 .get(&dungeon_talent)
1522 .copied();
1523 boss_level = state
1524 .character_state
1525 .talent_levels
1526 .get(&boss_talent)
1527 .copied();
1528 if state.character_state.character.current_chapter_level
1529 <= LOW_CHAPTER_EFFECT_MAX_CHAPTER
1530 {
1531 push_event(
1532 sink,
1533 OverlordEventEntityApplyEffect {
1534 entity_id: entity.id,
1535 effect_id: low_chapter_effect,
1536 origin: CombatEventOrigin::Core,
1537 },
1538 )?;
1539 }
1540 }
1541 if Some(entity.id) == party_player_id {
1542 let party = &state.party;
1543 if let Some(party_state) = &party.party_state {
1544 dungeon_level = party_state.talent_levels.get(&dungeon_talent).copied();
1545 boss_level = party_state.talent_levels.get(&boss_talent).copied();
1546 if let Some(adjusted) = party.party_adjusted_power {
1547 let raw = party_state.character.power as f64;
1548 if raw > 0.0 {
1549 let adjust_k = adjusted as f64 / raw;
1550 let stat_k = adjust_k.sqrt();
1551 let current_attack = attr_get(entity, "attack");
1552 let delta =
1553 (current_attack * stat_k).floor() as i64 - current_attack as i64;
1554 incr_attr(sink, entity.id, "attack", delta)?;
1555 let new_max_hp = ((entity.hp as f64) * stat_k).floor() as u64;
1556 push_event(
1557 sink,
1558 OverlordEventSetMaxHp {
1559 entity_id: entity.id,
1560 new_max_hp,
1561 new_hp: new_max_hp,
1562 },
1563 )?;
1564 }
1565 }
1566 }
1567 }
1568
1569 let mut talent_attack_mod = 0;
1576 if is_dungeon && let Some(level) = dungeon_level {
1577 talent_attack_mod += 200 * level;
1578 }
1579 if is_bossfight && let Some(level) = boss_level {
1580 talent_attack_mod += 200 * level;
1581 }
1582 if talent_attack_mod != 0 {
1583 incr_attr(sink, entity.id, "attack.mod", talent_attack_mod)?;
1584 }
1585 }
1586 Ok(())
1587}
1588
1589#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
1595pub struct WaveFightData {
1596 pub entities: Vec<WaveEntityPower>,
1598 pub waves: Vec<Vec<WaveSpawn>>,
1600 pub time: f64,
1602 pub power: Option<f64>,
1605 pub stream_active_count: Option<u64>,
1612 pub reward_mob_budget: Option<u64>,
1617 pub summon_wave_at_hp_fraction: Option<f64>,
1622 pub enemy_damage_mult: Option<f64>,
1626}
1627
1628#[derive(Clone, Debug, PartialEq, serde::Serialize)]
1629pub struct WaveEntityPower {
1630 pub entity_id: Option<String>,
1635 pub power: Option<f64>,
1638}
1639
1640#[derive(Clone, Debug, PartialEq, serde::Serialize)]
1641pub struct WaveSpawn {
1642 pub entity_id: String,
1643 pub delay: Option<f64>,
1648 pub position: Option<Coordinates>,
1651 pub cooldown_seconds: Option<f64>,
1654 pub row: Option<i64>,
1656}
1657
1658#[derive(Clone, Debug)]
1659struct SimEnemy {
1660 eff_damage: f64,
1661 ttk: f64,
1662 delay: f64,
1663 gated_pos: Option<i64>,
1667 is_boss: bool,
1670}
1671
1672fn wave_exit_gate_plan(wave: &[WaveSpawn], cap: Option<u64>) -> Vec<Option<i64>> {
1680 let mut plan = vec![None; wave.len()];
1681 let Some(cap) = cap else {
1682 return plan;
1683 };
1684 let cap = cap.max(1) as usize;
1685 if wave.len() <= cap {
1686 return plan;
1687 }
1688 let mut order: Vec<usize> = (0..wave.len()).collect();
1689 order.sort_by_key(|&i| (spawn_exit_cooldown_ms(&wave[i]), i));
1690 for (queue_idx, &spawn_idx) in order[cap..].iter().enumerate() {
1691 plan[spawn_idx] = Some(queue_idx as i64 + 1);
1692 }
1693 plan
1694}
1695
1696fn spawn_authored_row(spawn: &WaveSpawn) -> i64 {
1699 spawn
1700 .row
1701 .or(spawn.position.as_ref().map(|p| p.y))
1702 .unwrap_or(1)
1703 .clamp(0, BATTLEFIELD_HEIGHT - 1)
1704}
1705
1706fn wave_row_plan(
1721 wave: &[WaveSpawn],
1722 gate_plan: &[Option<i64>],
1723 config: &GameConfig,
1724 lookups: &ContentLookups,
1725 live_enemy_cells: &[(i64, i64)],
1726 anchor_x: i64,
1727 is_summon_wave: bool,
1728) -> Vec<i64> {
1729 let mut plan: Vec<i64> = wave.iter().map(spawn_authored_row).collect();
1730 let mut occupancy: std::collections::HashMap<i64, [i64; BATTLEFIELD_HEIGHT as usize]> =
1731 std::collections::HashMap::new();
1732 for &(x, y) in live_enemy_cells {
1733 let col = x - anchor_x;
1734 if (col == MELEE_COL_OFFSET || col == RANGED_COL_OFFSET)
1735 && (0..BATTLEFIELD_HEIGHT).contains(&y)
1736 {
1737 occupancy
1738 .entry(col)
1739 .or_insert([0; BATTLEFIELD_HEIGHT as usize])[y as usize] += 1;
1740 }
1741 }
1742 let mut order: Vec<usize> = (0..wave.len())
1743 .filter(|&i| gate_plan[i].is_none())
1744 .collect();
1745 order.sort_by_key(|&i| (spawn_exit_cooldown_ms(&wave[i]), i));
1746 for i in order {
1747 let col = match Uuid::parse_str(&wave[i].entity_id) {
1748 Ok(tpl) if template_has_melee_ability(config, lookups, tpl) => MELEE_COL_OFFSET,
1749 Ok(_) => RANGED_COL_OFFSET,
1750 Err(_) => continue,
1751 };
1752 let counts = occupancy
1753 .entry(col)
1754 .or_insert([0; BATTLEFIELD_HEIGHT as usize]);
1755 let cands: [i64; 4] = if is_summon_wave {
1756 [0, 2, plan[i], 1]
1757 } else {
1758 [plan[i], 1, 0, 2]
1759 };
1760 let mut best = cands[0];
1761 for cand in cands {
1762 if counts[cand as usize] < counts[best as usize] {
1763 best = cand;
1764 }
1765 }
1766 plan[i] = best;
1767 counts[best as usize] += 1;
1768 }
1769 plan
1770}
1771
1772pub fn gated_release_row(fight: &ActiveFight, released_id: Uuid, entrance_offset: i64) -> i64 {
1779 let Some(released) = fight.entities.iter().find(|e| e.id == released_id) else {
1780 return 1;
1781 };
1782 let park_x = released.coordinates.x;
1783 let target_x = park_x - entrance_offset;
1784 let authored_row = released.coordinates.y.clamp(0, BATTLEFIELD_HEIGHT - 1);
1785 let mut counts = [0i64; BATTLEFIELD_HEIGHT as usize];
1786 for e in &fight.entities {
1787 if e.id == released_id
1788 || e.team != EntityTeam::Enemy
1789 || e.hp == 0
1790 || e.attributes.0.contains_key("exit_gated")
1791 {
1792 continue;
1793 }
1794 let (ex, ey) = e
1795 .move_target
1796 .as_ref()
1797 .map(|t| (t.x, t.y))
1798 .unwrap_or((e.coordinates.x, e.coordinates.y));
1799 if (ex == target_x || ex == park_x) && (0..BATTLEFIELD_HEIGHT).contains(&ey) {
1800 counts[ey as usize] += 1;
1801 }
1802 }
1803 let mut best = authored_row;
1804 for cand in [authored_row, 1, 0, 2] {
1805 if counts[cand as usize] < counts[best as usize] {
1806 best = cand;
1807 }
1808 }
1809 best
1810}
1811
1812fn spawn_exit_cooldown_ms(spawn: &WaveSpawn) -> i64 {
1817 (spawn.cooldown_seconds.unwrap_or(0.0).max(0.0) * 1000.0) as i64
1818}
1819
1820fn slot_arrival_delay_seconds(
1832 fight_settings: &configs::fighting::FightSettings,
1833 cooldown_ms: i64,
1834 wave_min_cooldown_ms: i64,
1835 floor_ms: i64,
1836) -> f64 {
1837 let ms_per_cell = if cooldown_ms == wave_min_cooldown_ms {
1838 fight_settings.wave_entrance_rush_ms_per_cell
1839 } else {
1840 fight_settings.wave_entrance_walk_ms_per_cell
1841 };
1842 let run_ms = fight_settings.wave_entrance_offset_cells.max(0) * ms_per_cell as i64;
1843 ((cooldown_ms - floor_ms).max(0) + run_ms) as f64 / 1000.0
1844}
1845
1846fn sim_wave_choose_next_target(enemies: &[SimEnemy]) -> Option<usize> {
1847 let mut fastest = 1800.0f64;
1848 let mut idx = None;
1849 for (i, e) in enemies.iter().enumerate() {
1850 if e.gated_pos.is_some() {
1851 continue; }
1853 if e.delay <= 0.0 && e.ttk < fastest {
1854 idx = Some(i);
1855 fastest = e.ttk;
1861 }
1862 }
1863 idx
1864}
1865
1866fn sim_wave_choose_sim_time(enemies: &[SimEnemy], current_target: Option<usize>) -> f64 {
1867 let mut next_step = 1800.0f64;
1868 for e in enemies {
1869 if e.gated_pos.is_none() && e.delay > 0.0 && e.delay < next_step {
1870 next_step = e.delay;
1871 }
1872 }
1873 if let Some(idx) = current_target
1874 && let Some(t) = enemies.get(idx)
1875 && t.ttk < next_step
1876 {
1877 next_step = t.ttk;
1878 }
1879 next_step
1880}
1881
1882fn sim_wave_advance(
1883 enemies: &mut Vec<SimEnemy>,
1884 mob_damage: &mut f64,
1885 gated_release_run: f64,
1886 step_cap: Option<f64>,
1887) {
1888 let target = sim_wave_choose_next_target(enemies);
1889 let mut time = sim_wave_choose_sim_time(enemies, target);
1890 if let Some(cap) = step_cap {
1893 time = time.min(cap);
1894 }
1895 let mut killed = false;
1896 if let Some(t) = target {
1897 let dmg: f64 = enemies
1903 .iter()
1904 .filter(|e| e.delay <= 0.0 && e.gated_pos.is_none())
1905 .map(|e| e.eff_damage)
1906 .sum();
1907 *mob_damage += dmg * time;
1908 enemies[t].ttk -= time;
1909 if enemies[t].ttk <= 0.0 {
1910 enemies.remove(t);
1911 killed = true;
1912 }
1913 }
1914 for e in enemies.iter_mut() {
1915 if e.gated_pos.is_none() && e.delay > 0.0 {
1916 e.delay = (e.delay - time).max(0.0);
1917 }
1918 }
1919 if killed
1926 && let Some(next) = enemies
1927 .iter_mut()
1928 .filter(|e| e.gated_pos.is_some())
1929 .min_by_key(|e| e.gated_pos)
1930 {
1931 next.gated_pos = None;
1932 next.delay = gated_release_run;
1933 }
1934}
1935
1936#[allow(clippy::too_many_arguments)]
1942pub fn spawn_wave(
1943 sink: &mut dyn FightSink,
1944 rng: &GameRng,
1945 config: &GameConfig,
1946 lookups: &ContentLookups,
1947 fight: &ActiveFight,
1948 fight_data: &WaveFightData,
1949 base_power: f64,
1950 current_chapter: i64,
1951 fight_type: &str,
1952) -> Result<(), anyhow::Error> {
1953 const POWER_EFF_HP: f64 = 0.75;
1954 const POWER_ATTACK: f64 = 1.0 - POWER_EFF_HP;
1955 const SUMMON_ATTACK_SHARE: f64 = 0.15;
1963
1964 let is_dungeon = lookups
1969 .fight_template_is_dungeon
1970 .get(&fight.fight_id)
1971 .copied()
1972 .unwrap_or(false);
1973 let power = balance::enemy_power_scalar(base_power, current_chapter, fight_type, is_dungeon);
1974
1975 let hp_k = balance::hp_k_for_chapter(config, current_chapter);
1976 let eff = power / balance::BASE_POWER as f64;
1977 let mut eff_hp = balance::BASE_HP * (eff * hp_k).powf(0.5);
1978 let mut eff_attack = balance::BASE_ATTACK * (eff / hp_k).powf(0.5);
1979
1980 eff_hp *= balance::tuning().wave_damage_skew;
1985 eff_attack /= balance::tuning().wave_hp_skew;
1986 eff_hp *= fight_data.enemy_damage_mult.unwrap_or(1.0);
1990
1991 let player_dps = eff_attack * balance::BASE_SPELL_EFF * balance::DMG_K;
1992
1993 let mut min_power: Option<f64> = None;
1996 for ent in &fight_data.entities {
1997 if let Some(p) = ent.power {
1998 min_power = Some(min_power.map(|mp| mp.min(p)).unwrap_or(p));
1999 }
2000 }
2001 let min_power = min_power.unwrap_or(1.0);
2002
2003 let mut entity_powers: std::collections::HashMap<String, f64> =
2006 std::collections::HashMap::new();
2007 for ent in &fight_data.entities {
2008 let Some(id) = &ent.entity_id else {
2009 continue;
2010 };
2011 let p = ent.power.unwrap_or(1.0);
2012 entity_powers.insert(id.clone(), p / min_power);
2013 }
2014
2015 let mut mobs_eff_hp = 0.0f64;
2016 for wave in &fight_data.waves {
2017 for spawn in wave {
2018 let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
2019 mobs_eff_hp += enemy_power.powf(POWER_EFF_HP);
2020 }
2021 }
2022
2023 let time = fight_data.time;
2024 let player_damage = player_dps * time;
2025 let mob_hp_norm = if mobs_eff_hp > 0.0 {
2026 player_damage / mobs_eff_hp
2027 } else {
2028 0.0
2029 };
2030 let mob_norm_ttk = if player_dps > 0.0 {
2031 mob_hp_norm / player_dps
2032 } else {
2033 0.0
2034 };
2035
2036 let wave1_floor_ms = config
2042 .require_fight_template(fight.fight_id)
2043 .ok()
2044 .and_then(|t| t.start_fight_delay_ticks)
2045 .unwrap_or(config.fight_settings.start_fight_delay_ticks_default)
2046 as i64;
2047 let later_floor_ms = later_wave_entrance_floor_ticks(config, fight) as i64;
2048
2049 let mut overall_damage = 0.0f64;
2050 let gated_release_run = (config.fight_settings.wave_entrance_offset_cells.max(0)
2053 * config.fight_settings.wave_entrance_walk_ms_per_cell as i64)
2054 as f64
2055 / 1000.0;
2056 let build_wave_roster = |wave: &[WaveSpawn], floor_ms: i64| -> Vec<SimEnemy> {
2065 let wave_min_cooldown_ms = wave.iter().map(spawn_exit_cooldown_ms).min().unwrap_or(0);
2066 let gate_plan = wave_exit_gate_plan(wave, fight_data.stream_active_count);
2067 let mut sim_enemies: Vec<SimEnemy> = Vec::new();
2068 for (spawn_idx, spawn) in wave.iter().enumerate() {
2069 let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
2070 let gated_pos = gate_plan[spawn_idx];
2071 let delay = if gated_pos.is_some() {
2072 0.0 } else {
2074 slot_arrival_delay_seconds(
2075 &config.fight_settings,
2076 spawn_exit_cooldown_ms(spawn),
2077 wave_min_cooldown_ms,
2078 floor_ms,
2079 )
2080 };
2081 let is_boss = Uuid::parse_str(&spawn.entity_id)
2082 .ok()
2083 .and_then(|u| lookups.entity_template_is_boss.get(&u).copied())
2084 .unwrap_or(false);
2085 sim_enemies.push(SimEnemy {
2086 eff_damage: enemy_power.powf(POWER_ATTACK),
2087 ttk: mob_norm_ttk * enemy_power.powf(POWER_EFF_HP),
2088 delay,
2089 gated_pos,
2090 is_boss,
2091 });
2092 }
2093 sim_enemies
2094 };
2095
2096 let summon_frac = fight_data.summon_wave_at_hp_fraction;
2105 let n_waves = fight_data.waves.len();
2106 let summon_overlap = summon_frac.filter(|_| {
2107 n_waves >= 2
2108 && fight_data.waves[..n_waves - 1]
2109 .iter()
2110 .any(|w| build_wave_roster(w, 0).iter().any(|e| e.is_boss))
2111 });
2112
2113 if let Some(frac) = summon_overlap {
2114 for (sim_wave_idx, wave) in fight_data.waves[..n_waves - 1].iter().enumerate() {
2115 let floor_ms = if sim_wave_idx == 0 {
2116 wave1_floor_ms
2117 } else {
2118 later_floor_ms
2119 };
2120 let mut sim_enemies = build_wave_roster(wave, floor_ms);
2121 let boss_orig_ttk = sim_enemies
2122 .iter()
2123 .find(|e| e.is_boss)
2124 .map(|e| e.ttk)
2125 .unwrap_or(0.0);
2126 let mut summon_pending = boss_orig_ttk > 0.0;
2128 let mut mob_damage = 0.0f64;
2129 let mut iter = 0;
2130 while !sim_enemies.is_empty() && iter < 800 {
2131 iter += 1;
2132 let boss_margin = sim_enemies
2136 .iter()
2137 .find(|e| e.is_boss)
2138 .map(|b| b.ttk - frac * boss_orig_ttk);
2139 if summon_pending {
2140 match boss_margin {
2141 Some(margin) if margin <= 1e-9 => {
2142 summon_pending = false;
2143 sim_enemies
2146 .extend(build_wave_roster(&fight_data.waves[n_waves - 1], 0));
2147 }
2148 None => summon_pending = false,
2151 _ => {}
2152 }
2153 }
2154 let step_cap = if summon_pending {
2155 boss_margin.map(|m| m.max(1e-6))
2156 } else {
2157 None
2158 };
2159 sim_wave_advance(
2160 &mut sim_enemies,
2161 &mut mob_damage,
2162 gated_release_run,
2163 step_cap,
2164 );
2165 }
2166 overall_damage += mob_damage;
2167 }
2168 } else {
2169 for (sim_wave_idx, wave) in fight_data.waves.iter().enumerate() {
2170 let floor_ms = if sim_wave_idx == 0 {
2171 wave1_floor_ms
2172 } else {
2173 later_floor_ms
2174 };
2175 let mut sim_enemies = build_wave_roster(wave, floor_ms);
2176 let mut mob_damage = 0.0f64;
2177 let mut iter = 0;
2178 while !sim_enemies.is_empty() && iter < 400 {
2179 iter += 1;
2180 sim_wave_advance(&mut sim_enemies, &mut mob_damage, gated_release_run, None);
2181 }
2182 overall_damage += mob_damage;
2183 }
2184 }
2185
2186 let mob_dps_norm = if overall_damage > 0.0 {
2187 eff_hp / overall_damage
2188 } else {
2189 0.0
2190 };
2191 let mob_attack_norm = mob_dps_norm / balance::BASE_SPELL_EFF / balance::DMG_K;
2192
2193 let wave_idx = (fight.current_wave - 1) as usize;
2194
2195 let Some(current_wave) = fight_data.waves.get(wave_idx) else {
2196 return Ok(());
2197 };
2198
2199 let player_x = fight
2203 .entities
2204 .iter()
2205 .find(|e| e.id == fight.player_id)
2206 .map(|e| e.coordinates.x)
2207 .unwrap_or(0);
2208 let is_summon_wave =
2213 fight_data.summon_wave_at_hp_fraction.is_some() && wave_idx + 1 == fight_data.waves.len();
2214 let advances_formation = between_wave_behavior(config, fight).advance_formation;
2215 let anchor_x = if wave_idx > 0 && !is_summon_wave && advances_formation {
2216 player_x + FORMATION_ADVANCE_CELLS
2217 } else {
2218 player_x
2219 };
2220
2221 let min_cooldown_ms = current_wave
2224 .iter()
2225 .map(spawn_exit_cooldown_ms)
2226 .min()
2227 .unwrap_or(0);
2228
2229 let gate_plan = wave_exit_gate_plan(current_wave, fight_data.stream_active_count);
2233
2234 let live: Vec<(i64, i64)> = fight
2238 .entities
2239 .iter()
2240 .filter(|e| e.team == EntityTeam::Enemy && e.hp > 0)
2241 .map(|e| {
2242 let c = e.move_target.as_ref().unwrap_or(&e.coordinates);
2243 (c.x, c.y)
2244 })
2245 .collect();
2246 let row_plan = wave_row_plan(
2247 current_wave,
2248 &gate_plan,
2249 config,
2250 lookups,
2251 &live,
2252 anchor_x,
2253 is_summon_wave,
2254 );
2255
2256 let total_mob_count: usize = fight_data.waves.iter().map(|w| w.len()).sum();
2263 let wave_share_per10000: Option<i64> = fight_data.reward_mob_budget.map(|budget| {
2264 if total_mob_count == 0 {
2265 10000
2266 } else {
2267 ((budget as f64 / total_mob_count as f64) * 10000.0).round() as i64
2268 }
2269 });
2270
2271 let mut spawn_evts = Vec::new();
2272 for (spawn_idx, spawn) in current_wave.iter().enumerate() {
2273 let enemy_tpl_id = match Uuid::parse_str(&spawn.entity_id) {
2274 Ok(u) => u,
2275 Err(_) => continue,
2276 };
2277 let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
2278 let enemy_hp_norm = enemy_power.powf(POWER_EFF_HP);
2279 let enemy_attack_norm = enemy_power.powf(POWER_ATTACK)
2284 * if is_summon_wave && summon_overlap.is_some() {
2285 SUMMON_ATTACK_SHARE
2286 } else {
2287 1.0
2288 };
2289 let mut enemy_hp = enemy_hp_norm * mob_hp_norm * balance::k_hp(current_chapter);
2298 let mut enemy_attack =
2299 enemy_attack_norm * mob_attack_norm * balance::k_out(current_chapter);
2300
2301 let has_big_hp_bar = lookups
2302 .entity_template_is_boss
2303 .get(&enemy_tpl_id)
2304 .copied()
2305 .unwrap_or(false);
2306
2307 let mut attrs = EntityAttributes::default();
2308 if has_big_hp_bar {
2317 let t = balance::tuning();
2318 let crit = t.boss_crit_chance;
2319 if crit > 0.0 {
2320 attrs.add("crit_chance", crit as i64);
2321 enemy_attack /= 1.0 + (crit / 10000.0).clamp(0.0, 1.0);
2322 }
2323 let armor = t.boss_armor;
2324 if armor > 0.0 {
2325 attrs.add("armor", armor as i64);
2326 enemy_hp *= balance::armor_k(armor);
2327 }
2328 attrs.add(
2334 BOSS_OUTGOING_EQUIVALENCE_PPM_ATTR,
2335 (balance::boss_outgoing_equivalence(current_chapter) * 1_000_000.0).round() as i64,
2336 );
2337 }
2338 attrs.add("attack", enemy_attack.floor() as i64);
2339 attrs.add("hp", enemy_hp.floor() as i64);
2340 attrs.add("speed", 10000);
2341
2342 let cooldown_ms = spawn_exit_cooldown_ms(spawn);
2343 let col_offset = if template_has_melee_ability(config, lookups, enemy_tpl_id) {
2349 MELEE_COL_OFFSET
2350 } else {
2351 RANGED_COL_OFFSET
2352 };
2353 let row = row_plan[spawn_idx];
2354 let position = Coordinates {
2357 x: anchor_x + col_offset + config.fight_settings.wave_entrance_offset_cells,
2358 y: row,
2359 };
2360 if let Some(queue_pos) = gate_plan[spawn_idx] {
2364 attrs.add("exit_gated", queue_pos);
2368 } else {
2369 attrs.add("exit_cooldown_ms", cooldown_ms);
2370 if cooldown_ms == min_cooldown_ms {
2371 attrs.add("entrance_rush", 1);
2372 }
2373 }
2374 if let Some(ws) = wave_share_per10000 {
2376 attrs.add("wave_share", ws);
2377 }
2378 if is_summon_wave {
2383 attrs.add("summoned", 1);
2384 }
2385
2386 let spawn_id = uuid::Builder::from_random_bytes(rng.random_bytes()).into_uuid();
2389
2390 let spawn_evt = OverlordEventSpawnEntity {
2391 id: spawn_id,
2392 entity_template_id: enemy_tpl_id,
2393 position,
2394 entity_team: EntityTeam::Enemy,
2395 has_big_hp_bar,
2396 entity_attributes: attrs,
2397 };
2398 spawn_evts.push(spawn_evt);
2399 }
2400
2401 if !is_summon_wave {
2406 let total_hp: i64 = spawn_evts
2407 .iter()
2408 .map(|evt| {
2409 evt.entity_attributes
2410 .0
2411 .get("hp")
2412 .copied()
2413 .unwrap_or(0)
2414 .max(0)
2415 })
2416 .sum();
2417 if total_hp > 0 {
2418 for evt in &mut spawn_evts {
2419 let hp = evt.entity_attributes.0.get("hp").copied().unwrap_or(0);
2420 let share = (hp.max(0) as f64 / total_hp as f64 * 10_000.0).round() as i64;
2421 evt.entity_attributes.add("gauge_hp_share", share);
2422 }
2423 } else {
2424 for evt in &mut spawn_evts {
2429 evt.entity_attributes.add("gauge_hp_share", -1);
2432 }
2433 tracing::error!(
2434 fight_id = %fight.fight_id,
2435 "spawn_wave: wave has no HP budget — gauge shares stamped to zero"
2436 );
2437 }
2438 }
2439 for spawn_evt in spawn_evts {
2440 push_event(sink, spawn_evt)?;
2441 }
2442 Ok(())
2443}
2444
2445pub fn slot_promotion_needed(fight: &ActiveFight) -> bool {
2454 let Some(player) = fight.entities.iter().find(|e| e.id == fight.player_id) else {
2455 return false;
2456 };
2457 if player.hp == 0 || player.move_target.is_some() {
2458 return false;
2459 }
2460 let p = player.coordinates.x;
2461 let mut far_occupied = false;
2462 for e in &fight.entities {
2463 if e.team != EntityTeam::Enemy || e.hp == 0 {
2464 continue;
2465 }
2466 let col = e
2467 .move_target
2468 .as_ref()
2469 .map(|t| t.x)
2470 .unwrap_or(e.coordinates.x);
2471 if col <= p + MELEE_COL_OFFSET {
2472 return false;
2473 }
2474 if col == p + RANGED_COL_OFFSET {
2475 far_occupied = true;
2476 }
2477 }
2478 far_occupied
2479}
2480
2481fn template_has_melee_ability(config: &GameConfig, lookups: &ContentLookups, tpl_id: Uuid) -> bool {
2486 config
2487 .entity_template(tpl_id)
2488 .map(|tpl| {
2489 tpl.ability_ids
2490 .iter()
2491 .any(|ability_id| lookups.ability_range.get(ability_id).copied().unwrap_or(0) <= 1)
2492 })
2493 .unwrap_or(true)
2494}
2495
2496pub fn wave_data_from_config(cfg: &essences::fighting::PrepareFightWaves) -> WaveFightData {
2500 WaveFightData {
2501 entities: cfg
2502 .entities
2503 .iter()
2504 .map(|e| WaveEntityPower {
2505 entity_id: e.entity_id.clone(),
2506 power: e.power,
2507 })
2508 .collect(),
2509 waves: cfg
2510 .waves
2511 .iter()
2512 .map(|wave| {
2513 wave.iter()
2514 .map(|s| WaveSpawn {
2515 entity_id: s.entity_id.clone(),
2516 delay: Some(s.delay.unwrap_or(0.0)),
2520 position: s.position.clone(),
2521 cooldown_seconds: s.cooldown_seconds,
2522 row: s.row,
2523 })
2524 .collect()
2525 })
2526 .collect(),
2527 time: cfg.time,
2528 power: Some(cfg.power),
2529 stream_active_count: cfg.stream_active_count,
2530 reward_mob_budget: cfg.reward_mob_budget,
2531 summon_wave_at_hp_fraction: cfg.summon_wave_at_hp_fraction,
2532 enemy_damage_mult: cfg.enemy_damage_mult,
2533 }
2534}
2535
2536fn is_valid_target(
2541 lookups: &ContentLookups,
2542 target: &Entity,
2543 ability: &Ability,
2544 caster: &Entity,
2545) -> bool {
2546 let target_type = lookups
2547 .ability_target_type
2548 .get(&ability.template_id)
2549 .map(|s| s.as_str())
2550 .unwrap_or("");
2551 let by_team = match target_type {
2552 "Enemy" => target.team != caster.team,
2553 "Ally" => target.team == caster.team,
2554 _ => false,
2555 };
2556 let range = lookups
2557 .ability_range
2558 .get(&ability.template_id)
2559 .copied()
2560 .unwrap_or(0);
2561 by_team && get_distance_to(caster, target) <= range
2562}
2563
2564#[allow(clippy::too_many_arguments)]
2569pub fn cast(
2570 sink: &mut dyn FightSink,
2571 rng: &GameRng,
2572 config: &GameConfig,
2573 lookups: &ContentLookups,
2574 caster: &Entity,
2575 _ability: &Ability,
2576 valid_targets: &[Entity],
2577 natural_casts_number: i64,
2578) -> Result<(), anyhow::Error> {
2579 let mut casts_number = natural_casts_number;
2580 let is_multicast = entropy_throw(rng, lookups, caster, "multicast_chance", 0.0, 1.0);
2581 if is_multicast {
2582 casts_number *= 2;
2583 }
2584
2585 let extra_swings = attr_get(caster, crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS) as i64;
2594 let extra_swings = extra_swings.max(0);
2595 if extra_swings > 0 {
2596 incr_attr(
2597 sink,
2598 caster.id,
2599 crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS,
2600 -extra_swings,
2601 )?;
2602 }
2603
2604 let total_swings = casts_number + extra_swings;
2605 let base_cast_time = entity_get_tpl_cast_time(config, caster) as f64;
2606 let cast_time = (base_cast_time / total_swings as f64).floor() as i64;
2609 for i in 0..total_swings {
2610 let target = if valid_targets.len() > 1 {
2611 if i == 0 {
2612 get_target(caster, valid_targets)
2613 .cloned()
2614 .unwrap_or_else(|| valid_targets[0].clone())
2615 } else {
2616 let idx = rng.random_index(valid_targets.len());
2617 valid_targets[idx].clone()
2618 }
2619 } else {
2620 valid_targets[0].clone()
2621 };
2622 let delay = (i * cast_time).max(0) as u64;
2623 let duration = cast_time.max(0) as u64;
2624 if i < casts_number {
2625 sink.push_attack(delay, duration, target.id, CombatEventOrigin::Core)?;
2626 } else {
2627 OriginSink::proc(sink).push_attack(
2628 delay,
2629 duration,
2630 target.id,
2631 CombatEventOrigin::Core,
2632 )?;
2633 }
2634 }
2635 Ok(())
2636}
2637
2638pub fn on_cast(
2643 sink: &mut dyn FightSink,
2644 rng: &GameRng,
2645 lookups: &ContentLookups,
2646 effects: &mut dyn EffectCb,
2647 caster: &Entity,
2648) -> Result<(), anyhow::Error> {
2649 if entropy_throw(rng, lookups, caster, "bravery", 0.0, 1.0) {
2650 let pool = ["protection", "empower"];
2651 let idx = rng.random_index(pool.len());
2652 let code = pool[idx];
2653 apply_entity_effect(
2654 sink,
2655 lookups,
2656 effects,
2657 caster,
2658 code,
2659 balance::BRAVERY_BUFF_DURATION,
2660 )?;
2661 }
2662 Ok(())
2663}
2664
2665pub fn entity_run(
2706 sink: &mut dyn FightSink,
2707 lookups: &ContentLookups,
2708 entity: &Entity,
2709 to: Coordinates,
2710) -> Result<(), anyhow::Error> {
2711 const DEFAULT_TIME_PER_CELL: f64 = 500.0;
2712 let speed = get_entity_stat(lookups, entity, "speed");
2713 if speed == 0.0 {
2714 return Ok(());
2715 }
2716 let speed_norm = speed / 10000.0;
2717 let dx = (to.x - entity.coordinates.x) as f64;
2720 let dy = (to.y - entity.coordinates.y) as f64;
2721 let distance = (dx * dx + dy * dy).sqrt();
2722 if distance == 0.0 {
2723 return Ok(());
2724 }
2725 let mut time_per_cell = DEFAULT_TIME_PER_CELL / speed_norm;
2726 if entity.team == EntityTeam::Ally && lookups.ally_run_speed_mult > 0.0 {
2730 time_per_cell /= lookups.ally_run_speed_mult;
2731 }
2732 let duration = (time_per_cell * distance).floor() as u64;
2733 sink.push_run(to, duration, CombatEventOrigin::Core)
2734}
2735
2736#[allow(clippy::too_many_arguments)]
2741pub fn try_cast(
2742 sink: &mut dyn FightSink,
2743 rng: &GameRng,
2744 config: &GameConfig,
2745 lookups: &ContentLookups,
2746 fight: &ActiveFight,
2747 caster: &Entity,
2748 ability_id: Uuid,
2749 casts: i64,
2750) -> Result<(), anyhow::Error> {
2751 if attr_present(caster, "sleep") {
2752 return Ok(());
2753 }
2754 if config.ability_template(ability_id).is_none() {
2755 return Err(anyhow::anyhow!("try_cast: unknown ability {ability_id}"));
2756 }
2757 let target_type = lookups
2758 .ability_target_type
2759 .get(&ability_id)
2760 .cloned()
2761 .unwrap_or_default();
2762 let ability_val = Ability {
2763 template_id: ability_id,
2764 level: 1,
2765 shards_amount: 0,
2766 };
2767 if target_type == "Self" {
2768 cast(
2769 sink,
2770 rng,
2771 config,
2772 lookups,
2773 caster,
2774 &ability_val,
2775 std::slice::from_ref(caster),
2776 casts,
2777 )?;
2778 return Ok(());
2779 }
2780 let mut valid_targets = Vec::new();
2781 for ent in &fight.entities {
2782 if is_valid_target(lookups, ent, &ability_val, caster) {
2783 valid_targets.push(ent.clone());
2784 }
2785 }
2786 if !valid_targets.is_empty() {
2787 cast(
2788 sink,
2789 rng,
2790 config,
2791 lookups,
2792 caster,
2793 &ability_val,
2794 &valid_targets,
2795 casts,
2796 )?;
2797 return Ok(());
2798 }
2799 Ok(())
2804}
2805
2806#[cfg(test)]
2807mod tests {
2808 use super::*;
2809
2810 fn entity_at(team: EntityTeam, x: i64) -> Entity {
2811 Entity {
2812 id: uuid::Uuid::new_v4(),
2813 team,
2814 coordinates: Coordinates { x, y: 1 },
2815 ..Default::default()
2816 }
2817 }
2818
2819 #[test]
2824 fn is_valid_target_requires_populated_target_type_and_range() {
2825 let ability_id = uuid::Uuid::new_v4();
2826 let ability = Ability {
2827 template_id: ability_id,
2828 level: 1,
2829 shards_amount: 0,
2830 };
2831 let caster = entity_at(EntityTeam::Ally, 0);
2832 let enemy_in_range = entity_at(EntityTeam::Enemy, 1);
2833 let enemy_out_of_range = entity_at(EntityTeam::Enemy, 5);
2834 let ally = entity_at(EntityTeam::Ally, 1);
2835
2836 let empty = ContentLookups::default();
2838 assert!(
2839 !is_valid_target(&empty, &enemy_in_range, &ability, &caster),
2840 "empty lookups must yield no valid target (this was the run-through bug)"
2841 );
2842
2843 let mut populated = ContentLookups::default();
2845 populated
2846 .ability_target_type
2847 .insert(ability_id, "Enemy".to_string());
2848 populated.ability_range.insert(ability_id, 1);
2849
2850 assert!(
2851 is_valid_target(&populated, &enemy_in_range, &ability, &caster),
2852 "an in-range enemy must be a valid target once lookups are populated"
2853 );
2854 assert!(
2855 !is_valid_target(&populated, &enemy_out_of_range, &ability, &caster),
2856 "an out-of-range enemy must not be targetable (caster should advance)"
2857 );
2858 assert!(
2859 !is_valid_target(&populated, &ally, &ability, &caster),
2860 "an ally must not be a valid target for an Enemy-typed ability"
2861 );
2862 }
2863
2864 #[test]
2868 fn fight_primitives_emit_core() {
2869 let mut target = entity_at(EntityTeam::Enemy, 1);
2870 target.hp = 10;
2871 target.max_hp = 100;
2872
2873 let mut sink = NativeSink::default();
2874 heal_entity(&mut sink, None, &target, 5.0, CombatSource::Other).unwrap();
2875
2876 assert_eq!(sink.events.len(), 1);
2877 assert_eq!(
2878 sink.events[0].combat_origin(),
2879 Some(CombatEventOrigin::Core)
2880 );
2881 assert!(sink.events[0].is_core_combat_event());
2882 }
2883
2884 #[test]
2889 fn modifier_scope_marks_emitted_events_proc() {
2890 let mut target = entity_at(EntityTeam::Enemy, 1);
2891 target.hp = 10;
2892 target.max_hp = 100;
2893
2894 let mut sink = NativeSink::default();
2895 {
2896 let mut modifier_sink = OriginSink::proc(&mut sink);
2898 heal_entity(&mut modifier_sink, None, &target, 5.0, CombatSource::Other).unwrap();
2899 }
2900
2901 assert_eq!(sink.events.len(), 1);
2902 assert_eq!(
2903 sink.events[0].combat_origin(),
2904 Some(CombatEventOrigin::Proc)
2905 );
2906 assert!(
2907 !sink.events[0].is_core_combat_event(),
2908 "a modifier-produced event must never read as Core"
2909 );
2910 }
2911
2912 #[test]
2915 fn modifier_scope_changes_nothing_but_provenance() {
2916 let mut target = entity_at(EntityTeam::Enemy, 1);
2917 target.hp = 10;
2918 target.max_hp = 100;
2919
2920 let mut core_sink = NativeSink::default();
2921 let core_applied =
2922 heal_entity(&mut core_sink, None, &target, 5.0, CombatSource::Other).unwrap();
2923
2924 let mut proc_sink = NativeSink::default();
2925 let proc_applied = {
2926 let mut modifier_sink = OriginSink::proc(&mut proc_sink);
2927 heal_entity(&mut modifier_sink, None, &target, 5.0, CombatSource::Other).unwrap()
2928 };
2929
2930 assert_eq!(core_applied, proc_applied);
2931 assert_eq!(
2932 core_sink.events[0]
2933 .clone()
2934 .with_origin(CombatEventOrigin::Proc),
2935 proc_sink.events[0]
2936 );
2937 }
2938
2939 #[test]
2944 fn modifier_scope_carries_provenance_onto_scheduled_casts_and_runs() {
2945 let mut sink = NativeSink::default();
2946 {
2947 let mut modifier_sink = OriginSink::proc(&mut sink);
2948 modifier_sink
2949 .push_attack(0, 100, uuid::Uuid::from_u128(1), CombatEventOrigin::Core)
2950 .unwrap();
2951 modifier_sink
2952 .push_run(Coordinates { x: 1, y: 1 }, 100, CombatEventOrigin::Core)
2953 .unwrap();
2954 }
2955 assert_eq!(sink.casts.len(), 2);
2956 for cast in &sink.casts {
2957 assert_eq!(cast.origin, CombatEventOrigin::Proc);
2958 }
2959
2960 let mut core_sink = NativeSink::default();
2962 {
2963 let mut scope = OriginSink::new(&mut core_sink, CombatEventOrigin::Core);
2964 scope
2965 .push_attack(0, 100, uuid::Uuid::from_u128(1), CombatEventOrigin::Core)
2966 .unwrap();
2967 scope
2968 .push_run(Coordinates { x: 1, y: 1 }, 100, CombatEventOrigin::Core)
2969 .unwrap();
2970 }
2971 assert_eq!(core_sink.casts.len(), 2);
2972 for cast in &core_sink.casts {
2973 assert_eq!(cast.origin, CombatEventOrigin::Core);
2974 }
2975 }
2976
2977 #[test]
2982 fn modifier_scope_refuses_events_it_cannot_mark() {
2983 let spawn = || OverlordEvent::SpawnEntity {
2984 id: uuid::Uuid::from_u128(1),
2985 entity_template_id: uuid::Uuid::from_u128(2),
2986 position: Coordinates { x: 1, y: 1 },
2987 entity_team: EntityTeam::Enemy,
2988 has_big_hp_bar: false,
2989 entity_attributes: EntityAttributes::default(),
2990 };
2991
2992 let mut sink = NativeSink::default();
2993 {
2994 let mut modifier_sink = OriginSink::proc(&mut sink);
2995 assert!(modifier_sink.push_event(spawn()).is_err());
2996 }
2997 assert!(sink.events.is_empty());
2998
2999 let mut core_sink = NativeSink::default();
3000 {
3001 let mut scope = OriginSink::new(&mut core_sink, CombatEventOrigin::Core);
3002 scope.push_event(spawn()).unwrap();
3003 }
3004 assert_eq!(core_sink.events.len(), 1, "Core scope stays unrestricted");
3005 }
3006
3007 #[test]
3012 fn a_core_scope_never_downgrades_an_already_marked_event() {
3013 let mut sink = NativeSink::default();
3014 {
3015 let mut scope = OriginSink::new(&mut sink, CombatEventOrigin::Core);
3016 scope
3017 .push_event(OverlordEvent::Heal {
3018 entity_id: uuid::Uuid::from_u128(1),
3019 heal: 1,
3020 origin: CombatEventOrigin::Proc,
3021 by_entity_id: None,
3022 source: essences::fight_breakdown::CombatSource::Other,
3023 })
3024 .unwrap();
3025 }
3026 assert_eq!(
3027 sink.events[0].combat_origin(),
3028 Some(CombatEventOrigin::Proc)
3029 );
3030
3031 let mut sink = NativeSink::default();
3033 {
3034 let mut scope = OriginSink::new(&mut sink, CombatEventOrigin::Core);
3035 scope
3036 .push_event(OverlordEvent::Heal {
3037 entity_id: uuid::Uuid::from_u128(1),
3038 heal: 1,
3039 origin: CombatEventOrigin::Core,
3040 by_entity_id: None,
3041 source: essences::fight_breakdown::CombatSource::Other,
3042 })
3043 .unwrap();
3044 }
3045 assert_eq!(
3046 sink.events[0].combat_origin(),
3047 Some(CombatEventOrigin::Core)
3048 );
3049 }
3050
3051 #[test]
3056 fn modifier_scope_refuses_to_launder_through_cast_effect_from_event() {
3057 let core_caller = OverlordEvent::Damage {
3058 by_entity_id: None,
3059 entity_id: uuid::Uuid::from_u128(1),
3060 damage: 1,
3061 damage_data: Default::default(),
3062 origin: CombatEventOrigin::Core,
3063 source: essences::fight_breakdown::CombatSource::Other,
3064 };
3065 let laundered = OverlordEvent::CastEffectFromEvent {
3066 entity_id: uuid::Uuid::from_u128(1),
3067 effect_id: uuid::Uuid::from_u128(2),
3068 caller_event: Box::new(core_caller),
3069 };
3070 assert!(
3071 laundered.carries_origin(),
3072 "the predicate is satisfied — that is exactly the trap"
3073 );
3074
3075 let mut sink = NativeSink::default();
3076 {
3077 let mut modifier_sink = OriginSink::proc(&mut sink);
3078 assert!(modifier_sink.push_event(laundered).is_err());
3079 }
3080 assert!(sink.events.is_empty());
3081 }
3082
3083 #[test]
3086 fn modifier_scope_marks_carriers_too() {
3087 let mut sink = NativeSink::default();
3088 {
3089 let mut modifier_sink = OriginSink::proc(&mut sink);
3090 modifier_sink
3091 .push_event(OverlordEvent::CastEffect {
3092 entity_id: uuid::Uuid::from_u128(1),
3093 effect_id: uuid::Uuid::from_u128(2),
3094 origin: CombatEventOrigin::Core,
3095 })
3096 .unwrap();
3097 }
3098 assert_eq!(
3099 sink.events[0].carried_origin(),
3100 Some(CombatEventOrigin::Proc)
3101 );
3102 assert_eq!(
3103 sink.events[0].combat_origin(),
3104 None,
3105 "a carrier is not trigger surface"
3106 );
3107 }
3108
3109 fn lookups_with_speed(speed: f64) -> ContentLookups {
3110 let speed_id = uuid::Uuid::new_v4();
3111 let mut lookups = ContentLookups::default();
3112 lookups
3113 .attribute_by_code
3114 .insert("speed".to_string(), speed_id);
3115 lookups.attribute_base_value.insert(speed_id, speed);
3116 lookups
3117 }
3118
3119 fn advance_lookups(ability_id: uuid::Uuid, range: i64) -> ContentLookups {
3120 let mut lookups = lookups_with_speed(10000.0);
3121 lookups
3122 .ability_target_type
3123 .insert(ability_id, "Enemy".to_string());
3124 lookups.ability_range.insert(ability_id, range);
3125 lookups
3126 }
3127
3128 fn hero_ability_id() -> Uuid {
3132 Uuid::parse_str("da6c582b-7364-40bd-9b2d-946d8e20eaac").unwrap()
3133 }
3134
3135 fn run_try_cast(caster: &Entity, others: Vec<Entity>, lookups: &ContentLookups) -> NativeSink {
3139 use rand::SeedableRng;
3140 let config = configs::tests_game_config::generate_game_config_for_tests();
3141 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3142 let mut entities = vec![caster.clone()];
3143 entities.extend(others);
3144 let fight = ActiveFight {
3145 entities,
3146 ..Default::default()
3147 };
3148 let mut sink = NativeSink::default();
3149 try_cast(
3150 &mut sink,
3151 &rng,
3152 &config,
3153 lookups,
3154 &fight,
3155 caster,
3156 hero_ability_id(),
3157 1,
3158 )
3159 .unwrap();
3160 sink
3161 }
3162
3163 #[test]
3167 fn try_cast_without_target_in_range_stands_still() {
3168 let mut hero = entity_at(EntityTeam::Ally, 1);
3169 hero.hp = 100;
3170 let mut enemy = entity_at(EntityTeam::Enemy, 5);
3171 enemy.hp = 100;
3172 let lookups = advance_lookups(hero_ability_id(), 1);
3173 let sink = run_try_cast(&hero, vec![enemy], &lookups);
3174 assert!(sink.casts.is_empty(), "out of range: no cast and no run");
3175 assert!(sink.events.is_empty(), "out of range: no events either");
3176 }
3177
3178 fn living_enemy_at(x: i64) -> Entity {
3179 let mut e = entity_at(EntityTeam::Enemy, x);
3180 e.hp = 100;
3181 e
3182 }
3183
3184 fn promo_fight(player_x: i64, enemies: Vec<Entity>) -> ActiveFight {
3185 let mut player = entity_at(EntityTeam::Ally, player_x);
3186 player.hp = 100;
3187 let player_id = player.id;
3188 let mut entities = vec![player];
3189 entities.extend(enemies);
3190 ActiveFight {
3191 player_id,
3192 entities,
3193 ..Default::default()
3194 }
3195 }
3196
3197 #[test]
3199 fn promotion_fires_when_near_empty_and_far_occupied() {
3200 let fight = promo_fight(5, vec![living_enemy_at(5 + RANGED_COL_OFFSET)]);
3201 assert!(slot_promotion_needed(&fight));
3202 }
3203
3204 #[test]
3207 fn promotion_blocked_by_near_column() {
3208 let fight = promo_fight(5, vec![living_enemy_at(5 + MELEE_COL_OFFSET)]);
3209 assert!(!slot_promotion_needed(&fight));
3210
3211 let mut runner = living_enemy_at(9);
3212 runner.move_target = Some(Coordinates {
3213 x: 5 + MELEE_COL_OFFSET,
3214 y: 1,
3215 });
3216 let fight = promo_fight(5, vec![runner, living_enemy_at(5 + RANGED_COL_OFFSET)]);
3217 assert!(!slot_promotion_needed(&fight));
3218 }
3219
3220 #[test]
3223 fn promotion_ignores_parked_spawns_beyond_far_column() {
3224 let fight = promo_fight(5, vec![living_enemy_at(5 + RANGED_COL_OFFSET + 4)]);
3225 assert!(!slot_promotion_needed(&fight));
3226 }
3227
3228 #[test]
3231 fn promotion_requires_standing_living_player() {
3232 let mut fight = promo_fight(5, vec![living_enemy_at(5 + RANGED_COL_OFFSET)]);
3233 fight.entities[0].move_target = Some(Coordinates { x: 6, y: 1 });
3234 assert!(!slot_promotion_needed(&fight));
3235
3236 fight.entities[0].move_target = None;
3237 fight.entities[0].hp = 0;
3238 assert!(!slot_promotion_needed(&fight));
3239
3240 let mut corpse = living_enemy_at(5 + RANGED_COL_OFFSET);
3241 corpse.hp = 0;
3242 let fight = promo_fight(5, vec![corpse]);
3243 assert!(!slot_promotion_needed(&fight));
3244 }
3245
3246 #[test]
3249 fn entity_run_duration_uses_euclidean_distance() {
3250 let entity = entity_at(EntityTeam::Ally, 0); let lookups = lookups_with_speed(10000.0);
3252
3253 let mut sink = NativeSink::default();
3254 entity_run(&mut sink, &lookups, &entity, Coordinates { x: 1, y: 2 }).unwrap();
3255
3256 assert_eq!(sink.casts.len(), 1);
3257 assert_eq!(
3258 sink.casts[0].run_duration_ticks,
3259 Some(707),
3260 "√2 cells × 500ms, floored"
3261 );
3262 }
3263
3264 #[test]
3268 fn get_entity_stat_mod_floor_prevents_zeroing() {
3269 let attack_id = uuid::Uuid::new_v4();
3270 let mut lookups = ContentLookups::default();
3271 lookups
3272 .attribute_by_code
3273 .insert("attack".to_string(), attack_id);
3274 lookups.attribute_base_value.insert(attack_id, 600.0);
3275
3276 let mut weak = Entity {
3278 id: uuid::Uuid::new_v4(),
3279 ..Default::default()
3280 };
3281 weak.attributes.add("attack.mod", -50_000);
3282 let v = get_entity_stat(&lookups, &weak, "attack");
3283 assert!(v > 0.0, "floored stat must stay positive, got {v}");
3284 assert!(
3285 (v - 600.0 * balance::MIN_STAT_MOD_MULT).abs() < 1e-9,
3286 "attack.mod must floor at MIN_STAT_MOD_MULT (×0.05), got {v}"
3287 );
3288
3289 let neutral = Entity {
3291 id: uuid::Uuid::new_v4(),
3292 ..Default::default()
3293 };
3294 assert!((get_entity_stat(&lookups, &neutral, "attack") - 600.0).abs() < 1e-9);
3295 }
3296
3297 #[test]
3303 fn touch_enemy_dodge_entropy_rate_and_asymptote() {
3304 let k = balance::tuning().k_dodge;
3305 let mut target = Entity {
3306 id: uuid::Uuid::new_v4(),
3307 ..Default::default()
3308 };
3309 target.attributes.add("evasion", k as i64);
3310
3311 let rng = GameRng::from_values(vec![0.0]);
3313 let touched: Vec<bool> = (0..10)
3314 .map(|_| touch_enemy(&rng, &ContentLookups::default(), &target))
3315 .collect();
3316 let dodges = touched.iter().filter(|t| !**t).count();
3317 assert_eq!(dodges, 5, "dodge 0.5 ⇒ exactly every 2nd touch dodged");
3318 for w in touched.windows(2) {
3319 assert_ne!(w[0], w[1], "50% entropy dodge must strictly alternate");
3320 }
3321
3322 let mut glass = Entity {
3325 id: uuid::Uuid::new_v4(),
3326 ..Default::default()
3327 };
3328 glass.attributes.add("evasion", 10_000_000);
3329 let rng = GameRng::from_values(vec![0.0]);
3330 let touched_count = (0..10_000)
3331 .filter(|_| touch_enemy(&rng, &ContentLookups::default(), &glass))
3332 .count();
3333 assert!(
3334 touched_count >= 1,
3335 "even huge evasion must not become full immunity (got {touched_count} touches)"
3336 );
3337 }
3338
3339 #[test]
3349 fn damage_requires_received_damage_base_value() {
3350 use rand::SeedableRng;
3351
3352 let target = Entity {
3353 id: uuid::Uuid::new_v4(),
3354 hp: 1000,
3355 max_hp: 1000,
3356 ..Default::default()
3357 };
3358 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3359
3360 let empty = ContentLookups::default();
3363 let mut sink = NativeSink::default();
3364 let res = damage_entity(
3365 &mut sink,
3366 &rng,
3367 &empty,
3368 None,
3369 &target,
3370 100.0,
3371 CustomEventData::default(),
3372 CombatSource::Other,
3373 )
3374 .unwrap();
3375 assert!(
3376 res.is_none(),
3377 "missing attribute_base_value cancels all damage (loud no-damage guard)"
3378 );
3379
3380 let rd_id = uuid::Uuid::new_v4();
3382 let mut lookups = ContentLookups::default();
3383 lookups
3384 .attribute_by_code
3385 .insert("received_damage".to_string(), rd_id);
3386 lookups.attribute_base_value.insert(rd_id, 10000.0);
3387
3388 let mut sink = NativeSink::default();
3389 let res = damage_entity(
3390 &mut sink,
3391 &rng,
3392 &lookups,
3393 None,
3394 &target,
3395 100.0,
3396 CustomEventData::default(),
3397 CombatSource::Other,
3398 )
3399 .unwrap();
3400 assert!(
3401 res.is_some_and(|d| d > 0),
3402 "with received_damage base populated, a hit must deal damage"
3403 );
3404
3405 let mut tank = target.clone();
3409 tank.attributes.add("received_damage.mod", -50_000); let mut sink = NativeSink::default();
3411 let res = damage_entity(
3412 &mut sink,
3413 &rng,
3414 &lookups,
3415 None,
3416 &tank,
3417 100.0,
3418 CustomEventData::default(),
3419 CombatSource::Other,
3420 )
3421 .unwrap();
3422 assert!(
3423 res.is_some_and(|d| d > 0),
3424 "stacked protection must not make an entity unkillable (received_damage floor)"
3425 );
3426 }
3427
3428 fn spawn_wave_enemy_hp(power: Option<f64>, is_dungeon: bool) -> i64 {
3432 use rand::SeedableRng;
3433
3434 let enemy_id = uuid::Uuid::new_v4();
3435 let fight_data = WaveFightData {
3436 entities: vec![WaveEntityPower {
3437 entity_id: Some(enemy_id.to_string()),
3438 power: Some(1.0),
3439 }],
3440 waves: vec![vec![WaveSpawn {
3441 entity_id: enemy_id.to_string(),
3442 delay: Some(0.0),
3443 position: Some(Coordinates { x: 3, y: 3 }),
3444 cooldown_seconds: None,
3445 row: None,
3446 }]],
3447 time: 1.0,
3448 power,
3449 stream_active_count: None,
3450 reward_mob_budget: None,
3451 summon_wave_at_hp_fraction: None,
3452 enemy_damage_mult: None,
3453 };
3454
3455 let fight = ActiveFight {
3458 current_wave: 1,
3459 ..Default::default()
3460 };
3461
3462 let config = configs::tests_game_config::generate_game_config_for_tests();
3463 let mut lookups = ContentLookups::default();
3464 lookups
3465 .fight_template_is_dungeon
3466 .insert(fight.fight_id, is_dungeon);
3467 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3468 let mut sink = NativeSink::default();
3469
3470 spawn_wave(
3471 &mut sink,
3472 &rng,
3473 &config,
3474 &lookups,
3475 &fight,
3476 &fight_data,
3477 fight_data.power.unwrap_or(0.0),
3478 1,
3479 "CampaignFight",
3480 )
3481 .unwrap();
3482
3483 let spawn = sink
3484 .events
3485 .iter()
3486 .find_map(|ev| match ev {
3487 OverlordEvent::SpawnEntity {
3488 entity_attributes, ..
3489 } => Some(entity_attributes),
3490 _ => None,
3491 })
3492 .expect("spawn_wave must emit a SpawnEntity for the current wave");
3493
3494 spawn.0.get("hp").copied().unwrap_or(0)
3496 }
3497
3498 #[test]
3509 fn spawn_wave_enemy_hp_requires_nonzero_base_power() {
3510 assert_eq!(
3512 spawn_wave_enemy_hp(Some(0.0), true),
3513 0,
3514 "base_power 0 must reproduce the zero-HP bug on the dungeon path"
3515 );
3516
3517 assert!(
3519 spawn_wave_enemy_hp(Some(100.0), true) > 0,
3520 "a non-zero reference power must produce wave enemies with positive HP"
3521 );
3522
3523 assert!(
3526 spawn_wave_enemy_hp(Some(0.0), false) > 0,
3527 "campaign waves take power from the signed chapter curve, not base_power"
3528 );
3529 }
3530
3531 fn spawn_wave_attack_for(spawns: &[(Option<f64>, Option<f64>)]) -> i64 {
3535 spawn_wave_attack_capped(spawns, None)
3536 }
3537
3538 fn spawn_wave_attack_capped(spawns: &[(Option<f64>, Option<f64>)], cap: Option<u64>) -> i64 {
3539 use rand::SeedableRng;
3540 let enemy_id = uuid::Uuid::from_u128(7);
3541 let wave: Vec<WaveSpawn> = spawns
3542 .iter()
3543 .map(|(cooldown, delay)| WaveSpawn {
3544 entity_id: enemy_id.to_string(),
3545 delay: *delay,
3546 position: Some(Coordinates { x: 3, y: 1 }),
3547 cooldown_seconds: *cooldown,
3548 row: None,
3549 })
3550 .collect();
3551 let fight_data = WaveFightData {
3552 entities: vec![WaveEntityPower {
3553 entity_id: Some(enemy_id.to_string()),
3554 power: Some(1.0),
3555 }],
3556 waves: vec![wave],
3557 time: 30.0,
3558 power: Some(100.0),
3559 stream_active_count: cap,
3560 reward_mob_budget: None,
3561 summon_wave_at_hp_fraction: None,
3562 enemy_damage_mult: None,
3563 };
3564 let fight = ActiveFight {
3565 current_wave: 1,
3566 ..Default::default()
3567 };
3568 let config = configs::tests_game_config::generate_game_config_for_tests();
3569 let lookups = ContentLookups::default();
3570 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3571 let mut sink = NativeSink::default();
3572 spawn_wave(
3573 &mut sink,
3574 &rng,
3575 &config,
3576 &lookups,
3577 &fight,
3578 &fight_data,
3579 100.0,
3580 1,
3581 "CampaignFight",
3582 )
3583 .unwrap();
3584 sink.events
3585 .iter()
3586 .find_map(|ev| match ev {
3587 OverlordEvent::SpawnEntity {
3588 entity_attributes, ..
3589 } => Some(entity_attributes.0.get("attack").copied().unwrap_or(0)),
3590 _ => None,
3591 })
3592 .expect("spawn_wave must emit spawns")
3593 }
3594
3595 #[test]
3602 fn mini_sim_budgets_from_exit_cooldowns_not_legacy_delay() {
3603 let overlapped = spawn_wave_attack_for(&[(None, None), (None, None)]);
3604 let staggered = spawn_wave_attack_for(&[(None, None), (Some(10.0), None)]);
3605 assert!(overlapped > 0);
3606 assert!(
3607 staggered > overlapped,
3608 "a cooldown-staggered wave overlaps less and must normalize to a \
3609 higher attack (staggered {staggered} vs overlapped {overlapped})"
3610 );
3611
3612 let legacy_delay_only = spawn_wave_attack_for(&[(None, None), (None, Some(10.0))]);
3614 assert_eq!(
3615 legacy_delay_only, overlapped,
3616 "legacy `delay` must be dead as a balance input"
3617 );
3618 }
3619
3620 fn spawn_wave_shares(budget: Option<u64>, n: usize) -> Vec<Option<i64>> {
3623 use rand::SeedableRng;
3624 let enemy_id = uuid::Uuid::from_u128(13);
3625 let wave: Vec<WaveSpawn> = (0..n)
3626 .map(|_| WaveSpawn {
3627 entity_id: enemy_id.to_string(),
3628 delay: Some(0.0),
3629 position: Some(Coordinates { x: 3, y: 1 }),
3630 cooldown_seconds: None,
3631 row: None,
3632 })
3633 .collect();
3634 let fight_data = WaveFightData {
3635 entities: vec![WaveEntityPower {
3636 entity_id: Some(enemy_id.to_string()),
3637 power: Some(1.0),
3638 }],
3639 waves: vec![wave],
3640 time: 30.0,
3641 power: Some(100.0),
3642 stream_active_count: None,
3643 reward_mob_budget: budget,
3644 summon_wave_at_hp_fraction: None,
3645 enemy_damage_mult: None,
3646 };
3647 let fight = ActiveFight {
3648 current_wave: 1,
3649 ..Default::default()
3650 };
3651 let config = configs::tests_game_config::generate_game_config_for_tests();
3652 let lookups = ContentLookups::default();
3653 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3654 let mut sink = NativeSink::default();
3655 spawn_wave(
3656 &mut sink,
3657 &rng,
3658 &config,
3659 &lookups,
3660 &fight,
3661 &fight_data,
3662 100.0,
3663 1,
3664 "CampaignFight",
3665 )
3666 .unwrap();
3667 sink.events
3668 .iter()
3669 .filter_map(|ev| match ev {
3670 OverlordEvent::SpawnEntity {
3671 entity_attributes, ..
3672 } => Some(entity_attributes.0.get("wave_share").copied()),
3673 _ => None,
3674 })
3675 .collect()
3676 }
3677
3678 #[test]
3682 fn spawn_wave_stamps_wave_share_per_mob() {
3683 let shares = spawn_wave_shares(Some(2), 4);
3685 assert_eq!(shares.len(), 4);
3686 assert!(
3687 shares.iter().all(|s| *s == Some(5000)),
3688 "each mob must carry wave_share 5000, got {shares:?}"
3689 );
3690
3691 let unit = spawn_wave_shares(Some(3), 3);
3693 assert!(unit.iter().all(|s| *s == Some(10000)), "got {unit:?}");
3694
3695 let legacy = spawn_wave_shares(None, 4);
3697 assert_eq!(legacy.len(), 4);
3698 assert!(
3699 legacy.iter().all(|s| s.is_none()),
3700 "legacy content must not stamp wave_share, got {legacy:?}"
3701 );
3702 }
3703
3704 #[test]
3709 fn wave_share_makes_counter_proc_count_invariant() {
3710 use rand::SeedableRng;
3711 let lookups = ContentLookups::default();
3712 let counter_entity = || {
3713 let mut e = essences::entity::Entity::default();
3714 e.attributes.add("counterattack_chance", 2000); e
3716 };
3717
3718 let base = counter_entity();
3720 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(1));
3721 let base_procs = (0..90)
3722 .filter(|_| entropy_throw(&rng, &lookups, &base, "counterattack_chance", 0.0, 1.0))
3723 .count() as i64;
3724
3725 let weak = counter_entity();
3727 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(1));
3728 let weak_procs = (0..270)
3729 .filter(|_| {
3730 entropy_throw(
3731 &rng,
3732 &lookups,
3733 &weak,
3734 "counterattack_chance",
3735 0.0,
3736 1.0 / 3.0,
3737 )
3738 })
3739 .count() as i64;
3740
3741 assert!(
3742 (base_procs - weak_procs).abs() <= 2,
3743 "counter procs must stay count-invariant: base {base_procs} vs 3x-weak {weak_procs}"
3744 );
3745 }
3746
3747 #[test]
3752 fn slot_arrival_matches_exit_scheduling() {
3753 let config = configs::tests_game_config::generate_game_config_for_tests();
3754 let fs = &config.fight_settings;
3755 assert_eq!(slot_arrival_delay_seconds(fs, 0, 0, 2000), 0.8);
3757 assert_eq!(slot_arrival_delay_seconds(fs, 3000, 3000, 1150), 2.65);
3759 assert_eq!(slot_arrival_delay_seconds(fs, 500, 0, 2000), 2.0);
3761 assert_eq!(slot_arrival_delay_seconds(fs, 10_000, 0, 2000), 10.0);
3763 }
3764
3765 #[test]
3769 fn wave_exit_gate_plan_caps_by_cooldown_order() {
3770 let mk = |cd: Option<f64>| WaveSpawn {
3771 entity_id: "e".into(),
3772 delay: None,
3773 position: None,
3774 cooldown_seconds: cd,
3775 row: None,
3776 };
3777 let wave = vec![mk(Some(1.0)), mk(None), mk(Some(0.5)), mk(None)];
3778
3779 assert_eq!(wave_exit_gate_plan(&wave, None), vec![None; 4]);
3780 assert_eq!(wave_exit_gate_plan(&wave, Some(4)), vec![None; 4]);
3781 assert_eq!(wave_exit_gate_plan(&wave, Some(9)), vec![None; 4]);
3782
3783 assert_eq!(
3786 wave_exit_gate_plan(&wave, Some(2)),
3787 vec![Some(2), None, Some(1), None]
3788 );
3789 }
3790
3791 #[test]
3794 fn spawn_stamps_death_gated_exits_beyond_cap() {
3795 use rand::SeedableRng;
3796 let enemy_id = uuid::Uuid::new_v4();
3797 let mk = |cd: Option<f64>| WaveSpawn {
3798 entity_id: enemy_id.to_string(),
3799 delay: None,
3800 position: Some(Coordinates { x: 3, y: 1 }),
3801 cooldown_seconds: cd,
3802 row: Some(1),
3803 };
3804 let fight_data = WaveFightData {
3805 entities: vec![WaveEntityPower {
3806 entity_id: Some(enemy_id.to_string()),
3807 power: Some(1.0),
3808 }],
3809 waves: vec![vec![mk(None), mk(Some(2.5)), mk(Some(5.0))]],
3810 time: 30.0,
3811 power: Some(100.0),
3812 stream_active_count: Some(1),
3813 reward_mob_budget: None,
3814 summon_wave_at_hp_fraction: None,
3815 enemy_damage_mult: None,
3816 };
3817 let fight = ActiveFight {
3818 current_wave: 1,
3819 ..Default::default()
3820 };
3821 let config = configs::tests_game_config::generate_game_config_for_tests();
3822 let lookups = ContentLookups::default();
3823 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3824 let mut sink = NativeSink::default();
3825 spawn_wave(
3826 &mut sink,
3827 &rng,
3828 &config,
3829 &lookups,
3830 &fight,
3831 &fight_data,
3832 100.0,
3833 1,
3834 "CampaignFight",
3835 )
3836 .unwrap();
3837
3838 let attrs: Vec<_> = sink
3839 .events
3840 .iter()
3841 .filter_map(|ev| match ev {
3842 OverlordEvent::SpawnEntity {
3843 entity_attributes, ..
3844 } => Some(entity_attributes.clone()),
3845 _ => None,
3846 })
3847 .collect();
3848 assert_eq!(attrs.len(), 3);
3849
3850 assert!(attrs[0].0.contains_key("entrance_rush"));
3852 assert!(!attrs[0].0.contains_key("exit_gated"));
3853
3854 assert_eq!(attrs[1].0.get("exit_gated").copied(), Some(1));
3856 assert_eq!(attrs[2].0.get("exit_gated").copied(), Some(2));
3857 for a in &attrs[1..] {
3858 assert!(!a.0.contains_key("exit_cooldown_ms"));
3859 assert!(!a.0.contains_key("entrance_rush"));
3860 }
3861 }
3862
3863 #[test]
3868 fn mini_sim_models_death_gated_releases() {
3869 let overlapped =
3870 spawn_wave_attack_capped(&[(None, None), (None, None), (None, None)], None);
3871 let capped = spawn_wave_attack_capped(&[(None, None), (None, None), (None, None)], Some(1));
3872 let cap_unbound =
3873 spawn_wave_attack_capped(&[(None, None), (None, None), (None, None)], Some(3));
3874 assert!(overlapped > 0);
3875 assert!(
3876 capped > overlapped,
3877 "a death-gated wave overlaps less and must normalize to a higher \
3878 attack (capped {capped} vs overlapped {overlapped})"
3879 );
3880 assert_eq!(
3881 cap_unbound, overlapped,
3882 "a cap that never binds must be budget-identical to no cap"
3883 );
3884 }
3885
3886 #[test]
3893 fn spawn_sets_exit_cooldown_rush_and_slots() {
3894 use rand::SeedableRng;
3895
3896 let enemy_id = uuid::Uuid::new_v4();
3897 let mk = |cooldown: Option<f64>, row: i64| WaveSpawn {
3898 entity_id: enemy_id.to_string(),
3899 delay: Some(0.0),
3900 position: Some(Coordinates { x: 3, y: 3 }),
3901 cooldown_seconds: cooldown,
3902 row: Some(row),
3903 };
3904 let fight_data = WaveFightData {
3905 entities: vec![WaveEntityPower {
3906 entity_id: Some(enemy_id.to_string()),
3907 power: Some(1.0),
3908 }],
3909 waves: vec![vec![mk(None, 0), mk(Some(2.5), 2)]],
3910 time: 1.0,
3911 power: Some(100.0),
3912 stream_active_count: None,
3913 reward_mob_budget: None,
3914 summon_wave_at_hp_fraction: None,
3915 enemy_damage_mult: None,
3916 };
3917 let fight = ActiveFight {
3918 current_wave: 1,
3919 ..Default::default()
3920 };
3921 let config = configs::tests_game_config::generate_game_config_for_tests();
3922 let lookups = ContentLookups::default();
3923 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3924 let mut sink = NativeSink::default();
3925 spawn_wave(
3926 &mut sink,
3927 &rng,
3928 &config,
3929 &lookups,
3930 &fight,
3931 &fight_data,
3932 100.0,
3933 1,
3934 "CampaignFight",
3935 )
3936 .unwrap();
3937
3938 let spawns: Vec<_> = sink
3939 .events
3940 .iter()
3941 .filter_map(|ev| match ev {
3942 OverlordEvent::SpawnEntity {
3943 position,
3944 entity_attributes,
3945 ..
3946 } => Some((position.clone(), entity_attributes.clone())),
3947 _ => None,
3948 })
3949 .collect();
3950 assert_eq!(spawns.len(), 2);
3951
3952 assert_eq!(spawns[0].0, Coordinates { x: 5, y: 0 });
3955 assert_eq!(spawns[1].0, Coordinates { x: 5, y: 2 });
3956
3957 assert_eq!(spawns[0].1.0.get("exit_cooldown_ms").copied(), None);
3960 assert_eq!(spawns[1].1.0.get("exit_cooldown_ms").copied(), Some(2500));
3961 assert!(spawns[0].1.0.contains_key("entrance_rush"));
3962 assert!(!spawns[1].1.0.contains_key("entrance_rush"));
3963 for (_, attrs) in &spawns {
3964 assert!(
3965 !attrs.0.contains_key("wake_up_delay"),
3966 "slot model spawns never sleep"
3967 );
3968 }
3969 }
3970
3971 #[test]
3976 fn wave_row_plan_spreads_stacked_opening_rows() {
3977 let mk = |row: i64| WaveSpawn {
3978 entity_id: uuid::Uuid::nil().to_string(),
3979 delay: None,
3980 position: None,
3981 cooldown_seconds: None,
3982 row: Some(row),
3983 };
3984 let config = configs::tests_game_config::generate_game_config_for_tests();
3985 let lookups = ContentLookups::default();
3986
3987 let wave: Vec<_> = (0..5).map(|_| mk(1)).collect();
3990 let plan = wave_row_plan(&wave, &[None; 5], &config, &lookups, &[], 0, false);
3991 assert_eq!(plan, vec![1, 0, 2, 1, 0]);
3992
3993 let wave = vec![mk(0), mk(2)];
3995 let plan = wave_row_plan(&wave, &[None; 2], &config, &lookups, &[], 0, false);
3996 assert_eq!(plan, vec![0, 2]);
3997
3998 let wave = vec![mk(1)];
4000 assert_eq!(
4001 wave_row_plan(&wave, &[None], &config, &lookups, &[], 0, false),
4002 vec![1]
4003 );
4004
4005 let wave: Vec<_> = (0..4).map(|_| mk(1)).collect();
4007 let gate = vec![None, None, Some(1), Some(2)];
4008 let plan = wave_row_plan(&wave, &gate, &config, &lookups, &[], 0, false);
4009 assert_eq!(plan, vec![1, 0, 1, 1]);
4010 }
4011
4012 #[test]
4016 fn summon_wave_flanks_top_and_bottom_first() {
4017 let mk = |row: i64| WaveSpawn {
4018 entity_id: uuid::Uuid::nil().to_string(),
4019 delay: None,
4020 position: None,
4021 cooldown_seconds: None,
4022 row: Some(row),
4023 };
4024 let config = configs::tests_game_config::generate_game_config_for_tests();
4025 let lookups = ContentLookups::default();
4026 let anchor = 7;
4027 let boss = [(anchor + MELEE_COL_OFFSET, 1)];
4029
4030 let wave = vec![mk(0), mk(1)];
4033 let plan = wave_row_plan(&wave, &[None; 2], &config, &lookups, &boss, anchor, true);
4034 assert_eq!(plan, vec![0, 2]);
4035
4036 let boss_top = [(anchor + MELEE_COL_OFFSET, 0)];
4039 let plan = wave_row_plan(
4040 &wave, &[None; 2], &config, &lookups, &boss_top, anchor, true,
4041 );
4042 assert_eq!(plan, vec![2, 1]);
4043 }
4044
4045 #[test]
4049 fn gated_release_row_targets_the_empty_tile() {
4050 let offset = 4;
4051 let park_x = 5; let row_enemy = |x: i64, y: i64| {
4053 let mut e = living_enemy_at(x);
4054 e.coordinates.y = y;
4055 e
4056 };
4057 let mut released = row_enemy(park_x, 1);
4058 released.attributes.add("exit_gated", 1);
4059 let released_id = released.id;
4060
4061 let fight = promo_fight(0, vec![released.clone(), row_enemy(1, 0), row_enemy(1, 1)]);
4063 assert_eq!(gated_release_row(&fight, released_id, offset), 2);
4064
4065 let fight = promo_fight(0, vec![released.clone(), row_enemy(1, 0), row_enemy(1, 2)]);
4067 assert_eq!(gated_release_row(&fight, released_id, offset), 1);
4068
4069 let mut runner = row_enemy(9, 0);
4072 runner.move_target = Some(Coordinates { x: 1, y: 1 });
4073 let mut queued = row_enemy(park_x, 0);
4074 queued.attributes.add("exit_gated", 2);
4075 let fight = promo_fight(0, vec![released.clone(), runner, queued]);
4076 assert_eq!(gated_release_row(&fight, released_id, offset), 0);
4077
4078 let earlier = row_enemy(park_x, 0);
4081 let fight = promo_fight(0, vec![released, earlier, row_enemy(1, 1)]);
4082 assert_eq!(gated_release_row(&fight, released_id, offset), 2);
4083 }
4084
4085 fn init_fight_attack_mod_deltas(
4088 is_dungeon: bool,
4089 is_bossfight: bool,
4090 dungeon_talent_level: Option<i64>,
4091 boss_talent_level: Option<i64>,
4092 existing_attack_mod: i64,
4093 ) -> Vec<i64> {
4094 let fight_template_id = uuid::Uuid::new_v4();
4095 let mut lookups = ContentLookups::default();
4096 lookups
4097 .fight_template_is_dungeon
4098 .insert(fight_template_id, is_dungeon);
4099 lookups
4100 .fight_template_is_bossfight
4101 .insert(fight_template_id, is_bossfight);
4102
4103 let mut player = entity_at(EntityTeam::Ally, 0);
4104 player.attributes.add("attack.mod", existing_attack_mod);
4105
4106 let fight = ActiveFight {
4107 player_id: player.id,
4108 entities: vec![player.clone()],
4109 ..Default::default()
4110 };
4111
4112 let mut state = crate::state::OverlordState::default();
4113 if let Some(level) = dungeon_talent_level {
4114 state
4115 .character_state
4116 .talent_levels
4117 .0
4118 .insert(Uuid::parse_str(DUNGEON_TALENT_ID).unwrap(), level);
4119 }
4120 if let Some(level) = boss_talent_level {
4121 state
4122 .character_state
4123 .talent_levels
4124 .0
4125 .insert(Uuid::parse_str(BOSS_TALENT_ID).unwrap(), level);
4126 }
4127
4128 let mut sink = NativeSink::default();
4129 init_fight(&mut sink, &lookups, &fight, &state, fight_template_id).unwrap();
4130
4131 sink.events
4132 .iter()
4133 .filter_map(|e| match e {
4134 OverlordEvent::EntityIncrAttribute {
4135 entity_id,
4136 attribute,
4137 delta,
4138 } if *entity_id == player.id && attribute == "attack.mod" => Some(*delta),
4139 _ => None,
4140 })
4141 .collect()
4142 }
4143
4144 #[test]
4149 fn init_fight_talent_attack_mod_adds_on_top_of_existing_sources() {
4150 assert_eq!(
4152 init_fight_attack_mod_deltas(true, false, Some(3), None, 3000),
4153 vec![600]
4154 );
4155 assert_eq!(
4157 init_fight_attack_mod_deltas(false, true, None, Some(2), 3000),
4158 vec![400]
4159 );
4160 assert_eq!(
4162 init_fight_attack_mod_deltas(true, true, Some(3), Some(2), 3000),
4163 vec![1000]
4164 );
4165 assert_eq!(
4167 init_fight_attack_mod_deltas(true, true, None, None, 3000),
4168 Vec::<i64>::new()
4169 );
4170 }
4171
4172 fn aoe_dispatch(bonus_permyriad: i64, swings: usize) -> (Vec<i64>, Vec<OverlordEvent>) {
4186 use rand::SeedableRng;
4187
4188 let lookups = damaging_lookups();
4189 let mut caster = entity_at(EntityTeam::Ally, 1);
4190 caster.hp = 100;
4191 caster.max_hp = 100;
4192 caster.attributes.add("attack", 1_000);
4193 if bonus_permyriad != 0 {
4194 caster
4195 .attributes
4196 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, bonus_permyriad);
4197 caster
4201 .attributes
4202 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4203 }
4204
4205 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4206 let mut sink = NativeSink::default();
4207 let mut dealt = Vec::new();
4208 for i in 0..swings {
4209 let mut target = entity_at(EntityTeam::Enemy, 2 + i as i64);
4210 target.hp = 1_000_000;
4211 target.max_hp = 1_000_000;
4212 dealt.push(
4213 attack(
4214 &mut sink,
4215 &rng,
4216 &lookups,
4217 &mut NoopEffectCb,
4218 caster.id,
4219 &caster,
4220 &target,
4221 &AttackParams {
4222 is_crit: Some(false),
4223 ..AttackParams::default_power()
4224 },
4225 CombatSource::Other,
4226 )
4227 .unwrap()
4228 .expect("every swing must land"),
4229 );
4230 }
4231 (dealt, sink.events)
4232 }
4233
4234 #[test]
4240 fn a_next_attack_bonus_boosts_only_the_first_swing_of_an_aoe_dispatch() {
4241 let (plain, _) = aoe_dispatch(0, 3);
4242 let (boosted, events) = aoe_dispatch(2_000, 3);
4243
4244 assert!(
4245 (boosted[0] as f64 - plain[0] as f64 * 1.2).abs() <= 1.0,
4246 "the first target takes the +20%: {} -> {}",
4247 plain[0],
4248 boosted[0]
4249 );
4250 assert_eq!(
4251 &boosted[1..],
4252 &plain[1..],
4253 "every later target of the same cast must be unboosted"
4254 );
4255
4256 let consumed: Vec<i64> = events
4257 .iter()
4258 .filter_map(|event| match event {
4259 OverlordEvent::EntityIncrAttribute {
4260 attribute, delta, ..
4261 } if attribute == crate::mechanics::stones::NEXT_ATTACK_BONUS => Some(*delta),
4262 _ => None,
4263 })
4264 .collect();
4265 assert_eq!(
4266 consumed,
4267 vec![-2_000],
4268 "exactly one consume per dispatch — never one per target"
4269 );
4270 }
4271
4272 #[test]
4275 fn a_producer_scope_cannot_reclaim_the_dispatchs_one_shot() {
4276 let mut sink = NativeSink::default();
4277 assert_eq!(
4278 sink.claim_once(OnceSlot::DamageBonus, 2_000),
4279 2_000,
4280 "the first claim wins"
4281 );
4282 assert_eq!(
4283 OriginSink::proc(&mut sink).claim_once(OnceSlot::DamageBonus, 2_000),
4284 0,
4285 "a Proc scope inside the same dispatch must find it already spent"
4286 );
4287 assert_eq!(sink.claim_once(OnceSlot::DamageBonus, 2_000), 0);
4288
4289 let mut next = NativeSink::default();
4291 assert_eq!(next.claim_once(OnceSlot::DamageBonus, 2_000), 2_000);
4292 }
4293
4294 #[test]
4298 fn the_one_shot_budgets_do_not_take_each_others_latch() {
4299 let mut sink = NativeSink::default();
4300 assert_eq!(sink.claim_once(OnceSlot::CritChance, 1_500), 1_500);
4301 assert_eq!(
4302 sink.claim_once(OnceSlot::DamageBonus, 2_000),
4303 2_000,
4304 "the crit claim must not have spent the damage budget"
4305 );
4306 assert_eq!(
4307 sink.claim_once(OnceSlot::LawArm, 3_000),
4308 3_000,
4309 "nor the law arm's"
4310 );
4311 assert_eq!(
4312 sink.claim_once(OnceSlot::LawThisArm, 4_000),
4313 4_000,
4314 "nor the this-strike arm's — `RL-01` and `RL-02` can be armed together"
4315 );
4316
4317 assert_eq!(sink.claim_once(OnceSlot::CritChance, 1_500), 0);
4319 assert_eq!(sink.claim_once(OnceSlot::DamageBonus, 2_000), 0);
4320 assert_eq!(sink.claim_once(OnceSlot::LawArm, 3_000), 0);
4321 assert_eq!(sink.claim_once(OnceSlot::LawThisArm, 4_000), 0);
4322 }
4323
4324 fn damaging_lookups() -> ContentLookups {
4327 let rd_id = uuid::Uuid::new_v4();
4328 let mut lookups = ContentLookups::default();
4329 lookups
4330 .attribute_by_code
4331 .insert("received_damage".to_string(), rd_id);
4332 lookups.attribute_base_value.insert(rd_id, 10000.0);
4333 lookups
4334 }
4335
4336 fn attack_with_armed_bonus(bonus_permyriad: i64) -> (Option<i64>, Vec<OverlordEvent>) {
4339 use rand::SeedableRng;
4340
4341 let lookups = damaging_lookups();
4342 let mut caster = entity_at(EntityTeam::Ally, 1);
4343 caster.hp = 100;
4344 caster.max_hp = 100;
4345 caster.attributes.add("attack", 1_000);
4346 if bonus_permyriad != 0 {
4347 caster
4348 .attributes
4349 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, bonus_permyriad);
4350 caster
4354 .attributes
4355 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4356 }
4357 let mut target = entity_at(EntityTeam::Enemy, 2);
4358 target.hp = 1_000_000;
4359 target.max_hp = 1_000_000;
4360
4361 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4362 let mut sink = NativeSink::default();
4363 let dealt = attack(
4364 &mut sink,
4365 &rng,
4366 &lookups,
4367 &mut NoopEffectCb,
4368 caster.id,
4369 &caster,
4370 &target,
4371 &AttackParams {
4372 is_crit: Some(false),
4375 ..AttackParams::default_power()
4376 },
4377 CombatSource::Other,
4378 )
4379 .unwrap();
4380 (dealt, sink.events)
4381 }
4382
4383 #[test]
4385 fn a_next_attack_bonus_scales_the_swing_and_is_consumed() {
4386 let (plain, plain_events) = attack_with_armed_bonus(0);
4387 let (boosted, boosted_events) = attack_with_armed_bonus(2_000);
4388
4389 let plain = plain.expect("the baseline swing must land");
4390 let boosted = boosted.expect("the boosted swing must land");
4391 assert!(
4392 (boosted as f64 - plain as f64 * 1.2).abs() <= 1.0,
4393 "+20% armed must deal ~20% more: {plain} -> {boosted}"
4394 );
4395
4396 let consumed: Vec<i64> = boosted_events
4397 .iter()
4398 .filter_map(|event| match event {
4399 OverlordEvent::EntityIncrAttribute {
4400 attribute, delta, ..
4401 } if attribute == crate::mechanics::stones::NEXT_ATTACK_BONUS => Some(*delta),
4402 _ => None,
4403 })
4404 .collect();
4405 assert_eq!(
4406 consumed,
4407 vec![-2_000],
4408 "the armed bonus must be spent by the swing that used it"
4409 );
4410 assert!(
4411 !plain_events.iter().any(|event| matches!(
4412 event,
4413 OverlordEvent::EntityIncrAttribute { attribute, .. }
4414 if attribute == crate::mechanics::stones::NEXT_ATTACK_BONUS
4415 )),
4416 "an unarmed swing must not touch the bonus at all"
4417 );
4418 }
4419
4420 #[test]
4429 fn the_three_armed_budgets_stack_on_one_swing_and_each_is_spent_once() {
4430 use rand::SeedableRng;
4431
4432 let swing = |arm_all: bool| {
4433 let lookups = damaging_lookups();
4434 let mut caster = entity_at(EntityTeam::Ally, 1);
4435 caster.hp = 100;
4436 caster.max_hp = 100;
4437 caster.attributes.add("attack", 1_000);
4438 if arm_all {
4439 caster
4441 .attributes
4442 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, 2_000);
4443 caster
4444 .attributes
4445 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4446 crate::logic::combat_facts::record_cast_kind(
4449 &mut caster,
4450 crate::logic::combat_facts::CastKind::Basic,
4451 );
4452 caster
4453 .attributes
4454 .add(crate::mechanics::cores::ARM_BASIC, 3_000);
4455 caster
4456 .attributes
4457 .add(crate::mechanics::cores::ARM_THIS, 5_000);
4458 }
4459 let mut target = entity_at(EntityTeam::Enemy, 2);
4460 target.hp = 1_000_000;
4461 target.max_hp = 1_000_000;
4462
4463 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4464 let mut sink = NativeSink::default();
4465 let dealt = attack(
4466 &mut sink,
4467 &rng,
4468 &lookups,
4469 &mut NoopEffectCb,
4470 caster.id,
4471 &caster,
4472 &target,
4473 &AttackParams {
4474 is_crit: Some(false),
4475 ..AttackParams::default_power()
4476 },
4477 CombatSource::Other,
4478 )
4479 .unwrap();
4480 (dealt, sink.events)
4481 };
4482
4483 let (plain, _) = swing(false);
4484 let (boosted, events) = swing(true);
4485 let plain = plain.expect("the baseline swing must land");
4486 let boosted = boosted.expect("the boosted swing must land");
4487
4488 assert!(
4490 (boosted as f64 - plain as f64 * 2.0).abs() <= 1.0,
4491 "the three arms must sum into one multiplier: {plain} -> {boosted}"
4492 );
4493
4494 let spent = |key: &str| -> Vec<i64> {
4495 events
4496 .iter()
4497 .filter_map(|event| match event {
4498 OverlordEvent::EntityIncrAttribute {
4499 attribute, delta, ..
4500 } if attribute == key => Some(*delta),
4501 _ => None,
4502 })
4503 .collect()
4504 };
4505 assert_eq!(
4506 spent(crate::mechanics::stones::NEXT_ATTACK_BONUS),
4507 vec![-2_000],
4508 "the stone bonus is spent once"
4509 );
4510 assert_eq!(
4511 spent(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES),
4512 vec![-1],
4513 "and gives up exactly one charge"
4514 );
4515 assert_eq!(
4516 spent(crate::mechanics::cores::ARM_BASIC),
4517 vec![-3_000],
4518 "the law's family arm is spent once"
4519 );
4520 assert_eq!(
4521 spent(crate::mechanics::cores::ARM_THIS),
4522 vec![-5_000],
4523 "and so is the this-strike arm"
4524 );
4525 }
4526
4527 #[test]
4530 fn a_basic_swing_leaves_the_skill_arm_alone_while_spending_the_others() {
4531 use rand::SeedableRng;
4532
4533 let lookups = damaging_lookups();
4534 let mut caster = entity_at(EntityTeam::Ally, 1);
4535 caster.hp = 100;
4536 caster.max_hp = 100;
4537 caster.attributes.add("attack", 1_000);
4538 crate::logic::combat_facts::record_cast_kind(
4539 &mut caster,
4540 crate::logic::combat_facts::CastKind::Basic,
4541 );
4542 caster
4543 .attributes
4544 .add(crate::mechanics::cores::ARM_BASIC, 3_000);
4545 caster
4546 .attributes
4547 .add(crate::mechanics::cores::ARM_SKILL, 9_000);
4548
4549 let mut target = entity_at(EntityTeam::Enemy, 2);
4550 target.hp = 1_000_000;
4551 target.max_hp = 1_000_000;
4552
4553 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4554 let mut sink = NativeSink::default();
4555 attack(
4556 &mut sink,
4557 &rng,
4558 &lookups,
4559 &mut NoopEffectCb,
4560 caster.id,
4561 &caster,
4562 &target,
4563 &AttackParams {
4564 is_crit: Some(false),
4565 ..AttackParams::default_power()
4566 },
4567 CombatSource::Other,
4568 )
4569 .unwrap();
4570
4571 assert!(
4572 sink.events.iter().any(|event| matches!(
4573 event,
4574 OverlordEvent::EntityIncrAttribute { attribute, delta, .. }
4575 if attribute == crate::mechanics::cores::ARM_BASIC && *delta == -3_000
4576 )),
4577 "the Basic arm belongs to this swing"
4578 );
4579 assert!(
4580 !sink.events.iter().any(|event| matches!(
4581 event,
4582 OverlordEvent::EntityIncrAttribute { attribute, .. }
4583 if attribute == crate::mechanics::cores::ARM_SKILL
4584 )),
4585 "the Skill arm must be untouched by a Basic Attack"
4586 );
4587 }
4588
4589 #[test]
4598 fn a_magnitude_without_a_charge_does_not_scale_the_swing() {
4599 use rand::SeedableRng;
4600
4601 let swing = |with_charge: bool| {
4602 let lookups = damaging_lookups();
4603 let mut caster = entity_at(EntityTeam::Ally, 1);
4604 caster.hp = 100;
4605 caster.max_hp = 100;
4606 caster.attributes.add("attack", 1_000);
4607 caster
4608 .attributes
4609 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, 5_000);
4610 if with_charge {
4611 caster
4612 .attributes
4613 .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4614 }
4615 let mut target = entity_at(EntityTeam::Enemy, 2);
4616 target.hp = 1_000_000;
4617 target.max_hp = 1_000_000;
4618
4619 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4620 let mut sink = NativeSink::default();
4621 attack(
4622 &mut sink,
4623 &rng,
4624 &lookups,
4625 &mut NoopEffectCb,
4626 caster.id,
4627 &caster,
4628 &target,
4629 &AttackParams {
4630 is_crit: Some(false),
4631 ..AttackParams::default_power()
4632 },
4633 CombatSource::Other,
4634 )
4635 .unwrap()
4636 .expect("the swing must land")
4637 };
4638
4639 let (plain, _) = attack_with_armed_bonus(0);
4640 let plain = plain.expect("the baseline swing must land");
4641 assert_eq!(
4642 swing(false),
4643 plain,
4644 "a magnitude with no charge must leave the swing at its baseline"
4645 );
4646 assert!(
4647 (swing(true) as f64 - plain as f64 * 1.5).abs() <= 1.0,
4648 "the same magnitude WITH a charge must be worth +50%"
4649 );
4650 }
4651
4652 #[test]
4660 fn an_armed_extra_swing_is_added_to_this_cast_as_proc_and_is_consumed() {
4661 use rand::SeedableRng;
4662
4663 let config = configs::tests_game_config::generate_game_config_for_tests();
4664 let lookups = ContentLookups::default();
4665 let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
4666 let ability = Ability {
4667 template_id: hero_ability_id(),
4668 level: 1,
4669 shards_amount: 0,
4670 };
4671 let mut target = entity_at(EntityTeam::Enemy, 2);
4672 target.hp = 100;
4673
4674 let swing_origins = |caster: &Entity| -> (Vec<CombatEventOrigin>, Vec<i64>) {
4675 let mut sink = NativeSink::default();
4676 cast(
4677 &mut sink,
4678 &rng,
4679 &config,
4680 &lookups,
4681 caster,
4682 &ability,
4683 std::slice::from_ref(&target),
4684 1,
4685 )
4686 .unwrap();
4687 let consumed = sink
4688 .events
4689 .iter()
4690 .filter_map(|event| match event {
4691 OverlordEvent::EntityIncrAttribute {
4692 attribute, delta, ..
4693 } if attribute == crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS => {
4694 Some(*delta)
4695 }
4696 _ => None,
4697 })
4698 .collect();
4699 (sink.casts.iter().map(|c| c.origin).collect(), consumed)
4700 };
4701
4702 let mut plain = entity_at(EntityTeam::Ally, 1);
4703 plain.hp = 100;
4704 let (origins, consumed) = swing_origins(&plain);
4705 assert_eq!(
4706 origins,
4707 vec![CombatEventOrigin::Core],
4708 "an unarmed cast is one Core swing, exactly as before"
4709 );
4710 assert!(consumed.is_empty(), "nothing to consume");
4711
4712 let mut armed = plain.clone();
4713 armed
4714 .attributes
4715 .add(crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS, 1);
4716 let (origins, consumed) = swing_origins(&armed);
4717 assert_eq!(
4718 origins,
4719 vec![CombatEventOrigin::Core, CombatEventOrigin::Proc],
4720 "the natural swing stays Core and the granted one is Proc"
4721 );
4722 assert_eq!(
4723 consumed,
4724 vec![-1],
4725 "the arm is spent by the cast that used it"
4726 );
4727 }
4728
4729 #[test]
4736 fn armor_mod_composes_as_a_percentage_of_the_armor_rating() {
4737 let lookups = ContentLookups::default();
4738 let armored = |rating: i64, armor_mod: i64, bonus: i64| {
4739 let mut entity = Entity {
4740 id: uuid::Uuid::new_v4(),
4741 ..Default::default()
4742 };
4743 entity.attributes.add("armor", rating);
4744 entity.attributes.add("armor.bonus", bonus);
4745 entity.attributes.add("armor.mod", armor_mod);
4746 get_entity_stat(&lookups, &entity, "armor")
4747 };
4748
4749 assert!((armored(2_000, 1_000, 0) - 2_200.0).abs() < 1e-9);
4751 assert!((armored(2_000, 1_600, 0) - 2_320.0).abs() < 1e-9);
4753 assert!((armored(20_000, 1_000, 0) - 22_000.0).abs() < 1e-9);
4756 assert!((armored(2_000, 1_000, 500) - 2_750.0).abs() < 1e-9);
4758 let floored = armored(2_000, -50_000, 0);
4760 assert!(
4761 (floored - 2_000.0 * balance::MIN_STAT_MOD_MULT).abs() < 1e-9,
4762 "armor.mod must floor at MIN_STAT_MOD_MULT (×0.05), got {floored}"
4763 );
4764 assert!((armored(2_000, 0, 0) - 2_000.0).abs() < 1e-9);
4766 }
4767}