overlord_event_system/logic/
fighting.rs

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    currency::{CurrencySource, CurrencyUnit},
15    entity::{ActionWithDeadline, Coordinates, Entity, EntityAction, EntityAttributes, EntityId},
16    fighting::{EntityTeam, EntityType, FightEntity, FightType},
17    game::EntityTemplateId,
18};
19use event_system::{event::EventPluginized, script::random::GameRng, system::EventHandleResult};
20
21use rand::Rng;
22use uuid::Uuid;
23
24/// Multiplier applied to a campaign-chapter boss's currency drop so that
25/// deeper chapters pay out more ("pushing pays"). Grows geometrically as
26/// `growth^chapter_level`, bounded by `cap` (and a hard sane ceiling so a
27/// cheated/extreme chapter can never produce an infinite reward). Returns
28/// `1.0` (no scaling) when growth is unset or `<= 1.0`.
29fn boss_reward_chapter_multiplier(
30    growth: Option<f64>,
31    cap: Option<f64>,
32    chapter_level: i64,
33) -> f64 {
34    let Some(growth) = growth else { return 1.0 };
35    if growth <= 1.0 {
36        return 1.0;
37    }
38    // `min(1e6)` guards against `f64::INFINITY` at extreme chapter levels even
39    // when no config cap is set; `clamp(0, 1000)` keeps the `powi` exponent sane.
40    let cap = cap.unwrap_or(f64::INFINITY).min(1.0e6);
41    let exp = chapter_level.clamp(0, 1000) as i32;
42    growth.powi(exp).clamp(1.0, cap)
43}
44
45impl OverlordLogic {
46    fn compute_ability_slot_level(
47        &self,
48        slot_id: Option<AbilitySlotId>,
49        state: &OverlordState,
50    ) -> i64 {
51        let Some(slot_id) = slot_id else {
52            return 0;
53        };
54
55        let game_config = self.game_config.get();
56        let slot_level = state
57            .character_state
58            .character
59            .ability_slot_levels
60            .get(slot_id)
61            .copied()
62            .unwrap_or(0)
63            .max(0);
64        game_config
65            .game_settings
66            .ability_gacha
67            .slot_level_bonus_levels
68            .get(slot_level as usize)
69            .copied()
70            .or_else(|| {
71                game_config
72                    .game_settings
73                    .ability_gacha
74                    .slot_level_bonus_levels
75                    .last()
76                    .copied()
77            })
78            .unwrap_or(0)
79    }
80
81    /// Add charge to the pet combat state. If charge reaches max, queue a StartCastAbility
82    /// for the pet and reset charge to 0.
83    fn charge_pet_ability(&self, state: &mut OverlordState, charge_delta: i64, current_tick: u64) {
84        let Some(active_fight) = &mut state.active_fight else {
85            return;
86        };
87        let Some(pet_state) = &mut active_fight.pet_combat_state else {
88            return;
89        };
90
91        pet_state.charge = (pet_state.charge + charge_delta).min(pet_state.max_charge);
92
93        if pet_state.charge >= pet_state.max_charge {
94            let ability_id = pet_state.ability_id;
95            let pet_template_id = pet_state.pet_template_id;
96            let player_id = active_fight.player_id;
97
98            pet_state.charge = 0;
99
100            if let Some(player) = active_fight.entities.iter_mut().find(|e| e.id == player_id) {
101                player.actions_queue.push(&ActionWithDeadline {
102                    action: EntityAction::StartCastAbility {
103                        ability_id,
104                        by_entity_id: player_id,
105                        pet_id: Some(pet_template_id),
106                    },
107                    deadline_tick: current_tick,
108                });
109            }
110        }
111    }
112
113    #[allow(clippy::too_many_arguments)]
114    pub fn handle_spawn_entity(
115        &self,
116        id: EntityId,
117        entity_template_id: EntityTemplateId,
118        position: Coordinates,
119        team: EntityTeam,
120        has_big_hp_bar: bool,
121        entity_attributes: EntityAttributes,
122        current_tick: u64,
123        mut state: OverlordState,
124    ) -> EventHandleResult<OverlordEvent, OverlordState> {
125        let game_config = self.game_config.get();
126
127        let Some(active_fight) = &mut state.active_fight else {
128            return EventHandleResult::ok(state);
129        };
130
131        if active_fight.entities.iter().any(|entity| entity.id == id) {
132            tracing::error!("There is already an entity with id: {id}");
133            return EventHandleResult::fail(state);
134        }
135
136        let fight_entity = FightEntity {
137            entity_type: EntityType::PVEEntity { entity_template_id },
138            position,
139            has_big_hp_bar,
140            team,
141        };
142
143        let mut created_entity =
144            match create_pve_entity(id, &fight_entity, &game_config, Some(entity_attributes)) {
145                Ok(entity) => entity,
146                Err(err) => {
147                    tracing::error!("Couldn't create entity: {}", err.to_string());
148                    return EventHandleResult::fail(state);
149                }
150            };
151
152        if active_fight.current_wave > 1 {
153            created_entity.abilities.iter().for_each(|ability| {
154                let cooldown = game_config
155                    .ability_template(ability.ability.template_id)
156                    .map(|t| t.cooldown)
157                    .unwrap_or(0);
158                created_entity.actions_queue.push(&ActionWithDeadline {
159                    action: self.make_start_cast_ability_action(
160                        created_entity.id,
161                        ability.ability.template_id,
162                    ),
163                    deadline_tick: current_tick + cooldown,
164                })
165            });
166        }
167
168        active_fight.entities.push(created_entity);
169
170        EventHandleResult::ok(state)
171    }
172    pub fn handle_start_move(
173        &mut self,
174        entity_id: Uuid,
175        to: Coordinates,
176        duration_ticks: u64,
177        mut state: OverlordState,
178    ) -> EventHandleResult<OverlordEvent, OverlordState> {
179        let Some(active_fight) = &mut state.active_fight else {
180            return EventHandleResult::ok(state);
181        };
182
183        let Some(entity) = active_fight
184            .entities
185            .iter_mut()
186            .find(|entity| entity.id == entity_id)
187        else {
188            tracing::error!("Failed to find entity in state with id={}", entity_id);
189            return EventHandleResult::fail(state);
190        };
191
192        // Marks the entity as moving and reserves the destination for the whole
193        // run so a concurrently-planning opponent's `advance_entity` will not
194        // commit a run onto (or through) it. Cleared by `handle_end_move`.
195        entity.move_target = Some(to.clone());
196
197        // Walk the coordinates cell-by-cell over the run instead of teleporting
198        // to the destination: opponents must target the cell the runner is
199        // actually passing — a multi-cell run would otherwise let them engage
200        // him at the destination the moment he starts running.
201        let steps = move_progress_steps(&entity.coordinates, &to, duration_ticks);
202        if let Some((_, first)) = steps.first() {
203            entity.coordinates = first.clone();
204        }
205        for (delay_ticks, cell) in steps.into_iter().skip(1) {
206            self.fight_clock.schedule(
207                OverlordEvent::MoveProgress {
208                    entity_id,
209                    to: cell,
210                },
211                delay_ticks,
212            );
213        }
214
215        self.fight_clock
216            .schedule(OverlordEvent::EndMove { entity_id }, duration_ticks);
217
218        EventHandleResult::ok(state)
219    }
220
221    pub fn handle_move_progress(
222        &self,
223        entity_id: Uuid,
224        to: Coordinates,
225        mut state: OverlordState,
226    ) -> EventHandleResult<OverlordEvent, OverlordState> {
227        let Some(active_fight) = &mut state.active_fight else {
228            return EventHandleResult::ok(state);
229        };
230
231        // The runner can die or the move can end before a scheduled waypoint
232        // fires; a stale waypoint is normal, not an error.
233        if let Some(entity) = active_fight
234            .entities
235            .iter_mut()
236            .find(|entity| entity.id == entity_id)
237            && entity.move_target.is_some()
238        {
239            entity.coordinates = to;
240        }
241
242        EventHandleResult::ok(state)
243    }
244
245    pub fn handle_end_move(
246        &self,
247        entity_id: Uuid,
248        mut state: OverlordState,
249    ) -> EventHandleResult<OverlordEvent, OverlordState> {
250        let Some(active_fight) = &mut state.active_fight else {
251            return EventHandleResult::ok(state);
252        };
253
254        let Some(entity) = active_fight
255            .entities
256            .iter_mut()
257            .find(|entity| entity.id == entity_id)
258        else {
259            tracing::error!("Failed to find entity in state with id={}", entity_id);
260            return EventHandleResult::fail(state);
261        };
262
263        // Arrived: release the cell reservation (also clears the moving state).
264        entity.move_target = None;
265
266        EventHandleResult::ok_events(
267            state,
268            vec![EventPluginized::now(OverlordEvent::FightProgress {})],
269        )
270    }
271
272    pub fn handle_entity_stun(
273        &mut self,
274        entity_id: Uuid,
275        duration_ticks: u64,
276        current_tick: u64,
277        mut state: OverlordState,
278    ) -> EventHandleResult<OverlordEvent, OverlordState> {
279        let game_config = self.game_config.get();
280        let baseline_speed = game_config.game_settings.baseline_speed;
281        let player_id = state.active_fight.as_ref().map(|f| f.player_id);
282
283        let Some(active_fight) = &mut state.active_fight else {
284            tracing::error!("EntityStun received with no active fight (entity_id = {entity_id})");
285            return EventHandleResult::fail(state);
286        };
287
288        let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
289            tracing::error!("EntityStun: entity_id = {entity_id} not found in active fight");
290            return EventHandleResult::fail(state);
291        };
292
293        let entity_speed = entity.attributes.speed_or_baseline(baseline_speed);
294        let ability_ids: Vec<AbilityId> = entity
295            .abilities
296            .iter()
297            .map(|aa| aa.ability.template_id)
298            .collect();
299
300        for ability_id in ability_ids {
301            let base_cooldown = game_config
302                .ability_template(ability_id)
303                .map(|t| t.cooldown)
304                .unwrap_or(0);
305            let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
306                base_cooldown,
307                entity_speed,
308                baseline_speed,
309            );
310            entity.actions_queue.stun_ability(
311                ability_id,
312                duration_ticks,
313                scaled_cooldown,
314                current_tick,
315            );
316        }
317
318        // Mirror the stun to the player's wall-clock ActiveAbility.deadline values so Unity's
319        // cooldown bars freeze for the stun duration. For each ability:
320        // - mid-cast (deadline was None or in the past) → set deadline = now + full + stun
321        // - on cooldown → extend deadline by stun_duration_ms
322        // - off cooldown (no deadline) → set deadline = now + stun_duration_ms
323        if Some(entity.id) == player_id {
324            let now = ::time::utc_now();
325            let stun_ms = (duration_ticks as u128 * TICKER_UNIT_DURATION_MS) as i64;
326            for active_ability in entity.abilities.iter_mut() {
327                let base_cooldown = game_config
328                    .ability_template(active_ability.ability.template_id)
329                    .map(|t| t.cooldown)
330                    .unwrap_or(0);
331                let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
332                    base_cooldown,
333                    entity_speed,
334                    baseline_speed,
335                );
336                let cooldown_ms = (scaled_cooldown as u128 * TICKER_UNIT_DURATION_MS) as i64;
337
338                let new_deadline = match active_ability.deadline {
339                    Some(existing) if existing > now => {
340                        // On cooldown — extend by stun duration.
341                        existing + chrono::TimeDelta::milliseconds(stun_ms)
342                    }
343                    _ => {
344                        // Off cooldown OR mid-cast (no current deadline tracked client-side).
345                        // The queue's cancel-cast logic above already handled the in-flight case;
346                        // here we just expose the resulting freeze duration to the client. Use
347                        // full + stun to match the queue, falling back to stun-only when there is
348                        // no full cooldown (e.g. ability_template missing).
349                        let total_ms = if base_cooldown > 0 {
350                            cooldown_ms + stun_ms
351                        } else {
352                            stun_ms
353                        };
354                        now + chrono::TimeDelta::milliseconds(total_ms)
355                    }
356                };
357                active_ability.deadline = Some(new_deadline);
358            }
359        }
360
361        EventHandleResult::ok(state)
362    }
363
364    pub fn handle_entity_cancel_cast_with_cooldown(
365        &mut self,
366        entity_id: Uuid,
367        ability_id: Uuid,
368        current_tick: u64,
369        mut state: OverlordState,
370    ) -> EventHandleResult<OverlordEvent, OverlordState> {
371        let game_config = self.game_config.get();
372
373        let Some(active_fight) = &mut state.active_fight else {
374            tracing::error!(
375                "EntityCancelCastWithCooldown received with no active fight (entity_id = {entity_id})"
376            );
377            return EventHandleResult::fail(state);
378        };
379
380        let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
381            tracing::error!(
382                "EntityCancelCastWithCooldown: entity_id = {entity_id} not found in active fight"
383            );
384            return EventHandleResult::fail(state);
385        };
386
387        let Some(ability_template) = game_config.ability_template(ability_id) else {
388            tracing::error!(
389                "EntityCancelCastWithCooldown: ability_template not found for ability_id = {ability_id}"
390            );
391            return EventHandleResult::fail(state);
392        };
393        let cooldown = ability_template.cooldown;
394
395        let cancelled = entity
396            .actions_queue
397            .cancel_cast_and_set_cooldown(ability_id, current_tick + cooldown);
398
399        if !cancelled {
400            tracing::error!(
401                "EntityCancelCastWithCooldown: no in-flight cast for ability_id = {ability_id} on entity {entity_id}"
402            );
403            return EventHandleResult::fail(state);
404        }
405
406        EventHandleResult::ok(state)
407    }
408
409    pub fn handle_entity_add_ability_cooldown(
410        &mut self,
411        entity_id: Uuid,
412        ability_id: Uuid,
413        delta_ticks: i64,
414        current_tick: u64,
415        mut state: OverlordState,
416    ) -> EventHandleResult<OverlordEvent, OverlordState> {
417        let Some(active_fight) = &mut state.active_fight else {
418            tracing::error!(
419                "EntityAddAbilityCooldown received with no active fight (entity_id = {entity_id})"
420            );
421            return EventHandleResult::fail(state);
422        };
423
424        let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
425            tracing::error!(
426                "EntityAddAbilityCooldown: entity_id = {entity_id} not found in active fight"
427            );
428            return EventHandleResult::fail(state);
429        };
430
431        if !entity
432            .actions_queue
433            .adjust_ability_cooldown(ability_id, delta_ticks, current_tick)
434        {
435            tracing::error!(
436                "EntityAddAbilityCooldown: ability_id = {ability_id} not in cooldown queue for entity {entity_id}"
437            );
438            return EventHandleResult::fail(state);
439        }
440
441        EventHandleResult::ok(state)
442    }
443
444    pub fn handle_entity_incr_attribute(
445        &mut self,
446        entity_id: Uuid,
447        attribute: &str,
448        delta: i64,
449        current_tick: u64,
450        mut state: OverlordState,
451    ) -> EventHandleResult<OverlordEvent, OverlordState> {
452        let game_config = self.game_config.get();
453        let baseline_speed = game_config.game_settings.baseline_speed;
454        let player_id = state.active_fight.as_ref().map(|f| f.player_id);
455
456        let Some(active_fight) = &mut state.active_fight else {
457            return EventHandleResult::ok(state);
458        };
459
460        let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
461            tracing::debug!("Couldn't find entity_id = {}", entity_id);
462            return EventHandleResult::fail(state);
463        };
464
465        let old_speed = entity.attributes.speed_or_baseline(baseline_speed);
466        entity.attributes.add(attribute, delta);
467        let new_speed = entity.attributes.speed_or_baseline(baseline_speed);
468
469        if attribute == "speed" && old_speed != new_speed {
470            entity.actions_queue.rescale_cooldowns(
471                old_speed,
472                new_speed,
473                current_tick,
474                baseline_speed,
475            );
476
477            if Some(entity.id) == player_id {
478                let now = ::time::utc_now();
479                let baseline = baseline_speed.max(1) as i128;
480                let old_s = if old_speed <= 0 {
481                    baseline
482                } else {
483                    old_speed as i128
484                };
485                let new_s = if new_speed <= 0 {
486                    baseline
487                } else {
488                    new_speed as i128
489                };
490                for active_ability in entity.abilities.iter_mut() {
491                    let Some(deadline) = active_ability.deadline else {
492                        continue;
493                    };
494                    let remaining_ms = (deadline - now).num_milliseconds();
495                    if remaining_ms <= 0 {
496                        continue;
497                    }
498                    let scaled_ms = ((remaining_ms as i128) * old_s / new_s) as i64;
499                    let scaled_ms = scaled_ms.max(1);
500                    active_ability.deadline =
501                        Some(now + chrono::TimeDelta::milliseconds(scaled_ms));
502                }
503            }
504        }
505
506        entity.effect_ids = entity
507            .effect_ids
508            .iter()
509            .filter(|effect_id| {
510                if let Some(effect) = game_config.effect(**effect_id) {
511                    // tracing::error!("Found this effect: {effect:?}");
512                    // tracing::error!("Got this attributes: {:?}", entity.attributes);
513                    if effect.has_at_least_one_required_attribute(&entity.attributes) {
514                        true
515                    } else {
516                        if effect.interval_ticks.is_some() {
517                            entity.actions_queue.remove_cast_effect_action(effect.id);
518                        }
519                        false
520                    }
521                } else {
522                    false
523                }
524            })
525            .cloned()
526            .collect();
527
528        EventHandleResult::ok(state)
529    }
530
531    pub fn handle_entity_apply_effect(
532        &mut self,
533        entity_id: Uuid,
534        effect_id: Uuid,
535        current_tick: u64,
536        mut state: OverlordState,
537    ) -> EventHandleResult<OverlordEvent, OverlordState> {
538        let Some(active_fight) = &mut state.active_fight else {
539            return EventHandleResult::ok(state);
540        };
541
542        let game_config = self.game_config.get();
543
544        let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
545            tracing::debug!("Couldn't find entity_id = {}", entity_id);
546            return EventHandleResult::fail(state);
547        };
548
549        let Ok(effect) = game_config.require_effect(effect_id) else {
550            tracing::debug!("Couldn't find effect_id = {}", effect_id);
551            return EventHandleResult::fail(state);
552        };
553
554        if let Some(required_attributes) = &effect.required_attributes
555            && !required_attributes
556                .iter()
557                .any(|attr| entity.attributes.0.contains_key(attr))
558        {
559            tracing::error!("Effect has required attributes, but they are not set");
560            return EventHandleResult::fail(state);
561        }
562
563        for existing_effect_id in &entity.effect_ids {
564            if *existing_effect_id == effect.id {
565                tracing::error!("Effect is already set on entity");
566                return EventHandleResult::fail(state);
567            }
568        }
569
570        entity.effect_ids.push(effect.id);
571
572        if let Some(interval_ticks) = &effect.interval_ticks {
573            entity.actions_queue.push(&ActionWithDeadline {
574                action: self.make_cast_effect_action(entity_id, effect.id),
575                deadline_tick: current_tick + interval_ticks,
576            });
577        }
578
579        EventHandleResult::ok(state)
580    }
581
582    #[allow(clippy::too_many_arguments)]
583    pub fn handle_cast_effect(
584        &mut self,
585        entity_id: Uuid,
586        effect_id: Uuid,
587        caller_event: Option<Box<OverlordEvent>>,
588        rand_gen: rand::rngs::StdRng,
589        current_tick: u64,
590        mut state: OverlordState,
591    ) -> EventHandleResult<OverlordEvent, OverlordState> {
592        let Some(active_fight) = &mut state.active_fight else {
593            return EventHandleResult::ok(state);
594        };
595
596        let active_fight_cloned = active_fight.clone();
597
598        let game_config = self.game_config.get();
599
600        let Some(entity) = active_fight.entities.iter_mut().find(|e| e.id == entity_id) else {
601            tracing::debug!("Couldn't find entity_id = {}", entity_id);
602            return EventHandleResult::fail(state);
603        };
604
605        let Ok(effect) = game_config.require_effect(effect_id) else {
606            tracing::debug!("Couldn't find effect_id = {}", effect_id);
607            return EventHandleResult::fail(state);
608        };
609
610        if !entity.effect_ids.contains(&effect_id) {
611            tracing::error!("entity_id = {} has no effect_id = {}", entity_id, effect_id);
612            return EventHandleResult::fail(state);
613        }
614
615        let entity_cloned = entity.clone();
616
617        // Native `event` (effect) port: look up the effect's `script` fn
618        // and run it on the RNG snapshot.
619        let Some(native_name) = effect.behavior.as_deref() else {
620            tracing::error!("Effect {} has no script registered", effect_id);
621            return EventHandleResult::fail(state);
622        };
623        let Some(native_fn) = self.behaviors.event_fn(native_name) else {
624            tracing::error!("No native event fn registered for {native_name}");
625            return EventHandleResult::fail(state);
626        };
627
628        let rng = GameRng::new(rand_gen);
629        let events = match native_fn(&crate::behaviors::combat::effects::EventCtx {
630            entity: &entity_cloned,
631            fight: &active_fight_cloned,
632            rng: &rng,
633            current_tick,
634            fight_duration_ticks: current_tick - self.start_fight_tick,
635            caller_event: caller_event.as_deref(),
636            config: &game_config,
637            lookups: self.behaviors.lookups(),
638        }) {
639            Ok(events) => events,
640            Err(err) => {
641                tracing::error!("Effect script failed with error: {err:?}");
642                return EventHandleResult::fail(state);
643            }
644        };
645
646        if let Some(interval_ticks) = effect.interval_ticks {
647            entity.actions_queue.push(&ActionWithDeadline {
648                action: self.make_cast_effect_action(entity_id, effect.id),
649                deadline_tick: current_tick + interval_ticks,
650            });
651        }
652
653        EventHandleResult::ok_events(
654            state,
655            events.into_iter().map(EventPluginized::now).collect(),
656        )
657    }
658
659    pub fn handle_start_cast_ability(
660        &mut self,
661        _event: OverlordEvent,
662        by_entity_id: Uuid,
663        ability_id: AbilityId,
664        rand_gen: rand::rngs::StdRng,
665        current_tick: u64,
666        mut state: OverlordState,
667    ) -> EventHandleResult<OverlordEvent, OverlordState> {
668        let game_config = self.game_config.get();
669
670        let state_cloned = state.clone();
671
672        let Some(active_fight) = &mut state.active_fight else {
673            tracing::error!("No active fight for start_cast_ability");
674            return EventHandleResult::ok(state);
675        };
676        let active_fight_cloned = active_fight.clone();
677        let Some(casted_by_entity) = active_fight
678            .entities
679            .iter_mut()
680            .find(|e| e.id == by_entity_id)
681        else {
682            tracing::debug!("Couldn't find caster entity_id = {}", by_entity_id);
683            return EventHandleResult::fail(state);
684        };
685        let casted_by_entity_cloned = casted_by_entity.clone();
686        let Some(active_ability) = casted_by_entity
687            .abilities
688            .iter_mut()
689            .find(|equipped_ability| equipped_ability.ability.template_id == ability_id)
690        else {
691            tracing::error!(
692                "Couldn't find ability_id = {} in caster entity {:?}",
693                ability_id,
694                casted_by_entity
695            );
696            return EventHandleResult::fail(state);
697        };
698        let ability = &active_ability.ability;
699        let ability_template_id = ability.template_id;
700        let ability_level = ability.level;
701
702        let Some(ability_template) = game_config.ability_template(ability_template_id).cloned()
703        else {
704            tracing::error!(
705                "Couldn't find template for ability_id = {}",
706                ability_template_id
707            );
708            return EventHandleResult::fail(state);
709        };
710        let ability_cooldown = ability_template.cooldown;
711
712        let _ability_slot_level =
713            self.compute_ability_slot_level(active_ability.slot_id, &state_cloned);
714        let _ = (ability_level, &state_cloned);
715
716        // Native `start_cast_ability` port: look up the ability's
717        // `start_behavior` fn and run it on the RNG snapshot.
718        let native_result = (|| {
719            let name = ability_template.start_behavior.as_deref()?;
720            let f = self.behaviors.start_cast_ability_fn(name)?;
721            Some(f(
722                &crate::behaviors::combat::start_cast::StartCastAbilityCtx {
723                    caster: &casted_by_entity_cloned,
724                    fight: &active_fight_cloned,
725                    rng: &GameRng::new(rand_gen),
726                    ability_template_id,
727                    config: &game_config,
728                    lookups: self.behaviors.lookups(),
729                },
730            ))
731        })();
732
733        let results = match native_result {
734            Some(Ok(v)) => v,
735            other => {
736                if let Some(e) = state
737                    .active_fight
738                    .as_mut()
739                    .and_then(|af| af.entities.iter_mut().find(|e| e.id == by_entity_id))
740                {
741                    let baseline_speed = game_config.game_settings.baseline_speed;
742                    let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
743                        ability_cooldown,
744                        e.attributes.speed_or_baseline(baseline_speed),
745                        baseline_speed,
746                    );
747                    e.actions_queue.push(&ActionWithDeadline {
748                        action: self
749                            .make_start_cast_ability_action(by_entity_id, ability_template_id),
750                        deadline_tick: current_tick + scaled_cooldown,
751                    });
752                }
753
754                match other {
755                    Some(Err(err)) => {
756                        tracing::error!("Ability start cast script failed with error: {err:?}")
757                    }
758                    _ => tracing::error!(
759                        "Ability {ability_template_id} has no start_behavior registered"
760                    ),
761                }
762                return EventHandleResult::fail(state);
763            }
764        };
765
766        let (actions, events) =
767            match StartCastAbilityResult::vec_into_actions_with_deadlines_and_events(
768                &results,
769                state.character_state.character.class,
770                &game_config,
771                ability_template_id,
772                by_entity_id,
773                current_tick,
774            ) {
775                Ok((actions, events)) => (actions, events),
776                Err(err) => {
777                    tracing::error!(
778                        "Error converting StartCastAbilityResultVec = {:?} into EntityActionVec = {:?}",
779                        results,
780                        err
781                    );
782                    // The StartCastAbility action was already popped from the
783                    // actions queue by `handle_fight_progress`; requeue it
784                    // (like the script-failure path above) so a deterministic
785                    // conversion error doesn't stall the entity for the rest
786                    // of the fight.
787                    if let Some(entity) = state
788                        .active_fight
789                        .as_mut()
790                        .and_then(|af| af.entities.iter_mut().find(|e| e.id == by_entity_id))
791                    {
792                        let baseline_speed = game_config.game_settings.baseline_speed;
793                        let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
794                            ability_cooldown,
795                            entity.attributes.speed_or_baseline(baseline_speed),
796                            baseline_speed,
797                        );
798                        entity.actions_queue.push(&ActionWithDeadline {
799                            action: self
800                                .make_start_cast_ability_action(by_entity_id, ability_template_id),
801                            deadline_tick: current_tick + scaled_cooldown,
802                        });
803                    }
804                    return EventHandleResult::fail(state);
805                }
806            };
807
808        let baseline_speed = game_config.game_settings.baseline_speed;
809        let scaled_cooldown = essences::entity::scale_cooldown_for_speed(
810            ability_cooldown,
811            casted_by_entity
812                .attributes
813                .speed_or_baseline(baseline_speed),
814            baseline_speed,
815        );
816        casted_by_entity
817            .actions_queue
818            .append_start_cast_ability_result_actions(
819                &actions,
820                current_tick,
821                ability_template_id,
822                scaled_cooldown,
823            );
824        if casted_by_entity.id == active_fight.player_id && !actions.is_empty() {
825            active_ability.deadline = Some(
826                ::time::utc_now()
827                    + chrono::TimeDelta::milliseconds(
828                        (scaled_cooldown as u128 * TICKER_UNIT_DURATION_MS) as i64,
829                    ),
830            );
831        }
832
833        let now_events = self.route_delayed_to_clock(events);
834
835        EventHandleResult::ok_events(state, now_events)
836    }
837
838    /// Route delayed-marked pluginized events (StartedCastAbility wind-ups)
839    /// onto the combat clock; immediate ones flow out as regular events.
840    fn route_delayed_to_clock(
841        &mut self,
842        events: Vec<EventPluginized<OverlordEvent, OverlordState>>,
843    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
844        events
845            .into_iter()
846            .filter_map(|pluginized| {
847                let (event, delayed, _cron) = pluginized.into_parts();
848                if let Some(delayed) = delayed {
849                    self.fight_clock.schedule(event, delayed.ticks);
850                    None
851                } else {
852                    Some(EventPluginized::now(event))
853                }
854            })
855            .collect()
856    }
857
858    /// Route `StartCastProjectile` script outputs onto the combat clock
859    /// (clamped to at least one tick so the projectile fires on a later
860    /// tick); everything else flows out as immediate events.
861    fn route_projectiles_to_clock(
862        &mut self,
863        events: Vec<OverlordEvent>,
864    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
865        events
866            .into_iter()
867            .filter_map(|x| {
868                if let OverlordEvent::StartCastProjectile { delay, .. } = x {
869                    let delay = delay.max(1);
870                    self.fight_clock.schedule(x, delay);
871                    None
872                } else {
873                    Some(EventPluginized::now(x))
874                }
875            })
876            .collect()
877    }
878
879    #[allow(clippy::too_many_arguments)]
880    pub fn handle_cast_ability(
881        &mut self,
882        _event: OverlordEvent,
883        by_entity_id: Uuid,
884        to_entity_id: Uuid,
885        ability_id: AbilityId,
886        rand_gen: rand::rngs::StdRng,
887        current_tick: u64,
888        mut state: OverlordState,
889    ) -> EventHandleResult<OverlordEvent, OverlordState> {
890        let game_config = self.game_config.get();
891        let state_cloned = state.clone();
892
893        let Some(active_fight) = &mut state.active_fight else {
894            return EventHandleResult::ok(state);
895        };
896
897        let Some(target_entity) = active_fight
898            .entities
899            .iter()
900            .find(|e| e.id == to_entity_id)
901            .cloned()
902        else {
903            tracing::debug!("Couldn't find target entity_id = {to_entity_id}");
904            return EventHandleResult::fail(state);
905        };
906
907        let active_fight_clone = active_fight.clone();
908
909        let Some(casted_by_entity) = active_fight
910            .entities
911            .iter_mut()
912            .find(|e| e.id == by_entity_id)
913        else {
914            tracing::debug!("Couldn't find caster entity_id = {by_entity_id}");
915            return EventHandleResult::fail(state);
916        };
917
918        let Some(active_ability) = casted_by_entity
919            .abilities
920            .iter()
921            .find(|equipped_ability| equipped_ability.ability.template_id == ability_id)
922            .cloned()
923        else {
924            tracing::error!(
925                "Couldn't find ability_id = {} in caster entity {:?}",
926                ability_id,
927                casted_by_entity
928            );
929            return EventHandleResult::fail(state);
930        };
931        let ability = active_ability.ability;
932        let ability_slot_level =
933            self.compute_ability_slot_level(active_ability.slot_id, &state_cloned);
934
935        let player_id = active_fight.player_id;
936
937        let Some(ability_template) = game_config.ability_template(ability.template_id).cloned()
938        else {
939            tracing::error!(
940                "Couldn't find template for ability_id = {}",
941                ability.template_id
942            );
943            return EventHandleResult::fail(state);
944        };
945
946        let _ = (&state_cloned, ability_slot_level);
947
948        // Native `cast_ability` port: look up the ability's `script` fn
949        // and run it on the RNG snapshot.
950        let native_result = (|| {
951            let name = ability_template.behavior.as_deref()?;
952            let f = self.behaviors.cast_ability_fn(name)?;
953            Some(f(&crate::behaviors::combat::cast_ability::CastAbilityCtx {
954                caster_entity: casted_by_entity,
955                target_entity: &target_entity,
956                fight: &active_fight_clone,
957                rng: &GameRng::new(rand_gen),
958                ability_level: ability.level,
959                config: &game_config,
960                lookups: self.behaviors.lookups(),
961            }))
962        })();
963
964        match native_result {
965            Some(Ok(events)) => {
966                // Charge pet ability on player skill use
967                if by_entity_id == player_id {
968                    let game_config = self.game_config.get();
969                    if let Some(charge_rate) = state
970                        .active_fight
971                        .as_ref()
972                        .and_then(|af| af.pet_combat_state.as_ref())
973                        .and_then(|ps| game_config.pet_template(ps.pet_template_id))
974                        .map(|t| t.charge_rate_on_skill_use)
975                    {
976                        self.charge_pet_ability(&mut state, charge_rate, current_tick);
977                    }
978                }
979
980                let now_events = self.route_projectiles_to_clock(events);
981                EventHandleResult::ok_events(state, now_events)
982            }
983            Some(Err(err)) => {
984                tracing::error!("Ability cast script failed with error: {err:?}");
985                EventHandleResult::fail(state)
986            }
987            None => {
988                tracing::error!("Ability {} has no script registered", ability.template_id);
989                EventHandleResult::fail(state)
990            }
991        }
992    }
993
994    #[allow(clippy::too_many_arguments)]
995    pub fn handle_start_cast_projectile(
996        &mut self,
997        _event: OverlordEvent,
998        by_entity_id: Uuid,
999        to_entity_id: Uuid,
1000        projectile_id: Uuid,
1001        level: i64,
1002        current_tick: u64,
1003        mut state: OverlordState,
1004    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1005        let game_config = self.game_config.get();
1006
1007        let Some(active_fight) = &mut state.active_fight else {
1008            return EventHandleResult::ok(state);
1009        };
1010
1011        let Some(target_entity) = active_fight
1012            .entities
1013            .iter()
1014            .find(|e| e.id == to_entity_id)
1015            .cloned()
1016        else {
1017            tracing::debug!("Couldn't find entity_id = {}", to_entity_id);
1018            return EventHandleResult::fail(state);
1019        };
1020
1021        let Ok(projectile) = game_config.require_projectile(projectile_id) else {
1022            tracing::error!("Couldn't find projectile_id = {} in config", projectile_id);
1023            return EventHandleResult::fail(state);
1024        };
1025
1026        let active_fight_clone = active_fight.clone();
1027
1028        let Some(casted_by_entity) = active_fight
1029            .entities
1030            .iter_mut()
1031            .find(|e| e.id == by_entity_id)
1032        else {
1033            tracing::debug!("Couldn't find caster entity_id = {}", by_entity_id);
1034            return EventHandleResult::fail(state);
1035        };
1036
1037        let _ = (&active_fight_clone, current_tick);
1038
1039        // Native `start_cast_projectile` port: look up the projectile's
1040        // `start_behavior` fn and run it.
1041        let native_result = (|| {
1042            let name = projectile.start_behavior.as_deref()?;
1043            let f = self.behaviors.start_cast_projectile_fn(name)?;
1044            Some(f(
1045                &crate::behaviors::combat::start_cast::StartCastProjectileCtx {
1046                    caster_entity: casted_by_entity,
1047                    target_entity: &target_entity,
1048                },
1049            ))
1050        })();
1051
1052        let result = match native_result {
1053            Some(Ok(v)) => v,
1054            Some(Err(err)) => {
1055                tracing::error!("Projectile start cast script failed with error: {err:?}");
1056                return EventHandleResult::fail(state);
1057            }
1058            None => {
1059                tracing::error!("Projectile {projectile_id} has no start_behavior registered");
1060                return EventHandleResult::fail(state);
1061            }
1062        };
1063
1064        self.fight_clock.schedule(
1065            OverlordEvent::CastProjectile {
1066                by_entity_id,
1067                to_entity_id,
1068                projectile_id,
1069                level,
1070                projectile_data: result.projectile_data,
1071            },
1072            result.animation_duration_ticks as u64,
1073        );
1074
1075        EventHandleResult::ok_events(
1076            state,
1077            vec![EventPluginized::now(OverlordEvent::StartedCastProjectile {
1078                by_entity_id,
1079                to_entity_id,
1080                projectile_id,
1081                duration_ticks: result.animation_duration_ticks as u64,
1082            })],
1083        )
1084    }
1085
1086    #[allow(clippy::too_many_arguments)]
1087    pub fn handle_cast_projectile(
1088        &mut self,
1089        _event: OverlordEvent,
1090        by_entity_id: Uuid,
1091        to_entity_id: Uuid,
1092        projectile_id: Uuid,
1093        level: i64,
1094        projectile_data: &CustomEventData,
1095        rand_gen: rand::rngs::StdRng,
1096        current_tick: u64,
1097        state: OverlordState,
1098    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1099        let game_config = self.game_config.get();
1100
1101        let Some(active_fight) = &state.active_fight else {
1102            return EventHandleResult::ok(state);
1103        };
1104
1105        let Some(casted_by_entity) = active_fight.entities.iter().find(|e| e.id == by_entity_id)
1106        else {
1107            tracing::debug!("Couldn't find caster entity_id = {}", by_entity_id);
1108            return EventHandleResult::fail(state);
1109        };
1110
1111        let Some(target_entity) = active_fight
1112            .entities
1113            .iter()
1114            .find(|e| e.id == to_entity_id)
1115            .cloned()
1116        else {
1117            tracing::debug!("Couldn't find target entity_id = {}", to_entity_id);
1118            return EventHandleResult::fail(state);
1119        };
1120
1121        let Ok(projectile) = game_config.require_projectile(projectile_id) else {
1122            tracing::error!("Couldn't find projectile_id = {} in config", projectile_id);
1123            return EventHandleResult::fail(state);
1124        };
1125
1126        let _ = (projectile_data, current_tick);
1127
1128        // Native `cast_projectile` port: look up the projectile's `script`
1129        // fn and run it on the RNG snapshot.
1130        let native_result = (|| {
1131            let name = projectile.behavior.as_deref()?;
1132            let f = self.behaviors.cast_projectile_fn(name)?;
1133            Some(f(
1134                &crate::behaviors::combat::cast_projectile::CastProjectileCtx {
1135                    caster_entity: casted_by_entity,
1136                    target_entity: &target_entity,
1137                    fight: active_fight,
1138                    rng: &GameRng::new(rand_gen),
1139                    projectile_level: level,
1140                    config: &game_config,
1141                    lookups: self.behaviors.lookups(),
1142                },
1143            ))
1144        })();
1145
1146        match native_result {
1147            Some(Ok(events)) => {
1148                let now_events = self.route_projectiles_to_clock(events);
1149                EventHandleResult::ok_events(state, now_events)
1150            }
1151            Some(Err(err)) => {
1152                tracing::error!("Projectile cast script failed with error: {err:?}");
1153                EventHandleResult::fail(state)
1154            }
1155            None => {
1156                tracing::error!("Projectile {projectile_id} has no script registered");
1157                EventHandleResult::fail(state)
1158            }
1159        }
1160    }
1161
1162    pub fn handle_player_death(
1163        &mut self,
1164        mut state: OverlordState,
1165    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1166        let Some(active_fight) = &mut state.active_fight else {
1167            return EventHandleResult::ok(state);
1168        };
1169
1170        // The fight already ended (e.g. max-duration timeout): an in-flight
1171        // combat event killed the player afterwards. Don't schedule a second
1172        // EndFight for the same fight.
1173        if active_fight.fight_ended {
1174            return EventHandleResult::ok(state);
1175        }
1176
1177        active_fight.entities = Vec::new();
1178
1179        let fight_uuid = active_fight.id;
1180        active_fight.fight_ended = true;
1181        let end_fight_delay = self.get_end_fight_delay(active_fight.fight_id);
1182
1183        self.fight_clock.schedule(
1184            OverlordEvent::EndFight {
1185                fight_id: fight_uuid,
1186                is_win: false,
1187                pvp_state: state.pvp_state.clone().map(Box::new),
1188            },
1189            end_fight_delay,
1190        );
1191
1192        EventHandleResult::ok(state)
1193    }
1194
1195    pub fn handle_entity_death(
1196        &mut self,
1197        entity_id: Uuid,
1198        reward: Vec<CurrencyUnit>,
1199        rand_gen: rand::rngs::StdRng,
1200        mut state: OverlordState,
1201    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1202        let game_config = self.game_config.get();
1203
1204        let Some(active_fight) = &mut state.active_fight else {
1205            return EventHandleResult::ok(state);
1206        };
1207
1208        // The fight already ended (e.g. max-duration timeout): an in-flight
1209        // combat event killed an entity afterwards. Don't process the death —
1210        // it could schedule a second EndFight (or spawn a next wave) for a
1211        // fight that is already over.
1212        if active_fight.fight_ended {
1213            return EventHandleResult::ok(state);
1214        }
1215
1216        let Some(entity_idx) = active_fight
1217            .entities
1218            .iter()
1219            .position(|entity| entity.id == entity_id)
1220        else {
1221            tracing::error!("Failed to get entity with entity_id={}", entity_id);
1222            return EventHandleResult::fail(state);
1223        };
1224
1225        active_fight.entities.swap_remove(entity_idx);
1226
1227        let mut events = Vec::new();
1228
1229        events.push(Self::currency_increase(
1230            &reward,
1231            CurrencySource::EntityDeath,
1232        ));
1233
1234        let Some(active_fight) = &mut state.active_fight else {
1235            return EventHandleResult::ok(state);
1236        };
1237        let Ok(fight) = game_config.require_fight_template(active_fight.fight_id) else {
1238            tracing::error!(
1239                "Failed to get fight_template with id {} ",
1240                active_fight.fight_id
1241            );
1242            return EventHandleResult::fail(state);
1243        };
1244
1245        let has_any_ally = active_fight
1246            .entities
1247            .iter()
1248            .any(|e| e.team == EntityTeam::Ally);
1249
1250        if active_fight.get_enemies_amount() == 0 && has_any_ally {
1251            if active_fight.current_wave == fight.waves_amount {
1252                let fight_uuid = active_fight.id;
1253                active_fight.fight_ended = true;
1254                let end_fight_delay = self.get_end_fight_delay(active_fight.fight_id);
1255                self.fight_clock.schedule(
1256                    OverlordEvent::EndFight {
1257                        fight_id: fight_uuid,
1258                        is_win: true,
1259                        pvp_state: state.pvp_state.clone().map(Box::new),
1260                    },
1261                    end_fight_delay,
1262                );
1263                if fight.fight_type == FightType::CampaignBossFight {
1264                    events.push(EventPluginized::now(OverlordEvent::StageCleared {}));
1265                }
1266            } else {
1267                let fight_uuid = active_fight.id;
1268                active_fight.current_wave += 1;
1269                let active_fight_cloned = active_fight.clone();
1270                let current_chapter = state.character_state.character.current_chapter_level;
1271
1272                // Native `prepare_fight` interpreter: spawn the current wave from
1273                // the typed `prepare_fight_waves` config (the native data source),
1274                //
1275                // `base_power` was a literal argument of the legacy
1276                // `spawn_wave(...)` script, transpiled from the template's
1277                // TOP-LEVEL `power` field (`$.power`). Pass that — NOT
1278                // `wave_data.power` (the waves-blob's inner value, 4-26x
1279                // larger on live templates), which inflated campaign mob
1280                // stats 2-5x vs the legacy engine. See the matching fix in
1281                // `chapters_management.rs`.
1282                let prepare_fight_events = match fight.prepare_fight_waves.as_ref() {
1283                    Some(waves_cfg) => {
1284                        let wave_data = crate::mechanics::fight::wave_data_from_config(waves_cfg);
1285                        let fight_type_str = format!("{:?}", fight.fight_type);
1286                        let mut sink = crate::mechanics::fight::NativeSink::default();
1287                        let rng = GameRng::new(rand_gen);
1288                        match crate::mechanics::fight::spawn_wave(
1289                            &mut sink,
1290                            &rng,
1291                            &game_config,
1292                            self.behaviors.lookups(),
1293                            &active_fight_cloned,
1294                            &wave_data,
1295                            fight.power.map(|p| p as f64).unwrap_or(0.0),
1296                            current_chapter,
1297                            &fight_type_str,
1298                        ) {
1299                            Ok(()) => sink.events,
1300                            Err(err) => {
1301                                tracing::error!(
1302                                    "Prepare wave for new wave failed with error: {err:?}"
1303                                );
1304                                events.push(EventPluginized::now(OverlordEvent::EndFight {
1305                                    fight_id: fight_uuid,
1306                                    is_win: false,
1307                                    pvp_state: state.pvp_state.clone().map(Box::new),
1308                                }));
1309                                return EventHandleResult::ok_events(state, events);
1310                            }
1311                        }
1312                    }
1313                    None => {
1314                        tracing::error!(
1315                            "Fight {} has no prepare_fight_waves for next wave",
1316                            fight.id
1317                        );
1318                        events.push(EventPluginized::now(OverlordEvent::EndFight {
1319                            fight_id: fight_uuid,
1320                            is_win: false,
1321                            pvp_state: state.pvp_state.clone().map(Box::new),
1322                        }));
1323                        return EventHandleResult::ok_events(state, events);
1324                    }
1325                };
1326
1327                if prepare_fight_events.is_empty() {
1328                    tracing::error!("Prepare wave script returned no events");
1329                    events.push(EventPluginized::now(OverlordEvent::EndFight {
1330                        fight_id: fight_uuid,
1331                        is_win: false,
1332                        pvp_state: state.pvp_state.clone().map(Box::new),
1333                    }));
1334                    return EventHandleResult::ok_events(state, events);
1335                }
1336
1337                if !prepare_fight_events
1338                    .iter()
1339                    .any(|ev| matches!(ev, OverlordEvent::SpawnEntity { .. }))
1340                {
1341                    tracing::error!("Prepare wave script returned no SpawnEntity events");
1342                    events.push(EventPluginized::now(OverlordEvent::EndFight {
1343                        fight_id: fight_uuid,
1344                        is_win: false,
1345                        pvp_state: state.pvp_state.clone().map(Box::new),
1346                    }));
1347                    return EventHandleResult::ok_events(state, events);
1348                }
1349
1350                events.append(
1351                    &mut prepare_fight_events
1352                        .into_iter()
1353                        .map(EventPluginized::now)
1354                        .collect(),
1355                );
1356
1357                events.push(EventPluginized::now(OverlordEvent::WaveCleared {}));
1358            }
1359        }
1360
1361        EventHandleResult::ok_events(state, events)
1362    }
1363
1364    pub fn handle_heal(
1365        &self,
1366        entity_id: Uuid,
1367        heal: u64,
1368        mut state: OverlordState,
1369    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1370        let Some(active_fight) = &mut state.active_fight else {
1371            return EventHandleResult::ok(state);
1372        };
1373
1374        let Some(healed_entity) = active_fight
1375            .entities
1376            .iter_mut()
1377            .find(|entity| entity.id == entity_id)
1378        else {
1379            tracing::error!("Failed to get entity with entity_id={}", entity_id);
1380            return EventHandleResult::fail(state);
1381        };
1382
1383        healed_entity.hp = healed_entity
1384            .hp
1385            .saturating_add(heal)
1386            .min(healed_entity.max_hp);
1387
1388        EventHandleResult::ok(state)
1389    }
1390
1391    pub fn handle_damage(
1392        &self,
1393        entity_id: Uuid,
1394        damage: u64,
1395        current_tick: u64,
1396        mut rand_gen: rand::rngs::StdRng,
1397        mut state: OverlordState,
1398    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1399        let Some(active_fight) = &mut state.active_fight else {
1400            return EventHandleResult::ok(state);
1401        };
1402
1403        let player_id = active_fight.player_id;
1404
1405        let Some(damaged_entity) = active_fight
1406            .entities
1407            .iter_mut()
1408            .find(|entity| entity.id == entity_id)
1409        else {
1410            tracing::error!("Failed to get entity with entity_id={}", entity_id);
1411            return EventHandleResult::fail(state);
1412        };
1413
1414        let new_hp = damaged_entity.hp - damage.min(damaged_entity.hp);
1415        damaged_entity.hp = new_hp;
1416
1417        // Charge the pet when the player's side is involved in the damage:
1418        // either the player takes the hit, or an enemy takes a hit. The `Damage`
1419        // event carries no dealer id, so "enemy took damage" is the proxy for
1420        // "the player's side dealt it" — exact in PvE (all enemy damage,
1421        // including DoTs, originates from the player's side). In PvP this also
1422        // credits damage the party ally deals; tightening it would require
1423        // threading a damage-source id through the `Damage` event.
1424        let is_player_taking_damage = entity_id == player_id;
1425        let is_player_dealing_damage = !is_player_taking_damage
1426            && state
1427                .active_fight
1428                .as_ref()
1429                .and_then(|af| af.entities.iter().find(|e| e.id == entity_id))
1430                .is_some_and(|e| e.team == EntityTeam::Enemy);
1431        let game_config = self.game_config.get();
1432        let charge_rate = if is_player_taking_damage || is_player_dealing_damage {
1433            state
1434                .active_fight
1435                .as_ref()
1436                .and_then(|af| af.pet_combat_state.as_ref())
1437                .and_then(|ps| game_config.pet_template(ps.pet_template_id))
1438                .map(|t| {
1439                    if is_player_taking_damage {
1440                        t.charge_rate_on_damage_taken
1441                    } else {
1442                        t.charge_rate_on_damage_dealt
1443                    }
1444                })
1445        } else {
1446            None
1447        };
1448
1449        if let Some(rate) = charge_rate
1450            && rate > 0
1451        {
1452            self.charge_pet_ability(&mut state, rate, current_tick);
1453        }
1454
1455        if new_hp > 0 {
1456            return EventHandleResult::ok(state);
1457        }
1458
1459        let Some(active_fight) = &mut state.active_fight else {
1460            return EventHandleResult::ok(state);
1461        };
1462
1463        let Some(damaged_entity) = active_fight
1464            .entities
1465            .iter_mut()
1466            .find(|entity| entity.id == entity_id)
1467        else {
1468            return EventHandleResult::fail(state);
1469        };
1470
1471        // Capture values before dropping the mutable borrow
1472        let damaged_id = damaged_entity.id;
1473        let damaged_team = damaged_entity.team.clone();
1474        let damaged_rewards = damaged_entity.rewards.clone();
1475        let damaged_template_id = damaged_entity.entity_template_id;
1476
1477        let has_remaining_allies = active_fight
1478            .entities
1479            .iter()
1480            .any(|e| e.team == EntityTeam::Ally && e.id != damaged_id);
1481
1482        let mut events = Vec::new();
1483
1484        if damaged_team == EntityTeam::Enemy {
1485            // Enemy death — compute and drop rewards
1486            let mut currencies = Vec::new();
1487
1488            if state.pvp_state.is_none() {
1489                // "Pushing pays": a campaign-chapter boss drops more reward
1490                // currency the deeper the chapter, so clearing a fresh chapter
1491                // visibly pays out more from the boss (reusing the existing
1492                // fly-out drop VFX). Trash mobs and non-campaign fights (dungeon,
1493                // arena, single) are unaffected — only `is_boss` entities in a
1494                // campaign fight scale.
1495                let reward_multiplier = {
1496                    let is_boss = damaged_template_id
1497                        .and_then(|tid| game_config.entity_template(tid))
1498                        .is_some_and(|t| t.is_boss);
1499                    let is_campaign = game_config
1500                        .require_fight_template(active_fight.fight_id)
1501                        .map(|f| {
1502                            matches!(
1503                                f.fight_type,
1504                                FightType::CampaignFight | FightType::CampaignBossFight
1505                            )
1506                        })
1507                        .unwrap_or(false);
1508                    if is_boss && is_campaign {
1509                        boss_reward_chapter_multiplier(
1510                            game_config.game_settings.boss_reward_chapter_growth,
1511                            game_config.game_settings.boss_reward_max_multiplier,
1512                            state.character_state.character.current_chapter_level,
1513                        )
1514                    } else {
1515                        1.0
1516                    }
1517                };
1518
1519                if let Some(rewards) = damaged_rewards {
1520                    for reward in rewards {
1521                        if rand_gen.random_range(0.0..100.0) < reward.drop_chance.clamp(0.0, 100.0)
1522                        {
1523                            let rolled = if reward.from <= reward.to {
1524                                rand_gen.random_range(reward.from..=reward.to)
1525                            } else {
1526                                tracing::error!(
1527                                    "Entity {} has a bad reward range: {:?}",
1528                                    damaged_id,
1529                                    reward
1530                                );
1531                                0
1532                            };
1533                            let amount = if reward_multiplier > 1.0 {
1534                                ((rolled as f64) * reward_multiplier).round() as i64
1535                            } else {
1536                                rolled
1537                            };
1538                            currencies.push(CurrencyUnit {
1539                                currency_id: reward.currency_id,
1540                                amount,
1541                            });
1542                        }
1543                    }
1544                } else {
1545                    tracing::error!(
1546                        "Failed to get reward from damaged_entity with entity_id={}",
1547                        entity_id
1548                    );
1549                };
1550            }
1551
1552            events.push(EventPluginized::now(OverlordEvent::EntityDeath {
1553                entity_id: damaged_id,
1554                reward: currencies,
1555            }));
1556        } else if has_remaining_allies {
1557            // An ally dies but other allies survive — remove from fight, continue
1558            events.push(EventPluginized::now(OverlordEvent::EntityDeath {
1559                entity_id: damaged_id,
1560                reward: Vec::new(),
1561            }));
1562        } else {
1563            // Last ally dies — fight lost
1564            events.push(EventPluginized::now(OverlordEvent::PlayerDeath {}));
1565        }
1566
1567        EventHandleResult::ok_events(state, events)
1568    }
1569
1570    #[allow(dead_code)]
1571    fn get_entity_index_with_closest_start_cast(&self, entities: &[Entity]) -> usize {
1572        entities
1573            .iter()
1574            .enumerate()
1575            .filter_map(|(index, entity)| {
1576                entity
1577                    .actions_queue
1578                    .get_closest_start_cast_action_deadline()
1579                    .map(|deadline| (index, deadline))
1580            })
1581            .min_by_key(|&(_, deadline)| deadline)
1582            .map(|(index, _)| index)
1583            .unwrap_or(0)
1584    }
1585
1586    pub fn handle_fight_progress(
1587        &mut self,
1588        current_tick: u64,
1589        mut state: OverlordState,
1590    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1591        let Some(active_fight) = &mut state.active_fight else {
1592            tracing::error!("No active_fight for fight_progress");
1593            return EventHandleResult::ok(state);
1594        };
1595
1596        if active_fight.fight_ended {
1597            return EventHandleResult::ok(state);
1598        }
1599
1600        if active_fight.fight_stopped {
1601            return EventHandleResult::ok(state);
1602        }
1603
1604        if active_fight.entities.is_empty() {
1605            tracing::error!("No entities in fight");
1606            return EventHandleResult::ok(state);
1607        }
1608
1609        if current_tick - self.start_fight_tick >= active_fight.max_duration_ticks {
1610            active_fight.fight_ended = true;
1611            tracing::debug!("Fight lasted too long, ending it");
1612            let fight_uuid = active_fight.id;
1613            let fight_id = active_fight.fight_id;
1614            let end_fight_delay = self.get_end_fight_delay(fight_id);
1615            let pvp_state = state.pvp_state.clone().map(Box::new);
1616            self.fight_clock.schedule(
1617                OverlordEvent::EndFight {
1618                    fight_id: fight_uuid,
1619                    is_win: false,
1620                    pvp_state,
1621                },
1622                end_fight_delay,
1623            );
1624            return EventHandleResult::ok(state);
1625        }
1626
1627        let mut events = vec![];
1628
1629        for entity in &mut active_fight.entities {
1630            if entity.move_target.is_none()
1631                && let Some(action) = entity.actions_queue.pop(current_tick)
1632            {
1633                events.push(event_from_entity_action(action, entity.id));
1634            }
1635        }
1636
1637        EventHandleResult::ok_events(state, events)
1638    }
1639
1640    pub fn handle_set_max_hp(
1641        &mut self,
1642        entity_id: EntityId,
1643        new_max_hp: u64,
1644        new_hp: u64,
1645        mut state: OverlordState,
1646    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1647        let Some(active_fight) = &mut state.active_fight else {
1648            tracing::error!("No active fight for end_fight");
1649            return EventHandleResult::fail(state);
1650        };
1651
1652        let Some(entity) = active_fight
1653            .entities
1654            .iter_mut()
1655            .find(|entity| entity.id == entity_id)
1656        else {
1657            tracing::error!("Failed to get entity with entity_id={}", entity_id);
1658            return EventHandleResult::fail(state);
1659        };
1660
1661        entity.max_hp = new_max_hp;
1662        entity.hp = new_hp.min(new_max_hp);
1663
1664        EventHandleResult::ok(state)
1665    }
1666}
1667
1668/// Per-cell waypoints of a run from `from` to `to`: `(delay_ticks, cell)` for
1669/// every cell entered, in traversal order. The runner "enters" a cell at the
1670/// start of its traversal — the first waypoint has delay 0 (applied
1671/// immediately by `handle_start_move`) and the destination cell is reached one
1672/// cell-time before `EndMove`, the same coordinate timeline the old
1673/// one-`StartMove`-per-cell movement produced.
1674fn move_progress_steps(
1675    from: &Coordinates,
1676    to: &Coordinates,
1677    duration_ticks: u64,
1678) -> Vec<(u64, Coordinates)> {
1679    let dx = to.x - from.x;
1680    let dy = to.y - from.y;
1681    let steps = dx.abs().max(dy.abs()).max(1);
1682    (1..=steps)
1683        .map(|k| {
1684            let cell = Coordinates {
1685                x: from.x + dx * k / steps,
1686                y: from.y + dy * k / steps,
1687            };
1688            (duration_ticks * (k as u64 - 1) / steps as u64, cell)
1689        })
1690        .collect()
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696
1697    fn at(x: i64, y: i64) -> Coordinates {
1698        Coordinates { x, y }
1699    }
1700
1701    /// The waypoints must reproduce the old one-StartMove-per-cell coordinate
1702    /// timeline: enter cell k at (k-1)/N of the run, destination entered one
1703    /// cell-time before EndMove.
1704    #[test]
1705    fn move_progress_steps_match_per_cell_timeline() {
1706        // straight 4-cell run, 500ms per cell
1707        assert_eq!(
1708            move_progress_steps(&at(0, 1), &at(4, 1), 2000),
1709            vec![
1710                (0, at(1, 1)),
1711                (500, at(2, 1)),
1712                (1000, at(3, 1)),
1713                (1500, at(4, 1)),
1714            ]
1715        );
1716
1717        // single-cell diagonal sidestep: one immediate waypoint, like the old code
1718        assert_eq!(
1719            move_progress_steps(&at(2, 1), &at(3, 2), 707),
1720            vec![(0, at(3, 2))]
1721        );
1722
1723        // degenerate zero-distance move still yields the destination
1724        assert_eq!(
1725            move_progress_steps(&at(2, 1), &at(2, 1), 0),
1726            vec![(0, at(2, 1))]
1727        );
1728    }
1729}