1use crate::{
2 TICKER_UNIT_DURATION_MS,
3 behaviors::combat::start_cast::StartCastAbilityResult,
4 entities::{create_pve_entity, event_from_entity_action},
5 event::CustomEventData,
6 event::OverlordEvent,
7 game_config_helpers::GameConfigLookup,
8 logic::handler::OverlordLogic,
9 state::OverlordState,
10};
11
12use essences::{
13 abilities::{AbilityId, AbilitySlotId},
14 combat_origin::CombatEventOrigin,
15 currency::{CurrencySource, CurrencyUnit},
16 entity::{ActionWithDeadline, Coordinates, Entity, EntityAttributes, EntityId},
17 fight_breakdown::CombatSource,
18 fighting::{ActiveFight, EntityTeam, EntityType, FightEntity, FightType},
19 game::EntityTemplateId,
20};
21use event_system::{event::EventPluginized, script::random::GameRng, system::EventHandleResult};
22
23use rand::RngExt;
24use uuid::Uuid;
25
26const GAMBLING_INSURANCE_CHARGES_ATTR: &str = "gambling.insurance_charges";
27
28fn consume_insurance_on_lethal_hit(entity: &mut Entity, damage: u64) -> bool {
29 if damage == 0
30 || entity.hp == 0
31 || damage < entity.hp
32 || entity
33 .attributes
34 .0
35 .get(GAMBLING_INSURANCE_CHARGES_ATTR)
36 .copied()
37 .unwrap_or(0)
38 <= 0
39 {
40 return false;
41 }
42
43 entity.attributes.add(GAMBLING_INSURANCE_CHARGES_ATTR, -1);
44 true
45}
46
47fn breakdown_actor(
51 active_fight: &ActiveFight,
52 by_entity_id: Option<EntityId>,
53) -> crate::fight::BreakdownActor {
54 by_entity_id
55 .and_then(|id| active_fight.entities.iter().find(|entity| entity.id == id))
56 .map(crate::fight::BreakdownActor::from_entity)
57 .unwrap_or_else(crate::fight::BreakdownActor::unowned)
58}
59
60fn remove_hp_with_insurance(entity: &mut Entity, damage: u64) -> (u64, bool) {
61 let insurance_consumed = consume_insurance_on_lethal_hit(entity, damage);
62 let max_hp_removed = if insurance_consumed {
63 entity.hp.saturating_sub(1)
64 } else {
65 entity.hp
66 };
67 let actual_hp_removed = damage.min(max_hp_removed);
68 entity.hp -= actual_hp_removed;
69 (actual_hp_removed, insurance_consumed)
70}
71
72pub(super) struct FlipProgressMetrics {
73 pub(super) gains: opentelemetry::metrics::Counter<u64>,
74 pub(super) amount: opentelemetry::metrics::Histogram<f64>,
75 pub(super) flips: opentelemetry::metrics::Counter<u64>,
76}
77
78pub(super) fn flip_progress_metrics() -> &'static FlipProgressMetrics {
81 static METRICS: std::sync::OnceLock<FlipProgressMetrics> = std::sync::OnceLock::new();
82 METRICS.get_or_init(|| {
83 let meter = opentelemetry::global::meter("flip_progress");
84 FlipProgressMetrics {
85 gains: meter.u64_counter("flip_progress_gains_total").build(),
86 amount: meter
87 .f64_histogram("flip_progress_amount")
88 .with_boundaries(vec![0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0])
89 .build(),
90 flips: meter.u64_counter("global_flips_total").build(),
91 }
92 })
93}
94
95fn boss_reward_chapter_multiplier(
101 growth: Option<f64>,
102 cap: Option<f64>,
103 chapter_level: i64,
104) -> f64 {
105 let Some(growth) = growth else { return 1.0 };
106 if growth <= 1.0 {
107 return 1.0;
108 }
109 let cap = cap.unwrap_or(f64::INFINITY).min(1.0e6);
112 let exp = chapter_level.clamp(0, 1000) as i32;
113 growth.powi(exp).clamp(1.0, cap)
114}
115
116impl OverlordLogic {
117 fn compute_ability_slot_level(
118 &self,
119 slot_id: Option<AbilitySlotId>,
120 state: &OverlordState,
121 ) -> i64 {
122 let Some(slot_id) = slot_id else {
123 return 0;
124 };
125
126 let game_config = self.game_config.get();
127 let slot_level = state
128 .character_state
129 .character
130 .ability_slot_levels
131 .get(slot_id)
132 .copied()
133 .unwrap_or(0)
134 .max(0);
135 game_config
136 .game_settings
137 .ability_gacha
138 .slot_level_bonus_levels
139 .get(slot_level as usize)
140 .copied()
141 .or_else(|| {
142 game_config
143 .game_settings
144 .ability_gacha
145 .slot_level_bonus_levels
146 .last()
147 .copied()
148 })
149 .unwrap_or(0)
150 }
151
152 #[allow(clippy::too_many_arguments)]
153 pub fn handle_spawn_entity(
154 &mut self,
155 id: EntityId,
156 entity_template_id: EntityTemplateId,
157 position: Coordinates,
158 team: EntityTeam,
159 has_big_hp_bar: bool,
160 entity_attributes: EntityAttributes,
161 current_tick: u64,
162 mut state: OverlordState,
163 ) -> EventHandleResult<OverlordEvent, OverlordState> {
164 let game_config = self.game_config.get();
165
166 let Some(active_fight) = &mut state.active_fight else {
167 return EventHandleResult::ok(state);
168 };
169
170 if active_fight.fight_ended {
174 return EventHandleResult::ok(state);
175 }
176
177 if active_fight.entities.iter().any(|entity| entity.id == id) {
178 tracing::error!("There is already an entity with id: {id}");
179 return EventHandleResult::fail(state);
180 }
181
182 let fight_entity = FightEntity {
183 entity_type: EntityType::PVEEntity { entity_template_id },
184 position: position.clone(),
185 has_big_hp_bar,
186 team: team.clone(),
187 };
188
189 let mut created_entity =
190 match create_pve_entity(id, &fight_entity, &game_config, Some(entity_attributes)) {
191 Ok(entity) => entity,
192 Err(err) => {
193 tracing::error!("Couldn't create entity: {}", err.to_string());
194 return EventHandleResult::fail(state);
195 }
196 };
197
198 if active_fight.current_wave > 1 {
199 created_entity.abilities.iter().for_each(|ability| {
200 let cooldown = game_config
201 .ability_template(ability.ability.template_id)
202 .map(|t| t.cooldown)
203 .unwrap_or(0);
204 created_entity.actions_queue.push(&ActionWithDeadline::core(
207 self.make_start_cast_ability_action(
208 created_entity.id,
209 ability.ability.template_id,
210 ),
211 current_tick + cooldown,
212 ))
213 });
214 }
215
216 let wave_fight_template = game_config
224 .require_fight_template(active_fight.fight_id)
225 .ok()
226 .filter(|t| t.prepare_fight_waves.is_some());
227 let exit_gated = created_entity.attributes.0.contains_key("exit_gated");
231 if team == EntityTeam::Enemy
232 && !exit_gated
233 && let Some(template) = wave_fight_template
234 {
235 let ms_per_cell = if created_entity.attributes.0.contains_key("entrance_rush") {
238 game_config.fight_settings.wave_entrance_rush_ms_per_cell
239 } else {
240 game_config.fight_settings.wave_entrance_walk_ms_per_cell
241 };
242 let entrance_offset_cells = game_config.fight_settings.wave_entrance_offset_cells;
243 let run_ticks = entrance_offset_cells.max(0) as u64 * ms_per_cell;
244 let cooldown_ticks = created_entity
252 .attributes
253 .0
254 .get("exit_cooldown_ms")
255 .copied()
256 .unwrap_or(0)
257 .max(0) as u64;
258 let floor_ticks = if active_fight.current_wave == 1 {
259 template
260 .start_fight_delay_ticks
261 .unwrap_or(game_config.fight_settings.start_fight_delay_ticks_default)
262 } else {
263 crate::mechanics::fight::later_wave_entrance_floor_ticks(&game_config, active_fight)
264 };
265 let start_ticks = cooldown_ticks.max(floor_ticks);
266 let battle_cell = Coordinates {
267 x: position.x - entrance_offset_cells,
268 y: position.y,
269 };
270 self.fight_clock.schedule(
271 OverlordEvent::StartMove {
272 entity_id: id,
273 to: battle_cell,
274 duration_ticks: run_ticks,
275 },
276 start_ticks.max(1),
277 );
278 }
279
280 if team == EntityTeam::Enemy && created_entity.attributes.is_summoned() {
283 active_fight.summoned_entity_ids.push(id);
284 }
285
286 active_fight.entities.push(created_entity);
287
288 EventHandleResult::ok(state)
289 }
290 pub fn handle_start_move(
291 &mut self,
292 entity_id: Uuid,
293 to: Coordinates,
294 duration_ticks: u64,
295 mut state: OverlordState,
296 ) -> EventHandleResult<OverlordEvent, OverlordState> {
297 let Some(active_fight) = &mut state.active_fight else {
298 return EventHandleResult::ok(state);
299 };
300
301 let Some(entity) = active_fight
302 .entities
303 .iter_mut()
304 .find(|entity| entity.id == entity_id)
305 else {
306 tracing::error!("Failed to find entity in state with id={}", entity_id);
307 return EventHandleResult::fail(state);
308 };
309
310 entity.move_target = Some(to.clone());
314
315 let steps = move_progress_steps(&entity.coordinates, &to, duration_ticks);
320 if let Some((_, first)) = steps.first() {
321 entity.coordinates = first.clone();
322 }
323 for (delay_ticks, cell) in steps.into_iter().skip(1) {
324 self.fight_clock.schedule(
325 OverlordEvent::MoveProgress {
326 entity_id,
327 to: cell,
328 },
329 delay_ticks,
330 );
331 }
332
333 self.fight_clock
334 .schedule(OverlordEvent::EndMove { entity_id }, duration_ticks);
335
336 EventHandleResult::ok(state)
337 }
338
339 pub fn handle_move_progress(
340 &self,
341 entity_id: Uuid,
342 to: Coordinates,
343 mut state: OverlordState,
344 ) -> EventHandleResult<OverlordEvent, OverlordState> {
345 let Some(active_fight) = &mut state.active_fight else {
346 return EventHandleResult::ok(state);
347 };
348
349 if let Some(entity) = active_fight
352 .entities
353 .iter_mut()
354 .find(|entity| entity.id == entity_id)
355 && entity.move_target.is_some()
356 {
357 entity.coordinates = to;
358 }
359
360 EventHandleResult::ok(state)
361 }
362
363 fn slot_promotion_events(
370 &self,
371 active_fight: &ActiveFight,
372 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
373 let game_config = self.game_config.get();
374 let is_wave_fight = game_config
375 .require_fight_template(active_fight.fight_id)
376 .ok()
377 .is_some_and(|t| t.prepare_fight_waves.is_some());
378 if !is_wave_fight || !crate::mechanics::fight::slot_promotion_needed(active_fight) {
379 return Vec::new();
380 }
381 let step_ticks =
382 crate::mechanics::fight::formation_step_duration_ticks(self.behaviors.lookups(), 1.0);
383 active_fight
384 .entities
385 .iter()
386 .filter(|e| e.team == EntityTeam::Ally && e.hp > 0 && e.move_target.is_none())
387 .map(|e| {
388 EventPluginized::now(OverlordEvent::StartMove {
389 entity_id: e.id,
390 to: Coordinates {
391 x: e.coordinates.x + 1,
392 y: e.coordinates.y,
393 },
394 duration_ticks: step_ticks,
395 })
396 })
397 .collect()
398 }
399
400 pub fn handle_end_move(
401 &self,
402 entity_id: Uuid,
403 mut state: OverlordState,
404 ) -> EventHandleResult<OverlordEvent, OverlordState> {
405 let Some(active_fight) = &mut state.active_fight else {
406 return EventHandleResult::ok(state);
407 };
408
409 let Some(entity) = active_fight
410 .entities
411 .iter_mut()
412 .find(|entity| entity.id == entity_id)
413 else {
414 tracing::error!("Failed to find entity in state with id={}", entity_id);
415 return EventHandleResult::fail(state);
416 };
417
418 entity.move_target = None;
420
421 let mut events = vec![EventPluginized::now(OverlordEvent::FightProgress {})];
428 events.extend(self.slot_promotion_events(active_fight));
429
430 EventHandleResult::ok_events(state, events)
431 }
432
433 pub fn handle_entity_stun(
434 &mut self,
435 entity_id: Uuid,
436 duration_ticks: u64,
437 current_tick: u64,
438 mut state: OverlordState,
439 ) -> EventHandleResult<OverlordEvent, OverlordState> {
440 let game_config = self.game_config.get();
441 let baseline_speed = game_config.game_settings.baseline_speed;
442 let player_id = state.active_fight.as_ref().map(|f| f.player_id);
443
444 let Some(active_fight) = &mut state.active_fight else {
445 tracing::error!("EntityStun received with no active fight (entity_id = {entity_id})");
446 return EventHandleResult::fail(state);
447 };
448
449 let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
450 tracing::error!("EntityStun: entity_id = {entity_id} not found in active fight");
451 return EventHandleResult::fail(state);
452 };
453
454 let entity_speed = entity.attributes.speed_or_baseline(baseline_speed);
455 let ability_ids: Vec<AbilityId> = entity
456 .abilities
457 .iter()
458 .map(|aa| aa.ability.template_id)
459 .collect();
460 entity.attributes.set(
463 crate::fight::STUN_UNTIL_TICK_ATTR,
464 (current_tick + duration_ticks).min(i64::MAX as u64) as i64,
465 );
466
467 for ability_id in ability_ids {
468 let base_cooldown = game_config
469 .ability_template(ability_id)
470 .map(|t| t.cooldown)
471 .unwrap_or(0);
472 let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
473 base_cooldown,
474 entity_speed,
475 baseline_speed,
476 );
477 entity.actions_queue.stun_ability(
478 ability_id,
479 duration_ticks,
480 scaled_cooldown,
481 current_tick,
482 );
483 }
484
485 if Some(entity.id) == player_id {
491 let now = ::time::utc_now();
492 let stun_ms = (duration_ticks as u128 * TICKER_UNIT_DURATION_MS) as i64;
493 for active_ability in entity.abilities.iter_mut() {
494 let base_cooldown = game_config
495 .ability_template(active_ability.ability.template_id)
496 .map(|t| t.cooldown)
497 .unwrap_or(0);
498 let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
499 base_cooldown,
500 entity_speed,
501 baseline_speed,
502 );
503 let cooldown_ms = (scaled_cooldown as u128 * TICKER_UNIT_DURATION_MS) as i64;
504
505 let new_deadline = match active_ability.deadline {
506 Some(existing) if existing > now => {
507 existing + chrono::TimeDelta::milliseconds(stun_ms)
509 }
510 _ => {
511 let total_ms = if base_cooldown > 0 {
517 cooldown_ms + stun_ms
518 } else {
519 stun_ms
520 };
521 now + chrono::TimeDelta::milliseconds(total_ms)
522 }
523 };
524 active_ability.deadline = Some(new_deadline);
525 }
526 }
527
528 EventHandleResult::ok(state)
529 }
530
531 pub fn handle_entity_cancel_cast_with_cooldown(
532 &mut self,
533 entity_id: Uuid,
534 ability_id: Uuid,
535 current_tick: u64,
536 mut state: OverlordState,
537 ) -> EventHandleResult<OverlordEvent, OverlordState> {
538 let game_config = self.game_config.get();
539
540 let Some(active_fight) = &mut state.active_fight else {
541 tracing::error!(
542 "EntityCancelCastWithCooldown received with no active fight (entity_id = {entity_id})"
543 );
544 return EventHandleResult::fail(state);
545 };
546
547 let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
548 tracing::error!(
549 "EntityCancelCastWithCooldown: entity_id = {entity_id} not found in active fight"
550 );
551 return EventHandleResult::fail(state);
552 };
553
554 let Some(ability_template) = game_config.ability_template(ability_id) else {
555 tracing::error!(
556 "EntityCancelCastWithCooldown: ability_template not found for ability_id = {ability_id}"
557 );
558 return EventHandleResult::fail(state);
559 };
560 let cooldown = ability_template.cooldown;
561
562 let cancelled = entity
563 .actions_queue
564 .cancel_cast_and_set_cooldown(ability_id, current_tick + cooldown);
565
566 if !cancelled {
567 tracing::error!(
568 "EntityCancelCastWithCooldown: no in-flight cast for ability_id = {ability_id} on entity {entity_id}"
569 );
570 return EventHandleResult::fail(state);
571 }
572
573 EventHandleResult::ok(state)
574 }
575
576 pub fn handle_entity_add_ability_cooldown(
577 &mut self,
578 entity_id: Uuid,
579 ability_id: Uuid,
580 delta_ticks: i64,
581 current_tick: u64,
582 mut state: OverlordState,
583 ) -> EventHandleResult<OverlordEvent, OverlordState> {
584 let Some(active_fight) = &mut state.active_fight else {
585 tracing::error!(
586 "EntityAddAbilityCooldown received with no active fight (entity_id = {entity_id})"
587 );
588 return EventHandleResult::fail(state);
589 };
590
591 let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
592 tracing::error!(
593 "EntityAddAbilityCooldown: entity_id = {entity_id} not found in active fight"
594 );
595 return EventHandleResult::fail(state);
596 };
597
598 if !entity
599 .actions_queue
600 .adjust_ability_cooldown(ability_id, delta_ticks, current_tick)
601 {
602 tracing::error!(
603 "EntityAddAbilityCooldown: ability_id = {ability_id} not in cooldown queue for entity {entity_id}"
604 );
605 return EventHandleResult::fail(state);
606 }
607
608 EventHandleResult::ok(state)
609 }
610
611 pub fn handle_entity_incr_attribute(
612 &mut self,
613 entity_id: Uuid,
614 attribute: &str,
615 delta: i64,
616 current_tick: u64,
617 mut state: OverlordState,
618 ) -> EventHandleResult<OverlordEvent, OverlordState> {
619 let game_config = self.game_config.get();
620 let baseline_speed = game_config.game_settings.baseline_speed;
621 let player_id = state.active_fight.as_ref().map(|f| f.player_id);
622
623 let Some(active_fight) = &mut state.active_fight else {
624 return EventHandleResult::ok(state);
625 };
626
627 let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
628 tracing::debug!("Couldn't find entity_id = {}", entity_id);
629 return EventHandleResult::fail(state);
630 };
631
632 let old_speed = entity.attributes.speed_or_baseline(baseline_speed);
633 entity.attributes.add(attribute, delta);
634 const NON_NEGATIVE_POOLS: [&str; 3] = [
650 "shield",
651 crate::mechanics::stones::NEXT_ATTACK_BONUS,
652 crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS,
653 ];
654 if NON_NEGATIVE_POOLS.contains(&attribute)
655 && entity.attributes.0.get(attribute).is_some_and(|v| *v < 0)
656 {
657 entity.attributes.set(attribute, 0);
658 }
659 let new_speed = entity.attributes.speed_or_baseline(baseline_speed);
660
661 if attribute == "speed" && old_speed != new_speed {
662 entity.actions_queue.rescale_cooldowns(
663 old_speed,
664 new_speed,
665 current_tick,
666 baseline_speed,
667 );
668
669 if Some(entity.id) == player_id {
670 let now = ::time::utc_now();
671 let baseline = baseline_speed.max(1) as i128;
672 let old_s = if old_speed <= 0 {
673 baseline
674 } else {
675 old_speed as i128
676 };
677 let new_s = if new_speed <= 0 {
678 baseline
679 } else {
680 new_speed as i128
681 };
682 for active_ability in entity.abilities.iter_mut() {
683 let Some(deadline) = active_ability.deadline else {
684 continue;
685 };
686 let remaining_ms = (deadline - now).num_milliseconds();
687 if remaining_ms <= 0 {
688 continue;
689 }
690 let scaled_ms = ((remaining_ms as i128) * old_s / new_s) as i64;
691 let scaled_ms = scaled_ms.max(1);
692 active_ability.deadline =
693 Some(now + chrono::TimeDelta::milliseconds(scaled_ms));
694 }
695 }
696 }
697
698 entity.effect_ids = entity
699 .effect_ids
700 .iter()
701 .filter(|effect_id| {
702 if let Some(effect) = game_config.effect(**effect_id) {
703 if effect.has_at_least_one_required_attribute(&entity.attributes) {
706 true
707 } else {
708 if effect.interval_ticks.is_some() {
709 entity.actions_queue.remove_cast_effect_action(effect.id);
710 }
711 false
712 }
713 } else {
714 false
715 }
716 })
717 .cloned()
718 .collect();
719
720 EventHandleResult::ok(state)
721 }
722
723 pub fn handle_entity_apply_effect(
724 &mut self,
725 entity_id: Uuid,
726 effect_id: Uuid,
727 current_tick: u64,
728 mut state: OverlordState,
729 ) -> EventHandleResult<OverlordEvent, OverlordState> {
730 let dispatch_origin = self.dispatch_origin();
731
732 let Some(active_fight) = &mut state.active_fight else {
733 return EventHandleResult::ok(state);
734 };
735
736 let game_config = self.game_config.get();
737
738 let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
739 tracing::debug!("Couldn't find entity_id = {}", entity_id);
740 return EventHandleResult::fail(state);
741 };
742
743 let Ok(effect) = game_config.require_effect(effect_id) else {
744 tracing::debug!("Couldn't find effect_id = {}", effect_id);
745 return EventHandleResult::fail(state);
746 };
747
748 if let Some(required_attributes) = &effect.required_attributes
749 && !required_attributes
750 .iter()
751 .any(|attr| entity.attributes.0.contains_key(attr))
752 {
753 tracing::error!("Effect has required attributes, but they are not set");
754 return EventHandleResult::fail(state);
755 }
756
757 for existing_effect_id in &entity.effect_ids {
758 if *existing_effect_id == effect.id {
759 tracing::error!("Effect is already set on entity");
760 return EventHandleResult::fail(state);
761 }
762 }
763
764 entity.effect_ids.push(effect.id);
765
766 if let Some(interval_ticks) = &effect.interval_ticks {
767 entity.actions_queue.push(&ActionWithDeadline {
770 action: self.make_cast_effect_action(entity_id, effect.id),
771 deadline_tick: current_tick + interval_ticks,
772 origin: dispatch_origin,
773 });
774 }
775
776 EventHandleResult::ok(state)
777 }
778
779 #[allow(clippy::too_many_arguments)]
780 pub fn handle_cast_effect(
781 &mut self,
782 entity_id: Uuid,
783 effect_id: Uuid,
784 caller_event: Option<Box<OverlordEvent>>,
785 rand_gen: rand::rngs::StdRng,
786 current_tick: u64,
787 mut state: OverlordState,
788 ) -> EventHandleResult<OverlordEvent, OverlordState> {
789 let dispatch_origin = self.dispatch_origin();
790
791 let Some(active_fight) = &mut state.active_fight else {
792 return EventHandleResult::ok(state);
793 };
794
795 let active_fight_cloned = active_fight.clone();
796
797 let game_config = self.game_config.get();
798
799 let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
800 tracing::debug!("Couldn't find entity_id = {}", entity_id);
801 return EventHandleResult::fail(state);
802 };
803
804 let Ok(effect) = game_config.require_effect(effect_id) else {
805 tracing::debug!("Couldn't find effect_id = {}", effect_id);
806 return EventHandleResult::fail(state);
807 };
808
809 if !entity.effect_ids.contains(&effect_id) {
810 tracing::error!("entity_id = {} has no effect_id = {}", entity_id, effect_id);
811 return EventHandleResult::fail(state);
812 }
813
814 let entity_cloned = entity.clone();
815
816 let Some(native_name) = effect.behavior.as_deref() else {
819 tracing::error!("Effect {} has no script registered", effect_id);
820 return EventHandleResult::fail(state);
821 };
822 let Some(native_fn) = self.behaviors.event_fn(native_name) else {
823 tracing::error!("No native event fn registered for {native_name}");
824 return EventHandleResult::fail(state);
825 };
826
827 let rng = GameRng::new(rand_gen);
828 let events = match native_fn(&crate::behaviors::combat::effects::EventCtx {
829 entity: &entity_cloned,
830 fight: &active_fight_cloned,
831 rng: &rng,
832 current_tick,
833 fight_duration_ticks: current_tick - self.start_fight_tick,
834 caller_event: caller_event.as_deref(),
835 config: &game_config,
836 lookups: self.behaviors.lookups(),
837 }) {
838 Ok(events) => events,
839 Err(err) => {
840 tracing::error!("Effect script failed with error: {err:?}");
841 return EventHandleResult::fail(state);
842 }
843 };
844
845 if let Some(interval_ticks) = effect.interval_ticks {
846 entity.actions_queue.push(&ActionWithDeadline {
849 action: self.make_cast_effect_action(entity_id, effect.id),
850 deadline_tick: current_tick + interval_ticks,
851 origin: dispatch_origin,
852 });
853 }
854
855 EventHandleResult::ok_events(
856 state,
857 events.into_iter().map(EventPluginized::now).collect(),
858 )
859 }
860
861 pub fn handle_start_cast_ability(
862 &mut self,
863 _event: OverlordEvent,
864 by_entity_id: Uuid,
865 ability_id: AbilityId,
866 rand_gen: rand::rngs::StdRng,
867 current_tick: u64,
868 mut state: OverlordState,
869 ) -> EventHandleResult<OverlordEvent, OverlordState> {
870 let dispatch_origin = self.dispatch_origin();
871
872 let game_config = self.game_config.get();
873
874 let state_cloned = state.clone();
875
876 let Some(active_fight) = &mut state.active_fight else {
877 tracing::error!("No active fight for start_cast_ability");
878 return EventHandleResult::ok(state);
879 };
880 let active_fight_cloned = active_fight.clone();
881 let Some(casted_by_entity) = active_fight
882 .entities
883 .iter_mut()
884 .find(|e| e.id == by_entity_id)
885 else {
886 tracing::debug!("Couldn't find caster entity_id = {}", by_entity_id);
887 return EventHandleResult::fail(state);
888 };
889 let casted_by_entity_cloned = casted_by_entity.clone();
890 let Some(active_ability) = casted_by_entity
891 .abilities
892 .iter()
893 .find(|equipped_ability| equipped_ability.ability.template_id == ability_id)
894 .cloned()
895 else {
896 tracing::error!(
897 "Couldn't find ability_id = {} in caster entity {:?}",
898 ability_id,
899 casted_by_entity
900 );
901 return EventHandleResult::fail(state);
902 };
903 let ability = &active_ability.ability;
904 let ability_template_id = ability.template_id;
905 let ability_level = ability.level;
906
907 let Some(ability_template) = game_config.ability_template(ability_template_id).cloned()
908 else {
909 tracing::error!(
910 "Couldn't find template for ability_id = {}",
911 ability_template_id
912 );
913 return EventHandleResult::fail(state);
914 };
915 let ability_cooldown = ability_template.cooldown;
916
917 let is_pet_ability = false;
919
920 let _ability_slot_level =
921 self.compute_ability_slot_level(active_ability.slot_id, &state_cloned);
922 let _ = (ability_level, &state_cloned);
923
924 let stun_until = casted_by_entity_cloned
928 .attributes
929 .0
930 .get(crate::fight::STUN_UNTIL_TICK_ATTR)
931 .copied()
932 .unwrap_or(0);
933 if (current_tick as i64) < stun_until {
934 if !is_pet_ability {
935 casted_by_entity
936 .actions_queue
937 .push_start_cast_replacing(ability_template_id, stun_until as u64);
938 }
939 return EventHandleResult::ok(state);
940 } else if stun_until != 0 {
941 casted_by_entity
943 .attributes
944 .0
945 .remove(crate::fight::STUN_UNTIL_TICK_ATTR);
946 }
947
948 let stone_mods = crate::mechanics::ability_stones::resolver_for_caster(
952 &state_cloned,
953 &casted_by_entity_cloned,
954 )
955 .mods_for(&game_config, ability_template_id, ability_level);
956 let ability_cooldown = stone_mods.apply_cooldown(ability_cooldown);
957
958 let pet_mana_facets_apply = !is_pet_ability
976 && dispatch_origin.is_core()
977 && by_entity_id == active_fight_cloned.player_id
978 && crate::logic::combat_facts::cast_kind(
979 &game_config,
980 &casted_by_entity_cloned,
981 ability_template_id,
982 ) == crate::logic::combat_facts::CastKind::Skill;
983 let pet_budget_multiplier = pet_mana_facets_apply
984 .then(|| {
985 crate::mechanics::pet_facets::armed(
986 &casted_by_entity_cloned,
987 crate::mechanics::pet_facets::BUDGET_MULT,
988 crate::mechanics::pet_facets::BUDGET_CASTS,
989 )
990 })
991 .flatten();
992 let pet_open_tab = pet_mana_facets_apply
993 .then(|| {
994 crate::mechanics::pet_facets::armed(
995 &casted_by_entity_cloned,
996 crate::mechanics::pet_facets::OPEN_TAB_SURCHARGE,
997 crate::mechanics::pet_facets::OPEN_TAB_CHARGES,
998 )
999 })
1000 .flatten()
1001 .map(|surcharge| {
1002 (
1003 surcharge,
1004 crate::mechanics::pet_facets::attr(
1005 &casted_by_entity_cloned,
1006 crate::mechanics::pet_facets::OPEN_TAB_PAYLOAD_SHARE,
1007 ),
1008 )
1009 });
1010 let mut pet_budget_spent = false;
1014 let mut pet_open_tab_payload: Option<i64> = None;
1015 let mut paid_mana_x100: Option<i64> = None;
1016
1017 if !is_pet_ability && let Some(mana) = casted_by_entity.mana.as_mut() {
1018 let base_cost = stone_mods.apply_mana_cost(ability_template.mana_cost.max(0) as f64);
1019 let mana_cost = match pet_budget_multiplier {
1026 Some(multiplier) => base_cost * (multiplier as f64 / 10_000.0),
1027 None => base_cost,
1028 };
1029 let mana_cost = mana_cost.round().max(0.0);
1033 mana.regen_to(current_tick);
1034
1035 let surcharge = match pet_open_tab {
1040 Some((surcharge_share, _)) if mana_cost > 0.0 => {
1041 let wanted = mana_cost * (surcharge_share as f64 / 10_000.0);
1042 (mana.current - mana_cost).max(0.0).min(wanted)
1043 }
1044 _ => 0.0,
1045 };
1046
1047 if !mana.try_spend(mana_cost + surcharge) {
1048 let wait_ticks = mana.ms_until_affordable(mana_cost).unwrap_or(1).max(1);
1052 casted_by_entity
1053 .actions_queue
1054 .push_start_cast_replacing(ability_template_id, current_tick + wait_ticks);
1055 return EventHandleResult::ok(state);
1056 }
1057
1058 paid_mana_x100 = Some((mana_cost * 100.0).round() as i64);
1063 pet_budget_spent = pet_budget_multiplier.is_some();
1064 if let Some((_, payload_share)) = pet_open_tab
1065 && surcharge > 0.0
1066 && mana_cost > 0.0
1067 {
1068 pet_open_tab_payload =
1084 Some(((payload_share as f64) * (surcharge / mana_cost)).round() as i64);
1085 }
1086 }
1087
1088 let paid_mana_has_reader = !casted_by_entity.law_cores.laws.is_empty()
1095 || (by_entity_id == active_fight_cloned.player_id
1096 && state_cloned
1097 .character_state
1098 .stones
1099 .all()
1100 .any(|(_, stone)| stone.is_socketed()));
1101 match paid_mana_x100 {
1102 Some(paid) if paid_mana_has_reader => {
1103 crate::logic::combat_facts::record_paid_mana(casted_by_entity, paid);
1104 }
1105 _ => crate::logic::combat_facts::clear_paid_mana(casted_by_entity),
1106 }
1107
1108 if pet_budget_spent {
1111 crate::mechanics::pet_facets::spend_charge(
1112 casted_by_entity,
1113 crate::mechanics::pet_facets::BUDGET_MULT,
1114 crate::mechanics::pet_facets::BUDGET_CASTS,
1115 );
1116 }
1117 if pet_open_tab.is_some() {
1118 crate::mechanics::pet_facets::spend_charge(
1119 casted_by_entity,
1120 crate::mechanics::pet_facets::OPEN_TAB_SURCHARGE,
1121 crate::mechanics::pet_facets::OPEN_TAB_CHARGES,
1122 );
1123 casted_by_entity
1124 .attributes
1125 .set(crate::mechanics::pet_facets::OPEN_TAB_PAYLOAD_SHARE, 0);
1126 }
1127 if let Some(bonus) = pet_open_tab_payload.filter(|bonus| *bonus != 0) {
1128 casted_by_entity
1132 .attributes
1133 .set(crate::mechanics::pet_facets::NEXT_SKILL_BONUS, bonus);
1134 casted_by_entity
1135 .attributes
1136 .set(crate::mechanics::pet_facets::NEXT_SKILL_BONUS_CHARGES, 1);
1137 }
1138
1139 let native_result = (|| {
1142 let name = ability_template.start_behavior.as_deref()?;
1143 let f = self.behaviors.start_cast_ability_fn(name)?;
1144 Some(f(
1145 &crate::behaviors::combat::start_cast::StartCastAbilityCtx {
1146 caster: &casted_by_entity_cloned,
1147 fight: &active_fight_cloned,
1148 rng: &GameRng::new(rand_gen),
1149 ability_template_id,
1150 config: &game_config,
1151 lookups: self.behaviors.lookups(),
1152 },
1153 ))
1154 })();
1155
1156 let results = match native_result {
1157 Some(Ok(v)) => v,
1158 other => {
1159 if !is_pet_ability
1160 && let Some(e) = state
1161 .active_fight
1162 .as_mut()
1163 .and_then(|af| af.entities.iter_mut().find(|e| e.id == by_entity_id))
1164 {
1165 let baseline_speed = game_config.game_settings.baseline_speed;
1166 let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
1167 ability_cooldown,
1168 e.attributes.speed_or_baseline(baseline_speed),
1169 baseline_speed,
1170 );
1171 e.actions_queue.push_start_cast_replacing(
1172 ability_template_id,
1173 current_tick + scaled_cooldown,
1174 );
1175 }
1176
1177 match other {
1178 Some(Err(err)) => {
1179 tracing::error!("Ability start cast script failed with error: {err:?}")
1180 }
1181 _ => tracing::error!(
1182 "Ability {ability_template_id} has no start_behavior registered"
1183 ),
1184 }
1185 return EventHandleResult::fail(state);
1186 }
1187 };
1188
1189 let results: Vec<StartCastAbilityResult> = results
1194 .into_iter()
1195 .map(|result| match result {
1196 StartCastAbilityResult::Attack {
1197 delay_ticks,
1198 animation_duration_ticks,
1199 target_entity_id,
1200 origin,
1201 } => StartCastAbilityResult::Attack {
1202 delay_ticks: stone_mods.apply_cast_time(delay_ticks),
1203 animation_duration_ticks: stone_mods.apply_cast_time(animation_duration_ticks),
1204 target_entity_id,
1205 origin,
1208 },
1209 other => other,
1210 })
1211 .collect();
1212
1213 let caster_class = casted_by_entity_cloned
1217 .class_id
1218 .unwrap_or(state.character_state.character.class);
1219 let (actions, events) =
1220 match StartCastAbilityResult::vec_into_actions_with_deadlines_and_events(
1221 &results,
1222 caster_class,
1223 &game_config,
1224 ability_template_id,
1225 by_entity_id,
1226 current_tick,
1227 dispatch_origin,
1228 ) {
1229 Ok((actions, events)) => (actions, events),
1230 Err(err) => {
1231 tracing::error!(
1232 "Error converting StartCastAbilityResultVec = {:?} into EntityActionVec = {:?}",
1233 results,
1234 err
1235 );
1236 if !is_pet_ability
1242 && let Some(entity) = state
1243 .active_fight
1244 .as_mut()
1245 .and_then(|af| af.entities.iter_mut().find(|e| e.id == by_entity_id))
1246 {
1247 let baseline_speed = game_config.game_settings.baseline_speed;
1248 let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
1249 ability_cooldown,
1250 entity.attributes.speed_or_baseline(baseline_speed),
1251 baseline_speed,
1252 );
1253 entity.actions_queue.push_start_cast_replacing(
1254 ability_template_id,
1255 current_tick + scaled_cooldown,
1256 );
1257 }
1258 return EventHandleResult::fail(state);
1259 }
1260 };
1261
1262 let baseline_speed = game_config.game_settings.baseline_speed;
1263 let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
1264 ability_cooldown,
1265 casted_by_entity
1266 .attributes
1267 .speed_or_baseline(baseline_speed),
1268 baseline_speed,
1269 );
1270 if is_pet_ability {
1271 for action in &actions {
1275 casted_by_entity.actions_queue.push(action);
1276 }
1277 } else {
1278 casted_by_entity
1279 .actions_queue
1280 .append_start_cast_ability_result_actions(
1281 &actions,
1282 current_tick,
1283 ability_template_id,
1284 scaled_cooldown,
1285 );
1286 }
1287 if casted_by_entity.id == active_fight.player_id
1288 && !actions.is_empty()
1289 && !is_pet_ability
1290 && let Some(active_ability) = casted_by_entity
1291 .abilities
1292 .iter_mut()
1293 .find(|a| a.ability.template_id == ability_template_id)
1294 {
1295 active_ability.deadline = Some(
1296 ::time::utc_now()
1297 + chrono::TimeDelta::milliseconds(
1298 (scaled_cooldown as u128 * TICKER_UNIT_DURATION_MS) as i64,
1299 ),
1300 );
1301 }
1302
1303 let now_events = self.route_delayed_to_clock(events);
1304
1305 EventHandleResult::ok_events(state, now_events)
1306 }
1307
1308 fn route_delayed_to_clock(
1311 &mut self,
1312 events: Vec<EventPluginized<OverlordEvent, OverlordState>>,
1313 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1314 events
1315 .into_iter()
1316 .filter_map(|pluginized| {
1317 let (event, delayed, _cron) = pluginized.into_parts();
1318 if let Some(delayed) = delayed {
1319 self.fight_clock.schedule(event, delayed.ticks);
1320 None
1321 } else {
1322 Some(EventPluginized::now(event))
1323 }
1324 })
1325 .collect()
1326 }
1327
1328 fn route_projectiles_to_clock(
1338 &mut self,
1339 events: Vec<OverlordEvent>,
1340 ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1341 events
1342 .into_iter()
1343 .filter_map(|x| match x {
1344 OverlordEvent::StartCastProjectile { delay, .. } => {
1345 let delay = delay.max(1);
1346 self.fight_clock.schedule(x, delay);
1347 None
1348 }
1349 OverlordEvent::DerivedAbilityStrike {
1350 by_entity_id,
1351 to_entity_id,
1352 ability_id,
1353 level,
1354 payload_permille,
1355 source_paid_mana_x100,
1356 delay,
1357 } => {
1358 self.fight_clock.schedule(
1359 OverlordEvent::DerivedAbilityStrike {
1360 by_entity_id,
1361 to_entity_id,
1362 ability_id,
1363 level,
1364 payload_permille,
1365 source_paid_mana_x100,
1366 delay: 0,
1367 },
1368 delay.max(1),
1369 );
1370 None
1371 }
1372 OverlordEvent::EntityIncrAttributeDelayed {
1373 entity_id,
1374 attribute,
1375 delta,
1376 delay,
1377 } => {
1378 self.fight_clock.schedule(
1379 OverlordEvent::EntityIncrAttribute {
1380 entity_id,
1381 attribute,
1382 delta,
1383 },
1384 delay.max(1),
1385 );
1386 None
1387 }
1388 other => Some(EventPluginized::now(other)),
1389 })
1390 .collect()
1391 }
1392
1393 fn support_cast_events(
1400 mods: &essences::ability_stones::AbilityStoneMods,
1401 caster: &Entity,
1402 target_id: Uuid,
1403 ability_id: AbilityId,
1404 level: i64,
1405 ) -> Vec<OverlordEvent> {
1406 let mut events = Vec::new();
1407
1408 let source_paid_mana_x100 =
1413 crate::logic::combat_facts::paid_mana_x100(caster).unwrap_or(-1);
1414
1415 let mut derived = |copies: &essences::ability_stones::DerivedCopies| {
1416 if !copies.is_active() {
1417 return;
1418 }
1419 let step = copies.interval_ms.max(1);
1420 for index in 0..copies.count {
1421 events.push(OverlordEvent::DerivedAbilityStrike {
1422 by_entity_id: caster.id,
1423 to_entity_id: target_id,
1424 ability_id,
1425 level,
1426 payload_permille: (copies.payload * 1000.0).round() as i64,
1427 source_paid_mana_x100,
1428 delay: step * (index as u64 + 1),
1429 });
1430 }
1431 };
1432 derived(&mods.repeat);
1433 derived(&mods.pulse);
1434
1435 if mods.guard_next_hit > 0.0 {
1439 let target_reduction = (mods.guard_next_hit * 10000.0).round() as i64;
1440 let current = caster
1441 .attributes
1442 .0
1443 .get(crate::mechanics::fight::GUARD_REDUCTION_ATTR)
1444 .copied()
1445 .unwrap_or(0);
1446 if target_reduction != current {
1447 events.push(OverlordEvent::EntityIncrAttribute {
1448 entity_id: caster.id,
1449 attribute: crate::mechanics::fight::GUARD_REDUCTION_ATTR.to_string(),
1450 delta: target_reduction - current,
1451 });
1452 }
1453 events.push(OverlordEvent::EntityIncrAttribute {
1454 entity_id: caster.id,
1455 attribute: crate::mechanics::fight::GUARD_CHARGES_ATTR.to_string(),
1456 delta: 1,
1457 });
1458 }
1459
1460 if mods.incoming_damage_reduction > 0.0 && mods.incoming_damage_reduction_ms > 0 {
1464 let delta = (mods.incoming_damage_reduction * 10000.0).round() as i64;
1465 events.push(OverlordEvent::EntityIncrAttribute {
1466 entity_id: caster.id,
1467 attribute: "received_damage.mod".to_string(),
1468 delta: -delta,
1469 });
1470 events.push(OverlordEvent::EntityIncrAttributeDelayed {
1471 entity_id: caster.id,
1472 attribute: "received_damage.mod".to_string(),
1473 delta,
1474 delay: mods.incoming_damage_reduction_ms,
1475 });
1476 }
1477
1478 events
1479 }
1480
1481 #[allow(clippy::too_many_arguments)]
1494 pub fn handle_derived_ability_strike(
1495 &mut self,
1496 by_entity_id: Uuid,
1497 to_entity_id: Uuid,
1498 ability_id: AbilityId,
1499 level: i64,
1500 payload_permille: i64,
1501 source_paid_mana_x100: i64,
1502 rand_gen: rand::rngs::StdRng,
1503 mut state: OverlordState,
1504 ) -> EventHandleResult<OverlordEvent, OverlordState> {
1505 let game_config = self.game_config.get();
1506
1507 if let Some(fight) = state.active_fight.as_mut()
1511 && let Some(caster) = fight.entities.iter_mut().find(|e| e.id == by_entity_id)
1512 {
1513 if source_paid_mana_x100 >= 0 {
1514 crate::logic::combat_facts::record_paid_mana(caster, source_paid_mana_x100);
1515 } else {
1516 crate::logic::combat_facts::clear_paid_mana(caster);
1517 }
1518 }
1519
1520 let Some(active_fight) = &state.active_fight else {
1521 return EventHandleResult::ok(state);
1522 };
1523 let Some(caster) = active_fight.entities.iter().find(|e| e.id == by_entity_id) else {
1524 return EventHandleResult::ok(state);
1525 };
1526 let Some(target) = active_fight.entities.iter().find(|e| e.id == to_entity_id) else {
1527 return EventHandleResult::ok(state);
1530 };
1531
1532 let payload = (payload_permille.max(0) as f64) / 1000.0;
1533 let mods = crate::mechanics::ability_stones::resolver_for_caster(&state, caster).mods_for(
1534 &game_config,
1535 ability_id,
1536 level,
1537 );
1538
1539 let lookups = self.behaviors.lookups();
1540 let mut info =
1541 match crate::mechanics::content::ability_info(&game_config, lookups, ability_id, level)
1542 {
1543 Ok(info) => info,
1544 Err(err) => {
1545 tracing::error!(
1546 "DerivedAbilityStrike: no ability info for {ability_id}: {err:?}"
1547 );
1548 return EventHandleResult::fail(state);
1549 }
1550 };
1551 info.apply_stone_mods(&mods);
1552
1553 let rng = GameRng::new(rand_gen);
1554 let mut sink = crate::mechanics::fight::NativeSink::default();
1555
1556 let is_aoe = game_config
1559 .ability_template(ability_id)
1560 .is_some_and(|template| template.has_tag(essences::abilities::AbilityTag::Aoe));
1561 let targets: Vec<&Entity> = if is_aoe {
1562 crate::behaviors::combat::cast_ability::band_targets(
1563 active_fight,
1564 caster,
1565 info.max_targets,
1566 )
1567 } else {
1568 vec![target]
1569 };
1570
1571 let mut effects = crate::mechanics::effect_cb::OverlordEffectCb;
1572 for target in targets {
1573 if info.damage.is_some() || info.dot.is_some() {
1574 let params = crate::mechanics::fight::AttackParams {
1575 power: info.damage.map(|d| d * payload),
1576 dot_power: info.dot.map(|d| d * payload),
1577 no_counterattack: true,
1578 derived: true,
1579 ..Default::default()
1580 };
1581 if let Err(err) = crate::mechanics::fight::attack(
1582 &mut sink,
1583 &rng,
1584 lookups,
1585 &mut effects,
1586 active_fight.player_id,
1587 caster,
1588 target,
1589 ¶ms,
1590 CombatSource::AbilityDerived { ability_id },
1591 ) {
1592 tracing::error!("DerivedAbilityStrike attack failed: {err:?}");
1593 return EventHandleResult::fail(state);
1594 }
1595 } else if let Some(hot) = info.hot {
1596 let params = crate::mechanics::fight::SpellHealParams {
1598 power: Some(hot * payload),
1599 ..Default::default()
1600 };
1601 if let Err(err) = crate::mechanics::fight::spell_heal(
1602 &mut sink,
1603 &rng,
1604 lookups,
1605 caster,
1606 caster,
1607 ¶ms,
1608 CombatSource::AbilityDerived { ability_id },
1609 ) {
1610 tracing::error!("DerivedAbilityStrike heal failed: {err:?}");
1611 return EventHandleResult::fail(state);
1612 }
1613 }
1614 }
1615
1616 let events = sink
1617 .events
1618 .into_iter()
1619 .map(EventPluginized::now)
1620 .collect::<Vec<_>>();
1621 EventHandleResult::ok_events(state, events)
1622 }
1623
1624 #[allow(clippy::too_many_arguments)]
1625 pub fn handle_cast_ability(
1626 &mut self,
1627 _event: OverlordEvent,
1628 by_entity_id: Uuid,
1629 to_entity_id: Uuid,
1630 ability_id: AbilityId,
1631 rand_gen: rand::rngs::StdRng,
1632 mut state: OverlordState,
1633 ) -> EventHandleResult<OverlordEvent, OverlordState> {
1634 let game_config = self.game_config.get();
1635
1636 if self.dispatch_origin().is_core() {
1646 let has_stones = state
1647 .character_state
1648 .stones
1649 .all()
1650 .any(|(_, stone)| stone.is_socketed());
1651 let is_player = state
1652 .active_fight
1653 .as_ref()
1654 .is_some_and(|fight| fight.player_id == by_entity_id);
1655 if let Some(caster) = state
1656 .active_fight
1657 .as_mut()
1658 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == by_entity_id))
1659 && (!caster.law_cores.laws.is_empty() || (is_player && has_stones))
1660 {
1661 let kind = crate::logic::combat_facts::cast_kind(&game_config, caster, ability_id);
1662 crate::logic::combat_facts::record_cast_kind(caster, kind);
1663 }
1664 }
1665
1666 let mut double_strike: Option<f64> = None;
1679 let mut multicast: Option<f64> = None;
1680 if self.dispatch_origin().is_core() {
1681 let is_basic = state
1682 .active_fight
1683 .as_ref()
1684 .and_then(|fight| fight.entities.iter().find(|e| e.id == by_entity_id))
1685 .map(|caster| {
1686 crate::logic::combat_facts::cast_kind(&game_config, caster, ability_id)
1687 })
1688 == Some(crate::logic::combat_facts::CastKind::Basic);
1689
1690 let multicast_chance = state
1691 .active_fight
1692 .as_ref()
1693 .and_then(|fight| fight.entities.iter().find(|e| e.id == by_entity_id))
1694 .map(|caster| {
1695 crate::mechanics::fight::get_entity_stat(
1696 self.behaviors.lookups(),
1697 caster,
1698 "multicast_chance",
1699 ) / 10_000.0
1700 })
1701 .unwrap_or(0.0);
1702
1703 if let Some(caster) = state
1704 .active_fight
1705 .as_mut()
1706 .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == by_entity_id))
1707 {
1708 let is_repeat = crate::mechanics::class_passives::take_repeat_mark(caster);
1709 let chance = crate::mechanics::class_passives::double_strike_chance(caster);
1710 if is_basic && !is_repeat && chance > 0.0 {
1711 double_strike = Some(chance);
1714 }
1715
1716 let is_copy = crate::mechanics::class_passives::take_multicast_mark(caster);
1717 if !is_basic && !is_copy && multicast_chance > 0.0 {
1720 multicast = Some(multicast_chance.clamp(0.0, 1.0));
1721 }
1722 }
1723 }
1724
1725 let mut law_events =
1729 self.apply_law_pre_cast(&mut state, by_entity_id, ability_id, to_entity_id);
1730
1731 let state_cloned = state.clone();
1732
1733 let Some(active_fight) = &mut state.active_fight else {
1734 return EventHandleResult::ok(state);
1735 };
1736
1737 let Some(target_entity) = active_fight
1738 .entities
1739 .iter()
1740 .find(|e| e.id == to_entity_id)
1741 .cloned()
1742 else {
1743 tracing::debug!("Couldn't find target entity_id = {to_entity_id}");
1744 return EventHandleResult::fail(state);
1745 };
1746
1747 let active_fight_clone = active_fight.clone();
1748
1749 let Some(casted_by_entity) = active_fight
1750 .entities
1751 .iter_mut()
1752 .find(|e| e.id == by_entity_id)
1753 else {
1754 tracing::debug!("Couldn't find caster entity_id = {by_entity_id}");
1755 return EventHandleResult::fail(state);
1756 };
1757
1758 let Some(active_ability) = casted_by_entity
1759 .abilities
1760 .iter()
1761 .find(|equipped_ability| equipped_ability.ability.template_id == ability_id)
1762 .cloned()
1763 else {
1764 tracing::error!(
1765 "Couldn't find ability_id = {} in caster entity {:?}",
1766 ability_id,
1767 casted_by_entity
1768 );
1769 return EventHandleResult::fail(state);
1770 };
1771 let ability = active_ability.ability;
1772 let ability_slot_level =
1773 self.compute_ability_slot_level(active_ability.slot_id, &state_cloned);
1774
1775 let player_id = active_fight.player_id;
1776
1777 let Some(ability_template) = game_config.ability_template(ability.template_id).cloned()
1778 else {
1779 tracing::error!(
1780 "Couldn't find template for ability_id = {}",
1781 ability.template_id
1782 );
1783 return EventHandleResult::fail(state);
1784 };
1785
1786 let _ = (&state_cloned, ability_slot_level);
1787
1788 let cast_mods =
1793 crate::mechanics::ability_stones::resolver_for_caster(&state_cloned, casted_by_entity)
1794 .mods_for(&game_config, ability.template_id, ability.level);
1795 let support_events = Self::support_cast_events(
1796 &cast_mods,
1797 casted_by_entity,
1798 to_entity_id,
1799 ability.template_id,
1800 ability.level,
1801 );
1802
1803 let rng = GameRng::new(rand_gen);
1804
1805 let double_strike = double_strike.is_some_and(|chance| {
1809 crate::mechanics::fight::entropy_throw_p(
1810 &rng,
1811 casted_by_entity,
1812 crate::mechanics::class_passives::REPEAT_MARK,
1813 chance,
1814 )
1815 });
1816 if double_strike {
1817 crate::mechanics::class_passives::mark_repeat(casted_by_entity);
1818 }
1819
1820 let multicast = multicast.is_some_and(|chance| {
1821 crate::mechanics::fight::entropy_throw_p(
1822 &rng,
1823 casted_by_entity,
1824 "multicast_chance",
1825 chance,
1826 )
1827 });
1828 if multicast {
1829 crate::mechanics::class_passives::mark_multicast(casted_by_entity);
1830 }
1831
1832 let native_result = (|| {
1835 let name = ability_template.behavior.as_deref()?;
1836 let f = self.behaviors.cast_ability_fn(name)?;
1837 Some(f(&crate::behaviors::combat::cast_ability::CastAbilityCtx {
1838 caster_entity: casted_by_entity,
1839 target_entity: &target_entity,
1840 fight: &active_fight_clone,
1841 rng: &rng,
1842 ability_level: ability.level,
1843 ability_id: ability.template_id,
1844 config: &game_config,
1845 lookups: self.behaviors.lookups(),
1846 stones: crate::mechanics::ability_stones::resolver_for_caster(
1847 &state_cloned,
1848 casted_by_entity,
1849 ),
1850 source: CombatSource::AbilityCast {
1855 ability_id: ability.template_id,
1856 },
1857 }))
1858 })();
1859
1860 match native_result {
1861 Some(Ok(mut events)) => {
1862 let _ = player_id;
1863
1864 events.extend(support_events);
1865 let mut now_events = self.route_projectiles_to_clock(events);
1866 law_events.append(&mut now_events);
1869 if double_strike || multicast {
1874 law_events.push(EventPluginized::now(OverlordEvent::CastAbility {
1875 by_entity_id,
1876 to_entity_id,
1877 ability_id,
1878 origin: essences::combat_origin::CombatEventOrigin::Core,
1879 }));
1880 }
1881 EventHandleResult::ok_events(state, law_events)
1882 }
1883 Some(Err(err)) => {
1884 tracing::error!("Ability cast script failed with error: {err:?}");
1885 EventHandleResult::fail(state)
1886 }
1887 None => {
1888 tracing::error!("Ability {} has no script registered", ability.template_id);
1889 EventHandleResult::fail(state)
1890 }
1891 }
1892 }
1893
1894 #[allow(clippy::too_many_arguments)]
1895 pub fn handle_start_cast_projectile(
1896 &mut self,
1897 _event: OverlordEvent,
1898 by_entity_id: Uuid,
1899 to_entity_id: Uuid,
1900 projectile_id: Uuid,
1901 level: i64,
1902 source: CombatSource,
1903 current_tick: u64,
1904 mut state: OverlordState,
1905 ) -> EventHandleResult<OverlordEvent, OverlordState> {
1906 let game_config = self.game_config.get();
1907
1908 let Some(active_fight) = &mut state.active_fight else {
1909 return EventHandleResult::ok(state);
1910 };
1911
1912 let Some(target_entity) = active_fight
1913 .entities
1914 .iter()
1915 .find(|e| e.id == to_entity_id)
1916 .cloned()
1917 else {
1918 tracing::debug!("Couldn't find entity_id = {}", to_entity_id);
1919 return EventHandleResult::fail(state);
1920 };
1921
1922 let Ok(projectile) = game_config.require_projectile(projectile_id) else {
1923 tracing::error!("Couldn't find projectile_id = {} in config", projectile_id);
1924 return EventHandleResult::fail(state);
1925 };
1926
1927 let active_fight_clone = active_fight.clone();
1928
1929 let Some(casted_by_entity) = active_fight
1930 .entities
1931 .iter_mut()
1932 .find(|e| e.id == by_entity_id)
1933 else {
1934 tracing::debug!("Couldn't find caster entity_id = {}", by_entity_id);
1935 return EventHandleResult::fail(state);
1936 };
1937
1938 let _ = (&active_fight_clone, current_tick);
1939
1940 let native_result = (|| {
1943 let name = projectile.start_behavior.as_deref()?;
1944 let f = self.behaviors.start_cast_projectile_fn(name)?;
1945 Some(f(
1946 &crate::behaviors::combat::start_cast::StartCastProjectileCtx {
1947 caster_entity: casted_by_entity,
1948 target_entity: &target_entity,
1949 },
1950 ))
1951 })();
1952
1953 let result = match native_result {
1954 Some(Ok(v)) => v,
1955 Some(Err(err)) => {
1956 tracing::error!("Projectile start cast script failed with error: {err:?}");
1957 return EventHandleResult::fail(state);
1958 }
1959 None => {
1960 tracing::error!("Projectile {projectile_id} has no start_behavior registered");
1961 return EventHandleResult::fail(state);
1962 }
1963 };
1964
1965 self.fight_clock.schedule(
1966 OverlordEvent::CastProjectile {
1967 by_entity_id,
1968 to_entity_id,
1969 projectile_id,
1970 level,
1971 projectile_data: result.projectile_data,
1972 origin: CombatEventOrigin::Core,
1973 source,
1974 },
1975 result.animation_duration_ticks as u64,
1976 );
1977
1978 EventHandleResult::ok_events(
1979 state,
1980 vec![EventPluginized::now(OverlordEvent::StartedCastProjectile {
1981 by_entity_id,
1982 to_entity_id,
1983 projectile_id,
1984 duration_ticks: result.animation_duration_ticks as u64,
1985 origin: CombatEventOrigin::Core,
1986 source,
1987 })],
1988 )
1989 }
1990
1991 #[allow(clippy::too_many_arguments)]
1992 pub fn handle_cast_projectile(
1993 &mut self,
1994 _event: OverlordEvent,
1995 by_entity_id: Uuid,
1996 to_entity_id: Uuid,
1997 projectile_id: Uuid,
1998 level: i64,
1999 projectile_data: &CustomEventData,
2000 source: CombatSource,
2001 rand_gen: rand::rngs::StdRng,
2002 current_tick: u64,
2003 state: OverlordState,
2004 ) -> EventHandleResult<OverlordEvent, OverlordState> {
2005 let game_config = self.game_config.get();
2006
2007 let Some(active_fight) = &state.active_fight else {
2008 return EventHandleResult::ok(state);
2009 };
2010
2011 let Some(casted_by_entity) = active_fight.entities.iter().find(|e| e.id == by_entity_id)
2012 else {
2013 tracing::debug!("Couldn't find caster entity_id = {}", by_entity_id);
2014 return EventHandleResult::fail(state);
2015 };
2016
2017 let Some(target_entity) = active_fight
2018 .entities
2019 .iter()
2020 .find(|e| e.id == to_entity_id)
2021 .cloned()
2022 else {
2023 tracing::debug!("Couldn't find target entity_id = {}", to_entity_id);
2024 return EventHandleResult::fail(state);
2025 };
2026
2027 let Ok(projectile) = game_config.require_projectile(projectile_id) else {
2028 tracing::error!("Couldn't find projectile_id = {} in config", projectile_id);
2029 return EventHandleResult::fail(state);
2030 };
2031
2032 let _ = (projectile_data, current_tick);
2033
2034 let native_result = (|| {
2037 let name = projectile.behavior.as_deref()?;
2038 let f = self.behaviors.cast_projectile_fn(name)?;
2039 Some(f(
2040 &crate::behaviors::combat::cast_projectile::CastProjectileCtx {
2041 caster_entity: casted_by_entity,
2042 target_entity: &target_entity,
2043 fight: active_fight,
2044 rng: &GameRng::new(rand_gen),
2045 projectile_level: level,
2046 config: &game_config,
2047 lookups: self.behaviors.lookups(),
2048 stones: crate::mechanics::ability_stones::resolver_for_caster(
2049 &state,
2050 casted_by_entity,
2051 ),
2052 source: match source {
2056 CombatSource::Other => CombatSource::ProjectileHit { projectile_id },
2057 carried => carried,
2058 },
2059 },
2060 ))
2061 })();
2062
2063 match native_result {
2064 Some(Ok(events)) => {
2065 let now_events = self.route_projectiles_to_clock(events);
2066 EventHandleResult::ok_events(state, now_events)
2067 }
2068 Some(Err(err)) => {
2069 tracing::error!("Projectile cast script failed with error: {err:?}");
2070 EventHandleResult::fail(state)
2071 }
2072 None => {
2073 tracing::error!("Projectile {projectile_id} has no script registered");
2074 EventHandleResult::fail(state)
2075 }
2076 }
2077 }
2078
2079 pub fn handle_player_death(
2080 &mut self,
2081 mut state: OverlordState,
2082 ) -> EventHandleResult<OverlordEvent, OverlordState> {
2083 let Some(active_fight) = &mut state.active_fight else {
2084 return EventHandleResult::ok(state);
2085 };
2086
2087 if active_fight.fight_ended {
2091 return EventHandleResult::ok(state);
2092 }
2093
2094 active_fight.entities = Vec::new();
2095
2096 let fight_uuid = active_fight.id;
2097 active_fight.fight_ended = true;
2098 let end_fight_delay = self.get_end_fight_delay(active_fight.fight_id);
2099
2100 self.fight_clock.schedule(
2101 OverlordEvent::EndFight {
2102 fight_id: fight_uuid,
2103 is_win: false,
2104 pvp_state: state.pvp_state.clone().map(Box::new),
2105 },
2106 end_fight_delay,
2107 );
2108
2109 EventHandleResult::ok(state)
2110 }
2111
2112 fn release_next_gated_exit(
2125 &mut self,
2126 active_fight: &mut ActiveFight,
2127 fight_settings: &configs::fighting::FightSettings,
2128 dead_col_x: i64,
2129 ) {
2130 let entrance_offset = fight_settings.wave_entrance_offset_cells.max(0);
2131 let gated: Vec<(usize, i64, i64)> = active_fight
2132 .entities
2133 .iter()
2134 .enumerate()
2135 .filter(|(_, e)| e.team == EntityTeam::Enemy && e.hp > 0)
2136 .filter_map(|(i, e)| {
2137 e.attributes
2138 .0
2139 .get("exit_gated")
2140 .copied()
2141 .map(|k| (i, k, e.coordinates.x - entrance_offset))
2142 })
2143 .collect();
2144 let Some(idx) = gated
2145 .iter()
2146 .filter(|&&(_, _, landing_x)| landing_x == dead_col_x)
2147 .min_by_key(|&&(_, k, _)| k)
2148 .or_else(|| gated.iter().min_by_key(|&&(_, k, _)| k))
2149 .map(|&(i, _, _)| i)
2150 else {
2151 return;
2152 };
2153 let run_ticks = entrance_offset as u64 * fight_settings.wave_entrance_walk_ms_per_cell;
2154 let released_id = active_fight.entities[idx].id;
2158 let row =
2159 crate::mechanics::fight::gated_release_row(active_fight, released_id, entrance_offset);
2160 let entity = &mut active_fight.entities[idx];
2161 entity.attributes.set("exit_gated", 0);
2163 entity.coordinates.y = row;
2167 let battle_cell = Coordinates {
2168 x: entity.coordinates.x - entrance_offset,
2169 y: row,
2170 };
2171 self.fight_clock.schedule(
2172 OverlordEvent::StartMove {
2173 entity_id: entity.id,
2174 to: battle_cell,
2175 duration_ticks: run_ticks,
2176 },
2177 1,
2178 );
2179 }
2180
2181 pub fn handle_entity_death(
2182 &mut self,
2183 entity_id: Uuid,
2184 reward: Vec<CurrencyUnit>,
2185 mut rand_gen: rand::rngs::StdRng,
2186 mut state: OverlordState,
2187 ) -> EventHandleResult<OverlordEvent, OverlordState> {
2188 let game_config = self.game_config.get();
2189
2190 let is_pvp = state.pvp_state.is_some();
2194
2195 let Some(active_fight) = &mut state.active_fight else {
2196 return EventHandleResult::ok(state);
2197 };
2198
2199 if active_fight.fight_ended {
2204 return EventHandleResult::ok(state);
2205 }
2206
2207 let Some(entity_idx) = active_fight
2208 .entities
2209 .iter()
2210 .position(|entity| entity.id == entity_id)
2211 else {
2212 tracing::error!("Failed to get entity with entity_id={}", entity_id);
2213 return EventHandleResult::fail(state);
2214 };
2215
2216 let dead_team = active_fight.entities[entity_idx].team.clone();
2217 let dead_wave_share = active_fight.entities[entity_idx].attributes.wave_share();
2218 let fight_type = game_config
2229 .require_fight_template(active_fight.fight_id)
2230 .map(|f| f.fight_type.clone())
2231 .unwrap_or(essences::fighting::FightType::SingleFight);
2232 let is_campaign_fight = matches!(
2233 fight_type,
2234 essences::fighting::FightType::CampaignFight
2235 | essences::fighting::FightType::CampaignBossFight
2236 );
2237 let is_campaign_kill = is_campaign_fight && !is_pvp && active_fight.dungeon.is_none();
2238 let dead_is_summon = active_fight.entities[entity_idx].attributes.is_summoned();
2243 let is_boss_fight = fight_type == essences::fighting::FightType::CampaignBossFight;
2244 let is_ordinary_mob_kill = is_campaign_kill && !dead_is_summon && !is_boss_fight;
2245 let dead_col_x = {
2248 let e = &active_fight.entities[entity_idx];
2249 e.move_target
2250 .as_ref()
2251 .map(|t| t.x)
2252 .unwrap_or(e.coordinates.x)
2253 };
2254 active_fight.entities.swap_remove(entity_idx);
2255
2256 let mut events = Vec::new();
2257
2258 {
2269 const GOLD_CURRENCY_ID: Uuid = Uuid::from_u128(0x0194d64e_2386_7020_8b01_d6b3d5424506);
2272 let cookie_id = game_config.kill_faucet_settings.direct_cookie_currency_id;
2273 let kept: Vec<CurrencyUnit> = reward
2274 .iter()
2275 .filter(|unit| {
2276 unit.currency_id != GOLD_CURRENCY_ID && unit.currency_id != cookie_id
2277 })
2278 .cloned()
2279 .collect();
2280 if !kept.is_empty() {
2281 events.push(Self::currency_increase(&kept, CurrencySource::EntityDeath));
2282 }
2283 }
2284
2285 let chapter = state.character_state.character.current_chapter_level;
2295 let now = ::time::utc_now();
2296 {
2297 if dead_team == EntityTeam::Enemy
2298 && is_ordinary_mob_kill
2303 && let Some(band) = game_config.cores_settings.essence_band(chapter)
2304 {
2305 let daily = essences::kill_faucets::band_for_today(
2306 &mut state.character_state.kill_faucet_daily,
2307 essences::kill_faucets::KillFaucetFamily::CoreEssence,
2308 band.r_value,
2309 now,
2310 );
2311 let chance = band.base_chance * daily.decay();
2312 if chance > 0.0
2313 && rand::RngExt::random_range(&mut rand_gen, 0.0..1.0) < chance
2314 && let paid = daily.take(band.packet.get())
2315 && paid > 0
2316 {
2317 events.push(Self::currency_increase(
2318 &[CurrencyUnit {
2319 currency_id: game_config.cores_settings.upgrade_currency_id,
2320 amount: paid,
2321 }],
2322 CurrencySource::EntityDeath,
2323 ));
2324 }
2325 }
2326
2327 let law_chance = game_config.cores_settings.law_drop_chance;
2337 if dead_team == EntityTeam::Enemy && is_campaign_kill {
2338 let cores = &state.character_state.cores;
2339 let pool: Vec<essences::cores::LawTemplateId> =
2340 crate::mechanics::cores::laws_unlocked_by_core_level(
2341 &game_config,
2342 essences::flip::WorldSide::Real,
2343 cores.real_level,
2344 )
2345 .chain(crate::mechanics::cores::laws_unlocked_by_core_level(
2346 &game_config,
2347 essences::flip::WorldSide::Fantasy,
2348 cores.fantasy_level,
2349 ))
2350 .filter(|id| {
2351 game_config
2352 .laws
2353 .iter()
2354 .any(|law| law.id == *id && law.is_active)
2355 })
2356 .collect();
2357 if !pool.is_empty() {
2358 let daily = essences::kill_faucets::band_for_today(
2359 &mut state.character_state.kill_faucet_daily,
2360 essences::kill_faucets::KillFaucetFamily::LawCopies,
2361 game_config.kill_faucet_settings.law_copies_d,
2362 now,
2363 );
2364 let chance = law_chance * dead_wave_share * daily.decay();
2365 if chance > 0.0
2366 && rand::RngExt::random_range(&mut rand_gen, 0.0..1.0) < chance
2367 && daily.take(1) > 0
2368 {
2369 let index = rand::RngExt::random_range(&mut rand_gen, 0..pool.len());
2370 events.push(EventPluginized::now(OverlordEvent::NewLawCopies {
2371 law_template_id: pool[index],
2372 amount: 1,
2373 }));
2374 }
2375 }
2376 }
2377 }
2378
2379 let first_socket_chapter = game_config
2388 .stones_settings
2389 .socket_unlocks
2390 .iter()
2391 .map(|unlock| unlock.unlock_chapter)
2392 .min()
2393 .unwrap_or(i64::MAX);
2394 if dead_team == EntityTeam::Enemy && is_campaign_kill && chapter >= first_socket_chapter {
2395 let equipment_decay = {
2399 let daily = essences::kill_faucets::band_for_today(
2400 &mut state.character_state.kill_faucet_daily,
2401 essences::kill_faucets::KillFaucetFamily::EquipmentStones,
2402 game_config.kill_faucet_settings.equipment_stones_d,
2403 now,
2404 );
2405 if daily.remaining() > 0 {
2406 daily.decay()
2407 } else {
2408 0.0
2409 }
2410 };
2411 let stone_event =
2412 self.roll_stone_kill_drop(&mut rand_gen, dead_wave_share * equipment_decay);
2413 if stone_event.is_some() {
2414 essences::kill_faucets::band_for_today(
2415 &mut state.character_state.kill_faucet_daily,
2416 essences::kill_faucets::KillFaucetFamily::EquipmentStones,
2417 game_config.kill_faucet_settings.equipment_stones_d,
2418 now,
2419 )
2420 .take(1);
2421 }
2422 events.extend(stone_event);
2423
2424 let artifact_decay = {
2434 let daily = essences::kill_faucets::band_for_today(
2435 &mut state.character_state.kill_faucet_daily,
2436 essences::kill_faucets::KillFaucetFamily::ArtifactStones,
2437 game_config.kill_faucet_settings.artifact_stones_d,
2438 now,
2439 );
2440 if daily.remaining() > 0 {
2441 daily.decay()
2442 } else {
2443 0.0
2444 }
2445 };
2446 let artifact_stone_chance = game_config.artifacts_settings.stone_drop.mob_kill_chance
2447 * dead_wave_share
2448 * artifact_decay;
2449 let artifact_event =
2450 self.roll_artifact_stone_drop(&mut rand_gen, artifact_stone_chance, chapter);
2451 if artifact_event.is_some() {
2452 essences::kill_faucets::band_for_today(
2453 &mut state.character_state.kill_faucet_daily,
2454 essences::kill_faucets::KillFaucetFamily::ArtifactStones,
2455 game_config.kill_faucet_settings.artifact_stones_d,
2456 now,
2457 )
2458 .take(1);
2459 }
2460 events.extend(artifact_event);
2461 }
2462
2463 if dead_team == EntityTeam::Enemy && is_ordinary_mob_kill {
2469 let settings = &game_config.kill_faucet_settings;
2470 let tickets = settings.direct_cookie_tickets_per_kill;
2471 let mut paid = 0i64;
2472 {
2473 let daily = essences::kill_faucets::band_for_today(
2474 &mut state.character_state.kill_faucet_daily,
2475 essences::kill_faucets::KillFaucetFamily::DirectCookies,
2476 settings.direct_cookies_d,
2477 now,
2478 );
2479 for _ in 0..tickets {
2480 if daily.remaining() <= 0 {
2481 break;
2482 }
2483 let chance = settings.direct_cookie_chance(daily.granted);
2488 if chance > 0.0 && rand::RngExt::random_range(&mut rand_gen, 0.0..1.0) < chance
2489 {
2490 paid += daily.take(1);
2491 }
2492 }
2493 }
2494 if paid > 0 {
2495 events.push(Self::currency_increase(
2496 &[CurrencyUnit {
2497 currency_id: settings.direct_cookie_currency_id,
2498 amount: paid,
2499 }],
2500 CurrencySource::EntityDeath,
2501 ));
2502 }
2503 }
2504
2505 let Some(active_fight) = &mut state.active_fight else {
2506 return EventHandleResult::ok(state);
2507 };
2508
2509 let Ok(fight) = game_config.require_fight_template(active_fight.fight_id) else {
2510 tracing::error!(
2511 "Failed to get fight_template with id {} ",
2512 active_fight.fight_id
2513 );
2514 return EventHandleResult::fail(state);
2515 };
2516
2517 let has_any_ally = active_fight
2518 .entities
2519 .iter()
2520 .any(|e| e.team == EntityTeam::Ally);
2521
2522 if dead_team == EntityTeam::Enemy {
2530 self.release_next_gated_exit(active_fight, &game_config.fight_settings, dead_col_x);
2531 }
2532
2533 if dead_team == EntityTeam::Enemy && active_fight.get_enemies_amount() > 0 {
2536 let promotion = self.slot_promotion_events(active_fight);
2537 events.extend(promotion);
2538 }
2539
2540 let pending_summon_skipped = fight
2544 .prepare_fight_waves
2545 .as_ref()
2546 .and_then(|w| w.summon_wave_at_hp_fraction)
2547 .is_some()
2548 && active_fight.current_wave == fight.waves_amount - 1;
2549
2550 if active_fight.get_enemies_amount() == 0 && has_any_ally {
2551 if active_fight.current_wave == fight.waves_amount || pending_summon_skipped {
2552 let fight_uuid = active_fight.id;
2553 active_fight.fight_ended = true;
2554 let end_fight_delay = self.get_end_fight_delay(active_fight.fight_id);
2555 self.fight_clock.schedule(
2556 OverlordEvent::EndFight {
2557 fight_id: fight_uuid,
2558 is_win: true,
2559 pvp_state: state.pvp_state.clone().map(Box::new),
2560 },
2561 end_fight_delay,
2562 );
2563 if fight.fight_type == FightType::CampaignBossFight {
2564 events.push(EventPluginized::now(OverlordEvent::StageCleared {}));
2565 }
2566 } else {
2567 let fight_uuid = active_fight.id;
2568 active_fight.current_wave += 1;
2569 let active_fight_cloned = active_fight.clone();
2570 let current_chapter = state.character_state.character.current_chapter_level;
2571
2572 let prepare_fight_events = match fight.prepare_fight_waves.as_ref() {
2583 Some(waves_cfg) => {
2584 let wave_data = crate::mechanics::fight::wave_data_from_config(waves_cfg);
2585 let fight_type_str = format!("{:?}", fight.fight_type);
2586 let mut sink = crate::mechanics::fight::NativeSink::default();
2587 let rng = GameRng::new(rand_gen);
2588 match crate::mechanics::fight::spawn_wave(
2589 &mut sink,
2590 &rng,
2591 &game_config,
2592 self.behaviors.lookups(),
2593 &active_fight_cloned,
2594 &wave_data,
2595 fight.power.map(|p| p as f64).unwrap_or(0.0),
2596 current_chapter,
2597 &fight_type_str,
2598 ) {
2599 Ok(()) => sink.events,
2600 Err(err) => {
2601 tracing::error!(
2602 "Prepare wave for new wave failed with error: {err:?}"
2603 );
2604 events.push(EventPluginized::now(OverlordEvent::EndFight {
2605 fight_id: fight_uuid,
2606 is_win: false,
2607 pvp_state: state.pvp_state.clone().map(Box::new),
2608 }));
2609 return EventHandleResult::ok_events(state, events);
2610 }
2611 }
2612 }
2613 None => {
2614 tracing::error!(
2615 "Fight {} has no prepare_fight_waves for next wave",
2616 fight.id
2617 );
2618 events.push(EventPluginized::now(OverlordEvent::EndFight {
2619 fight_id: fight_uuid,
2620 is_win: false,
2621 pvp_state: state.pvp_state.clone().map(Box::new),
2622 }));
2623 return EventHandleResult::ok_events(state, events);
2624 }
2625 };
2626
2627 if prepare_fight_events.is_empty() {
2628 tracing::error!("Prepare wave script returned no events");
2629 events.push(EventPluginized::now(OverlordEvent::EndFight {
2630 fight_id: fight_uuid,
2631 is_win: false,
2632 pvp_state: state.pvp_state.clone().map(Box::new),
2633 }));
2634 return EventHandleResult::ok_events(state, events);
2635 }
2636
2637 if !prepare_fight_events
2638 .iter()
2639 .any(|ev| matches!(ev, OverlordEvent::SpawnEntity { .. }))
2640 {
2641 tracing::error!("Prepare wave script returned no SpawnEntity events");
2642 events.push(EventPluginized::now(OverlordEvent::EndFight {
2643 fight_id: fight_uuid,
2644 is_win: false,
2645 pvp_state: state.pvp_state.clone().map(Box::new),
2646 }));
2647 return EventHandleResult::ok_events(state, events);
2648 }
2649
2650 let between_wave_behavior = crate::mechanics::fight::between_wave_behavior(
2661 &game_config,
2662 &active_fight_cloned,
2663 );
2664 for ev in prepare_fight_events {
2665 self.fight_clock
2666 .schedule(ev, between_wave_behavior.spawn_delay_ticks);
2667 }
2668
2669 if between_wave_behavior.advance_formation {
2684 let dash_ticks = game_config.fight_settings.formation_advance_ticks;
2685 for ally in active_fight_cloned
2686 .entities
2687 .iter()
2688 .filter(|e| e.team == EntityTeam::Ally && e.hp > 0)
2689 {
2690 self.fight_clock.schedule(
2691 OverlordEvent::StartMove {
2692 entity_id: ally.id,
2693 to: Coordinates {
2694 x: ally.coordinates.x
2695 + crate::mechanics::fight::FORMATION_ADVANCE_CELLS,
2696 y: ally.coordinates.y,
2697 },
2698 duration_ticks: dash_ticks,
2699 },
2700 between_wave_behavior.spawn_delay_ticks,
2701 );
2702 }
2703 }
2704
2705 events.push(EventPluginized::now(OverlordEvent::WaveCleared {}));
2706 }
2707 }
2708
2709 EventHandleResult::ok_events(state, events)
2710 }
2711
2712 pub fn handle_heal(
2713 &mut self,
2714 by_entity_id: Option<EntityId>,
2715 entity_id: Uuid,
2716 heal: u64,
2717 source: CombatSource,
2718 mut state: OverlordState,
2719 ) -> EventHandleResult<OverlordEvent, OverlordState> {
2720 let Some(active_fight) = &mut state.active_fight else {
2721 return EventHandleResult::ok(state);
2722 };
2723
2724 let fight_instance_id = active_fight.id;
2727 let fight_template_id = active_fight.fight_id;
2728 let actor = breakdown_actor(active_fight, by_entity_id);
2729
2730 let Some(healed_entity) = active_fight
2731 .entities
2732 .iter_mut()
2733 .find(|entity| entity.id == entity_id)
2734 else {
2735 tracing::error!("Failed to get entity with entity_id={}", entity_id);
2736 return EventHandleResult::fail(state);
2737 };
2738
2739 let hp_before = healed_entity.hp;
2740 healed_entity.hp = healed_entity
2741 .hp
2742 .saturating_add(heal)
2743 .min(healed_entity.max_hp);
2744 let applied = healed_entity.hp - hp_before;
2747
2748 self.record_fight_breakdown(
2749 fight_instance_id,
2750 fight_template_id,
2751 actor,
2752 source,
2753 0,
2754 applied,
2755 false,
2756 );
2757
2758 EventHandleResult::ok(state)
2759 }
2760
2761 #[allow(clippy::too_many_arguments)]
2762 pub fn handle_damage(
2763 &mut self,
2764 by_entity_id: Option<Uuid>,
2765 entity_id: Uuid,
2766 damage: u64,
2767 damage_data: &CustomEventData,
2768 source: CombatSource,
2769 mut rand_gen: rand::rngs::StdRng,
2770 mut state: OverlordState,
2771 ) -> EventHandleResult<OverlordEvent, OverlordState> {
2772 let Some(active_fight) = &mut state.active_fight else {
2773 return EventHandleResult::ok(state);
2774 };
2775
2776 let fight_instance_id = active_fight.id;
2779 let fight_template_id = active_fight.fight_id;
2780 let actor = breakdown_actor(active_fight, by_entity_id);
2781
2782 let Some(damaged_entity) = active_fight
2783 .entities
2784 .iter_mut()
2785 .find(|entity| entity.id == entity_id)
2786 else {
2787 tracing::error!("Failed to get entity with entity_id={}", entity_id);
2788 return EventHandleResult::fail(state);
2789 };
2790
2791 let (actual_hp_removed, insurance_consumed) =
2792 remove_hp_with_insurance(damaged_entity, damage);
2793 let new_hp = damaged_entity.hp;
2794
2795 self.record_fight_breakdown(
2798 fight_instance_id,
2799 fight_template_id,
2800 actor,
2801 source,
2802 actual_hp_removed,
2803 0,
2804 damage_data.0.get("crit").is_some_and(|crit| *crit > 0),
2805 );
2806 if insurance_consumed {
2807 tracing::debug!(
2808 %entity_id,
2809 damage,
2810 "Consumed gambling insurance lethal-save charge"
2811 );
2812 }
2813 let game_config = self.game_config.get();
2822 let damaged_is_boss = damaged_entity.has_big_hp_bar;
2823 let damaged_max_hp = damaged_entity.max_hp;
2824 let victim_gauge_share = damaged_entity.attributes.gauge_hp_share();
2825
2826 let mut gauge_events = Vec::new();
2834 if actual_hp_removed > 0 && damaged_max_hp > 0 {
2835 let coefficient = game_config.flip_settings.damage_gauge_coefficient;
2836 let hp_share = actual_hp_removed as f64 / damaged_max_hp as f64;
2837 if let Some(flip) = crate::logic::stones::accumulate_flip_gauge(
2838 &mut state,
2839 entity_id,
2840 coefficient * hp_share,
2841 essences::flip::FlipProgressSource::DamageReceived,
2842 &game_config,
2843 ) {
2844 gauge_events.push(EventPluginized::now(flip));
2845 }
2846 if let Some(dealer) = by_entity_id
2847 && dealer != entity_id
2848 && let Some(flip) = crate::logic::stones::accumulate_flip_gauge(
2849 &mut state,
2850 dealer,
2851 coefficient * hp_share * victim_gauge_share,
2852 essences::flip::FlipProgressSource::DamageDealt,
2853 &game_config,
2854 )
2855 {
2856 gauge_events.push(EventPluginized::now(flip));
2857 }
2858 }
2859
2860 if new_hp > 0 {
2861 let mut events = Vec::new();
2862 events.extend(gauge_events);
2863
2864 let current_chapter = state.character_state.character.current_chapter_level;
2872 if damaged_is_boss
2873 && let Some(active_fight) = &mut state.active_fight
2874 && let Ok(template) = game_config.require_fight_template(active_fight.fight_id)
2875 && let Some(waves_cfg) = template.prepare_fight_waves.as_ref()
2876 && let Some(fraction) = waves_cfg.summon_wave_at_hp_fraction
2877 && active_fight.current_wave < template.waves_amount
2878 && (new_hp as f64) < fraction * damaged_max_hp as f64
2879 {
2880 active_fight.current_wave = template.waves_amount;
2881 let active_fight_cloned = active_fight.clone();
2882 let wave_data = crate::mechanics::fight::wave_data_from_config(waves_cfg);
2883 let fight_type_str = format!("{:?}", template.fight_type);
2884 let mut sink = crate::mechanics::fight::NativeSink::default();
2885 let rng = GameRng::new(rand_gen);
2886 match crate::mechanics::fight::spawn_wave(
2887 &mut sink,
2888 &rng,
2889 &game_config,
2890 self.behaviors.lookups(),
2891 &active_fight_cloned,
2892 &wave_data,
2893 template.power.map(|p| p as f64).unwrap_or(0.0),
2894 current_chapter,
2895 &fight_type_str,
2896 ) {
2897 Ok(()) => {
2898 events.extend(sink.events.into_iter().map(EventPluginized::now));
2899 }
2900 Err(err) => {
2901 tracing::error!("boss summon spawn_wave failed: {err}");
2902 }
2903 }
2904 }
2905
2906 return EventHandleResult::ok_events(state, events);
2907 }
2908
2909 let Some(active_fight) = &mut state.active_fight else {
2910 return EventHandleResult::ok(state);
2911 };
2912
2913 let Some(damaged_entity) = active_fight
2914 .entities
2915 .iter_mut()
2916 .find(|entity| entity.id == entity_id)
2917 else {
2918 return EventHandleResult::fail(state);
2919 };
2920
2921 let damaged_id = damaged_entity.id;
2923 let damaged_team = damaged_entity.team.clone();
2924 let damaged_rewards = damaged_entity.rewards.clone();
2925 let damaged_template_id = damaged_entity.entity_template_id;
2926 let damaged_wave_share = damaged_entity.attributes.wave_share();
2929
2930 let has_remaining_allies = active_fight
2931 .entities
2932 .iter()
2933 .any(|e| e.team == EntityTeam::Ally && e.id != damaged_id);
2934
2935 let mut events = gauge_events;
2938
2939 let mut artifact_stone_drop = None;
2940 if damaged_team == EntityTeam::Enemy {
2941 let mut currencies = Vec::new();
2943
2944 if state.pvp_state.is_none() {
2945 let is_boss = damaged_template_id
2952 .and_then(|tid| game_config.entity_template(tid))
2953 .is_some_and(|t| t.is_boss);
2954 let is_campaign = game_config
2955 .require_fight_template(active_fight.fight_id)
2956 .map(|f| {
2957 matches!(
2958 f.fight_type,
2959 FightType::CampaignFight | FightType::CampaignBossFight
2960 )
2961 })
2962 .unwrap_or(false);
2963 let reward_multiplier = if is_boss && is_campaign {
2964 boss_reward_chapter_multiplier(
2965 game_config.game_settings.boss_reward_chapter_growth,
2966 game_config.game_settings.boss_reward_max_multiplier,
2967 state.character_state.character.current_chapter_level,
2968 )
2969 } else {
2970 1.0
2971 };
2972
2973 if is_boss && is_campaign {
2981 let now = ::time::utc_now();
2982 let boss_decay = {
2983 let daily = essences::kill_faucets::band_for_today(
2984 &mut state.character_state.kill_faucet_daily,
2985 essences::kill_faucets::KillFaucetFamily::ArtifactStones,
2986 game_config.kill_faucet_settings.artifact_stones_d,
2987 now,
2988 );
2989 if daily.remaining() > 0 {
2990 daily.decay()
2991 } else {
2992 0.0
2993 }
2994 };
2995 let chance = game_config
2996 .artifacts_settings
2997 .stone_drop
2998 .chapter_boss_chance
2999 * boss_decay;
3000 artifact_stone_drop = self.roll_artifact_stone_drop(
3001 &mut rand_gen,
3002 chance,
3003 state.character_state.character.current_chapter_level,
3004 );
3005 if artifact_stone_drop.is_some() {
3006 essences::kill_faucets::band_for_today(
3007 &mut state.character_state.kill_faucet_daily,
3008 essences::kill_faucets::KillFaucetFamily::ArtifactStones,
3009 game_config.kill_faucet_settings.artifact_stones_d,
3010 now,
3011 )
3012 .take(1);
3013 }
3014 }
3015
3016 if let Some(rewards) = damaged_rewards {
3017 for reward in rewards {
3018 let drop_chance = (reward.drop_chance.clamp(0.0, 100.0)
3021 * damaged_wave_share)
3022 .clamp(0.0, 100.0);
3023 if rand_gen.random_range(0.0..100.0) < drop_chance {
3024 let rolled = if reward.from <= reward.to {
3025 rand_gen.random_range(reward.from..=reward.to)
3026 } else {
3027 tracing::error!(
3028 "Entity {} has a bad reward range: {:?}",
3029 damaged_id,
3030 reward
3031 );
3032 0
3033 };
3034 let is_progression_currency = reward.currency_id
3053 == game_config.game_settings.ability_gacha.currency_id
3054 || reward.currency_id
3055 == game_config.game_settings.pet_gacha.currency_id
3056 || reward.currency_id
3057 == game_config.kill_faucet_settings.boss_gems_currency_id;
3058 let mut amount = if reward_multiplier > 1.0 && !is_progression_currency
3059 {
3060 ((rolled as f64) * reward_multiplier).round() as i64
3061 } else {
3062 rolled
3063 };
3064
3065 let now = ::time::utc_now();
3072 if reward.currency_id
3073 == game_config.kill_faucet_settings.skill_chapter_currency_id
3074 {
3075 if is_boss && is_campaign {
3076 let pass_finished = !state.progress_pass.tiers.is_empty()
3077 && state
3078 .progress_pass
3079 .tiers
3080 .iter()
3081 .all(|tier| tier.is_unlocked);
3082 let cap = if pass_finished {
3083 game_config
3084 .kill_faucet_settings
3085 .skill_chapter_cap_after_pass
3086 } else {
3087 game_config.kill_faucet_settings.skill_chapter_cap_with_pass
3088 };
3089 amount = essences::kill_faucets::exact_cap_for_today(
3090 &mut state.character_state.kill_faucet_daily,
3091 essences::kill_faucets::KillFaucetFamily::SkillChapters,
3092 cap,
3093 now,
3094 )
3095 .take(amount);
3096 } else {
3097 amount = 0;
3098 }
3099 } else if reward.currency_id
3100 == game_config.kill_faucet_settings.boss_gems_currency_id
3101 {
3102 if is_boss && is_campaign {
3103 amount = essences::kill_faucets::exact_cap_for_today(
3104 &mut state.character_state.kill_faucet_daily,
3105 essences::kill_faucets::KillFaucetFamily::BossGems,
3106 game_config.kill_faucet_settings.boss_gems_daily_cap,
3107 now,
3108 )
3109 .take(amount);
3110 } else {
3111 amount = 0;
3112 }
3113 }
3114
3115 if amount > 0 {
3116 currencies.push(CurrencyUnit {
3117 currency_id: reward.currency_id,
3118 amount,
3119 });
3120 }
3121 }
3122 }
3123 } else {
3124 tracing::error!(
3125 "Failed to get reward from damaged_entity with entity_id={}",
3126 entity_id
3127 );
3128 };
3129 }
3130
3131 events.push(EventPluginized::now(OverlordEvent::EntityDeath {
3132 entity_id: damaged_id,
3133 reward: currencies,
3134 origin: CombatEventOrigin::Core,
3135 }));
3136 events.extend(artifact_stone_drop);
3137 } else if has_remaining_allies {
3138 events.push(EventPluginized::now(OverlordEvent::EntityDeath {
3140 entity_id: damaged_id,
3141 reward: Vec::new(),
3142 origin: CombatEventOrigin::Core,
3143 }));
3144 } else {
3145 events.push(EventPluginized::now(OverlordEvent::PlayerDeath {}));
3147 }
3148
3149 EventHandleResult::ok_events(state, events)
3150 }
3151
3152 pub fn handle_fight_progress(
3153 &mut self,
3154 current_tick: u64,
3155 mut state: OverlordState,
3156 ) -> EventHandleResult<OverlordEvent, OverlordState> {
3157 let Some(active_fight) = &mut state.active_fight else {
3158 tracing::error!("No active_fight for fight_progress");
3159 return EventHandleResult::ok(state);
3160 };
3161
3162 if active_fight.fight_ended {
3163 return EventHandleResult::ok(state);
3164 }
3165
3166 if active_fight.fight_stopped {
3167 return EventHandleResult::ok(state);
3168 }
3169
3170 if active_fight.entities.is_empty() {
3171 tracing::error!("No entities in fight");
3172 return EventHandleResult::ok(state);
3173 }
3174
3175 if current_tick - self.start_fight_tick >= active_fight.max_duration_ticks {
3176 active_fight.fight_ended = true;
3177 tracing::debug!("Fight lasted too long, ending it");
3178 let fight_uuid = active_fight.id;
3179 let fight_id = active_fight.fight_id;
3180 let end_fight_delay = self.get_end_fight_delay(fight_id);
3181 let pvp_state = state.pvp_state.clone().map(Box::new);
3182 self.fight_clock.schedule(
3183 OverlordEvent::EndFight {
3184 fight_id: fight_uuid,
3185 is_win: false,
3186 pvp_state,
3187 },
3188 end_fight_delay,
3189 );
3190 return EventHandleResult::ok(state);
3191 }
3192
3193 let mut events = vec![];
3194 let game_config = self.game_config.get();
3195
3196 let periodic: Vec<(EntityId, f64, u64)> = active_fight
3201 .entities
3202 .iter()
3203 .filter(|entity| {
3204 crate::mechanics::class_passives::periodic_is_due(entity, current_tick)
3205 })
3206 .filter_map(|entity| {
3207 let share = crate::mechanics::class_passives::periodic_heal_share(entity)?;
3208 let period = game_config
3209 .classes
3210 .iter()
3211 .find(|class| Some(class.id) == entity.class_id)
3212 .map(|class| class.passive_period_ticks)
3213 .unwrap_or(0);
3214 (share > 0.0 && period > 0).then_some((entity.id, share, period))
3215 })
3216 .collect();
3217
3218 for (healer_id, share, period) in periodic {
3219 let team = active_fight
3220 .entities
3221 .iter()
3222 .find(|entity| entity.id == healer_id)
3223 .map(|entity| entity.team.clone());
3224 let target = active_fight
3227 .entities
3228 .iter()
3229 .filter(|entity| Some(&entity.team) == team.as_ref() && entity.max_hp > 0)
3230 .min_by(|a, b| {
3231 let share_of = |e: &essences::entity::Entity| e.hp as f64 / e.max_hp as f64;
3232 share_of(a)
3233 .partial_cmp(&share_of(b))
3234 .unwrap_or(std::cmp::Ordering::Equal)
3235 })
3236 .map(|entity| (entity.id, entity.max_hp));
3237
3238 if let Some(healer) = active_fight
3239 .entities
3240 .iter_mut()
3241 .find(|entity| entity.id == healer_id)
3242 {
3243 healer.attributes.set(
3244 crate::mechanics::class_passives::PASSIVE_DUE,
3245 crate::mechanics::class_passives::next_due(period, current_tick),
3246 );
3247 }
3248
3249 if let Some((target_id, max_hp)) = target {
3250 let heal = (share * max_hp as f64).floor() as u64;
3251 if heal > 0 {
3252 events.push(EventPluginized::now(OverlordEvent::Heal {
3253 entity_id: target_id,
3254 heal,
3255 by_entity_id: Some(healer_id),
3258 origin: essences::combat_origin::CombatEventOrigin::Core,
3259 source: CombatSource::Regeneration,
3260 }));
3261 }
3262 }
3263 }
3264
3265 for entity in &mut active_fight.entities {
3266 if let Some(mana) = entity.mana.as_mut() {
3270 mana.regen_to(current_tick);
3271 }
3272
3273 if entity.move_target.is_none()
3274 && let Some(queued) = entity.actions_queue.pop(current_tick)
3275 {
3276 events.push(event_from_entity_action(
3277 queued.action,
3278 entity.id,
3279 queued.origin,
3280 ));
3281 }
3282 }
3283
3284 EventHandleResult::ok_events(state, events)
3285 }
3286
3287 pub fn handle_set_max_hp(
3288 &mut self,
3289 entity_id: EntityId,
3290 new_max_hp: u64,
3291 new_hp: u64,
3292 mut state: OverlordState,
3293 ) -> EventHandleResult<OverlordEvent, OverlordState> {
3294 let Some(active_fight) = &mut state.active_fight else {
3295 tracing::error!("No active fight for end_fight");
3296 return EventHandleResult::fail(state);
3297 };
3298
3299 let Some(entity) = active_fight
3300 .entities
3301 .iter_mut()
3302 .find(|entity| entity.id == entity_id)
3303 else {
3304 tracing::error!("Failed to get entity with entity_id={}", entity_id);
3305 return EventHandleResult::fail(state);
3306 };
3307
3308 entity.max_hp = new_max_hp;
3309 entity.hp = new_hp.min(new_max_hp);
3310
3311 EventHandleResult::ok(state)
3312 }
3313}
3314
3315fn move_progress_steps(
3322 from: &Coordinates,
3323 to: &Coordinates,
3324 duration_ticks: u64,
3325) -> Vec<(u64, Coordinates)> {
3326 let dx = to.x - from.x;
3327 let dy = to.y - from.y;
3328 let steps = dx.abs().max(dy.abs()).max(1);
3329 (1..=steps)
3330 .map(|k| {
3331 let cell = Coordinates {
3332 x: from.x + dx * k / steps,
3333 y: from.y + dy * k / steps,
3334 };
3335 (duration_ticks * (k as u64 - 1) / steps as u64, cell)
3336 })
3337 .collect()
3338}
3339
3340#[cfg(test)]
3341mod tests {
3342 use super::*;
3343
3344 fn at(x: i64, y: i64) -> Coordinates {
3345 Coordinates { x, y }
3346 }
3347
3348 #[test]
3352 fn move_progress_steps_match_per_cell_timeline() {
3353 assert_eq!(
3355 move_progress_steps(&at(0, 1), &at(4, 1), 2000),
3356 vec![
3357 (0, at(1, 1)),
3358 (500, at(2, 1)),
3359 (1000, at(3, 1)),
3360 (1500, at(4, 1)),
3361 ]
3362 );
3363
3364 assert_eq!(
3366 move_progress_steps(&at(2, 1), &at(3, 2), 707),
3367 vec![(0, at(3, 2))]
3368 );
3369
3370 assert_eq!(
3372 move_progress_steps(&at(2, 1), &at(2, 1), 0),
3373 vec![(0, at(2, 1))]
3374 );
3375 }
3376}