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    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
47/// The breakdown actor `by_entity_id` names, or the unowned row when the amount
48/// carries no dealer (a DoT tick, a fight-start script) or its dealer has
49/// already left the field.
50fn 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
78/// Shared by every gauge producer. Each records with a `source` label — a
79/// producer that skips these is simply invisible on the dashboard.
80pub(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
95/// Multiplier applied to a campaign-chapter boss's currency drop so that
96/// deeper chapters pay out more ("pushing pays"). Grows geometrically as
97/// `growth^chapter_level`, bounded by `cap` (and a hard sane ceiling so a
98/// cheated/extreme chapter can never produce an infinite reward). Returns
99/// `1.0` (no scaling) when growth is unset or `<= 1.0`.
100fn 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    // `min(1e6)` guards against `f64::INFINITY` at extreme chapter levels even
110    // when no config cap is set; `clamp(0, 1000)` keeps the `powi` exponent sane.
111    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        // A delayed next-wave spawn (the 0.5 s between-wave pause schedules SpawnEntity events on
171        // the fight clock) can be delivered after the fight already ended — e.g. the player died
172        // or the max-duration EndFight fired inside the pause window. Drop it.
173        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                // A spawned combatant fights with its own Core actions, whoever
205                // spawned it — see `OverlordEvent::carried_origin`.
206                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        // Wave entrance (combat-feel port): a wave mob spawns the configured
217        // entrance offset beyond its battle cell (off-screen) and runs
218        // onto it as a real scheduled server move, timed to land right at its
219        // wake (an awake spawn — delay 0 or a streaming pool release — runs in
220        // immediately). The client just renders the StartMove segment, so the
221        // entrance is uniform-speed and desync-free by construction. Gated to
222        // enemy mobs of wave fights: PvP opponents and summons spawn in place.
223        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        // Death-gated mobs (`exit_gated`, the revived streaming cap) park with
228        // NO timer: `handle_entity_death` schedules their entrance when a kill
229        // frees their queue position.
230        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            // Opening echelon rushes (lab waveRushMult 2.5); later echelons and
236            // streaming pool releases walk (waveRushMultLater 1.0).
237            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            // Slot model (§2.6): the unit exits from behind the screen edge at
245            // its config cooldown, counted from the wave's spawn batch. The
246            // floor keeps every exit behind the ally side's own arrival: wave 1
247            // waits for StartFight (the hero's client run-in lands exactly
248            // then), later waves for the formation dash landing — dash and
249            // exits count from the same batch tick, so cooldown-0 units step
250            // out right as the formation plants.
251            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        // Boss-summon: record reinforcement ids so kill quests can skip their
281        // deaths (the entity is gone from `entities` by quest-tick time).
282        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        // Marks the entity as moving and reserves the destination for the whole
311        // run so a concurrently-planning opponent's `advance_entity` will not
312        // commit a run onto (or through) it. Cleared by `handle_end_move`.
313        entity.move_target = Some(to.clone());
314
315        // Walk the coordinates cell-by-cell over the run instead of teleporting
316        // to the destination: opponents must target the cell the runner is
317        // actually passing — a multi-cell run would otherwise let them engage
318        // him at the destination the moment he starts running.
319        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        // The runner can die or the move can end before a scheduled waypoint
350        // fires; a stale waypoint is normal, not an error.
351        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    /// Slot-model promotion (docs/combat-grid-migration-plan.md §2.2): when the
364    /// near column has emptied while the far column holds a living enemy, the
365    /// whole ally formation steps one column forward — the sliding window moves
366    /// with the hero, so the old far column IS the new melee column. Fired on
367    /// enemy deaths and enemy landings; gated to wave fights (PvP/summon
368    /// opponents spawn in place and never promote).
369    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        // Arrived: release the cell reservation (also clears the moving state).
419        entity.move_target = None;
420
421        // EVERY landing re-checks promotion, not just enemy entrances filling
422        // the far column: the HERO's own landing is the only re-check point for
423        // a near-column death that happened while he was mid-move — the
424        // death-side check declines while the player moves, and without this
425        // the formation never steps and the hero busy-stands out of reach
426        // until the timeout (the "hero just stands there" mid-wave deadlock).
427        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        // A StartCastAbility popped this same tick is already in flight as an event and can't be
461        // frozen through the queue — record the stun window so its handler skips the cast.
462        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        // Mirror the stun to the player's wall-clock ActiveAbility.deadline values so Unity's
486        // cooldown bars freeze for the stun duration. For each ability:
487        // - mid-cast (deadline was None or in the past) → set deadline = now + full + stun
488        // - on cooldown → extend deadline by stun_duration_ms
489        // - off cooldown (no deadline) → set deadline = now + stun_duration_ms
490        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                        // On cooldown — extend by stun duration.
508                        existing + chrono::TimeDelta::milliseconds(stun_ms)
509                    }
510                    _ => {
511                        // Off cooldown OR mid-cast (no current deadline tracked client-side).
512                        // The queue's cancel-cast logic above already handled the in-flight case;
513                        // here we just expose the resulting freeze duration to the client. Use
514                        // full + stun to match the queue, falling back to stun-only when there is
515                        // no full cooldown (e.g. ability_template missing).
516                        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        // Consumable pools may never go negative.
635        //
636        // Each of these is spent by one code path and removed by another, and
637        // the two race. A timed shield (Effect Stone) schedules the exact
638        // inverse of what it granted; if `damage_entity` already spent the pool,
639        // that inverse lands on an empty one. A negative `shield` then takes the
640        // `dmg > shield` branch in `damage_entity`, where `dmg_hp = dmg - shield`
641        // makes the victim take MORE damage than the hit, and
642        // `custom_data.add("shield_damage", shield)` ships a negative absorb to
643        // the client. The armed one-shots are the same shape: an over-consume
644        // would sit negative and silently swallow the next arms instead of
645        // applying them.
646        //
647        // Clamping at empty is the invariant both sides already assume.
648        // (`set` drops zeros, so this removes the key outright.)
649        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                    // tracing::error!("Found this effect: {effect:?}");
704                    // tracing::error!("Got this attributes: {:?}", entity.attributes);
705                    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            // A ticking effect armed by a modifier keeps that modifier's mark
768            // on every tick it fires — the queue carries it across the hop.
769            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        // Native `event` (effect) port: look up the effect's `script` fn
817        // and run it on the RNG snapshot.
818        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            // Re-arm under the same mark the tick ran under — see
847            // `handle_entity_apply_effect`.
848            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        // Pets cast nothing, so every cast follows the cooldown metronome.
918        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        // In-flight-pop stun race: this event was popped from the actions queue on the same
925        // tick a stun landed, so the queue freeze couldn't reach it. Skip the cast and retry
926        // once the stun ends (merges with the stun-placed cooldown entry).
927        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            // Expired — drop the marker so it doesn't linger in synced state.
942            casted_by_entity
943                .attributes
944                .0
945                .remove(crate::fight::STUN_UNTIL_TICK_ATTR);
946        }
947
948        // Ability stones rescale this cast's cooldown, mana cost and cast time.
949        // Nothing socketed (or a non-player caster) resolves to identity, so a
950        // stoneless character casts on exactly the config numbers.
951        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        // Mana gate. A cast the pool cannot pay simply does not happen: the
959        // ability waits for regen and retries at the tick regen will actually
960        // cover the cost — ONE scheduled retry, not a 10 Hz poll of the actions
961        // queue. `handle_fight_progress` pops a single action per entity per
962        // heartbeat, so a per-heartbeat retry would also eat the slot of an
963        // ability that CAN be paid for. The cooldown already served is NOT
964        // restarted: the retry entry carries the refill tick, never a fresh
965        // cooldown, and `push_start_cast_replacing` keeps the later deadline of
966        // the two. Pet ults and mobs carry no pool and are never gated
967        // (acceptance criterion 12).
968        // The two Ledger Mimic facets, both of which price ONE original cast of
969        // the player's: `Budget Plan` pays less for the next three, `Open Tab`
970        // deliberately overpays for one and turns the surcharge into payload.
971        // Both are inert for a mob, a pet ult, a Basic Attack and a derived
972        // (non-Core) cast, so the ordinary mana gate below is unchanged for
973        // everyone else. Read off the clone, because the pool is borrowed
974        // mutably below.
975        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        // Written back after the pool is done with, so `Budget Plan`'s charge is
1011        // spent by the cast that actually got the discount and not by one the
1012        // pool refused.
1013        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            // `Budget Plan`: "Mana Cost x0.75". Resonance is deliberately NOT
1020            // touched — in this codebase a law's Resonance is its template's own
1021            // number and was never priced off mana, so "Resonance equals the
1022            // price actually paid" is already true of a cheaper cast, and paying
1023            // any Resonance out here would break the rule that a facet creates
1024            // none.
1025            let mana_cost = match pet_budget_multiplier {
1026                Some(multiplier) => base_cost * (multiplier as f64 / 10_000.0),
1027                None => base_cost,
1028            };
1029            // BAL-015: the multipliers form one commutative product, rounded
1030            // mathematically exactly once after the last of them; there is no
1031            // minimum cost. The mana conditions then read this whole number.
1032            let mana_cost = mana_cost.round().max(0.0);
1033            mana.regen_to(current_tick);
1034
1035            // `Open Tab`: overpay up to a share of the cost, but only out of
1036            // what is left AFTER the cast itself is paid for — "if mana is
1037            // available" is the whole gate, and a pool that cannot cover the
1038            // surcharge simply pays the ordinary price.
1039            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                // `None` = no regen at all, or a cost above the whole pool:
1049                // there is no refill tick to aim at, so fall back to the next
1050                // tick instead of parking the ability for the fight.
1051                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: only now are the facet charges spent, so a cast the pool
1059            // refused keeps them for the next attempt.
1060            // The `Open Tab` surcharge is a deliberate overpay converted to
1061            // damage, not part of the Skill's price, so it is not recorded.
1062            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                // The surcharge is mana and the payoff is damage, so the bonus
1069                // is the share OF THE RELATIVE overpay: paying the full 50%
1070                // surcharge at a 60% share arms +30%, and paying half of it
1071                // arms +15%.
1072                //
1073                // "+30%" here is +30% OF ATTACK, not of the cast's own payload —
1074                // `logic::pet_facets` spends this arm through the same
1075                // `Attack x share x DMG_K` conversion every other derived hit
1076                // uses. That is deliberate and NOT the `Second Spark`
1077                // convention (which repeats a share of what the Skill actually
1078                // landed): this facet converts MANA into damage, and mana has
1079                // no payload of its own to take a share of, so Attack is the
1080                // price. Read `open_tab_payload_share_percent`'s label the same
1081                // way — it is the share of the SURCHARGE that becomes a bonus,
1082                // not a share of the payload.
1083                pet_open_tab_payload =
1084                    Some(((payload_share as f64) * (surcharge / mana_cost)).round() as i64);
1085            }
1086        }
1087
1088        // What the pool just charged, remembered for the mana conditions of
1089        // laws and trigger stones evaluated when this cast resolves. Guarded
1090        // like the cast-kind record (acceptance #14): written only when a law
1091        // or a socketed stone will read it. A cast that skipped the gate (a pet
1092        // ult, an entity with no pool) clears instead, so its resolution cannot
1093        // be priced at the previous cast's cost.
1094        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        // Facet charges are spent on the entity itself, disjoint from the pool
1109        // borrow above.
1110        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            // Spent by the pet hook on this cast's own landed Core hit, as a
1129            // derived (`Proc`) hit beside the real one — the same shape every
1130            // other payload bonus in the game takes.
1131            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        // Native `start_cast_ability` port: look up the ability's
1140        // `start_behavior` fn and run it on the RNG snapshot.
1141        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        // Quickcast (support stone): the cast occupies the caster for less time.
1190        // Everything downstream — the CastAbility deadline, the animation the
1191        // client plays, the action slot — reads these two numbers, so scaling
1192        // them here is the whole operation.
1193        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                    // Rescaling the cast time does not change the provenance of
1206                    // the dispatch.
1207                    origin,
1208                },
1209                other => other,
1210            })
1211            .collect();
1212
1213        // Basic-vs-skill is decided by the CASTER's class — a party ally or a
1214        // PvP opponent may run a different class than the session character.
1215        // Classless entities (mobs) keep the historical session-class fallback.
1216        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                    // The StartCastAbility action was already popped from the
1237                    // actions queue by `handle_fight_progress`; requeue it
1238                    // (like the script-failure path above) so a deterministic
1239                    // conversion error doesn't stall the entity for the rest
1240                    // of the fight.
1241                    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            // Charge/one-shot cast: run the produced cast actions, but do NOT
1272            // schedule the next StartCastAbility — the pet charge system (or
1273            // fight start, for support passives) is the only trigger.
1274            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    /// Route delayed-marked pluginized events (StartedCastAbility wind-ups)
1309    /// onto the combat clock; immediate ones flow out as regular events.
1310    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    /// Route `StartCastProjectile` script outputs onto the combat clock
1329    /// (clamped to at least one tick so the projectile fires on a later
1330    /// tick); everything else flows out as immediate events.
1331    ///
1332    /// The two support-stone timers ride the same clock: a derived strike
1333    /// (Repeat / Pulse) is scheduled with its delay stripped, so the copy that
1334    /// fires later resolves immediately instead of rescheduling itself, and a
1335    /// delayed attribute change (the Shelter window closing) is scheduled as the
1336    /// plain `EntityIncrAttribute` it becomes.
1337    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    /// The support-stone operations that belong to the CAST, not to a single
1394    /// hit: the delayed derived copies (Repeat / Pulse) and the two defensive
1395    /// windows (Fortify's guard charge, Shelter's damage-reduction window).
1396    ///
1397    /// Derived copies are deliberately not casts — see
1398    /// [`OverlordEvent::DerivedAbilityStrike`].
1399    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        // The price this cast finally paid, carried onto every copy so a
1409        // Mana-conditioned law or trigger prices the re-trigger by the SOURCE
1410        // cast, not by whatever the caster happened to cast in between
1411        // (BAL-019).
1412        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        // Fortify: arm one guard charge that softens the next incoming hit.
1436        // `guard_reduction` is SET (delta to the current value), `guard_charges`
1437        // accumulates, so two casts before the enemy swings leave two charges.
1438        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        // Shelter: a timed `received_damage` reduction. The fight clock owns the
1461        // window, so nothing is polled per tick and the exact reverse lands even
1462        // if the caster casts again meanwhile.
1463        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    /// A derived copy of an ability's payload (Repeat / Pulse). Re-resolves the
1482    /// ability's numbers with the caster's stones, scales them by the copy's
1483    /// share and lands them.
1484    ///
1485    /// It is NOT a cast: no `on_cast`, no mana, no cooldown, no pet charge and
1486    /// no further shape expansion — a copy never copies itself, which is the
1487    /// whole of the anti-recursion rule. What it IS, per BAL-019, is real
1488    /// combat output: the hit is Core, so the laws, equipment triggers and pet
1489    /// facets downstream of it react exactly as they would to the original,
1490    /// and it carries the source cast's final paid Mana price so a
1491    /// Mana-conditioned reaction judges it by that cast. The damage itself is
1492    /// tagged `derived`.
1493    #[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        // Re-establish the source cast's price for the length of this
1508        // resolution. A delayed copy lands after other casts may have paid
1509        // their own, and the recorded fact is per-entity, not per-cast.
1510        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            // The original target died before the copy landed: a derived copy
1528            // does not re-target, it simply fizzles.
1529            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        // An AoE ability keeps its shape when it repeats; a single-target one
1557        // repeats on the target it was aimed at.
1558        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                    &params,
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                // A healing ability's copy heals; the caster is its own target.
1597                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                    &params,
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        // Which family this cast belongs to, recorded on the caster before
1637        // anything reads it: `mechanics::fight::attack` picks the law arm to
1638        // spend by it, and the stones runtime reads it from its own hook. A
1639        // derived cast is not one of the hero's own and leaves the last Core
1640        // answer standing.
1641        //
1642        // Written only when one of those two will read it, so a character with
1643        // no laws and nothing socketed still comes out of a fight with no
1644        // bookkeeping on it at all (acceptance #14).
1645        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        // BAL-033 Rogue passive: an original Basic Attack may repeat itself at
1667        // full damage. The repeat is a real Core cast — it moves attack
1668        // counters, can crit and drives laws, triggers and facets — but it may
1669        // not repeat ITSELF, which the one-shot latch below enforces: the
1670        // repeat consumes the mark and rolls nothing.
1671        //
1672        // A Skill gets the same treatment from Multicast (BAL-034): any
1673        // ORIGINAL skill — class abilities and heals included — may cast a
1674        // second time. The copy pays no Mana and sets no extra cooldown,
1675        // because both are charged by `StartCastAbility`, which the copy never
1676        // goes through; it inherits the original's recorded paid Mana, so the
1677        // Mana laws read the real cost rather than zero.
1678        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                    // Eligible; the roll itself happens below, on the RNG this
1712                    // dispatch already carries.
1713                    double_strike = Some(chance);
1714                }
1715
1716                let is_copy = crate::mechanics::class_passives::take_multicast_mark(caster);
1717                // Basic attacks already multicast by doubling the swing list in
1718                // `fight::cast`, so rolling here too would pay them twice.
1719                if !is_basic && !is_copy && multicast_chance > 0.0 {
1720                    multicast = Some(multicast_chance.clamp(0.0, 1.0));
1721                }
1722            }
1723        }
1724
1725        // Laws whose condition is the cast itself fire BEFORE it resolves, so
1726        // `RL-01` ("every 5th Basic Attack: THIS strike deals +100%") can arm
1727        // the strike that triggered it (`logic::laws`).
1728        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        // Support stones that act on the CAST rather than on a single hit:
1789        // the delayed derived copies (Repeat / Pulse) and the two defensive
1790        // windows (Fortify / Shelter). Resolved once, from the same resolver
1791        // the ability behaviors use.
1792        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        // The Rogue's repeat, resolved on the same entropy accumulator crit,
1806        // dodge and block use: the expected rate is exactly the authored
1807        // chance, with none of the streaks an independent roll produces.
1808        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        // Native `cast_ability` port: look up the ability's `script` fn
1833        // and run it on the RNG snapshot.
1834        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                // Pets cast nothing in this build (`is_pet_ability` is a
1851                // hardcoded `false` above), so every cast that reaches here is
1852                // the combatant's own ability. When the pet ult casts again,
1853                // this is the one place that must answer `PetUlt`.
1854                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 go FIRST: an armed splash belongs to the swing
1867                // that spent it, not to whatever the script queued after.
1868                law_events.append(&mut now_events);
1869                // The Rogue's repeat swings after the strike that produced it,
1870                // and the Mage's copy follows the skill that produced it. Both
1871                // are ordinary Core casts — they drive laws, triggers and
1872                // facets — and both are latched against repeating themselves.
1873                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        // Native `start_cast_projectile` port: look up the projectile's
1941        // `start_behavior` fn and run it.
1942        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        // Native `cast_projectile` port: look up the projectile's `script`
2035        // fn and run it on the RNG snapshot.
2036        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                    // The launcher's own source, carried down the chain: the
2053                    // hit belongs to the skill that fired it. `ProjectileHit`
2054                    // remains for a projectile with no launcher to name.
2055                    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        // The fight already ended (e.g. max-duration timeout): an in-flight
2088        // combat event killed the player afterwards. Don't schedule a second
2089        // EndFight for the same fight.
2090        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    /// Death-gated exit (the revived streaming cap — docs/growing-enemy-waves-plan.md):
2113    /// mobs beyond a wave's `stream_active_count` spawn parked off-screen with an
2114    /// `exit_gated` queue position instead of an exit timer. Each enemy death
2115    /// releases exactly one: clear the attribute (a synchronous state mutation,
2116    /// so same-tick AoE deaths each release exactly one) and schedule its
2117    /// entrance run at the walk tempo, mirroring `handle_spawn_entity`'s timer
2118    /// path.
2119    ///
2120    /// Which one: the freed tile is in the DEAD mob's column (`dead_col_x`), so
2121    /// a parked mob landing in that same column goes first — a melee kill
2122    /// releases a melee, not the ranged queue head (owner 2026-07-13). Within
2123    /// the column (or when none matches) the lowest queue position wins.
2124    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        // Land on the least-occupied row of the mob's column — the space the
2155        // kill just freed — instead of the authored park row. Computed before
2156        // clearing the gate so the count excludes still-parked queue mates.
2157        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        // `set` drops zeros — the attribute disappears, the mob is released.
2162        entity.attributes.set("exit_gated", 0);
2163        // Park row follows the landing row so a same-tick second release
2164        // counts this mob on its real tile (its entrance move is only
2165        // scheduled next tick).
2166        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        // BAL-031: the law / equipment-stone / artifact-stone mob faucets are
2191        // scoped to the owner's own CAMPAIGN fight. Read before the mutable
2192        // borrow of `state.active_fight` below.
2193        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        // The fight already ended (e.g. max-duration timeout): an in-flight
2200        // combat event killed an entity afterwards. Don't process the death —
2201        // it could schedule a second EndFight (or spawn a next wave) for a
2202        // fight that is already over.
2203        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        // BAL-031: dungeon enemies and PvP entities fund nothing on this path —
2219        // a dungeon clear pays its own separate leg instead. Retry kills stay
2220        // eligible (a retry is still a campaign fight), and boss-summoned adds
2221        // need no check here: `wave_share()` already returns 0 for them, which
2222        // zeroes every faucet below.
2223        // A2-BAL-003 §3.5: the check used to be `!is_pvp && dungeon.is_none()`,
2224        // which is a statement about what a fight is NOT. Anything that is
2225        // neither PvP nor a dungeon paid the faucets — including the
2226        // cheat-driven `SingleFight` path and any fight type added later.
2227        // Require the fight to positively be a campaign fight instead.
2228        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        // BAL-008: direct Cookies come only from ORDINARY campaign mobs. Bosses,
2239        // their summoned adds and every other mode are excluded, and unlike the
2240        // other faucets the ticket chance does not scale by `wave_share` — the
2241        // five tickets are authored against raw eligible kills.
2242        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        // The dead mob's column (its reserved landing while mid-run): the tile a
2246        // gated release should refill (see `release_next_gated_exit`).
2247        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        // BAL-038 removes the legacy repeatable Gold and Cookie entity-death
2259        // rewards: the `45% x 1 Cookie` (superseded by the five-ticket route
2260        // below) and the per-kill Gold, which was unbudgeted on top of the
2261        // signed funding split (`80% item sales + 10% dungeon + 5% AFK + 5%
2262        // quests` after the dungeon gate, `80/10/10` before it).
2263        //
2264        // ONLY those two families are removed. Every other authored entity
2265        // currency drop (boss Skill Crystals, Gems, ...) is outside BAL-038's
2266        // scope and keeps paying, as do the separately configured non-kill
2267        // clear / first-clear bundles.
2268        {
2269            // Gold's id is fixed content (the same literal the sale path in
2270            // `behaviors::items::item_price` pays with).
2271            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        // BAL-010: the Core Essence faucet. Supply steps by REACHED CHAPTER, and
2286        // this is the second raw-kill exception beside direct Cookies —
2287        // `wave_share` deliberately does NOT apply, because the packet is already
2288        // authored per band against a canonical ~520 eligible kills per 60m.
2289        //
2290        // Every roll below runs through the BAL-038 daily contract: the band is
2291        // snapshotted on the first eligible roll after the reset, the chance
2292        // decays once the granted amount passes `D`, and the packet is clipped so
2293        // a large grant cannot step past `ceil(2D)`.
2294        let chapter = state.character_state.character.current_chapter_level;
2295        let now = ::time::utc_now();
2296        {
2297            if dead_team == EntityTeam::Enemy
2298                // Same population as direct Cookies: raw ordinary campaign-mob
2299                // deaths. Bosses, summons and dungeon kills are excluded — the
2300                // per-band packet is authored against the canonical ~520
2301                // eligible kills per 60m, which counts none of those.
2302                && 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            // Law COPIES — not law unlocks. Since the acquisition pass the
2328            // unlock is deterministic (`mechanics::cores::grant_unlocked_laws`
2329            // on a core upgrade) and this path funds LEVELLING only: one law
2330            // template per proc, banked as stock against `law_upgrade_ladder`.
2331            //
2332            // BAL-031: the pool is uniform over the laws the player's OWN core
2333            // levels have unlocked (and that are active) — a locked or inactive
2334            // template never drops, so the signed catalog timing is measured
2335            // against the deterministic unlock schedule, not the whole catalog.
2336            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        // Trigger/Effect stones. Their faucet moved here from the item-chest
2380        // open (`logic::items::open_item_case`): chests are a purchased channel,
2381        // so the old placement paid a whale opening ~26k chests a day two orders
2382        // of magnitude more stones than a player who fought for them. Same
2383        // `wave_share` scaling as every other per-kill payout.
2384        //
2385        // BAL-020 full-loop denial: no drops before the first socket milestone
2386        // (ch25) — a stone that cannot be socketed must not occupy inventory.
2387        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            // Trigger and Effect share ONE counter: the kind is decided by the
2396            // internal 50/50 roll after the drop is granted, so counting them
2397            // apart would let a build farm twice the authored budget.
2398            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            // Artifact stones get a THIRD faucet here, alongside the chapter
2425            // boss and the cleared dungeon — deliberately a much smaller chance,
2426            // since the events it joins are per-chapter and per-run while this
2427            // one is per-mob.
2428            //
2429            // The BAL-038 counter covers the campaign mob AND boss legs, which is
2430            // why a campaign boss consumes it twice (once inside the weighted mob
2431            // pool, once through its own extra leg). The dungeon-clear leg is a
2432            // separate faucet and never touches this budget.
2433            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        // BAL-008: direct campaign Cookies. Five INDEPENDENT tickets per eligible
2464        // ordinary mob death, resolved in sequence; each success grants one
2465        // Cookie, immediately advances the daily `x`, and is surfaced separately.
2466        // This is the ONLY direct campaign Cookie route — the legacy 45%x1 entity
2467        // reward is gone, so a kill can no longer fund both.
2468        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                    // The chance is read fresh per ticket: a success inside this
2484                    // very kill lowers the next ticket's odds, which is what makes
2485                    // the five tickets a decaying sequence rather than five draws
2486                    // at one rate.
2487                    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        // Death-gated exit release (revived streaming cap): an ENEMY kill frees
2523        // exactly one parked mob — the lowest queue position. Gated mobs are
2524        // live entities, so this is race-free by construction: the attribute
2525        // clear below is a synchronous state mutation visible to the next
2526        // death handler in the same tick (no over-release on AoE), and the
2527        // wave-completion check further down needs no extra gate — a parked
2528        // mob keeps `get_enemies_amount() > 0` until it is released and dies.
2529        if dead_team == EntityTeam::Enemy {
2530            self.release_next_gated_exit(active_fight, &game_config.fight_settings, dead_col_x);
2531        }
2532
2533        // Slot promotion on a mid-wave enemy death (§2.2): the near column may
2534        // have just emptied while the far one still fights.
2535        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        // A pending SUMMON wave never spawns on wave clear: it only fires from
2541        // the damage handler when the boss crosses its HP fraction. If the boss
2542        // died first, the last wave is skipped and the fight ends here.
2543        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                // Native `prepare_fight` interpreter: spawn the current wave from
2573                // the typed `prepare_fight_waves` config (the native data source),
2574                //
2575                // `base_power` was a literal argument of the legacy
2576                // `spawn_wave(...)` script, transpiled from the template's
2577                // TOP-LEVEL `power` field (`$.power`). Pass that — NOT
2578                // `wave_data.power` (the waves-blob's inner value, 4-26x
2579                // larger on live templates), which inflated campaign mob
2580                // stats 2-5x vs the legacy engine. See the matching fix in
2581                // `chapters_management.rs`.
2582                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                // Between-wave pause (docs/combat-feel-porting-plan.md [3.3]): the next wave's
2651                // spawn batch — the EXISTING events spawn_wave produced (spawns + their sleep
2652                // effects, in original order) — is scheduled on the fight clock after the
2653                // per-dungeon override or the standard 0.5 s pause.
2654                // Deliberately NO new event variants (postcard is ordinal — old clients would
2655                // break on decode). WaveCleared still goes out immediately for the UI; delivery
2656                // races with EndFight are cut off by the fight_ended guard in handle_spawn_entity.
2657                // NB: fight-clock ticks are MILLISECONDS (cp. `end_fight_delay_ticks_default:
2658                // 500` = 0.5 s and cooldowns added to `current_tick` directly), not 100 ms
2659                // server frames.
2660                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                // Formation dash (§2.7): advancing fights move the whole ally
2670                // side FORMATION_ADVANCE_CELLS to the new anchor after the pause.
2671                // Stationary dungeons omit the move entirely; their wave slots
2672                // use the current player column and their exits have no formation
2673                // floor.
2674                //
2675                // EVERY living ally dashes — including one mid-move at clear time
2676                // (e.g. a promotion step still in flight). Filtering on
2677                // `move_target.is_none()` HERE (clear time) dropped such an ally
2678                // from the scheduled dash, and the slot model has no catch-up: the
2679                // hero would strand 4 cells back and idle to a timeout loss (branch
2680                // review 4.1). Always emitting the formation move also preserves
2681                // reachability when a dungeon shortens the spawn pause below one
2682                // normal movement step.
2683                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        // Read the healer before the healed entity is borrowed mutably; a
2725        // self-heal is the common case, so the two are usually the same row.
2726        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        // Applied heal, not requested heal: overheal above `max_hp` never
2745        // happened as far as the breakdown is concerned.
2746        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        // Read the dealer before the victim is borrowed mutably — it may be the
2777        // same entity (retaliation, reflected damage).
2778        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        // Applied damage, so an insurance save or an overkill contributes only
2796        // the HP it actually took.
2797        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        // A2-BAL-001: ordinary damage no longer produces any control effect.
2814        // The automatic boss stagger that used to live here is gone, together
2815        // with the `spawn_wave` Attack compensation that paid for its downtime
2816        // — removing only one half would have changed real difficulty.
2817        //
2818        // `EntityStun` itself is deliberately kept: it is the substrate a future
2819        // explicitly-authored control effect would use. It simply has no
2820        // production source any more.
2821        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        // BAL-026: ACTUAL HP removed feeds the shared gauge, scale-independent
2827        // by construction. Received normalizes by the victim's own bar; dealt
2828        // additionally scales by the victim's wave share, so absolute stats
2829        // never speed the bar up. Overkill is already cut by the actual-loss
2830        // clamp; self-damage counts once, as received; ownerless damage grants
2831        // no dealt half. A combatant without a flip state accrues nothing —
2832        // `accumulate_flip_gauge` no-ops for them.
2833        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            // Boss summon (owner 2026-07-10): dropping below the configured HP
2865            // fraction spawns the template's LAST wave immediately — anchored at
2866            // the player (spawn_wave detects the summon wave), entering via the
2867            // normal entrance runs. The latch is `current_wave` itself (set to
2868            // the last wave), so it persists in saves and can't re-trigger; a
2869            // boss killed before the trigger never spawns it (the death handler
2870            // skips a pending summon wave).
2871            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        // Capture values before dropping the mutable borrow
2922        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        // §2 consumer 1: per-kill drop CHANCE scales by the dying mob's
2927        // wave_share so the fight's total drop expectation is count-invariant.
2928        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        // The killing blow's gauge still counts (actual loss, no overkill);
2936        // the stun events die with the boss.
2937        let mut events = gauge_events;
2938
2939        let mut artifact_stone_drop = None;
2940        if damaged_team == EntityTeam::Enemy {
2941            // Enemy death — compute and drop rewards
2942            let mut currencies = Vec::new();
2943
2944            if state.pvp_state.is_none() {
2945                // "Pushing pays": a campaign-chapter boss drops more reward
2946                // currency the deeper the chapter, so clearing a fresh chapter
2947                // visibly pays out more from the boss (reusing the existing
2948                // fly-out drop VFX). Trash mobs and non-campaign fights (dungeon,
2949                // arena, single) are unaffected — only `is_boss` entities in a
2950                // campaign fight scale.
2951                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                // BAL-031: a campaign chapter boss rolls its own extra artifact
2974                // stone leg (signed 2%), separate from the currency drops and
2975                // from the artifacts themselves. The leg shares the BAL-038
2976                // ArtifactStones daily counter with the mob leg — the authored
2977                // `D = 2.585 mob + 0.940 boss = 3.525` covers both, so this roll
2978                // decays and consumes the same budget and cannot step past the
2979                // combined cap of 8.
2980                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                        // Scale the CHANCE (not the amount) by wave_share — the
3019                        // expectation scales exactly, no integer distortion.
3020                        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                            // A2-BAL-004 §4.2: progression currencies and the
3035                            // boss-only Gems budget do NOT ride the chapter
3036                            // growth multiplier.
3037                            //
3038                            // `boss_reward_chapter_growth` exists to make deeper
3039                            // chapters pay more of the currencies you SPEND on
3040                            // depth — Gold and the like. It was applied to every
3041                            // reward on the entity, so Skill Crystals rode it
3042                            // too: capped at x12, an ordinary boss is worth
3043                            // `0.30 x 8 x 12 = 28.8` Crystals by ch52, repeat
3044                            // kills included and no daily cap. That is what puts
3045                            // roughly 3,000 Crystals in a wallet by ch139 from
3046                            // boss kills alone, against a signed supply of tens.
3047                            //
3048                            // Gacha pull currency is a different economy: its
3049                            // supply is signed per game-day, not per chapter
3050                            // depth, so scaling it by depth has no target to
3051                            // land on.
3052                            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                            // Resource addendum 3: chapter Skill income and
3066                            // Gems are boss-only exact-cap faucets. The packet
3067                            // remains authored on the entity for VFX/content
3068                            // inspection, while this persisted daily cap makes
3069                            // repeat farming finite and keeps the split stable
3070                            // as chapter speed changes.
3071                            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            // An ally dies but other allies survive — remove from fight, continue
3139            events.push(EventPluginized::now(OverlordEvent::EntityDeath {
3140                entity_id: damaged_id,
3141                reward: Vec::new(),
3142                origin: CombatEventOrigin::Core,
3143            }));
3144        } else {
3145            // Last ally dies — fight lost
3146            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        // BAL-033 Priest passive: every period, heal the ally with the lowest
3197        // HP SHARE — in solo that is the Priest. The timer is fight-local and
3198        // rides the fight clock, so it neither drifts with heartbeat cadence
3199        // nor carries between fights.
3200        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            // Lowest HP SHARE, not lowest HP: a big ally at half health needs
3225            // the heal more than a small one two points down.
3226            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                        // Class-passive periodic heal — no catalog id to
3256                        // stamp, so it reads as passive regeneration.
3257                        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            // Mana accrues on the fight clock, drift-free: the pool carries the
3267            // tick it was last accrued at, so the amount restored is exactly the
3268            // elapsed fight time regardless of heartbeat cadence.
3269            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
3315/// Per-cell waypoints of a run from `from` to `to`: `(delay_ticks, cell)` for
3316/// every cell entered, in traversal order. The runner "enters" a cell at the
3317/// start of its traversal — the first waypoint has delay 0 (applied
3318/// immediately by `handle_start_move`) and the destination cell is reached one
3319/// cell-time before `EndMove`, the same coordinate timeline the old
3320/// one-`StartMove`-per-cell movement produced.
3321fn 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    /// The waypoints must reproduce the old one-StartMove-per-cell coordinate
3349    /// timeline: enter cell k at (k-1)/N of the run, destination entered one
3350    /// cell-time before EndMove.
3351    #[test]
3352    fn move_progress_steps_match_per_cell_timeline() {
3353        // straight 4-cell run, 500ms per cell
3354        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        // single-cell diagonal sidestep: one immediate waypoint, like the old code
3365        assert_eq!(
3366            move_progress_steps(&at(2, 1), &at(3, 2), 707),
3367            vec![(0, at(3, 2))]
3368        );
3369
3370        // degenerate zero-distance move still yields the destination
3371        assert_eq!(
3372            move_progress_steps(&at(2, 1), &at(2, 1), 0),
3373            vec![(0, at(2, 1))]
3374        );
3375    }
3376}