overlord_event_system/logic/
chapters_management.rs

1use crate::{
2    entities,
3    event::{OverlordEvent, PrepareFightType},
4    game_config_helpers::GameConfigLookup,
5    logic::handler::OverlordLogic,
6    state::OverlordState,
7};
8
9use essences::{
10    currency::{CurrencyConsumer, CurrencyUnit, check_can_decrease_currencies},
11    dungeons::DungeonTemplateId,
12    entity::{ActionWithDeadline, Entity, EntityAction, EntityId, EntityState},
13    fighting::{
14        ActiveDungeon, ActiveFight, EntityTeam, EntityType, FightTemplate, FightTemplateId,
15        FightType,
16    },
17    game::Chapter,
18    pvp::PVPState,
19};
20
21use analytics::constants::METRICS_TARGET;
22use configs::game_config::GameConfig;
23use event_system::{event::EventPluginized, script::random::GameRng, system::EventHandleResult};
24use rand::Rng;
25
26impl OverlordLogic {
27    pub fn is_battle_active(&self, state: &OverlordState) -> bool {
28        if let Some(active_fight) = &state.active_fight {
29            let has_player = active_fight
30                .entities
31                .iter()
32                .any(|e| e.id == active_fight.player_id);
33
34            let has_enemy = active_fight
35                .entities
36                .iter()
37                .any(|e| e.team == EntityTeam::Enemy);
38
39            has_player && has_enemy
40        } else {
41            false
42        }
43    }
44
45    pub fn handle_prepare_fight(
46        &mut self,
47        prepare_fight_type: PrepareFightType,
48        rand_gen: rand::rngs::StdRng,
49        mut state: OverlordState,
50    ) -> EventHandleResult<OverlordEvent, OverlordState> {
51        let game_config = self.game_config.get();
52
53        if state.pvp_state.is_some() {
54            return EventHandleResult::fail(state);
55        }
56
57        let is_retry_boss_fight = matches!(prepare_fight_type, PrepareFightType::RetryBossFight);
58
59        match prepare_fight_type {
60            PrepareFightType::PVEFight => {
61                if !self.validate_pve_fight(&state) {
62                    return EventHandleResult::fail(state);
63                }
64            }
65            PrepareFightType::PVPFight { .. } => {}
66            PrepareFightType::RetryBossFight => {
67                if !self.validate_prepare_retry_boss_fight(&state) {
68                    return EventHandleResult::fail(state);
69                }
70            }
71            PrepareFightType::DungeonFight {
72                dungeon_id,
73                difficulty,
74            } => {
75                if !self.validate_dungeon_fight(&state, dungeon_id, difficulty) {
76                    return EventHandleResult::fail(state);
77                }
78            }
79            PrepareFightType::ForfeitDungeonFight => {}
80            PrepareFightType::SingleFight { .. } => {}
81        };
82
83        // Item TTL: remove expired items here, at the start of the next fight,
84        // before its power snapshot is built — so they never drop mid-fight.
85        // Snapshot the inventory only when something is removed, so a failed
86        // prepare below can roll it back — else it would strip items without
87        // emitting ItemsExpired and orphan their DB rows.
88        let now = ::time::utc_now();
89        let pre_sweep_inventory = state
90            .character_state
91            .inventory
92            .iter()
93            .any(|item| item.expires_at.is_some_and(|exp| exp <= now))
94            .then(|| state.character_state.inventory.clone());
95        let expiry_events = super::items::sweep_expired_items(&mut state, now);
96
97        // The previous fight (if any) is over: drop its pending combat events
98        // so the new fight schedules onto a clean clock. The saved clock is
99        // restored on every failure exit below, so a failed PrepareFight can't
100        // kill the running fight's heartbeat and pending scheduling.
101        // The FightProgress heartbeat is re-installed below on success.
102        let saved_clock = self.fight_clock.clone();
103        self.fight_clock.clear();
104        state.active_fight = None;
105
106        let prepare_fight_events = match prepare_fight_type {
107            PrepareFightType::PVEFight => self.handle_pve_prepare_fight(&mut state, rand_gen),
108            PrepareFightType::ForfeitDungeonFight => {
109                self.handle_pve_prepare_fight(&mut state, rand_gen)
110            }
111            PrepareFightType::PVPFight {
112                fight_id,
113                pvp_state,
114            } => self.handle_pvp_prepare_fight(&mut state, fight_id, pvp_state, rand_gen),
115            PrepareFightType::RetryBossFight => {
116                match game_config
117                    .require_chapter_by_level(state.character_state.character.current_chapter_level)
118                {
119                    Ok(chapter) => {
120                        state.character_state.character.current_fight_number =
121                            (chapter.fight_ids.len() - 1) as i64;
122                        self.handle_pve_prepare_fight(&mut state, rand_gen)
123                    }
124                    Err(_) => {
125                        tracing::error!(
126                            "Failed to get chapter with chapter_level={}",
127                            state.character_state.character.current_chapter_level
128                        );
129                        None
130                    }
131                }
132            }
133            PrepareFightType::DungeonFight {
134                dungeon_id,
135                difficulty,
136            } => self.handle_dungeon_prepare_fight(&mut state, dungeon_id, difficulty, rand_gen),
137            PrepareFightType::SingleFight { fight_templated_id } => {
138                self.handle_single_prepare_fight(&mut state, fight_templated_id, rand_gen)
139            }
140        };
141
142        let Some(events) = prepare_fight_events else {
143            tracing::error!("Preparing fight failed, something went wrong");
144            self.fight_clock = saved_clock;
145            if let Some(inventory) = pre_sweep_inventory {
146                state.character_state.inventory = inventory;
147            }
148            return EventHandleResult::fail(state);
149        };
150
151        if state.active_fight.is_none() {
152            tracing::error!("No active fight after preparing fight");
153            self.fight_clock = saved_clock;
154            if let Some(inventory) = pre_sweep_inventory {
155                state.character_state.inventory = inventory;
156            }
157            return EventHandleResult::fail(state);
158        };
159
160        if is_retry_boss_fight {
161            // Consume the boss retry only once the prepare succeeded: failure
162            // results still apply their state, and a prematurely-set flag
163            // makes `validate_prepare_retry_boss_fight` reject every further
164            // retry even though the boss fight was never restarted.
165            state.character_state.character.last_boss_fight_won = true;
166        }
167
168        self.fight_clock.set_heartbeat(
169            OverlordEvent::FightProgress {},
170            game_config.game_settings.fight_progress_tick,
171        );
172
173        // Emit the chapter-start expiry events ahead of the fight's own events.
174        let mut events = events;
175        events.splice(0..0, expiry_events);
176
177        EventHandleResult::ok_events(state, events)
178    }
179
180    fn validate_pve_fight(&self, state: &OverlordState) -> bool {
181        if let Some(active_fight) = &state.active_fight
182            && !active_fight.fight_ended
183            && active_fight.dungeon.is_some()
184        {
185            tracing::error!("Dungeon fight is in progress");
186            return false;
187        };
188
189        true
190    }
191
192    fn validate_prepare_retry_boss_fight(&self, state: &OverlordState) -> bool {
193        let game_config = self.game_config.get();
194
195        // These validation failures are expected in normal client-server
196        // async play: the server state has already moved on while the
197        // client's cached state is a few state_patches behind. They should
198        // log at info, not error — otherwise they bury real bugs in error
199        // metrics/alerts during every boss defeat or fight overlap.
200        if state.character_state.character.last_boss_fight_won {
201            tracing::info!("The last boss fight was won, so we can't try to retry boss fight");
202            return false;
203        }
204
205        let Some(active_fight) = &state.active_fight else {
206            return true;
207        };
208
209        if active_fight.dungeon.is_some() {
210            tracing::info!("Dungeon fight is in progress");
211            return false;
212        }
213
214        let Ok(fight) = game_config.require_fight_template(active_fight.fight_id) else {
215            tracing::error!(
216                "Failed to get fight_template with id {} ",
217                active_fight.fight_id,
218            );
219            return false;
220        };
221
222        if fight.fight_type == FightType::CampaignBossFight && self.is_battle_active(state) {
223            tracing::info!("The current fight is a boss fight, so we can't retry a boss fight");
224            return false;
225        };
226
227        true
228    }
229
230    fn validate_dungeon_fight(
231        &self,
232        state: &OverlordState,
233        dungeon_id: DungeonTemplateId,
234        difficulty: i64,
235    ) -> bool {
236        let game_config = self.game_config.get();
237
238        if *state
239            .dungeons
240            .completed_difficulties
241            .get(&dungeon_id)
242            .unwrap_or(&0)
243            + 1
244            < difficulty
245        {
246            tracing::error!(
247                "Maximum available difficulty is {} ",
248                *state
249                    .dungeons
250                    .completed_difficulties
251                    .get(&dungeon_id)
252                    .unwrap_or(&0)
253                    + 1
254            );
255            return false;
256        }
257
258        let Ok(dungeon) = game_config.require_dungeon_template(dungeon_id) else {
259            tracing::error!("Failed to get dungeon_template with id {} ", dungeon_id);
260            return false;
261        };
262
263        if state.character_state.character.current_chapter_level < dungeon.chapter_level_unlock {
264            tracing::error!(
265                "Current chapter level is: {}, required is {} ",
266                state.character_state.character.current_chapter_level,
267                dungeon.chapter_level_unlock
268            );
269            return false;
270        }
271
272        if difficulty > dungeon.max_difficulty_level {
273            tracing::error!(
274                "Maximum difficulty for dungeon with id: {}, is {} ",
275                dungeon_id,
276                dungeon.max_difficulty_level
277            );
278            return false;
279        }
280
281        let keys_required = vec![CurrencyUnit {
282            currency_id: dungeon.key_currency_id,
283            amount: 1,
284        }];
285
286        if !check_can_decrease_currencies(&state.character_state.currencies, &keys_required) {
287            tracing::error!("Not enough keys for running dungeon: {}", dungeon_id);
288            return false;
289        }
290
291        true
292    }
293
294    fn generate_pve_fight_entities(
295        &self,
296        entities: &mut Vec<Entity>,
297        fight: &FightTemplate,
298        state: &mut OverlordState,
299        rand_gen: &mut rand::rngs::StdRng,
300    ) -> anyhow::Result<(Entity, Option<EntityId>)> {
301        let game_config = self.game_config.get();
302
303        fight.fight_entities.iter().for_each(|entity| {
304            let created_entity = match entities::create_pve_entity(
305                uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
306                entity,
307                &game_config,
308                None,
309            ) {
310                Ok(entity) => entity,
311                Err(err) => {
312                    tracing::error!("Failed creating entity {}", err.to_string());
313                    return;
314                }
315            };
316
317            entities.push(created_entity);
318        });
319
320        let player = match entities::create_player_entity(
321            &state.character_state,
322            uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
323            &game_config,
324        ) {
325            Ok(entity) => entity,
326            Err(err) => {
327                anyhow::bail!("Failed creating player entity {}", err);
328            }
329        };
330
331        entities.push(player.clone());
332
333        let mut party_player_id = None;
334        if let Some(party_state) = &state.party.party_state {
335            match entities::create_party_entity(
336                party_state,
337                uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
338                &game_config,
339            ) {
340                Ok(party_ally) => {
341                    party_player_id = Some(party_ally.id);
342                    entities.push(party_ally);
343                }
344                Err(err) => {
345                    tracing::error!("Failed creating party ally entity {}", err);
346                }
347            }
348        }
349
350        Ok((player, party_player_id))
351    }
352
353    fn generate_pvp_fight_entities(
354        &self,
355        entities: &mut Vec<Entity>,
356        fight: &FightTemplate,
357        pvp_state: &PVPState,
358        state: &mut OverlordState,
359        rand_gen: &mut rand::rngs::StdRng,
360    ) -> anyhow::Result<(Entity, Option<EntityId>)> {
361        let game_config = self.game_config.get();
362
363        fight.fight_entities.iter().for_each(|entity| {
364            let created_entity = match entity.entity_type {
365                EntityType::PVEEntity {
366                    entity_template_id: _,
367                } => {
368                    match entities::create_pve_entity(
369                        uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
370                        entity,
371                        &game_config,
372                        None,
373                    ) {
374                        Ok(entity) => entity,
375                        Err(err) => {
376                            tracing::error!("{}", err.to_string());
377                            return;
378                        }
379                    }
380                }
381                EntityType::PVPEntity => {
382                    match entities::create_pvp_entity(
383                        &EntityState::Opponent(&pvp_state.opponent_state),
384                        entity,
385                        &game_config,
386                    ) {
387                        Ok(entity) => entity,
388                        Err(err) => {
389                            tracing::error!("{}", err.to_string());
390                            return;
391                        }
392                    }
393                }
394            };
395
396            entities.push(created_entity);
397        });
398
399        let player = match entities::create_player_entity(
400            &state.character_state,
401            uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
402            &game_config,
403        ) {
404            Ok(entity) => entity,
405            Err(err) => {
406                anyhow::bail!("Failed creating player entity {}", err);
407            }
408        };
409
410        entities.push(player.clone());
411
412        // No party ally in PvP — arena is strictly 1v1
413        let party_player_id = None;
414
415        Ok((player, party_player_id))
416    }
417
418    fn run_prepare_fight_script(
419        &self,
420        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
421        fight: &FightTemplate,
422        active_fight: &ActiveFight,
423        state: &mut OverlordState,
424        rand_gen: rand::rngs::StdRng,
425    ) -> anyhow::Result<()> {
426        let game_config = self.game_config.get();
427        let current_chapter = state.character_state.character.current_chapter_level;
428
429        // Native `prepare_fight` interpreter: spawn the initial wave from the
430        // typed `prepare_fight_waves` config (the native data source), replacing
431        // migrated next-wave path in `logic::fighting`.
432        //
433        // `base_power` was a literal argument of the legacy `spawn_wave(...)`
434        // script, transpiled at deploy time from the template's TOP-LEVEL
435        // `power` field (`$.power`). The earlier port passed
436        // `wave_data.power` — the waves-blob's inner `power`, an unrelated
437        // value that runs 4-26x larger on live campaign templates — which
438        // inflated every campaign mob's hp/attack by 2-5x vs the legacy
439        // engine. Pass the template's own `power`, like the scripts did.
440        // Templates that spawn their enemies directly via `fight_entities` carry
441        // optional extra wave-spawn step, not a requirement). Treat an absent
442        // wave config as "nothing to spawn here" instead of an error — the
443        // initial entities are already built by `generate_pve_fight_entities`.
444        let Some(waves_cfg) = fight.prepare_fight_waves.as_ref() else {
445            return Ok(());
446        };
447
448        let wave_data = crate::mechanics::fight::wave_data_from_config(waves_cfg);
449        let fight_type_str = format!("{:?}", fight.fight_type);
450        let mut sink = crate::mechanics::fight::NativeSink::default();
451        let rng = GameRng::new(rand_gen);
452        if let Err(err) = crate::mechanics::fight::spawn_wave(
453            &mut sink,
454            &rng,
455            &game_config,
456            self.behaviors.lookups(),
457            active_fight,
458            &wave_data,
459            fight.power.map(|p| p as f64).unwrap_or(0.0),
460            current_chapter,
461            &fight_type_str,
462        ) {
463            anyhow::bail!("Prepare fight wave spawn failed with error: {err:?}");
464        }
465
466        events.append(&mut sink.events.into_iter().map(EventPluginized::now).collect());
467
468        Ok(())
469    }
470
471    /// Schedule `StartFight` for a freshly prepared fight, honoring the
472    /// template's start delay override.
473    fn schedule_start_fight(
474        &mut self,
475        fight: &FightTemplate,
476        fight_id: uuid::Uuid,
477        game_config: &GameConfig,
478    ) {
479        let start_fight_delay = fight
480            .start_fight_delay_ticks
481            .unwrap_or(game_config.fight_settings.start_fight_delay_ticks_default);
482
483        self.fight_clock
484            .schedule(OverlordEvent::StartFight { fight_id }, start_fight_delay);
485    }
486
487    fn handle_pve_prepare_fight(
488        &mut self,
489        state: &mut OverlordState,
490        mut rand_gen: rand::rngs::StdRng,
491    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
492        let game_config = self.game_config.get();
493
494        let mut events = vec![];
495        let Ok(chapter) = game_config
496            .require_chapter_by_level(state.character_state.character.current_chapter_level)
497        else {
498            tracing::error!(
499                "Failed to get chapter with chapter_level={}",
500                state.character_state.character.current_chapter_level
501            );
502            return None;
503        };
504
505        let Some(fight_id) = chapter
506            .fight_ids
507            .get(state.character_state.character.current_fight_number as usize)
508            .cloned()
509        else {
510            tracing::error!(
511                "Failed to get fight {} for chapter_level={}",
512                state.character_state.character.current_fight_number,
513                state.character_state.character.current_chapter_level
514            );
515            return None;
516        };
517
518        let Ok(fight) = game_config.require_fight_template(fight_id) else {
519            tracing::error!("Failed to get fight_template with id {} ", fight_id,);
520            return None;
521        };
522
523        let mut entities = vec![];
524
525        let (player, party_player_id) =
526            match self.generate_pve_fight_entities(&mut entities, fight, state, &mut rand_gen) {
527                Ok(result) => result,
528                Err(err) => {
529                    tracing::error!("Got error, while creating PVE entities: {}", err);
530                    return None;
531                }
532            };
533
534        let (pet_combat_state, leader_pet_template_id) = self.build_pet_combat_state(state);
535
536        let active_fight = ActiveFight {
537            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
538            fight_id,
539            current_wave: 1,
540            player_id: player.id,
541            party_player_id,
542            entities,
543            max_duration_ticks: fight.max_duration_ticks,
544            fight_stopped: false,
545            fight_ended: false,
546            dungeon: None,
547            paused: false,
548            pet_combat_state,
549            leader_pet_template_id,
550        };
551
552        state.active_fight = Some(active_fight.clone());
553
554        if let Err(err) =
555            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
556        {
557            tracing::error!("Error running pve prepare_fight script: {err:?}");
558            return None;
559        }
560
561        self.schedule_start_fight(fight, active_fight.id, &game_config);
562
563        tracing::debug!(
564            "Starting chapter_level={}, fight_number={}, fight_id={}",
565            chapter.level,
566            state.character_state.character.current_fight_number,
567            fight_id,
568        );
569
570        Some(events)
571    }
572
573    fn handle_pvp_prepare_fight(
574        &mut self,
575        state: &mut OverlordState,
576        fight_id: FightTemplateId,
577        pvp_state: Box<PVPState>,
578        mut rand_gen: rand::rngs::StdRng,
579    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
580        let game_config = self.game_config.get();
581        // Applied to the state only after the fight is successfully built:
582        // failure results still apply their state, and a half-set `pvp_state`
583        // would make `handle_prepare_fight`'s entry guard reject every
584        // subsequent PrepareFight for the session.
585        let prepared_pvp_state = *pvp_state.clone();
586
587        let mut events = vec![];
588
589        let Ok(fight) = game_config.require_fight_template(fight_id) else {
590            tracing::error!("Failed to get fight_template with id {} ", fight_id);
591            return None;
592        };
593
594        let mut entities = vec![];
595
596        let (player, party_player_id) = match self.generate_pvp_fight_entities(
597            &mut entities,
598            fight,
599            &pvp_state,
600            state,
601            &mut rand_gen,
602        ) {
603            Ok(result) => result,
604            Err(err) => {
605                tracing::error!("Got error, while creating PVP entities: {}", err);
606                return None;
607            }
608        };
609
610        let (pet_combat_state, leader_pet_template_id) = self.build_pet_combat_state(state);
611
612        let active_fight = ActiveFight {
613            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
614            fight_id,
615            current_wave: 1,
616            player_id: player.id,
617            party_player_id,
618            entities,
619            max_duration_ticks: fight.max_duration_ticks,
620            fight_stopped: false,
621            fight_ended: false,
622            dungeon: None,
623            paused: false,
624            pet_combat_state,
625            leader_pet_template_id,
626        };
627
628        state.active_fight = Some(active_fight.clone());
629
630        if let Err(err) =
631            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
632        {
633            tracing::error!("Error running pvp prepare_fight script: {err:?}");
634            return None;
635        }
636
637        state.pvp_state = Some(prepared_pvp_state);
638
639        self.schedule_start_fight(fight, active_fight.id, &game_config);
640
641        Some(events)
642    }
643
644    fn handle_dungeon_prepare_fight(
645        &mut self,
646        state: &mut OverlordState,
647        dungeon_id: DungeonTemplateId,
648        difficulty: i64,
649        mut rand_gen: rand::rngs::StdRng,
650    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
651        let game_config = self.game_config.get();
652
653        let Ok(dungeon) = game_config.require_dungeon_template(dungeon_id) else {
654            tracing::error!("Failed to get dungeon_template with id {} ", dungeon_id);
655            return None;
656        };
657
658        let Some(fight_template_id) = dungeon.fight_template_ids.get((difficulty - 1) as usize)
659        else {
660            tracing::error!(
661                "Failed to get fight_template from dungeon with id {}, for difficulty: {}",
662                dungeon_id,
663                difficulty
664            );
665            return None;
666        };
667
668        let Ok(fight) = game_config.require_fight_template(*fight_template_id) else {
669            tracing::error!(
670                "Failed to get fight_template with id {} ",
671                fight_template_id
672            );
673            return None;
674        };
675
676        let mut events = vec![];
677
678        let mut entities = vec![];
679
680        let (player, party_player_id) =
681            match self.generate_pve_fight_entities(&mut entities, fight, state, &mut rand_gen) {
682                Ok(result) => result,
683                Err(err) => {
684                    tracing::error!("Got error, while creating entities: {}", err);
685                    return None;
686                }
687            };
688
689        let (pet_combat_state, leader_pet_template_id) = self.build_pet_combat_state(state);
690
691        let active_fight = ActiveFight {
692            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
693            fight_id: *fight_template_id,
694            current_wave: 1,
695            player_id: player.id,
696            party_player_id,
697            entities,
698            max_duration_ticks: fight.max_duration_ticks,
699            fight_stopped: false,
700            fight_ended: false,
701            dungeon: Some(ActiveDungeon {
702                id: dungeon_id,
703                difficulty,
704            }),
705            paused: false,
706            pet_combat_state,
707            leader_pet_template_id,
708        };
709
710        state.active_fight = Some(active_fight.clone());
711
712        if let Err(err) =
713            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
714        {
715            tracing::error!("Error running pvp prepare_fight script: {err:?}");
716            return None;
717        }
718
719        self.schedule_start_fight(fight, active_fight.id, &game_config);
720
721        Some(events)
722    }
723
724    fn handle_single_prepare_fight(
725        &mut self,
726        state: &mut OverlordState,
727        fight_template_id: FightTemplateId,
728        mut rand_gen: rand::rngs::StdRng,
729    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
730        let game_config = self.game_config.get();
731
732        let Ok(fight) = game_config.require_fight_template(fight_template_id) else {
733            tracing::error!(
734                "Failed to get fight_template with id {} ",
735                fight_template_id
736            );
737            return None;
738        };
739
740        let mut events = vec![];
741
742        let mut entities = vec![];
743
744        let (player, party_player_id) =
745            match self.generate_pve_fight_entities(&mut entities, fight, state, &mut rand_gen) {
746                Ok(result) => result,
747                Err(err) => {
748                    tracing::error!("Got error, while creating entities: {}", err);
749                    return None;
750                }
751            };
752
753        let (pet_combat_state, leader_pet_template_id) = self.build_pet_combat_state(state);
754
755        let active_fight = ActiveFight {
756            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
757            fight_id: fight_template_id,
758            current_wave: 1,
759            player_id: player.id,
760            party_player_id,
761            entities,
762            max_duration_ticks: fight.max_duration_ticks,
763            fight_stopped: false,
764            paused: false,
765            fight_ended: false,
766            dungeon: None,
767            pet_combat_state,
768            leader_pet_template_id,
769        };
770
771        state.active_fight = Some(active_fight.clone());
772
773        if let Err(err) =
774            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
775        {
776            tracing::error!("Error running single prepare_fight script: {err:?}");
777            return None;
778        }
779
780        self.schedule_start_fight(fight, active_fight.id, &game_config);
781
782        Some(events)
783    }
784
785    pub fn handle_start_fight(
786        &mut self,
787        event: OverlordEvent,
788        fight_id: uuid::Uuid,
789        // The native `fight_start` fns are RNG-free (init_fight + optional static
790        _rand_gen: rand::rngs::StdRng,
791        current_tick: u64,
792        mut state: OverlordState,
793    ) -> EventHandleResult<OverlordEvent, OverlordState> {
794        let game_config = self.game_config.get();
795
796        let Some(active_fight) = &mut state.active_fight else {
797            tracing::error!("No active fight for start_fight");
798            return EventHandleResult::fail(state);
799        };
800
801        if active_fight.id != fight_id {
802            tracing::error!(
803                "StartFight fight_id mismatch: expected {}, got {}",
804                active_fight.id,
805                fight_id
806            );
807            return EventHandleResult::fail(state);
808        }
809
810        let Ok(fight_template) = game_config.require_fight_template(active_fight.fight_id) else {
811            tracing::error!("No fight template with fight_id: {}", active_fight.fight_id);
812            return EventHandleResult::fail(state);
813        };
814
815        self.start_fight_tick = current_tick;
816
817        let mut events = vec![];
818
819        active_fight.entities.iter_mut().for_each(|e| {
820            e.abilities.iter_mut().for_each(|ability| {
821                e.actions_queue.push(&ActionWithDeadline {
822                    action: self.make_start_cast_ability_action(e.id, ability.ability.template_id),
823                    deadline_tick: current_tick,
824                })
825            })
826        });
827
828        // Emit StartCastAbility for support pets' passive abilities at fight start
829        let player_id = active_fight.player_id;
830        for pet in state.character_state.equipped_pets.supports() {
831            if let Some(ability_template) = pet
832                .passive_ability_id
833                .and_then(|id| game_config.ability_template(id))
834            {
835                let passive_ability =
836                    essences::abilities::Ability::from_template(ability_template, None, None);
837                let passive_ability_id = passive_ability.template_id;
838
839                // Add passive as a one-shot ability on the player entity
840                if let Some(player_entity) =
841                    active_fight.entities.iter_mut().find(|e| e.id == player_id)
842                {
843                    player_entity
844                        .abilities
845                        .push(essences::abilities::ActiveAbility {
846                            ability: passive_ability,
847                            deadline: None,
848                            slot_id: None,
849                        });
850                    player_entity.actions_queue.push(&ActionWithDeadline {
851                        action: EntityAction::StartCastAbility {
852                            ability_id: passive_ability_id,
853                            by_entity_id: player_id,
854                            pet_id: Some(pet.template_id),
855                        },
856                        deadline_tick: current_tick,
857                    });
858                }
859            }
860        }
861
862        active_fight.max_duration_ticks = fight_template.max_duration_ticks;
863
864        let active_fight_for_native = active_fight.clone();
865        let start_behavior = fight_template.start_behavior.clone();
866        // scope; the native `fight_start` fns read it from `Fight`/`State`, so
867        // the StartFight event itself is no longer an input.
868        let _ = event;
869
870        // Native `fight_start` port: look up the named fn on the registry and
871        // drive it with `FightStartCtx`. Falls back to no events when no native
872        // ref / fn is present.
873        let Some(native_name) = start_behavior.as_deref() else {
874            tracing::error!("Fight template {} has no start_behavior", fight_template.id);
875            return EventHandleResult::fail(state);
876        };
877        let Some(native_fn) = self.behaviors.fight_start_fn(native_name) else {
878            tracing::error!("No registered fight_start native fn named {native_name}");
879            return EventHandleResult::fail(state);
880        };
881        match native_fn(&crate::behaviors::combat::fight_start::FightStartCtx {
882            fight: &active_fight_for_native,
883            state: &state,
884            lookups: self.behaviors.lookups(),
885        }) {
886            Ok(start_fight_events) => {
887                events.append(
888                    &mut start_fight_events
889                        .into_iter()
890                        .map(EventPluginized::now)
891                        .collect(),
892                );
893            }
894            Err(err) => {
895                tracing::error!("Start fight native fn failed with error: {err:?}");
896                return EventHandleResult::fail(state);
897            }
898        };
899
900        EventHandleResult::ok_events(state, events)
901    }
902
903    pub fn get_prepare_fight_delay(
904        &self,
905        is_win: bool,
906        chapter: &Chapter,
907        state: &OverlordState,
908    ) -> u64 {
909        let game_config = self.game_config.get();
910
911        let Some(fight_id) = chapter
912            .fight_ids
913            .get(state.character_state.character.current_fight_number as usize)
914            .cloned()
915        else {
916            tracing::error!(
917                "Failed to get fight {} for chapter_level={}",
918                state.character_state.character.current_fight_number,
919                state.character_state.character.current_chapter_level
920            );
921            return 0;
922        };
923
924        let Ok(fight) = game_config.require_fight_template(fight_id) else {
925            tracing::error!("Failed to get fight_template with id {} ", fight_id,);
926            return 0;
927        };
928
929        if is_win {
930            fight.prepare_fight_win_duration_ticks.unwrap_or(
931                game_config
932                    .fight_settings
933                    .prepare_fight_win_delay_ticks_default,
934            )
935        } else {
936            fight.prepare_fight_lose_duration_ticks.unwrap_or(
937                game_config
938                    .fight_settings
939                    .prepare_fight_lose_delay_ticks_default,
940            )
941        }
942    }
943
944    pub fn get_end_fight_delay(&self, fight_id: FightTemplateId) -> u64 {
945        let game_config = self.game_config.get();
946
947        let Ok(fight) = game_config.require_fight_template(fight_id) else {
948            tracing::error!("Failed to get fight_template with id {} ", fight_id,);
949            return 0;
950        };
951
952        fight
953            .end_fight_delay_ticks
954            .unwrap_or(game_config.fight_settings.end_fight_delay_ticks_default)
955    }
956
957    /// Shared end-of-fight tail: keep the loop going by scheduling the next
958    /// PrepareFight, or stop fighting when the template says so.
959    /// If (is_win && stop_on_win) or (!is_win && stop_on_lose) - we stop fighting
960    fn continue_or_stop_fight_loop(
961        &mut self,
962        state: &mut OverlordState,
963        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
964        current_fight: &FightTemplate,
965        is_win: bool,
966        prepare_fight_delay_ticks: u64,
967    ) {
968        if (!is_win || !current_fight.stop_on_win) && (is_win || !current_fight.stop_on_lose) {
969            events.push(EventPluginized::now(
970                OverlordEvent::RefreshPartyMemberState {},
971            ));
972            self.fight_clock.schedule(
973                OverlordEvent::PrepareFight {
974                    prepare_fight_type: PrepareFightType::PVEFight,
975                },
976                prepare_fight_delay_ticks,
977            );
978        } else if let Some(fight) = state.active_fight.as_mut() {
979            fight.fight_stopped = true;
980        }
981    }
982
983    fn end_pvp_fight(
984        &mut self,
985        is_win: bool,
986        pvp_state: &PVPState,
987        prepare_fight_delay_ticks: u64,
988        current_fight: &FightTemplate,
989        mut state: OverlordState,
990    ) -> EventHandleResult<OverlordEvent, OverlordState> {
991        let mut events = vec![];
992        if let Some(vassal) = &pvp_state.vassal {
993            if is_win {
994                state.character_state.vassals.push(vassal.clone());
995                if let Some(bundle_id) = current_fight.bundle_reward_id {
996                    events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
997                        bundle_ids: vec![bundle_id],
998                        source: essences::currency::CurrencySource::PvpVassalReward,
999                    }));
1000                }
1001            } else {
1002                tracing::error!("Expected to add vassal, but PVP is lost");
1003            }
1004        }
1005
1006        if let Some(rating_change) = &pvp_state.rating_change {
1007            if is_win {
1008                state.character_state.character.arena_rating +=
1009                    rating_change.winner_rating_increase;
1010            } else {
1011                state.character_state.character.arena_rating += rating_change.loser_rating_decrease;
1012            }
1013        }
1014
1015        state.pvp_state = None;
1016
1017        self.continue_or_stop_fight_loop(
1018            &mut state,
1019            &mut events,
1020            current_fight,
1021            is_win,
1022            prepare_fight_delay_ticks,
1023        );
1024
1025        EventHandleResult::ok_events(state, events)
1026    }
1027
1028    fn end_pve_fight(
1029        &mut self,
1030        is_win: bool,
1031        current_chapter: &Chapter,
1032        prepare_fight_delay_ticks: u64,
1033        current_fight: &FightTemplate,
1034        mut state: OverlordState,
1035    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1036        let game_config = self.game_config.get();
1037
1038        let mut events = vec![];
1039
1040        let prev_chapter_level = state.character_state.character.current_chapter_level;
1041        let mut next_chapter_level = prev_chapter_level;
1042        let mut next_fight_number = state.character_state.character.current_fight_number + 1;
1043
1044        if is_win {
1045            if next_fight_number >= current_chapter.fight_ids.len() as i64 {
1046                next_fight_number = 0;
1047                if game_config
1048                    .chapter_by_level(next_chapter_level + 1)
1049                    .is_some()
1050                {
1051                    state.character_state.character.last_boss_fight_won = true;
1052                    next_chapter_level += 1;
1053                }
1054            } else {
1055                let next_fight_id = &current_chapter.fight_ids[next_fight_number as usize];
1056
1057                let Ok(next_fight) = game_config.require_fight_template(*next_fight_id) else {
1058                    tracing::error!(
1059                        "Failed to get next fight_template with id={}",
1060                        next_fight_id
1061                    );
1062                    return EventHandleResult::fail(state);
1063                };
1064
1065                // Uncomment to restore the old behavior where losing the boss
1066                // gates auto-progression wave -> boss until a RetryBossFight event.
1067                // if !state.character_state.character.last_boss_fight_won
1068                //     && next_fight.fight_type == FightType::CampaignBossFight
1069                // {
1070                //     next_fight_number = 0;
1071                // }
1072                let _ = next_fight;
1073            }
1074
1075            state.character_state.character.current_chapter_level = next_chapter_level;
1076            state.character_state.character.current_fight_number = next_fight_number;
1077
1078            if self.should_reset_afk_timer_on_gating_unlock(prev_chapter_level, &state) {
1079                events.push(EventPluginized::now(
1080                    OverlordEvent::AfkRewardsGatingUnlocked {},
1081                ));
1082            }
1083
1084            if !state.character_state.character.rate_us_shown
1085                && let Some(threshold) = game_config.game_settings.rate_us_chapter
1086                && prev_chapter_level < threshold
1087                && next_chapter_level >= threshold
1088            {
1089                events.push(EventPluginized::now(OverlordEvent::ShowRateUs {}));
1090                tracing::info!(
1091                    target: METRICS_TARGET,
1092                    event_type = "show_rate_us",
1093                    character_id = %state.character_state.character.id,
1094                    chapter_level = next_chapter_level,
1095                    trigger = "chapter_advance",
1096                    "Show rate us",
1097                );
1098            }
1099
1100            if let Some(bundle_id) = current_fight.bundle_reward_id {
1101                events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
1102                    bundle_ids: vec![bundle_id],
1103                    source: essences::currency::CurrencySource::ChapterReward,
1104                }));
1105            }
1106        } else {
1107            if current_fight.fight_type == FightType::CampaignBossFight {
1108                state.character_state.character.last_boss_fight_won = false;
1109            }
1110
1111            next_fight_number = 0;
1112            state.character_state.character.current_fight_number = next_fight_number;
1113        }
1114
1115        self.continue_or_stop_fight_loop(
1116            &mut state,
1117            &mut events,
1118            current_fight,
1119            is_win,
1120            prepare_fight_delay_ticks,
1121        );
1122
1123        EventHandleResult::ok_events(state, events)
1124    }
1125
1126    fn end_dungeon_fight(
1127        &mut self,
1128        is_win: bool,
1129        prepare_fight_delay_ticks: u64,
1130        active_dungeon: &ActiveDungeon,
1131        current_fight: &FightTemplate,
1132        mut state: OverlordState,
1133    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1134        let game_config = self.game_config.get();
1135
1136        let Ok(dungeon) = game_config.require_dungeon_template(active_dungeon.id) else {
1137            tracing::error!(
1138                "Failed to get dungeon_template with id {} ",
1139                active_dungeon.id
1140            );
1141            return EventHandleResult::fail(state);
1142        };
1143
1144        let mut events = vec![];
1145
1146        if is_win {
1147            let keys_price = vec![CurrencyUnit {
1148                currency_id: dungeon.key_currency_id,
1149                amount: 1,
1150            }];
1151
1152            let Some(currency_event) =
1153                Self::currency_decrease(&state, &keys_price, CurrencyConsumer::DungeonFightEnd)
1154            else {
1155                return EventHandleResult::fail(state);
1156            };
1157            events.push(currency_event);
1158
1159            if let Some(bundle_id) = current_fight.bundle_reward_id {
1160                events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
1161                    bundle_ids: vec![bundle_id],
1162                    source: essences::currency::CurrencySource::DungeonReward,
1163                }));
1164            }
1165
1166            state
1167                .dungeons
1168                .completed_difficulties
1169                .entry(dungeon.id)
1170                .and_modify(|v| {
1171                    if active_dungeon.difficulty > *v {
1172                        *v = active_dungeon.difficulty;
1173                    }
1174                })
1175                .or_insert(active_dungeon.difficulty);
1176        }
1177
1178        self.continue_or_stop_fight_loop(
1179            &mut state,
1180            &mut events,
1181            current_fight,
1182            is_win,
1183            prepare_fight_delay_ticks,
1184        );
1185
1186        EventHandleResult::ok_events(state, events)
1187    }
1188
1189    /// Pure predicate: is the chapter transition crossing the AFK rewards unlock
1190    /// threshold *and* is the elapsed time since the last claim shorter than
1191    /// `min_required_time_sec`? Callers use this to decide whether to emit
1192    /// `AfkRewardsGatingUnlocked`; the async handler for that event owns the
1193    /// actual state mutation.
1194    pub(crate) fn should_reset_afk_timer_on_gating_unlock(
1195        &self,
1196        prev_chapter_level: i64,
1197        state: &OverlordState,
1198    ) -> bool {
1199        let game_config = self.game_config.get();
1200        let unlock_chapter = game_config.gatings.afk_rewards_button_unlock_chapter;
1201        let new_chapter_level = state.character_state.character.current_chapter_level;
1202
1203        if prev_chapter_level >= unlock_chapter || new_chapter_level < unlock_chapter {
1204            return false;
1205        }
1206
1207        let min_required_time_sec = game_config.afk_rewards_settings.min_required_time_sec as i64;
1208        let elapsed = ::time::utc_now()
1209            .signed_duration_since(state.character_state.character.last_afk_reward_claimed_at)
1210            .num_seconds();
1211
1212        elapsed < min_required_time_sec
1213    }
1214
1215    pub fn handle_afk_rewards_gating_unlocked(
1216        &self,
1217        mut state: OverlordState,
1218    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1219        let min_required_time_sec = self
1220            .game_config
1221            .get()
1222            .afk_rewards_settings
1223            .min_required_time_sec as i64;
1224        state.character_state.character.last_afk_reward_claimed_at =
1225            ::time::utc_now() - chrono::Duration::seconds(min_required_time_sec);
1226        EventHandleResult::ok(state)
1227    }
1228
1229    pub fn handle_end_fight(
1230        &mut self,
1231        fight_id: uuid::Uuid,
1232        is_win: bool,
1233        pvp_state: Option<&PVPState>,
1234        mut state: OverlordState,
1235    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1236        let game_config = self.game_config.get();
1237
1238        let Some(active_fight) = &mut state.active_fight else {
1239            tracing::error!("No active fight for end_fight");
1240            return EventHandleResult::fail(state);
1241        };
1242
1243        if active_fight.id != fight_id {
1244            tracing::error!(
1245                "EndFight fight_id mismatch: expected {}, got {}",
1246                active_fight.id,
1247                fight_id
1248            );
1249            return EventHandleResult::fail(state);
1250        }
1251
1252        if self.ended_fight_id == Some(fight_id) {
1253            // A duplicate EndFight for the same fight is an expected race:
1254            // the max-duration timeout and an in-flight combat event (e.g. a
1255            // projectile killing the last entity) can each schedule one. The
1256            // end-of-fight pipeline (PvP rating, vassal, reward bundles,
1257            // chapter advance) must run exactly once per fight. (The
1258            // `fight_ended` flag can't be the guard here: producers set it
1259            // before scheduling the EndFight event.)
1260            tracing::info!("Fight {fight_id} already ended, ignoring duplicate EndFight");
1261            return EventHandleResult::fail(state);
1262        }
1263
1264        let Ok(current_fight) = game_config.require_fight_template(active_fight.fight_id) else {
1265            tracing::error!(
1266                "Failed to get currrent fight_template with id={}",
1267                active_fight.fight_id
1268            );
1269            return EventHandleResult::fail(state);
1270        };
1271
1272        active_fight.fight_ended = true;
1273        self.ended_fight_id = Some(fight_id);
1274
1275        if current_fight.fight_type == FightType::SingleFight {
1276            self.fight_clock.schedule(
1277                OverlordEvent::PrepareFight {
1278                    prepare_fight_type: PrepareFightType::PVEFight,
1279                },
1280                game_config
1281                    .fight_settings
1282                    .prepare_fight_win_delay_ticks_default,
1283            );
1284            return EventHandleResult::ok_events(
1285                state,
1286                vec![EventPluginized::now(
1287                    OverlordEvent::RefreshPartyMemberState {},
1288                )],
1289            );
1290        }
1291
1292        let active_fight = active_fight.clone();
1293
1294        let Ok(current_chapter) = game_config
1295            .require_chapter_by_level(state.character_state.character.current_chapter_level)
1296            .cloned()
1297        else {
1298            tracing::error!(
1299                "Failed to get chapter with chapter_level={}",
1300                state.character_state.character.current_chapter_level
1301            );
1302            return EventHandleResult::fail(state);
1303        };
1304
1305        let prepare_fight_delay_ticks =
1306            self.get_prepare_fight_delay(is_win, &current_chapter, &state);
1307
1308        if let Some(pvp_state) = pvp_state {
1309            return self.end_pvp_fight(
1310                is_win,
1311                pvp_state,
1312                prepare_fight_delay_ticks,
1313                current_fight,
1314                state,
1315            );
1316        }
1317
1318        if let Some(active_dungeon) = &active_fight.dungeon {
1319            return self.end_dungeon_fight(
1320                is_win,
1321                prepare_fight_delay_ticks,
1322                active_dungeon,
1323                current_fight,
1324                state,
1325            );
1326        }
1327
1328        self.end_pve_fight(
1329            is_win,
1330            &current_chapter,
1331            prepare_fight_delay_ticks,
1332            current_fight,
1333            state,
1334        )
1335    }
1336}