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
27const PARTY_REFRESH_INTERVAL_SEC: i64 = 30;
30
31const MASTERY_CRYSTAL_MISSES: &str = "mastery_crystal_misses";
35
36fn 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 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 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 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 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 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 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 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 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 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 state.flip_state.progress = 0.0;
527 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 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 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 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 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 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 let _ = event;
966
967 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 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 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 = ¤t_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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let duration_ticks = self.fight_clock.now().saturating_sub(self.start_fight_tick);
1596 let fight_template_id = active_fight.fight_id;
1597 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, ¤t_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 ¤t_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 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 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 #[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 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 #[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 #[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 #[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 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 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 #[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}