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, EntityId, EntityState},
13    fighting::{
14        ActiveDungeon, ActiveFight, EntityTeam, EntityType, FightTemplate, FightTemplateId,
15        FightType,
16    },
17    flip::WorldSide,
18    game::Chapter,
19    pvp::PVPState,
20};
21
22use analytics::constants::METRICS_TARGET;
23use configs::game_config::GameConfig;
24use event_system::{event::EventPluginized, script::random::GameRng, system::EventHandleResult};
25use rand::RngExt;
26
27/// Minimum interval between party-ally DB refreshes, in game seconds
28/// (matching the party action cooldown).
29const PARTY_REFRESH_INTERVAL_SEC: i64 = 30;
30
31/// How many campaign-boss first clears in a row have failed to roll a Mastery
32/// Crystal. Lives in `custom_values` — one integer of progression state, which
33/// does not deserve a column of its own.
34const MASTERY_CRYSTAL_MISSES: &str = "mastery_crystal_misses";
35
36/// The one-time Core Essence unlock packet for a fresh campaign crossing.
37/// Keeping the crossing predicate and the authored payload together makes the
38/// no-reconnect/no-double-grant invariant directly testable.
39fn core_unlock_essence_grant_event(
40    game_config: &GameConfig,
41    prev_chapter_level: i64,
42    next_chapter_level: i64,
43) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
44    let settings = &game_config.cores_settings;
45    if prev_chapter_level >= settings.unlock_chapter
46        || next_chapter_level < settings.unlock_chapter
47        || settings.unlock_essence_grant <= 0
48    {
49        return None;
50    }
51
52    Some(EventPluginized::now(OverlordEvent::CurrencyIncrease {
53        currencies: vec![CurrencyUnit {
54            currency_id: settings.upgrade_currency_id,
55            amount: settings.unlock_essence_grant,
56        }],
57        currency_source: essences::currency::CurrencySource::ChapterReward,
58    }))
59}
60
61impl OverlordLogic {
62    /// Returns the `RefreshPartyMemberState` event if the player has a party
63    /// ally and the TTL since the last refresh has elapsed. Updates the
64    /// last-refresh time when it decides to emit.
65    ///
66    /// The refresh reloads the ally's whole character sheet from the DB, so
67    /// emitting it after every fight is the dominant DB cost of a partied
68    /// session. An ally's power drifts well under 1% in 30 seconds.
69    fn maybe_refresh_party_event(
70        &mut self,
71        state: &OverlordState,
72    ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
73        state.character_state.character.party_character_id?;
74
75        let now = ::time::utc_now();
76        if self.last_party_refresh_at.is_some_and(|last| {
77            now.signed_duration_since(last).num_seconds() < PARTY_REFRESH_INTERVAL_SEC
78        }) {
79            return None;
80        }
81
82        self.last_party_refresh_at = Some(now);
83        Some(EventPluginized::now(
84            OverlordEvent::RefreshPartyMemberState {},
85        ))
86    }
87
88    /// Arena world switch (select-opponent screen): the side this player
89    /// STARTS arena fights on — both as the attacker and as the snapshot
90    /// opponent in other players' fights. Only meaningful once the flip is
91    /// unlocked; before that there is no switch to set.
92    pub fn handle_set_arena_world_side(
93        &self,
94        side: WorldSide,
95        mut state: OverlordState,
96    ) -> EventHandleResult<OverlordEvent, OverlordState> {
97        let game_config = self.game_config.get();
98        if !game_config
99            .flip_settings
100            .is_unlocked(state.character_state.character.current_chapter_level)
101        {
102            tracing::warn!(
103                "SetArenaWorldSide rejected: flip locked at chapter {}",
104                state.character_state.character.current_chapter_level
105            );
106            return EventHandleResult::fail(state);
107        }
108
109        state.character_state.character.arena_world_side = Some(side);
110        EventHandleResult::ok(state)
111    }
112
113    pub fn is_battle_active(&self, state: &OverlordState) -> bool {
114        if let Some(active_fight) = &state.active_fight {
115            let has_player = active_fight
116                .entities
117                .iter()
118                .any(|e| e.id == active_fight.player_id);
119
120            let has_enemy = active_fight
121                .entities
122                .iter()
123                .any(|e| e.team == EntityTeam::Enemy);
124
125            has_player && has_enemy
126        } else {
127            false
128        }
129    }
130
131    pub fn handle_prepare_fight(
132        &mut self,
133        prepare_fight_type: PrepareFightType,
134        rand_gen: rand::rngs::StdRng,
135        mut state: OverlordState,
136    ) -> EventHandleResult<OverlordEvent, OverlordState> {
137        let game_config = self.game_config.get();
138
139        if state.pvp_state.is_some() {
140            return EventHandleResult::fail(state);
141        }
142
143        let is_retry_boss_fight = matches!(prepare_fight_type, PrepareFightType::RetryBossFight);
144
145        match prepare_fight_type {
146            PrepareFightType::PVEFight => {
147                if !self.validate_pve_fight(&state) {
148                    return EventHandleResult::fail(state);
149                }
150            }
151            PrepareFightType::PVPFight { .. } => {}
152            PrepareFightType::RetryBossFight => {
153                if !self.validate_prepare_retry_boss_fight(&state) {
154                    return EventHandleResult::fail(state);
155                }
156            }
157            PrepareFightType::DungeonFight {
158                dungeon_id,
159                difficulty,
160            } => {
161                if !self.validate_dungeon_fight(&state, dungeon_id, difficulty) {
162                    return EventHandleResult::fail(state);
163                }
164            }
165            PrepareFightType::ForfeitDungeonFight => {}
166            PrepareFightType::SingleFight { .. } => {}
167        };
168
169        // Item TTL: remove expired items here, at the start of the next fight,
170        // before its power snapshot is built — so they never drop mid-fight.
171        // Snapshot the inventory only when something is removed, so a failed
172        // prepare below can roll it back — else it would strip items without
173        // emitting ItemsExpired and orphan their DB rows.
174        //
175        // Skipped for PvP: the fight is prepared twice — once by the
176        // precalculation that decides it, once live for the player to watch —
177        // and each run reads the wall clock separately. An item expiring in
178        // between would change the power snapshot of only one of them. Expired
179        // items are swept at the next PvE prepare.
180        let is_pvp = matches!(prepare_fight_type, PrepareFightType::PVPFight { .. });
181        let now = ::time::utc_now();
182        let pre_sweep_inventory = (!is_pvp)
183            .then(|| {
184                state
185                    .character_state
186                    .inventory
187                    .iter()
188                    .any(|item| item.expires_at.is_some_and(|exp| exp <= now))
189                    .then(|| state.character_state.inventory.clone())
190            })
191            .flatten();
192        let expiry_events = if is_pvp {
193            Vec::new()
194        } else {
195            super::items::sweep_expired_items(&mut state, now)
196        };
197
198        // The previous fight (if any) is over: drop its pending combat events
199        // so the new fight schedules onto a clean clock. The saved clock is
200        // restored on every failure exit below, so a failed PrepareFight can't
201        // kill the running fight's heartbeat and pending scheduling.
202        // The FightProgress heartbeat is re-installed below on success.
203        let saved_clock = self.fight_clock.clone();
204        self.fight_clock.clear();
205        state.active_fight = None;
206
207        let prepare_fight_events = match prepare_fight_type {
208            PrepareFightType::PVEFight => self.handle_pve_prepare_fight(&mut state, rand_gen),
209            PrepareFightType::ForfeitDungeonFight => {
210                self.handle_pve_prepare_fight(&mut state, rand_gen)
211            }
212            PrepareFightType::PVPFight {
213                fight_id,
214                pvp_state,
215            } => self.handle_pvp_prepare_fight(&mut state, fight_id, pvp_state, rand_gen),
216            PrepareFightType::RetryBossFight => {
217                match game_config
218                    .require_chapter_by_level(state.character_state.character.current_chapter_level)
219                {
220                    Ok(chapter) => {
221                        state.character_state.character.current_fight_number =
222                            (chapter.fight_ids.len() - 1) as i64;
223                        self.handle_pve_prepare_fight(&mut state, rand_gen)
224                    }
225                    Err(_) => {
226                        tracing::error!(
227                            "Failed to get chapter with chapter_level={}",
228                            state.character_state.character.current_chapter_level
229                        );
230                        None
231                    }
232                }
233            }
234            PrepareFightType::DungeonFight {
235                dungeon_id,
236                difficulty,
237            } => self.handle_dungeon_prepare_fight(&mut state, dungeon_id, difficulty, rand_gen),
238            PrepareFightType::SingleFight { fight_templated_id } => {
239                self.handle_single_prepare_fight(&mut state, fight_templated_id, rand_gen)
240            }
241        };
242
243        let Some(events) = prepare_fight_events else {
244            tracing::error!("Preparing fight failed, something went wrong");
245            self.fight_clock = saved_clock;
246            if let Some(inventory) = pre_sweep_inventory {
247                state.character_state.inventory = inventory;
248            }
249            return EventHandleResult::fail(state);
250        };
251
252        if state.active_fight.is_none() {
253            tracing::error!("No active fight after preparing fight");
254            self.fight_clock = saved_clock;
255            if let Some(inventory) = pre_sweep_inventory {
256                state.character_state.inventory = inventory;
257            }
258            return EventHandleResult::fail(state);
259        };
260
261        if is_retry_boss_fight {
262            // Consume the boss retry only once the prepare succeeded: failure
263            // results still apply their state, and a prematurely-set flag
264            // makes `validate_prepare_retry_boss_fight` reject every further
265            // retry even though the boss fight was never restarted.
266            state.character_state.character.last_boss_fight_won = true;
267        }
268
269        self.fight_clock.set_heartbeat(
270            OverlordEvent::FightProgress {},
271            game_config.game_settings.fight_progress_tick,
272        );
273
274        // Emit the chapter-start expiry events ahead of the fight's own events.
275        let mut events = events;
276        events.splice(0..0, expiry_events);
277
278        EventHandleResult::ok_events(state, events)
279    }
280
281    fn validate_pve_fight(&self, state: &OverlordState) -> bool {
282        if let Some(active_fight) = &state.active_fight
283            && !active_fight.fight_ended
284            && active_fight.dungeon.is_some()
285        {
286            tracing::error!("Dungeon fight is in progress");
287            return false;
288        };
289
290        true
291    }
292
293    fn validate_prepare_retry_boss_fight(&self, state: &OverlordState) -> bool {
294        let game_config = self.game_config.get();
295
296        // These validation failures are expected in normal client-server
297        // async play: the server state has already moved on while the
298        // client's cached state is a few state_patches behind. They should
299        // log at info, not error — otherwise they bury real bugs in error
300        // metrics/alerts during every boss defeat or fight overlap.
301        if state.character_state.character.last_boss_fight_won {
302            tracing::info!("The last boss fight was won, so we can't try to retry boss fight");
303            return false;
304        }
305
306        let Some(active_fight) = &state.active_fight else {
307            return true;
308        };
309
310        if active_fight.dungeon.is_some() {
311            tracing::info!("Dungeon fight is in progress");
312            return false;
313        }
314
315        let Ok(fight) = game_config.require_fight_template(active_fight.fight_id) else {
316            tracing::error!(
317                "Failed to get fight_template with id {} ",
318                active_fight.fight_id,
319            );
320            return false;
321        };
322
323        if fight.fight_type == FightType::CampaignBossFight && self.is_battle_active(state) {
324            tracing::info!("The current fight is a boss fight, so we can't retry a boss fight");
325            return false;
326        };
327
328        true
329    }
330
331    fn validate_dungeon_fight(
332        &self,
333        state: &OverlordState,
334        dungeon_id: DungeonTemplateId,
335        difficulty: i64,
336    ) -> bool {
337        let game_config = self.game_config.get();
338
339        if *state
340            .dungeons
341            .completed_difficulties
342            .get(&dungeon_id)
343            .unwrap_or(&0)
344            + 1
345            < difficulty
346        {
347            tracing::error!(
348                "Maximum available difficulty is {} ",
349                *state
350                    .dungeons
351                    .completed_difficulties
352                    .get(&dungeon_id)
353                    .unwrap_or(&0)
354                    + 1
355            );
356            return false;
357        }
358
359        let Ok(dungeon) = game_config.require_dungeon_template(dungeon_id) else {
360            tracing::error!("Failed to get dungeon_template with id {} ", dungeon_id);
361            return false;
362        };
363
364        if state.character_state.character.current_chapter_level < dungeon.chapter_level_unlock {
365            tracing::error!(
366                "Current chapter level is: {}, required is {} ",
367                state.character_state.character.current_chapter_level,
368                dungeon.chapter_level_unlock
369            );
370            return false;
371        }
372
373        if difficulty > dungeon.max_difficulty_level {
374            tracing::error!(
375                "Maximum difficulty for dungeon with id: {}, is {} ",
376                dungeon_id,
377                dungeon.max_difficulty_level
378            );
379            return false;
380        }
381
382        // Difficulties open one at a time (BAL-036 source audit). The server owns
383        // that rule: without it a client could enter any difficulty directly, and
384        // the first-clear bonus — which is keyed on the highest difficulty
385        // cleared — would silently skip every difficulty jumped over.
386        let completed_difficulty = *state
387            .dungeons
388            .completed_difficulties
389            .get(&dungeon_id)
390            .unwrap_or(&0);
391        if difficulty > completed_difficulty + 1 {
392            tracing::error!(
393                "Dungeon {dungeon_id} difficulty {difficulty} is not open yet; \
394                 highest cleared is {completed_difficulty}"
395            );
396            return false;
397        }
398
399        let keys_required = vec![CurrencyUnit {
400            currency_id: dungeon.key_currency_id,
401            amount: 1,
402        }];
403
404        if !check_can_decrease_currencies(&state.character_state.currencies, &keys_required) {
405            tracing::error!("Not enough keys for running dungeon: {}", dungeon_id);
406            return false;
407        }
408
409        true
410    }
411
412    fn generate_pve_fight_entities(
413        &self,
414        entities: &mut Vec<Entity>,
415        fight: &FightTemplate,
416        state: &mut OverlordState,
417        rand_gen: &mut rand::rngs::StdRng,
418    ) -> anyhow::Result<(Entity, Option<EntityId>)> {
419        let game_config = self.game_config.get();
420
421        fight.fight_entities.iter().for_each(|entity| {
422            let created_entity = match entities::create_pve_entity(
423                uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
424                entity,
425                &game_config,
426                None,
427            ) {
428                Ok(entity) => entity,
429                Err(err) => {
430                    tracing::error!("Failed creating entity {}", err.to_string());
431                    return;
432                }
433            };
434
435            entities.push(created_entity);
436        });
437
438        // BAL-015: the durable gauge mirrors the fight-local one, so it resets
439        // with it — the client never renders last fight's leftover charge.
440        state.flip_state.progress = 0.0;
441        let player = match entities::create_player_entity(
442            &state.character_state,
443            state.flip_state,
444            uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
445            &game_config,
446        ) {
447            Ok(entity) => entity,
448            Err(err) => {
449                anyhow::bail!("Failed creating player entity {}", err);
450            }
451        };
452
453        entities.push(player.clone());
454
455        let mut party_player_id = None;
456        if let Some(party_state) = &state.party.party_state {
457            match entities::create_party_entity(
458                party_state,
459                state.party.party_flip_state.unwrap_or_default(),
460                uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
461                &game_config,
462            ) {
463                Ok(party_ally) => {
464                    party_player_id = Some(party_ally.id);
465                    entities.push(party_ally);
466                }
467                Err(err) => {
468                    tracing::error!("Failed creating party ally entity {}", err);
469                }
470            }
471        }
472
473        entities::stamp_gauge_hp_shares(entities);
474        Ok((player, party_player_id))
475    }
476
477    fn generate_pvp_fight_entities(
478        &self,
479        entities: &mut Vec<Entity>,
480        fight: &FightTemplate,
481        pvp_state: &PVPState,
482        state: &mut OverlordState,
483        rand_gen: &mut rand::rngs::StdRng,
484    ) -> anyhow::Result<(Entity, Option<EntityId>)> {
485        let game_config = self.game_config.get();
486
487        fight.fight_entities.iter().for_each(|entity| {
488            let created_entity = match entity.entity_type {
489                EntityType::PVEEntity {
490                    entity_template_id: _,
491                } => {
492                    match entities::create_pve_entity(
493                        uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
494                        entity,
495                        &game_config,
496                        None,
497                    ) {
498                        Ok(entity) => entity,
499                        Err(err) => {
500                            tracing::error!("{}", err.to_string());
501                            return;
502                        }
503                    }
504                }
505                EntityType::PVPEntity => {
506                    match entities::create_pvp_entity(
507                        &EntityState::Opponent(&pvp_state.opponent_state),
508                        pvp_state.opponent_flip_state,
509                        entity,
510                        &game_config,
511                    ) {
512                        Ok(entity) => entity,
513                        Err(err) => {
514                            tracing::error!("{}", err.to_string());
515                            return;
516                        }
517                    }
518                }
519            };
520
521            entities.push(created_entity);
522        });
523
524        // BAL-015: the durable gauge mirrors the fight-local one, so it resets
525        // with it — the client never renders last fight's leftover charge.
526        state.flip_state.progress = 0.0;
527        // Arena world switch: PvP fights start on the side the player picked on
528        // the select-opponent screen, not on the durable flip side. Only the
529        // fight-local seed is overridden — the durable state keeps evolving
530        // through in-fight flips as usual.
531        let mut player_flip_state = state.flip_state;
532        if let Some(side) = state.character_state.character.arena_world_side {
533            player_flip_state.active_side = side;
534        }
535        let player = match entities::create_player_entity(
536            &state.character_state,
537            player_flip_state,
538            uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
539            &game_config,
540        ) {
541            Ok(entity) => entity,
542            Err(err) => {
543                anyhow::bail!("Failed creating player entity {}", err);
544            }
545        };
546
547        entities.push(player.clone());
548
549        // No party ally in PvP — arena is strictly 1v1
550        let party_player_id = None;
551
552        entities::stamp_gauge_hp_shares(entities);
553        Ok((player, party_player_id))
554    }
555
556    fn run_prepare_fight_script(
557        &self,
558        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
559        fight: &FightTemplate,
560        active_fight: &ActiveFight,
561        state: &mut OverlordState,
562        rand_gen: rand::rngs::StdRng,
563    ) -> anyhow::Result<()> {
564        let game_config = self.game_config.get();
565        let current_chapter = state.character_state.character.current_chapter_level;
566
567        // Native `prepare_fight` interpreter: spawn the initial wave from the
568        // typed `prepare_fight_waves` config (the native data source), replacing
569        // migrated next-wave path in `logic::fighting`.
570        //
571        // `base_power` was a literal argument of the legacy `spawn_wave(...)`
572        // script, transpiled at deploy time from the template's TOP-LEVEL
573        // `power` field (`$.power`). The earlier port passed
574        // `wave_data.power` — the waves-blob's inner `power`, an unrelated
575        // value that runs 4-26x larger on live campaign templates — which
576        // inflated every campaign mob's hp/attack by 2-5x vs the legacy
577        // engine. Pass the template's own `power`, like the scripts did.
578        // Templates that spawn their enemies directly via `fight_entities` carry
579        // optional extra wave-spawn step, not a requirement). Treat an absent
580        // wave config as "nothing to spawn here" instead of an error — the
581        // initial entities are already built by `generate_pve_fight_entities`.
582        let Some(waves_cfg) = fight.prepare_fight_waves.as_ref() else {
583            return Ok(());
584        };
585
586        let wave_data = crate::mechanics::fight::wave_data_from_config(waves_cfg);
587        let fight_type_str = format!("{:?}", fight.fight_type);
588        let mut sink = crate::mechanics::fight::NativeSink::default();
589        let rng = GameRng::new(rand_gen);
590        if let Err(err) = crate::mechanics::fight::spawn_wave(
591            &mut sink,
592            &rng,
593            &game_config,
594            self.behaviors.lookups(),
595            active_fight,
596            &wave_data,
597            fight.power.map(|p| p as f64).unwrap_or(0.0),
598            current_chapter,
599            &fight_type_str,
600        ) {
601            anyhow::bail!("Prepare fight wave spawn failed with error: {err:?}");
602        }
603
604        events.append(&mut sink.events.into_iter().map(EventPluginized::now).collect());
605
606        Ok(())
607    }
608
609    /// Schedule `StartFight` for a freshly prepared fight, honoring the
610    /// template's start delay override.
611    fn schedule_start_fight(
612        &mut self,
613        fight: &FightTemplate,
614        fight_id: uuid::Uuid,
615        game_config: &GameConfig,
616    ) {
617        let start_fight_delay = fight
618            .start_fight_delay_ticks
619            .unwrap_or(game_config.fight_settings.start_fight_delay_ticks_default);
620
621        self.fight_clock
622            .schedule(OverlordEvent::StartFight { fight_id }, start_fight_delay);
623    }
624
625    fn handle_pve_prepare_fight(
626        &mut self,
627        state: &mut OverlordState,
628        mut rand_gen: rand::rngs::StdRng,
629    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
630        let game_config = self.game_config.get();
631
632        let mut events = vec![];
633        let Ok(chapter) = game_config
634            .require_chapter_by_level(state.character_state.character.current_chapter_level)
635        else {
636            tracing::error!(
637                "Failed to get chapter with chapter_level={}",
638                state.character_state.character.current_chapter_level
639            );
640            return None;
641        };
642
643        let Some(fight_id) = chapter
644            .fight_ids
645            .get(state.character_state.character.current_fight_number as usize)
646            .cloned()
647        else {
648            tracing::error!(
649                "Failed to get fight {} for chapter_level={}",
650                state.character_state.character.current_fight_number,
651                state.character_state.character.current_chapter_level
652            );
653            return None;
654        };
655
656        let Ok(fight) = game_config.require_fight_template(fight_id) else {
657            tracing::error!("Failed to get fight_template with id {} ", fight_id,);
658            return None;
659        };
660
661        let mut entities = vec![];
662
663        let (player, party_player_id) =
664            match self.generate_pve_fight_entities(&mut entities, fight, state, &mut rand_gen) {
665                Ok(result) => result,
666                Err(err) => {
667                    tracing::error!("Got error, while creating PVE entities: {}", err);
668                    return None;
669                }
670            };
671
672        let active_fight = ActiveFight {
673            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
674            fight_id,
675            current_wave: 1,
676            player_id: player.id,
677            party_player_id,
678            entities,
679            max_duration_ticks: fight.max_duration_ticks,
680            fight_stopped: false,
681            fight_ended: false,
682            dungeon: None,
683            paused: false,
684            pending_wave_spawns: Vec::new(),
685            summoned_entity_ids: Vec::new(),
686        };
687
688        state.active_fight = Some(active_fight.clone());
689
690        if let Err(err) =
691            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
692        {
693            tracing::error!("Error running pve prepare_fight script: {err:?}");
694            return None;
695        }
696
697        self.schedule_start_fight(fight, active_fight.id, &game_config);
698
699        tracing::debug!(
700            "Starting chapter_level={}, fight_number={}, fight_id={}",
701            chapter.level,
702            state.character_state.character.current_fight_number,
703            fight_id,
704        );
705
706        Some(events)
707    }
708
709    fn handle_pvp_prepare_fight(
710        &mut self,
711        state: &mut OverlordState,
712        fight_id: FightTemplateId,
713        pvp_state: Box<PVPState>,
714        mut rand_gen: rand::rngs::StdRng,
715    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
716        let game_config = self.game_config.get();
717        // Applied to the state only after the fight is successfully built:
718        // failure results still apply their state, and a half-set `pvp_state`
719        // would make `handle_prepare_fight`'s entry guard reject every
720        // subsequent PrepareFight for the session.
721        let prepared_pvp_state = *pvp_state.clone();
722
723        let mut events = vec![];
724
725        let Ok(fight) = game_config.require_fight_template(fight_id) else {
726            tracing::error!("Failed to get fight_template with id {} ", fight_id);
727            return None;
728        };
729
730        let mut entities = vec![];
731
732        let (player, party_player_id) = match self.generate_pvp_fight_entities(
733            &mut entities,
734            fight,
735            &pvp_state,
736            state,
737            &mut rand_gen,
738        ) {
739            Ok(result) => result,
740            Err(err) => {
741                tracing::error!("Got error, while creating PVP entities: {}", err);
742                return None;
743            }
744        };
745
746        let active_fight = ActiveFight {
747            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
748            fight_id,
749            current_wave: 1,
750            player_id: player.id,
751            party_player_id,
752            entities,
753            max_duration_ticks: fight.max_duration_ticks,
754            fight_stopped: false,
755            fight_ended: false,
756            dungeon: None,
757            paused: false,
758            pending_wave_spawns: Vec::new(),
759            summoned_entity_ids: Vec::new(),
760        };
761
762        state.active_fight = Some(active_fight.clone());
763
764        if let Err(err) =
765            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
766        {
767            tracing::error!("Error running pvp prepare_fight script: {err:?}");
768            return None;
769        }
770
771        state.pvp_state = Some(prepared_pvp_state);
772
773        self.schedule_start_fight(fight, active_fight.id, &game_config);
774
775        Some(events)
776    }
777
778    fn handle_dungeon_prepare_fight(
779        &mut self,
780        state: &mut OverlordState,
781        dungeon_id: DungeonTemplateId,
782        difficulty: i64,
783        mut rand_gen: rand::rngs::StdRng,
784    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
785        let game_config = self.game_config.get();
786
787        let Ok(dungeon) = game_config.require_dungeon_template(dungeon_id) else {
788            tracing::error!("Failed to get dungeon_template with id {} ", dungeon_id);
789            return None;
790        };
791
792        let Some(fight_template_id) = dungeon.fight_template_ids.get((difficulty - 1) as usize)
793        else {
794            tracing::error!(
795                "Failed to get fight_template from dungeon with id {}, for difficulty: {}",
796                dungeon_id,
797                difficulty
798            );
799            return None;
800        };
801
802        let Ok(fight) = game_config.require_fight_template(*fight_template_id) else {
803            tracing::error!(
804                "Failed to get fight_template with id {} ",
805                fight_template_id
806            );
807            return None;
808        };
809
810        let mut events = vec![];
811
812        let mut entities = vec![];
813
814        let (player, party_player_id) =
815            match self.generate_pve_fight_entities(&mut entities, fight, state, &mut rand_gen) {
816                Ok(result) => result,
817                Err(err) => {
818                    tracing::error!("Got error, while creating entities: {}", err);
819                    return None;
820                }
821            };
822
823        let active_fight = ActiveFight {
824            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
825            fight_id: *fight_template_id,
826            current_wave: 1,
827            player_id: player.id,
828            party_player_id,
829            entities,
830            max_duration_ticks: fight.max_duration_ticks,
831            fight_stopped: false,
832            fight_ended: false,
833            dungeon: Some(ActiveDungeon {
834                id: dungeon_id,
835                difficulty,
836            }),
837            paused: false,
838            pending_wave_spawns: Vec::new(),
839            summoned_entity_ids: Vec::new(),
840        };
841
842        state.active_fight = Some(active_fight.clone());
843
844        if let Err(err) =
845            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
846        {
847            tracing::error!("Error running pvp prepare_fight script: {err:?}");
848            return None;
849        }
850
851        self.schedule_start_fight(fight, active_fight.id, &game_config);
852
853        Some(events)
854    }
855
856    fn handle_single_prepare_fight(
857        &mut self,
858        state: &mut OverlordState,
859        fight_template_id: FightTemplateId,
860        mut rand_gen: rand::rngs::StdRng,
861    ) -> Option<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
862        let game_config = self.game_config.get();
863
864        let Ok(fight) = game_config.require_fight_template(fight_template_id) else {
865            tracing::error!(
866                "Failed to get fight_template with id {} ",
867                fight_template_id
868            );
869            return None;
870        };
871
872        let mut events = vec![];
873
874        let mut entities = vec![];
875
876        let (player, party_player_id) =
877            match self.generate_pve_fight_entities(&mut entities, fight, state, &mut rand_gen) {
878                Ok(result) => result,
879                Err(err) => {
880                    tracing::error!("Got error, while creating entities: {}", err);
881                    return None;
882                }
883            };
884
885        let active_fight = ActiveFight {
886            id: uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid(),
887            fight_id: fight_template_id,
888            current_wave: 1,
889            player_id: player.id,
890            party_player_id,
891            entities,
892            max_duration_ticks: fight.max_duration_ticks,
893            fight_stopped: false,
894            paused: false,
895            fight_ended: false,
896            dungeon: None,
897            pending_wave_spawns: Vec::new(),
898            summoned_entity_ids: Vec::new(),
899        };
900
901        state.active_fight = Some(active_fight.clone());
902
903        if let Err(err) =
904            self.run_prepare_fight_script(&mut events, fight, &active_fight, state, rand_gen)
905        {
906            tracing::error!("Error running single prepare_fight script: {err:?}");
907            return None;
908        }
909
910        self.schedule_start_fight(fight, active_fight.id, &game_config);
911
912        Some(events)
913    }
914
915    pub fn handle_start_fight(
916        &mut self,
917        event: OverlordEvent,
918        fight_id: uuid::Uuid,
919        // The native `fight_start` fns are RNG-free (init_fight + optional static
920        // spawns), but the Team Die of the starting world is rolled from here.
921        rand_gen: rand::rngs::StdRng,
922        current_tick: u64,
923        mut state: OverlordState,
924    ) -> EventHandleResult<OverlordEvent, OverlordState> {
925        let game_config = self.game_config.get();
926
927        let Some(active_fight) = &mut state.active_fight else {
928            tracing::error!("No active fight for start_fight");
929            return EventHandleResult::fail(state);
930        };
931
932        if active_fight.id != fight_id {
933            tracing::error!(
934                "StartFight fight_id mismatch: expected {}, got {}",
935                active_fight.id,
936                fight_id
937            );
938            return EventHandleResult::fail(state);
939        }
940
941        let Ok(fight_template) = game_config.require_fight_template(active_fight.fight_id) else {
942            tracing::error!("No fight template with fight_id: {}", active_fight.fight_id);
943            return EventHandleResult::fail(state);
944        };
945
946        self.start_fight_tick = current_tick;
947
948        let mut events = vec![];
949
950        active_fight.entities.iter_mut().for_each(|e| {
951            e.abilities.iter_mut().for_each(|ability| {
952                e.actions_queue.push(&ActionWithDeadline::core(
953                    self.make_start_cast_ability_action(e.id, ability.ability.template_id),
954                    current_tick,
955                ))
956            })
957        });
958
959        active_fight.max_duration_ticks = fight_template.max_duration_ticks;
960
961        let active_fight_for_native = active_fight.clone();
962        let start_behavior = fight_template.start_behavior.clone();
963        // scope; the native `fight_start` fns read it from `Fight`/`State`, so
964        // the StartFight event itself is no longer an input.
965        let _ = event;
966
967        // Native `fight_start` port: look up the named fn on the registry and
968        // drive it with `FightStartCtx`. Falls back to no events when no native
969        // ref / fn is present.
970        let Some(native_name) = start_behavior.as_deref() else {
971            tracing::error!("Fight template {} has no start_behavior", fight_template.id);
972            return EventHandleResult::fail(state);
973        };
974        let Some(native_fn) = self.behaviors.fight_start_fn(native_name) else {
975            tracing::error!("No registered fight_start native fn named {native_name}");
976            return EventHandleResult::fail(state);
977        };
978        match native_fn(&crate::behaviors::combat::fight_start::FightStartCtx {
979            fight: &active_fight_for_native,
980            state: &state,
981            lookups: self.behaviors.lookups(),
982        }) {
983            Ok(start_fight_events) => {
984                events.append(
985                    &mut start_fight_events
986                        .into_iter()
987                        .map(EventPluginized::now)
988                        .collect(),
989                );
990            }
991            Err(err) => {
992                tracing::error!("Start fight native fn failed with error: {err:?}");
993                return EventHandleResult::fail(state);
994            }
995        };
996
997        // Entering the starting world rolls the Team Die, the same die a flip
998        // rolls. Last, so the outcome resolves against the fight the native
999        // `fight_start` fn has finished building.
1000        let mut rng = rand_gen;
1001        events.extend(self.roll_team_die_on_fight_start(&mut state, &mut rng));
1002
1003        EventHandleResult::ok_events(state, events)
1004    }
1005
1006    pub fn get_prepare_fight_delay(
1007        &self,
1008        is_win: bool,
1009        chapter: &Chapter,
1010        state: &OverlordState,
1011    ) -> u64 {
1012        let game_config = self.game_config.get();
1013
1014        let Some(fight_id) = chapter
1015            .fight_ids
1016            .get(state.character_state.character.current_fight_number as usize)
1017            .cloned()
1018        else {
1019            tracing::error!(
1020                "Failed to get fight {} for chapter_level={}",
1021                state.character_state.character.current_fight_number,
1022                state.character_state.character.current_chapter_level
1023            );
1024            return 0;
1025        };
1026
1027        let Ok(fight) = game_config.require_fight_template(fight_id) else {
1028            tracing::error!("Failed to get fight_template with id {} ", fight_id,);
1029            return 0;
1030        };
1031
1032        if is_win {
1033            fight.prepare_fight_win_duration_ticks.unwrap_or(
1034                game_config
1035                    .fight_settings
1036                    .prepare_fight_win_delay_ticks_default,
1037            )
1038        } else {
1039            fight.prepare_fight_lose_duration_ticks.unwrap_or(
1040                game_config
1041                    .fight_settings
1042                    .prepare_fight_lose_delay_ticks_default,
1043            )
1044        }
1045    }
1046
1047    pub fn get_end_fight_delay(&self, fight_id: FightTemplateId) -> u64 {
1048        let game_config = self.game_config.get();
1049
1050        let Ok(fight) = game_config.require_fight_template(fight_id) else {
1051            tracing::error!("Failed to get fight_template with id {} ", fight_id,);
1052            return 0;
1053        };
1054
1055        fight
1056            .end_fight_delay_ticks
1057            .unwrap_or(game_config.fight_settings.end_fight_delay_ticks_default)
1058    }
1059
1060    /// Shared end-of-fight tail: keep the loop going by scheduling the next
1061    /// PrepareFight, or stop fighting when the template says so.
1062    /// If (is_win && stop_on_win) or (!is_win && stop_on_lose) - we stop fighting
1063    fn continue_or_stop_fight_loop(
1064        &mut self,
1065        state: &mut OverlordState,
1066        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
1067        current_fight: &FightTemplate,
1068        is_win: bool,
1069        prepare_fight_delay_ticks: u64,
1070    ) {
1071        if (!is_win || !current_fight.stop_on_win) && (is_win || !current_fight.stop_on_lose) {
1072            events.extend(self.maybe_refresh_party_event(state));
1073            self.fight_clock.schedule(
1074                OverlordEvent::PrepareFight {
1075                    prepare_fight_type: PrepareFightType::PVEFight,
1076                },
1077                prepare_fight_delay_ticks,
1078            );
1079        } else if let Some(fight) = state.active_fight.as_mut() {
1080            fight.fight_stopped = true;
1081        }
1082    }
1083
1084    fn end_pvp_fight(
1085        &mut self,
1086        is_win: bool,
1087        pvp_state: &PVPState,
1088        prepare_fight_delay_ticks: u64,
1089        current_fight: &FightTemplate,
1090        mut state: OverlordState,
1091    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1092        let mut events = vec![];
1093        if let Some(vassal) = &pvp_state.vassal {
1094            if is_win {
1095                state.character_state.vassals.push(vassal.clone());
1096                if let Some(bundle_id) = current_fight.bundle_reward_id {
1097                    events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
1098                        bundle_ids: vec![bundle_id],
1099                        source: essences::currency::CurrencySource::PvpVassalReward,
1100                    }));
1101                }
1102            } else {
1103                tracing::error!("Expected to add vassal, but PVP is lost");
1104            }
1105        }
1106
1107        if let Some(rating_change) = &pvp_state.rating_change {
1108            if is_win {
1109                state.character_state.character.arena_rating +=
1110                    rating_change.winner_rating_increase;
1111            } else {
1112                state.character_state.character.arena_rating += rating_change.loser_rating_decrease;
1113            }
1114        }
1115
1116        state.pvp_state = None;
1117
1118        self.continue_or_stop_fight_loop(
1119            &mut state,
1120            &mut events,
1121            current_fight,
1122            is_win,
1123            prepare_fight_delay_ticks,
1124        );
1125
1126        EventHandleResult::ok_events(state, events)
1127    }
1128
1129    fn end_pve_fight(
1130        &mut self,
1131        is_win: bool,
1132        current_chapter: &Chapter,
1133        prepare_fight_delay_ticks: u64,
1134        current_fight: &FightTemplate,
1135        rand_gen: &mut rand::rngs::StdRng,
1136        mut state: OverlordState,
1137    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1138        let game_config = self.game_config.get();
1139
1140        let mut events = vec![];
1141
1142        let prev_chapter_level = state.character_state.character.current_chapter_level;
1143        let mut next_chapter_level = prev_chapter_level;
1144        let mut next_fight_number = state.character_state.character.current_fight_number + 1;
1145
1146        if is_win {
1147            if next_fight_number >= current_chapter.fight_ids.len() as i64 {
1148                next_fight_number = 0;
1149                if game_config
1150                    .chapter_by_level(next_chapter_level + 1)
1151                    .is_some()
1152                {
1153                    state.character_state.character.last_boss_fight_won = true;
1154                    next_chapter_level += 1;
1155                }
1156            } else {
1157                let next_fight_id = &current_chapter.fight_ids[next_fight_number as usize];
1158
1159                let Ok(next_fight) = game_config.require_fight_template(*next_fight_id) else {
1160                    tracing::error!(
1161                        "Failed to get next fight_template with id={}",
1162                        next_fight_id
1163                    );
1164                    return EventHandleResult::fail(state);
1165                };
1166
1167                // Uncomment to restore the old behavior where losing the boss
1168                // gates auto-progression wave -> boss until a RetryBossFight event.
1169                // if !state.character_state.character.last_boss_fight_won
1170                //     && next_fight.fight_type == FightType::CampaignBossFight
1171                // {
1172                //     next_fight_number = 0;
1173                // }
1174                let _ = next_fight;
1175            }
1176
1177            state.character_state.character.current_chapter_level = next_chapter_level;
1178            state.character_state.character.current_fight_number = next_fight_number;
1179
1180            // BAL-024/BAL-025: reaching an equipment gate guarantees its stone
1181            // package. The grant rides the ordinary PlayerNewStones path, and
1182            // the missing-check makes it a no-op for anything already owned.
1183            let (trigger_stones, effect_stones) = Self::missing_milestone_stones(
1184                &game_config,
1185                next_chapter_level,
1186                &state.character_state.stones,
1187            );
1188            if !trigger_stones.is_empty() || !effect_stones.is_empty() {
1189                events.push(EventPluginized::now(OverlordEvent::PlayerNewStones {
1190                    trigger_stones,
1191                    effect_stones,
1192                }));
1193            }
1194
1195            // BAL-031: each newly-usable artifact socket family guarantees one
1196            // compatible L1 stone. Same converging missing-check as the
1197            // equipment milestones — a family the player already owns a stone
1198            // of needs nothing, so the grant is idempotent.
1199            let socket_stones = Self::missing_socket_artifact_stones(
1200                &game_config,
1201                next_chapter_level,
1202                &state.character_state.artifacts,
1203            );
1204            if !socket_stones.is_empty() {
1205                events.push(EventPluginized::now(OverlordEvent::PlayerNewArtifacts {
1206                    artifacts: Vec::new(),
1207                    artifact_stones: socket_stones,
1208                }));
1209            }
1210
1211            // BAL-010: reaching the Cores gate grants BOTH cores at level 1 for
1212            // free -- the old level-1 price of 700 is gone from the ladder, which
1213            // now starts at level 2. Written directly rather than through an
1214            // event because it is a plain level floor, and the `<` checks make it
1215            // idempotent: it converges on every chapter advance and on reconnect,
1216            // and can never lower a core the player has already upgraded.
1217            let core_unlock_chapter = game_config.cores_settings.unlock_chapter;
1218            if next_chapter_level >= core_unlock_chapter {
1219                let cores = &mut state.character_state.cores;
1220                // Per side, and only for a side we actually raise: the level
1221                // alone is not the grant, because law unlocks are keyed to core
1222                // level and a granted core without its level-1 laws is one the
1223                // player cannot build on. Touching a side that was already above
1224                // 1 would backfill laws the ordinary upgrade path owns.
1225                for side in [
1226                    essences::flip::WorldSide::Real,
1227                    essences::flip::WorldSide::Fantasy,
1228                ] {
1229                    let level = match side {
1230                        essences::flip::WorldSide::Real => &mut cores.real_level,
1231                        essences::flip::WorldSide::Fantasy => &mut cores.fantasy_level,
1232                    };
1233                    if *level >= 1 {
1234                        continue;
1235                    }
1236                    *level = 1;
1237                    crate::mechanics::cores::grant_unlocked_laws(&game_config, cores, side);
1238                }
1239            }
1240
1241            // D1 all-free supply floor: the grant belongs to the transition,
1242            // not merely to "being above" the gate. That makes it exactly once
1243            // for fresh progression, immune to reconnect/replayed convergence,
1244            // and deliberately non-retroactive for migrated accounts that were
1245            // already beyond ch21 before this knob existed.
1246            if let Some(grant) = core_unlock_essence_grant_event(
1247                &game_config,
1248                prev_chapter_level,
1249                next_chapter_level,
1250            ) {
1251                events.push(grant);
1252            }
1253
1254            if self.should_reset_afk_timer_on_gating_unlock(prev_chapter_level, &state) {
1255                events.push(EventPluginized::now(
1256                    OverlordEvent::AfkRewardsGatingUnlocked {},
1257                ));
1258            }
1259
1260            if !state.character_state.character.rate_us_shown
1261                && let Some(threshold) = game_config.game_settings.rate_us_chapter
1262                && prev_chapter_level < threshold
1263                && next_chapter_level >= threshold
1264            {
1265                events.push(EventPluginized::now(OverlordEvent::ShowRateUs {}));
1266                tracing::info!(
1267                    target: METRICS_TARGET,
1268                    event_type = "show_rate_us",
1269                    character_id = %state.character_state.character.id,
1270                    chapter_level = next_chapter_level,
1271                    trigger = "chapter_advance",
1272                    "Show rate us",
1273                );
1274            }
1275
1276            if let Some(bundle_id) = current_fight.bundle_reward_id {
1277                events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
1278                    bundle_ids: vec![bundle_id],
1279                    source: essences::currency::CurrencySource::ChapterReward,
1280                }));
1281            }
1282
1283            // BAL-035: Mastery is paid by campaign-boss FIRST clears only. A
1284            // chapter advance IS that first clear — `current_chapter_level` is
1285            // monotonic — so a farmed boss pays nothing and the schedule stays
1286            // tied to progression rather than to time spent.
1287            if next_chapter_level > prev_chapter_level {
1288                events.extend(Self::mastery_first_clear_rewards(
1289                    &game_config,
1290                    &mut state,
1291                    next_chapter_level,
1292                    rand_gen,
1293                ));
1294            }
1295        } else {
1296            if current_fight.fight_type == FightType::CampaignBossFight {
1297                state.character_state.character.last_boss_fight_won = false;
1298            }
1299
1300            next_fight_number = 0;
1301            state.character_state.character.current_fight_number = next_fight_number;
1302        }
1303
1304        // The starting world alternates on DEATH (restart) and on entering the
1305        // NEXT CHAPTER — wave→wave advances inside a chapter keep it, and the
1306        // chapter boss is always entered in the world its waves were fought in
1307        // (guard below; it also covers a death-restart into a boss-only
1308        // chapter). Only once the flip is unlocked: before that the player
1309        // stays on the default side. The durable side seeds the next fight's
1310        // entity at PrepareFight, and the EndFight ws handler already persists
1311        // `flip_state`.
1312        let world_changing_transition = !is_win || next_chapter_level != prev_chapter_level;
1313        if world_changing_transition && game_config.flip_settings.is_unlocked(next_chapter_level) {
1314            let upcoming_chapter = if next_chapter_level == prev_chapter_level {
1315                Some(current_chapter)
1316            } else {
1317                game_config.chapter_by_level(next_chapter_level)
1318            };
1319            let upcoming_is_boss = upcoming_chapter
1320                .and_then(|chapter| chapter.fight_ids.get(next_fight_number as usize))
1321                .and_then(|fight_id| game_config.fight_template(*fight_id))
1322                .is_some_and(|fight| fight.fight_type == FightType::CampaignBossFight);
1323
1324            if !upcoming_is_boss {
1325                state.flip_state.active_side = state.flip_state.active_side.flipped();
1326                state.flip_state.revision = state.flip_state.revision.saturating_add(1);
1327            }
1328        }
1329
1330        self.continue_or_stop_fight_loop(
1331            &mut state,
1332            &mut events,
1333            current_fight,
1334            is_win,
1335            prepare_fight_delay_ticks,
1336        );
1337
1338        EventHandleResult::ok_events(state, events)
1339    }
1340
1341    /// Shards for the `clear`-th campaign-boss first clear, plus the Crystal
1342    /// roll that rides the same event (BAL-035).
1343    ///
1344    /// The Crystal is a `25%` roll with a pity counter: the fourth consecutive
1345    /// miss pays out. The counter lives in `custom_values` — it is one small
1346    /// integer of progression state, and putting it there keeps the schedule
1347    /// off the characters table.
1348    pub(crate) fn mastery_first_clear_rewards(
1349        game_config: &configs::game_config::GameConfig,
1350        state: &mut OverlordState,
1351        clear: i64,
1352        rand_gen: &mut rand::rngs::StdRng,
1353    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1354        let mastery = &game_config.game_settings.mastery;
1355        let mut rewards = Vec::new();
1356
1357        let shards = mastery.shards_for_clear(clear);
1358        if shards > 0 {
1359            rewards.push(CurrencyUnit {
1360                currency_id: mastery.shard_currency_id,
1361                amount: shards,
1362            });
1363        }
1364
1365        let misses = state
1366            .character_state
1367            .character
1368            .custom_values
1369            .0
1370            .get(MASTERY_CRYSTAL_MISSES)
1371            .copied()
1372            .unwrap_or(0);
1373        let pity_due = mastery.crystal_pity_clears > 0 && misses + 1 >= mastery.crystal_pity_clears;
1374        let won =
1375            pity_due || rand::RngExt::random_range(rand_gen, 0.0..1.0) < mastery.crystal_chance;
1376
1377        if won {
1378            rewards.push(CurrencyUnit {
1379                currency_id: mastery.crystal_currency_id,
1380                amount: 1,
1381            });
1382        }
1383
1384        let next_misses = if won { 0 } else { misses + 1 };
1385        state
1386            .character_state
1387            .character
1388            .custom_values
1389            .0
1390            .insert(MASTERY_CRYSTAL_MISSES.to_string(), next_misses);
1391
1392        // The pity counter is account-wide (BAL-035), so the in-memory write
1393        // above is not enough: `characters.custom_values` is only written from
1394        // the `SetCustomValue` path, and without the event the counter resets
1395        // to its stored value on every reconnect. Emitted even when the clear
1396        // pays nothing — a miss still has to be counted.
1397        let mut events = vec![EventPluginized::now(OverlordEvent::SetCustomValue {
1398            key: MASTERY_CRYSTAL_MISSES.to_string(),
1399            value: next_misses,
1400        })];
1401
1402        if !rewards.is_empty() {
1403            events.push(Self::currency_increase(
1404                &rewards,
1405                essences::currency::CurrencySource::ChapterReward,
1406            ));
1407        }
1408        events
1409    }
1410
1411    fn end_dungeon_fight(
1412        &mut self,
1413        is_win: bool,
1414        prepare_fight_delay_ticks: u64,
1415        active_dungeon: &ActiveDungeon,
1416        current_fight: &FightTemplate,
1417        rand_gen: &mut rand::rngs::StdRng,
1418        mut state: OverlordState,
1419    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1420        let game_config = self.game_config.get();
1421
1422        let Ok(dungeon) = game_config.require_dungeon_template(active_dungeon.id) else {
1423            tracing::error!(
1424                "Failed to get dungeon_template with id {} ",
1425                active_dungeon.id
1426            );
1427            return EventHandleResult::fail(state);
1428        };
1429
1430        let mut events = vec![];
1431
1432        if is_win {
1433            let keys_price = vec![CurrencyUnit {
1434                currency_id: dungeon.key_currency_id,
1435                amount: 1,
1436            }];
1437
1438            let Some(currency_event) =
1439                Self::currency_decrease(&state, &keys_price, CurrencyConsumer::DungeonFightEnd)
1440            else {
1441                return EventHandleResult::fail(state);
1442            };
1443            events.push(currency_event);
1444
1445            // BAL-036: the first clear of a difficulty pays its resource bundle
1446            // TWICE, every later clear once. Difficulties open in order, so the
1447            // highest one cleared is an exact record of what has been paid.
1448            let is_first_clear = active_dungeon.difficulty
1449                > *state
1450                    .dungeons
1451                    .completed_difficulties
1452                    .get(&dungeon.id)
1453                    .unwrap_or(&0);
1454
1455            if let Some(bundle_id) = current_fight.bundle_reward_id {
1456                // Two separate single-bundle grants rather than one grouped
1457                // pair: a dungeon reward stays an ordinary ungrouped bundle
1458                // row, and grouping keeps meaning "this came from a raid".
1459                let payouts = if is_first_clear { 2 } else { 1 };
1460                for _ in 0..payouts {
1461                    events.push(EventPluginized::now(OverlordEvent::AddBundleGroup {
1462                        bundle_ids: vec![bundle_id],
1463                        source: essences::currency::CurrencySource::DungeonReward,
1464                    }));
1465                }
1466            }
1467
1468            // Artifact stones drop from chapter bosses and cleared dungeons — a
1469            // different faucet from the artifacts themselves, so the two
1470            // collections never compete. One roll per clear: the first-clear
1471            // bonus doubles the resource bundle and nothing else (BAL-036).
1472            let dungeon_chance = game_config.artifacts_settings.stone_drop.dungeon_chance;
1473            events.extend(self.roll_artifact_stone_drop(
1474                rand_gen,
1475                dungeon_chance,
1476                state.character_state.character.current_chapter_level,
1477            ));
1478
1479            state
1480                .dungeons
1481                .completed_difficulties
1482                .entry(dungeon.id)
1483                .and_modify(|v| {
1484                    if active_dungeon.difficulty > *v {
1485                        *v = active_dungeon.difficulty;
1486                    }
1487                })
1488                .or_insert(active_dungeon.difficulty);
1489        }
1490
1491        self.continue_or_stop_fight_loop(
1492            &mut state,
1493            &mut events,
1494            current_fight,
1495            is_win,
1496            prepare_fight_delay_ticks,
1497        );
1498
1499        EventHandleResult::ok_events(state, events)
1500    }
1501
1502    /// Pure predicate: is the chapter transition crossing the AFK rewards unlock
1503    /// threshold *and* is the elapsed time since the last claim shorter than
1504    /// `min_required_time_sec`? Callers use this to decide whether to emit
1505    /// `AfkRewardsGatingUnlocked`; the async handler for that event owns the
1506    /// actual state mutation.
1507    pub(crate) fn should_reset_afk_timer_on_gating_unlock(
1508        &self,
1509        prev_chapter_level: i64,
1510        state: &OverlordState,
1511    ) -> bool {
1512        let game_config = self.game_config.get();
1513        let unlock_chapter = game_config.gatings.afk_rewards_button_unlock_chapter;
1514        let new_chapter_level = state.character_state.character.current_chapter_level;
1515
1516        if prev_chapter_level >= unlock_chapter || new_chapter_level < unlock_chapter {
1517            return false;
1518        }
1519
1520        let min_required_time_sec = game_config.afk_rewards_settings.min_required_time_sec as i64;
1521        let elapsed = ::time::utc_now()
1522            .signed_duration_since(state.character_state.character.last_afk_reward_claimed_at)
1523            .num_seconds();
1524
1525        elapsed < min_required_time_sec
1526    }
1527
1528    pub fn handle_afk_rewards_gating_unlocked(
1529        &self,
1530        mut state: OverlordState,
1531    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1532        let min_required_time_sec = self
1533            .game_config
1534            .get()
1535            .afk_rewards_settings
1536            .min_required_time_sec as i64;
1537        state.character_state.character.last_afk_reward_claimed_at =
1538            ::time::utc_now() - chrono::Duration::seconds(min_required_time_sec);
1539        EventHandleResult::ok(state)
1540    }
1541
1542    /// `rand_gen` is threaded in for the placeholder artifact-stone drop on a
1543    /// cleared dungeon; nothing else in the end-of-fight pipeline rolls.
1544    pub fn handle_end_fight(
1545        &mut self,
1546        fight_id: uuid::Uuid,
1547        is_win: bool,
1548        pvp_state: Option<&PVPState>,
1549        mut rand_gen: rand::rngs::StdRng,
1550        mut state: OverlordState,
1551    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1552        let game_config = self.game_config.get();
1553
1554        let Some(active_fight) = &mut state.active_fight else {
1555            tracing::error!("No active fight for end_fight");
1556            return EventHandleResult::fail(state);
1557        };
1558
1559        if active_fight.id != fight_id {
1560            tracing::error!(
1561                "EndFight fight_id mismatch: expected {}, got {}",
1562                active_fight.id,
1563                fight_id
1564            );
1565            return EventHandleResult::fail(state);
1566        }
1567
1568        if self.ended_fight_id == Some(fight_id) {
1569            // A duplicate EndFight for the same fight is an expected race:
1570            // the max-duration timeout and an in-flight combat event (e.g. a
1571            // projectile killing the last entity) can each schedule one. The
1572            // end-of-fight pipeline (PvP rating, vassal, reward bundles,
1573            // chapter advance) must run exactly once per fight. (The
1574            // `fight_ended` flag can't be the guard here: producers set it
1575            // before scheduling the EndFight event.)
1576            tracing::info!("Fight {fight_id} already ended, ignoring duplicate EndFight");
1577            return EventHandleResult::fail(state);
1578        }
1579
1580        let Ok(current_fight) = game_config.require_fight_template(active_fight.fight_id) else {
1581            tracing::error!(
1582                "Failed to get currrent fight_template with id={}",
1583                active_fight.fight_id
1584            );
1585            return EventHandleResult::fail(state);
1586        };
1587
1588        active_fight.fight_ended = true;
1589        self.ended_fight_id = Some(fight_id);
1590
1591        // Before the mode branch, so campaign, arena and dungeon all get the
1592        // summary from one place — and after the idempotency guard above, which
1593        // makes the write exactly-once for free. `SingleFight` returns early a
1594        // few lines down and would otherwise be the one mode without it.
1595        let duration_ticks = self.fight_clock.now().saturating_sub(self.start_fight_tick);
1596        let fight_template_id = active_fight.fight_id;
1597        // Always written, even when nothing was dealt: an empty summary for
1598        // THIS fight is a different statement from the previous fight's summary
1599        // left standing, and the client would have no way to tell them apart.
1600        state.last_fight_breakdown = Some(
1601            self.fight_breakdown_acc
1602                .take()
1603                .filter(|acc| acc.fight_instance_id() == fight_id)
1604                .map(|acc| acc.finish(is_win, duration_ticks))
1605                .unwrap_or_else(|| essences::fight_breakdown::FightBreakdown {
1606                    fight_instance_id: fight_id,
1607                    fight_id: fight_template_id,
1608                    is_win,
1609                    duration_ticks,
1610                    actors: Vec::new(),
1611                }),
1612        );
1613
1614        if current_fight.fight_type == FightType::SingleFight {
1615            self.fight_clock.schedule(
1616                OverlordEvent::PrepareFight {
1617                    prepare_fight_type: PrepareFightType::PVEFight,
1618                },
1619                game_config
1620                    .fight_settings
1621                    .prepare_fight_win_delay_ticks_default,
1622            );
1623            let events = self.maybe_refresh_party_event(&state).into_iter().collect();
1624            return EventHandleResult::ok_events(state, events);
1625        }
1626
1627        let active_fight = active_fight.clone();
1628
1629        let Ok(current_chapter) = game_config
1630            .require_chapter_by_level(state.character_state.character.current_chapter_level)
1631            .cloned()
1632        else {
1633            tracing::error!(
1634                "Failed to get chapter with chapter_level={}",
1635                state.character_state.character.current_chapter_level
1636            );
1637            return EventHandleResult::fail(state);
1638        };
1639
1640        let prepare_fight_delay_ticks =
1641            self.get_prepare_fight_delay(is_win, &current_chapter, &state);
1642
1643        if let Some(pvp_state) = pvp_state {
1644            return self.end_pvp_fight(
1645                is_win,
1646                pvp_state,
1647                prepare_fight_delay_ticks,
1648                current_fight,
1649                state,
1650            );
1651        }
1652
1653        if let Some(active_dungeon) = &active_fight.dungeon {
1654            return self.end_dungeon_fight(
1655                is_win,
1656                prepare_fight_delay_ticks,
1657                active_dungeon,
1658                current_fight,
1659                &mut rand_gen,
1660                state,
1661            );
1662        }
1663
1664        self.end_pve_fight(
1665            is_win,
1666            &current_chapter,
1667            prepare_fight_delay_ticks,
1668            current_fight,
1669            &mut rand_gen,
1670            state,
1671        )
1672    }
1673}
1674
1675#[cfg(test)]
1676mod core_unlock_grant_tests {
1677    use super::*;
1678
1679    #[test]
1680    fn crossing_the_gate_emits_one_authored_chapter_reward_and_cannot_repeat() {
1681        let mut config = configs::tests_game_config::generate_game_config_for_tests();
1682        config.cores_settings.unlock_chapter = 21;
1683        config.cores_settings.unlock_essence_grant = 210;
1684        let core_id = config.cores_settings.upgrade_currency_id;
1685
1686        let grant = core_unlock_essence_grant_event(&config, 20, 21)
1687            .expect("the fresh 20 -> 21 crossing grants once");
1688        match grant.event() {
1689            OverlordEvent::CurrencyIncrease {
1690                currencies,
1691                currency_source,
1692            } => {
1693                assert_eq!(
1694                    *currency_source,
1695                    essences::currency::CurrencySource::ChapterReward
1696                );
1697                assert_eq!(
1698                    currencies,
1699                    &vec![CurrencyUnit {
1700                        currency_id: core_id,
1701                        amount: 210,
1702                    }]
1703                );
1704            }
1705            other => panic!("unexpected unlock grant event: {other:?}"),
1706        }
1707
1708        assert!(
1709            core_unlock_essence_grant_event(&config, 21, 22).is_none(),
1710            "ordinary progress/reconnect above the gate cannot repeat the grant"
1711        );
1712        assert!(
1713            core_unlock_essence_grant_event(&config, 22, 22).is_none(),
1714            "reprocessing the same above-gate chapter cannot double grant"
1715        );
1716    }
1717}
1718
1719#[cfg(test)]
1720mod mastery_tests {
1721    use super::*;
1722    use configs::game_settings::{MasterySettings, MasteryShardBand};
1723
1724    /// The authored schedule (BAL-035): ten clears per band.
1725    fn bands() -> Vec<MasteryShardBand> {
1726        [
1727            (10, 10),
1728            (20, 25),
1729            (30, 30),
1730            (40, 35),
1731            (50, 40),
1732            (60, 45),
1733            (70, 50),
1734            (80, 50),
1735            (90, 55),
1736            (100, 60),
1737            (300, 60),
1738        ]
1739        .into_iter()
1740        .map(|(up_to_clear, shards)| MasteryShardBand {
1741            up_to_clear,
1742            shards,
1743        })
1744        .collect()
1745    }
1746
1747    fn settings(crystal_chance: f64, crystal_pity_clears: i64) -> MasterySettings {
1748        MasterySettings {
1749            shard_currency_id: uuid::Uuid::nil(),
1750            crystal_currency_id: uuid::Uuid::nil(),
1751            shard_bands: bands(),
1752            crystal_chance,
1753            crystal_pity_clears,
1754        }
1755    }
1756
1757    /// The currencies one first clear grants, and the miss counter it leaves
1758    /// behind. `chance` drives the roll; `0.0` never wins on its own, so the
1759    /// only Crystal it can produce is the pity one.
1760    fn grant(
1761        config: &configs::game_config::GameConfig,
1762        state: &mut OverlordState,
1763        clear: i64,
1764    ) -> Vec<CurrencyUnit> {
1765        use rand::SeedableRng;
1766        let mut rng = rand::rngs::StdRng::seed_from_u64(1);
1767        let events = OverlordLogic::mastery_first_clear_rewards(config, state, clear, &mut rng);
1768        events
1769            .iter()
1770            .filter_map(|event| match event.event() {
1771                OverlordEvent::CurrencyIncrease { currencies, .. } => Some(currencies.clone()),
1772                _ => None,
1773            })
1774            .flatten()
1775            .collect()
1776    }
1777
1778    fn misses(state: &OverlordState) -> i64 {
1779        state
1780            .character_state
1781            .character
1782            .custom_values
1783            .0
1784            .get(MASTERY_CRYSTAL_MISSES)
1785            .copied()
1786            .unwrap_or(0)
1787    }
1788
1789    /// The Crystal is never certain until the pity clear, and the pity clear is
1790    /// certain — that pair is what makes the schedule bounded without making it
1791    /// deterministic.
1792    #[test]
1793    fn the_fourth_consecutive_miss_is_guaranteed_a_crystal() {
1794        let mut config = configs::tests_game_config::generate_game_config_for_tests();
1795        // A chance of zero isolates the pity: every win here is the guarantee.
1796        config.game_settings.mastery.crystal_chance = 0.0;
1797        let crystal_id = config.game_settings.mastery.crystal_currency_id;
1798        let shard_id = config.game_settings.mastery.shard_currency_id;
1799
1800        let mut state = OverlordState::default();
1801
1802        for clear in 1..=3 {
1803            let granted = grant(&config, &mut state, clear);
1804            assert!(
1805                granted.iter().all(|unit| unit.currency_id == shard_id),
1806                "clear {clear} pays Shards only while the pity is still counting"
1807            );
1808            assert_eq!(misses(&state), clear, "the miss counter walks up");
1809        }
1810
1811        let fourth = grant(&config, &mut state, 4);
1812        assert_eq!(
1813            fourth
1814                .iter()
1815                .find(|unit| unit.currency_id == crystal_id)
1816                .map(|unit| unit.amount),
1817            Some(1),
1818            "the fourth consecutive miss pays exactly one Crystal"
1819        );
1820        assert_eq!(misses(&state), 0, "a Crystal resets the counter");
1821    }
1822
1823    /// A natural win resets the same counter a pity win does, so the guarantee
1824    /// can never stack up behind a lucky streak.
1825    #[test]
1826    fn a_natural_crystal_resets_the_pity_counter() {
1827        let mut config = configs::tests_game_config::generate_game_config_for_tests();
1828        config.game_settings.mastery.crystal_chance = 1.0;
1829        let crystal_id = config.game_settings.mastery.crystal_currency_id;
1830
1831        let mut state = OverlordState::default();
1832        state
1833            .character_state
1834            .character
1835            .custom_values
1836            .0
1837            .insert(MASTERY_CRYSTAL_MISSES.to_string(), 2);
1838
1839        let granted = grant(&config, &mut state, 1);
1840        assert!(granted.iter().any(|unit| unit.currency_id == crystal_id));
1841        assert_eq!(misses(&state), 0);
1842    }
1843
1844    /// The counter has to leave the tick as an event: a direct state write is
1845    /// dropped at reconnect, which would silently make the pity session-local.
1846    #[test]
1847    fn the_miss_counter_is_emitted_for_persistence() {
1848        use rand::SeedableRng;
1849
1850        let mut config = configs::tests_game_config::generate_game_config_for_tests();
1851        config.game_settings.mastery.crystal_chance = 0.0;
1852        let mut state = OverlordState::default();
1853        let mut rng = rand::rngs::StdRng::seed_from_u64(1);
1854
1855        let events = OverlordLogic::mastery_first_clear_rewards(&config, &mut state, 1, &mut rng);
1856        let written = events.iter().find_map(|event| match event.event() {
1857            OverlordEvent::SetCustomValue { key, value } if key == MASTERY_CRYSTAL_MISSES => {
1858                Some(*value)
1859            }
1860            _ => None,
1861        });
1862
1863        assert_eq!(
1864            written,
1865            Some(misses(&state)),
1866            "the persisted counter must match the one the state carries"
1867        );
1868    }
1869
1870    /// Every clear pays its band's Shards regardless of how the Crystal rolled —
1871    /// the two are one event, not two sources.
1872    #[test]
1873    fn shards_ride_the_same_clear_as_the_crystal_roll() {
1874        let config = configs::tests_game_config::generate_game_config_for_tests();
1875        let shard_id = config.game_settings.mastery.shard_currency_id;
1876        let mut state = OverlordState::default();
1877
1878        let granted = grant(&config, &mut state, 1);
1879        assert_eq!(
1880            granted
1881                .iter()
1882                .find(|unit| unit.currency_id == shard_id)
1883                .map(|unit| unit.amount),
1884            Some(10),
1885            "the first band pays 10 Shards"
1886        );
1887    }
1888
1889    #[test]
1890    fn each_band_pays_its_own_rate() {
1891        let mastery = settings(0.25, 4);
1892        // First and last clear of a band pay the same — the band is a step, not
1893        // a ramp.
1894        for (clear, expected) in [
1895            (1, 10),
1896            (10, 10),
1897            (11, 25),
1898            (20, 25),
1899            (50, 40),
1900            (51, 45),
1901            (100, 60),
1902        ] {
1903            assert_eq!(mastery.shards_for_clear(clear), expected, "clear {clear}");
1904        }
1905    }
1906
1907    #[test]
1908    fn the_last_band_runs_on_past_its_own_edge() {
1909        let mastery = settings(0.25, 4);
1910        assert_eq!(mastery.shards_for_clear(300), 60);
1911        // A player deeper than the authored schedule keeps earning at the final
1912        // rate rather than dropping to nothing.
1913        assert_eq!(mastery.shards_for_clear(301), 60);
1914        assert_eq!(mastery.shards_for_clear(5_000), 60);
1915    }
1916
1917    #[test]
1918    fn a_clear_before_the_first_one_pays_nothing() {
1919        let mastery = settings(0.25, 4);
1920        assert_eq!(mastery.shards_for_clear(0), 0);
1921        assert_eq!(mastery.shards_for_clear(-1), 0);
1922    }
1923
1924    /// The three cumulative anchors the card names: the Shard schedule is what
1925    /// puts Class L3, L10 and L20 at chapters 10, 100 and 300.
1926    #[test]
1927    fn the_schedule_reproduces_the_cards_cumulative_anchors() {
1928        let mastery = settings(0.25, 4);
1929        let total_through = |last: i64| -> i64 {
1930            (1..=last)
1931                .map(|clear| mastery.shards_for_clear(clear))
1932                .sum()
1933        };
1934        assert_eq!(total_through(10), 100, "Class L3");
1935        assert_eq!(total_through(100), 4_000, "Class L10");
1936        assert_eq!(total_through(300), 16_000, "Class L20");
1937    }
1938}