overlord_event_system/logic/
cheats.rs

1use crate::{
2    cases::try_finalize_item,
3    entities::create_pve_entity,
4    event::{Cheat, OverlordEvent, PrepareFightType},
5    gacha::item_case::generate_item_from_template,
6    game_config_helpers::GameConfigLookup,
7    logic::handler::OverlordLogic,
8    state::OverlordState,
9};
10
11use configs::cheats::CheatScriptId;
12use essences::{
13    abilities::AbilityShard,
14    ability_stones::{AbilityStoneDrop, OwnedAbilityStone},
15    entity::{ActionWithDeadline, Coordinates, EntityAttributes},
16    fighting::{EntityType, FightEntity, FightTemplateId},
17    items::{Item, ItemTemplateId},
18    pets::{Pet, PetDrop},
19    ratings::{RatingRewardAvailability, RatingRewardPeriod, RatingType},
20    skins::SkinId,
21    stones::StoneTemplateId,
22};
23use essences::{
24    currency::{CurrencySource, CurrencyUnit, check_can_decrease_currencies},
25    fighting::EntityTeam,
26    game::EntityTemplateId,
27};
28use event_system::script::random::GameRng;
29use event_system::{event::EventPluginized, system::EventHandleResult};
30use rand::RngExt;
31
32/// Ceiling on `Cheat::OpenItemCase`. Not a balance number: one rolled item
33/// turns into several cascade events, and `EVENT_SUBGRAPH_MAX_DEPTH` (10_000)
34/// aborts the entire batch once the cascade crosses it.
35const CHEAT_OPEN_ITEM_CASE_MAX_BATCH: i64 = 1000;
36
37impl OverlordLogic {
38    pub fn handle_run_cheat(
39        &mut self,
40        cheat: &Cheat,
41        current_tick: u64,
42        rand_gen: rand::rngs::StdRng,
43        state: OverlordState,
44    ) -> EventHandleResult<OverlordEvent, OverlordState> {
45        match cheat {
46            Cheat::PauseCombat => self.handle_cheat_pause_combat(state),
47            Cheat::UnpauseCombat => self.handle_cheat_unpause_combat(state),
48            Cheat::GodModeOn => self.handle_cheat_god_mode(state),
49            Cheat::GodModeOff => self.handle_cheat_god_mode_off(state),
50            Cheat::GetRich => self.handle_cheat_get_rich(state),
51            Cheat::StartFight { templated_id } => {
52                self.handle_cheat_start_fight(*templated_id, state)
53            }
54            Cheat::SpawnEntity {
55                entity_id,
56                x,
57                y,
58                team,
59                attributes,
60            } => self.handle_cheat_spawn_entity(
61                *entity_id,
62                *x,
63                *y,
64                team.clone(),
65                attributes,
66                current_tick,
67                state,
68                rand_gen,
69            ),
70            Cheat::WearItems { items_ids } => {
71                self.handle_cheat_wear_equipment_set(items_ids, state, rand_gen)
72            }
73            Cheat::ClearInventory => self.handle_cheat_clear_inventory(state),
74            Cheat::ClearSlot { item_id } => self.handle_cheat_clear_slot(*item_id, state),
75            Cheat::GetAllSpells => self.handle_cheat_get_all_spells(state),
76            Cheat::SetChapter { chapter } => self.handle_cheat_set_chapter(*chapter, state),
77            Cheat::EquipSkin { skin_id } => self.handle_cheat_equip_skin(*skin_id, state),
78            Cheat::UnequipSkin { skin_id } => self.handle_cheat_unequip_skin(*skin_id, state),
79            Cheat::NewLevel { level } => self.handle_cheat_new_level(*level, state),
80            Cheat::Script { script_id } => self.handle_cheat_script(*script_id, rand_gen, state),
81            Cheat::MakeDailyRatingRewardsReady => {
82                self.handle_cheat_make_rating_rewards_ready(RatingRewardPeriod::Daily, state)
83            }
84            Cheat::MakeWeeklyRatingRewardsReady => {
85                self.handle_cheat_make_rating_rewards_ready(RatingRewardPeriod::Weekly, state)
86            }
87            Cheat::GetAllStones => self.handle_cheat_get_all_stones(state),
88            Cheat::GetAllAbilityStones => self.handle_cheat_get_all_ability_stones(state),
89            Cheat::GetAllPets => self.handle_cheat_get_all_pets(state),
90            Cheat::GetAllArtifacts => self.handle_cheat_get_all_artifacts(state),
91            Cheat::GetAllLaws => self.handle_cheat_get_all_laws(state),
92            Cheat::OpenItemCase { batch_size } => {
93                self.handle_cheat_open_item_case(*batch_size, false, rand_gen, state)
94            }
95            Cheat::OpenItemCaseFree { batch_size } => {
96                self.handle_cheat_open_item_case(*batch_size, true, rand_gen, state)
97            }
98        }
99    }
100
101    /// Opens `batch_size` item cases with the chest-level batch cap (the
102    /// level's `max_batch_size`) lifted. `grant_keys` decides the other gate:
103    /// `false` is [`Cheat::OpenItemCase`] and spends the character's own chest
104    /// keys, refusing the open when there are not enough; `true` is
105    /// [`Cheat::OpenItemCaseFree`] and grants the keys the open is about to
106    /// spend, so the wallet nets zero and an empty one still rolls.
107    ///
108    /// Everything downstream is the real pipeline: the same gacha roll, the
109    /// same Plinko paths and pin bonuses, the same skin unlocks and quest
110    /// progress. On top of it, with auto-chest off, the cheat replays what the
111    /// client does with a delivered batch — per logical slot the strongest
112    /// roll is kept and the weaker same-slot rolls are sold. Auto-chest ON is
113    /// left alone: `handle_player_new_items` already runs its own filter and
114    /// stack merge over the batch, and a second sell pass would chase items it
115    /// has already removed.
116    fn handle_cheat_open_item_case(
117        &self,
118        batch_size: i64,
119        grant_keys: bool,
120        mut rng: rand::rngs::StdRng,
121        state: OverlordState,
122    ) -> EventHandleResult<OverlordEvent, OverlordState> {
123        if self.frontend {
124            // Rolling is server-only, as for every other case open: the client
125            // learns the outcome from the state patch.
126            return EventHandleResult::ok(state);
127        }
128
129        if batch_size <= 0 {
130            tracing::error!("Cheat OpenItemCase: batch_size must be positive, got {batch_size}");
131            return EventHandleResult::fail(state);
132        }
133        // Not a balance gate — a cascade one. A rolled item that loses its slot
134        // costs three more events on the way out, and blowing through
135        // `EVENT_SUBGRAPH_MAX_DEPTH` aborts the whole batch with an error
136        // instead of opening anything.
137        let batch_size = batch_size.min(CHEAT_OPEN_ITEM_CASE_MAX_BATCH);
138
139        // The paying variant is checked here rather than left to
140        // `handle_player_new_items`: that charge happens after the roll, and a
141        // batch that fails there would already sit in the DB as temporary
142        // items that never become permanent.
143        let key_currency_id = self.game_config.get().game_settings.item_case_currency_id;
144        if !grant_keys {
145            let price = vec![CurrencyUnit {
146                currency_id: key_currency_id,
147                amount: batch_size,
148            }];
149            if !check_can_decrease_currencies(&state.character_state.currencies, &price) {
150                tracing::error!(
151                    "Cheat OpenItemCase: not enough currency for {batch_size} opens, available = {:?}",
152                    state.character_state.currencies
153                );
154                return EventHandleResult::fail(state);
155            }
156        }
157
158        // `max_batch` is the requested size itself: that argument IS the
159        // chest-level cap on the normal path, and lifting it is the point.
160        let mut result = self.open_item_case(batch_size, batch_size, &mut rng, state);
161        if !result.success() {
162            return result;
163        }
164
165        let Some(items) = result
166            .events()
167            .iter()
168            .find_map(|event| match event.event() {
169                OverlordEvent::PlayerNewItems { items } => Some(items.clone()),
170                _ => None,
171            })
172        else {
173            return result;
174        };
175        if items.is_empty() {
176            return result;
177        }
178
179        // The balls do not fly for a cheat batch. `PlinkoBallDropped` is the
180        // only reason the client animates an open, and 100 of them take
181        // minutes of flight time to play out — the point of a bulk cheat is
182        // that the inventory just appears. The pin bonuses those balls grant
183        // are applied right here instead, so the batch still lands the economy
184        // of a real run of the same size; only the presentation is dropped.
185        let game_config = self.game_config.get();
186        let (state, events) = result.state_and_events_mut();
187        let mut paths = Vec::new();
188        events.retain(|event| match event.event() {
189            OverlordEvent::PlinkoBallDropped { path, .. } => {
190                paths.push(path.clone());
191                false
192            }
193            _ => true,
194        });
195        let chest_level = state.character_state.character.item_case_level;
196        for path in &paths {
197            crate::mechanics::plinko::apply_pin_bonuses(
198                &game_config.plinko_settings,
199                chest_level,
200                path,
201                &mut state.character_state.plinko_pin_bonuses,
202            );
203        }
204
205        // The free variant pays for the open the cheat just made. Emitted
206        // events are processed in order, so this lands before `PlayerNewItems`
207        // charges for the same amount — the net effect on the wallet is zero
208        // and the cheat works at a zero balance. Rolls that failed to finalize
209        // are not delivered and are not paid for, hence `items.len()` rather
210        // than `batch_size`.
211        if grant_keys {
212            let keys = vec![CurrencyUnit {
213                currency_id: key_currency_id,
214                amount: items.len() as i64,
215            }];
216            result
217                .events_mut()
218                .insert(0, Self::currency_increase(&keys, CurrencySource::Cheat));
219        }
220
221        if !result.state().character_state.character.auto_chest_enabled {
222            match self.cheat_batch_sell_events(&items, result.state()) {
223                Ok(sell_events) => result.events_mut().extend(sell_events),
224                Err(e) => {
225                    // The batch is already rolled and paid for; losing the
226                    // keep/sell pass is worth less than dropping it.
227                    tracing::error!("Cheat OpenItemCase: couldn't rank the batch: {e}");
228                }
229            }
230        }
231
232        result
233    }
234
235    /// The client's keep/sell rule over one delivered batch: an item is sold
236    /// when another item of the same logical slot in the same batch is
237    /// stronger. Ranked by character power with that item equipped — the one
238    /// metric the auto-chest filter, the stack merge and the client's compare
239    /// arrows all use. A tie keeps both, exactly as on the client.
240    ///
241    /// Sells are `SellItem` events rather than direct state edits so the whole
242    /// batch travels the ordinary sell path: sale price, experience, the
243    /// inventory row deletion and the client's own reaction to it.
244    fn cheat_batch_sell_events(
245        &self,
246        items: &[Item],
247        state: &OverlordState,
248    ) -> anyhow::Result<Vec<EventPluginized<OverlordEvent, OverlordState>>> {
249        let game_config = self.game_config.get();
250
251        let mut powers = Vec::with_capacity(items.len());
252        for item in items {
253            powers.push(state.calculate_power_with_candidate_equipped(
254                item,
255                &game_config,
256                &self.behaviors,
257            )?);
258        }
259
260        let mut events = Vec::new();
261        for (idx, item) in items.iter().enumerate() {
262            let key = item.equipment_slot_key();
263            let has_stronger_same_slot = items.iter().enumerate().any(|(other_idx, other)| {
264                other_idx != idx
265                    && other.equipment_slot_key() == key
266                    && powers[other_idx] > powers[idx]
267            });
268            if has_stronger_same_slot {
269                events.push(EventPluginized::now(OverlordEvent::SellItem {
270                    item_id: item.id,
271                }));
272            }
273        }
274
275        Ok(events)
276    }
277
278    /// Grants one copy of every catalog artifact and artifact stone by
279    /// delegating to the normal grant path, so a repeat run banks upgrade
280    /// copies exactly as a repeat bundle grant or drop would.
281    fn handle_cheat_get_all_artifacts(
282        &self,
283        state: OverlordState,
284    ) -> EventHandleResult<OverlordEvent, OverlordState> {
285        let game_config = self.game_config.get();
286        let artifacts: Vec<_> = game_config.artifacts.iter().map(|a| a.id).collect();
287        let artifact_stones: Vec<_> = game_config.artifact_stones.iter().map(|s| s.id).collect();
288        self.handle_player_new_artifacts(&artifacts, &artifact_stones, state)
289    }
290
291    /// Grants every catalog law the character does not own yet as a plain
292    /// level-1 `OwnedLaw` — the same shape the core-level grant produces.
293    /// Already-owned laws are untouched: unlocking is the cheat's job, copy
294    /// income levels them.
295    fn handle_cheat_get_all_laws(
296        &self,
297        mut state: OverlordState,
298    ) -> EventHandleResult<OverlordEvent, OverlordState> {
299        let game_config = self.game_config.get();
300        let cores = &mut state.character_state.cores;
301        for law in &game_config.laws {
302            if cores.law(law.id).is_some() {
303                continue;
304            }
305            cores.laws.push(essences::cores::OwnedLaw {
306                template_id: law.id,
307                level: 1,
308                copies: 0,
309                slot_index: None,
310            });
311        }
312        EventHandleResult::ok(state)
313    }
314
315    /// Grants one copy of every catalog pet, with the same first-copy-owns /
316    /// later-copies-bank-shards rule the gacha uses, so a second run feeds
317    /// upgrades rather than doing nothing.
318    fn handle_cheat_get_all_pets(
319        &mut self,
320        mut state: OverlordState,
321    ) -> EventHandleResult<OverlordEvent, OverlordState> {
322        let game_config = self.game_config.get();
323
324        let mut drops = Vec::with_capacity(game_config.pet_templates.len());
325        for template in &game_config.pet_templates {
326            let Some(rarity) = game_config.pet_rarity(template.rarity_id) else {
327                tracing::warn!(
328                    pet = %template.id,
329                    rarity = %template.rarity_id,
330                    "Pet template references a rarity that is not in config — skipped"
331                );
332                continue;
333            };
334
335            let owned = state
336                .character_state
337                .all_pets
338                .iter_mut()
339                .find(|owned| owned.template_id == template.id);
340
341            let is_new = owned.is_none();
342            match owned {
343                // The first copy is the pet itself, so a spare only counts from
344                // the second — the shard bar reads `shards / required`.
345                Some(owned) => owned.shards_amount += 1,
346                None => state.character_state.all_pets.push(Pet::from_template(
347                    template,
348                    rarity.clone(),
349                    1,
350                    0,
351                )),
352            }
353
354            drops.push(PetDrop {
355                template: template.clone(),
356                is_new,
357                is_checkpoint: false,
358            });
359        }
360
361        if drops.is_empty() {
362            return EventHandleResult::ok(state);
363        }
364
365        EventHandleResult::ok_events(
366            state,
367            vec![EventPluginized::now(OverlordEvent::NewPets { pets: drops })],
368        )
369    }
370
371    /// Grants one raw copy of every ability support stone, with the same
372    /// copy-vs-new-stack rule the chapter faucet uses, so a second run banks
373    /// copies toward an upgrade rather than doing nothing.
374    fn handle_cheat_get_all_ability_stones(
375        &mut self,
376        mut state: OverlordState,
377    ) -> EventHandleResult<OverlordEvent, OverlordState> {
378        let game_config = self.game_config.get();
379
380        let mut drops = Vec::with_capacity(game_config.ability_stones.len());
381        for template in &game_config.ability_stones {
382            let owned = state
383                .character_state
384                .ability_stones
385                .iter_mut()
386                .find(|owned| owned.template_id == template.id);
387
388            let is_new = owned.is_none();
389            match owned {
390                Some(owned) => owned.copies += 1,
391                None => {
392                    let mut granted = OwnedAbilityStone::new(template.id);
393                    granted.copies = 1;
394                    state.character_state.ability_stones.push(granted);
395                }
396            }
397
398            drops.push(AbilityStoneDrop {
399                stone_id: template.id,
400                copies: 1,
401                is_new,
402            });
403        }
404
405        if drops.is_empty() {
406            return EventHandleResult::ok(state);
407        }
408
409        EventHandleResult::ok_events(
410            state,
411            vec![EventPluginized::now(OverlordEvent::NewAbilityStones {
412                stones: drops,
413            })],
414        )
415    }
416
417    /// Grants one copy of every catalog stone by delegating to the normal grant
418    /// path, so tier resolution and the copy-vs-instance rule stay in one place.
419    fn handle_cheat_get_all_stones(
420        &mut self,
421        state: OverlordState,
422    ) -> EventHandleResult<OverlordEvent, OverlordState> {
423        let game_config = self.game_config.get();
424
425        let trigger_stones: Vec<StoneTemplateId> =
426            game_config.trigger_stones.iter().map(|s| s.id).collect();
427        let effect_stones: Vec<StoneTemplateId> =
428            game_config.effect_stones.iter().map(|s| s.id).collect();
429
430        self.handle_player_new_stones(&trigger_stones, &effect_stones, state)
431    }
432
433    fn handle_cheat_make_rating_rewards_ready(
434        &self,
435        period: RatingRewardPeriod,
436        mut state: OverlordState,
437    ) -> EventHandleResult<OverlordEvent, OverlordState> {
438        for rating_type in [RatingType::Arena, RatingType::PvE] {
439            let availability = state
440                .rating_reward_availability
441                .iter_mut()
442                .find(|availability| availability.rating_type == rating_type);
443
444            if let Some(availability) = availability {
445                match period {
446                    RatingRewardPeriod::Daily => availability.daily_available = true,
447                    RatingRewardPeriod::Weekly => availability.weekly_available = true,
448                }
449            } else {
450                state
451                    .rating_reward_availability
452                    .push(RatingRewardAvailability {
453                        rating_type,
454                        daily_available: period == RatingRewardPeriod::Daily,
455                        weekly_available: period == RatingRewardPeriod::Weekly,
456                    });
457            }
458        }
459
460        EventHandleResult::ok(state)
461    }
462
463    fn handle_cheat_pause_combat(
464        &self,
465        mut state: OverlordState,
466    ) -> EventHandleResult<OverlordEvent, OverlordState> {
467        if let Some(fight) = &mut state.active_fight {
468            fight.paused = true;
469        }
470
471        EventHandleResult::ok(state)
472    }
473
474    fn handle_cheat_unpause_combat(
475        &self,
476        mut state: OverlordState,
477    ) -> EventHandleResult<OverlordEvent, OverlordState> {
478        if let Some(fight) = &mut state.active_fight {
479            fight.paused = false;
480        }
481
482        EventHandleResult::ok(state)
483    }
484
485    fn handle_cheat_god_mode(
486        &mut self,
487        mut state: OverlordState,
488    ) -> EventHandleResult<OverlordEvent, OverlordState> {
489        let Some(active_fight) = &mut state.active_fight else {
490            return EventHandleResult::ok(state);
491        };
492
493        let Some(entity) = active_fight
494            .entities
495            .iter_mut()
496            .find(|e| e.id == active_fight.player_id)
497        else {
498            tracing::error!(
499                "Couldn't find player entity with id = {}",
500                active_fight.player_id
501            );
502            return EventHandleResult::fail(state);
503        };
504
505        entity.attributes.set("godmode", 1);
506
507        EventHandleResult::ok(state)
508    }
509
510    fn handle_cheat_god_mode_off(
511        &mut self,
512        mut state: OverlordState,
513    ) -> EventHandleResult<OverlordEvent, OverlordState> {
514        let Some(active_fight) = &mut state.active_fight else {
515            return EventHandleResult::ok(state);
516        };
517
518        let Some(entity) = active_fight
519            .entities
520            .iter_mut()
521            .find(|e| e.id == active_fight.player_id)
522        else {
523            tracing::error!(
524                "Couldn't find player entity with id = {}",
525                active_fight.player_id
526            );
527            return EventHandleResult::fail(state);
528        };
529
530        entity.attributes.set("godmode", 0);
531
532        EventHandleResult::ok(state)
533    }
534
535    fn handle_cheat_get_rich(
536        &mut self,
537        state: OverlordState,
538    ) -> EventHandleResult<OverlordEvent, OverlordState> {
539        let game_config = self.game_config.get();
540
541        let currencies: Vec<CurrencyUnit> = game_config
542            .currencies
543            .iter()
544            .map(|currency| CurrencyUnit {
545                currency_id: currency.id,
546                amount: 1000000,
547            })
548            .collect();
549
550        let events = vec![Self::currency_increase(&currencies, CurrencySource::Cheat)];
551        EventHandleResult::ok_events(state, events)
552    }
553
554    fn handle_cheat_clear_inventory(
555        &mut self,
556        mut state: OverlordState,
557    ) -> EventHandleResult<OverlordEvent, OverlordState> {
558        // Remove all items from inventory
559        state.character_state.inventory.clear();
560
561        EventHandleResult::ok(state)
562    }
563
564    fn handle_cheat_clear_slot(
565        &mut self,
566        item_id: uuid::Uuid,
567        mut state: OverlordState,
568    ) -> EventHandleResult<OverlordEvent, OverlordState> {
569        let Some(inv_item_idx) = state
570            .character_state
571            .inventory
572            .iter()
573            .position(|x| x.id == item_id)
574        else {
575            tracing::error!("Tried clearing undefined item: item_id={}", item_id);
576            return EventHandleResult::fail(state);
577        };
578
579        state.character_state.inventory.remove(inv_item_idx);
580
581        EventHandleResult::ok(state)
582    }
583
584    fn handle_cheat_get_all_spells(
585        &mut self,
586        mut state: OverlordState,
587    ) -> EventHandleResult<OverlordEvent, OverlordState> {
588        let game_config = self.game_config.get();
589
590        // Collect all gacha abilities with 1 shard each
591        let ability_shards: Vec<AbilityShard> = game_config
592            .abilities
593            .iter()
594            .filter(|ability| ability.is_gacha_ability)
595            .map(|ability| AbilityShard {
596                ability_id: ability.id,
597                shards_amount: 1,
598            })
599            .collect();
600
601        // Update existing abilities or add new ones
602        for shard in &ability_shards {
603            if let Some(existing_ability) = state
604                .character_state
605                .all_abilities
606                .iter_mut()
607                .find(|a| a.template_id == shard.ability_id)
608            {
609                // Add shards to existing ability
610                existing_ability.shards_amount += shard.shards_amount;
611            } else {
612                // This is a new ability, we need to create it
613                let Some(template) = game_config.ability_template(shard.ability_id) else {
614                    tracing::error!(
615                        "Failed to find ability template with id={}",
616                        shard.ability_id
617                    );
618                    continue;
619                };
620
621                let new_ability = essences::abilities::Ability::from_template(template, None, None);
622                state.character_state.all_abilities.push(new_ability);
623            }
624        }
625
626        EventHandleResult::ok(state)
627    }
628
629    fn handle_cheat_set_chapter(
630        &mut self,
631        chapter: i64,
632        mut state: OverlordState,
633    ) -> EventHandleResult<OverlordEvent, OverlordState> {
634        let game_config = self.game_config.get();
635
636        let prev_chapter_level = state.character_state.character.current_chapter_level;
637        state.character_state.character.current_chapter_level = chapter;
638        state.character_state.character.current_fight_number = 0;
639        state.character_state.character.last_boss_fight_won = true;
640
641        let Ok(current_chapter) = game_config
642            .require_chapter_by_level(state.character_state.character.current_chapter_level)
643            .cloned()
644        else {
645            tracing::error!(
646                "Failed to get chapter with chapter_level={}",
647                state.character_state.character.current_chapter_level
648            );
649            return EventHandleResult::fail(state);
650        };
651
652        let prepare_fight_delay_ticks =
653            self.get_prepare_fight_delay(true, &current_chapter, &state);
654
655        let mut events = vec![];
656        if self.should_reset_afk_timer_on_gating_unlock(prev_chapter_level, &state) {
657            events.push(EventPluginized::now(
658                OverlordEvent::AfkRewardsGatingUnlocked {},
659            ));
660        }
661        self.fight_clock.schedule(
662            OverlordEvent::PrepareFight {
663                prepare_fight_type: PrepareFightType::PVEFight,
664            },
665            prepare_fight_delay_ticks,
666        );
667
668        EventHandleResult::ok_events(state, events)
669    }
670
671    fn handle_cheat_start_fight(
672        &mut self,
673        fight_templated_id: FightTemplateId,
674        state: OverlordState,
675    ) -> EventHandleResult<OverlordEvent, OverlordState> {
676        let game_config = self.game_config.get();
677
678        self.fight_clock.schedule(
679            OverlordEvent::PrepareFight {
680                prepare_fight_type: PrepareFightType::SingleFight { fight_templated_id },
681            },
682            game_config
683                .fight_settings
684                .prepare_fight_win_delay_ticks_default,
685        );
686
687        EventHandleResult::ok(state)
688    }
689
690    #[allow(clippy::too_many_arguments)]
691    fn handle_cheat_spawn_entity(
692        &mut self,
693        entity_template_id: EntityTemplateId,
694        x: i64,
695        y: i64,
696        team: EntityTeam,
697        attributes: &Vec<(String, i64)>,
698        current_tick: u64,
699        mut state: OverlordState,
700        mut rand_gen: rand::rngs::StdRng,
701    ) -> EventHandleResult<OverlordEvent, OverlordState> {
702        let game_config = self.game_config.get();
703
704        let Some(active_fight) = &mut state.active_fight else {
705            return EventHandleResult::ok(state);
706        };
707
708        let entity_id = uuid::Builder::from_random_bytes(rand_gen.random()).into_uuid();
709
710        if active_fight
711            .entities
712            .iter()
713            .any(|entity| entity.id == entity_id)
714        {
715            tracing::error!("There is already an entity with id: {entity_id}");
716            return EventHandleResult::fail(state);
717        }
718
719        let Some(player) = active_fight.get_player() else {
720            tracing::error!("No player in fight");
721            return EventHandleResult::fail(state);
722        };
723
724        let position = Coordinates {
725            x: player.coordinates.x + x,
726            y: (player.coordinates.y + y).clamp(0, 2),
727        };
728
729        let fight_entity = FightEntity {
730            entity_type: EntityType::PVEEntity { entity_template_id },
731            position,
732            has_big_hp_bar: false,
733            team,
734        };
735
736        let mut entity_attributes = EntityAttributes::default();
737
738        for (key, value) in attributes {
739            entity_attributes.add(key, *value);
740        }
741
742        let mut created_entity = match create_pve_entity(
743            entity_id,
744            &fight_entity,
745            &game_config,
746            Some(entity_attributes),
747        ) {
748            Ok(entity) => entity,
749            Err(err) => {
750                tracing::error!("Couldn't create entity: {}", err.to_string());
751                return EventHandleResult::fail(state);
752            }
753        };
754
755        created_entity.abilities.iter().for_each(|ability| {
756            let cooldown = game_config
757                .ability_template(ability.ability.template_id)
758                .map(|t| t.cooldown)
759                .unwrap_or(0);
760            created_entity.actions_queue.push(&ActionWithDeadline::core(
761                self.make_start_cast_ability_action(created_entity.id, ability.ability.template_id),
762                current_tick + cooldown,
763            ))
764        });
765
766        active_fight.entities.push(created_entity);
767
768        EventHandleResult::ok(state)
769    }
770
771    fn handle_cheat_wear_equipment_set(
772        &mut self,
773        items_ids: &Vec<ItemTemplateId>,
774        mut state: OverlordState,
775        mut rand_gen: rand::rngs::StdRng,
776    ) -> EventHandleResult<OverlordEvent, OverlordState> {
777        let game_config = self.game_config.get();
778
779        state.character_state.inventory.clear();
780
781        for item_id in items_ids {
782            let item_template = game_config
783                .require_item_template(*item_id)
784                .unwrap_or_else(|_| panic!("Failed to get item with id={item_id}"));
785
786            let rarity = game_config
787                .require_item_rarity(item_template.rarity_id)
788                .unwrap_or_else(|_| {
789                    panic!("Failed to get rarity with id={}", item_template.rarity_id)
790                })
791                .clone();
792
793            let mut item = generate_item_from_template(
794                item_template,
795                rarity,
796                state.character_state.character.character_level,
797                // Cheat-spawned: keep the template's own count.
798                item_template.attributes_settings.optional_attributes_count,
799                &game_config,
800                &mut rand_gen,
801            );
802
803            let mut finalized_item = match try_finalize_item(
804                &mut item,
805                &game_config,
806                &self.behaviors,
807                &GameRng::from_entropy(),
808            ) {
809                Ok(()) => item,
810                Err(e) => {
811                    tracing::error!("Failed to finalize item: {}", e);
812                    return EventHandleResult::fail(state);
813                }
814            };
815
816            finalized_item.is_equipped = true;
817
818            state.character_state.inventory.push(finalized_item);
819        }
820
821        EventHandleResult::ok(state)
822    }
823
824    fn handle_cheat_equip_skin(
825        &mut self,
826        skin_id: SkinId,
827        mut state: OverlordState,
828    ) -> EventHandleResult<OverlordEvent, OverlordState> {
829        let game_config = self.game_config.get();
830
831        let Some(config_skin) = game_config.skin(skin_id) else {
832            tracing::error!("Failed to find skin with id: {skin_id}");
833            return EventHandleResult::fail(state);
834        };
835
836        let new_skin_type = config_skin.skin_type;
837
838        if let Some(pos) = state
839            .character_state
840            .character_skins
841            .equipped
842            .iter()
843            .position(|&s| {
844                game_config
845                    .skin(s)
846                    .is_some_and(|cs| cs.skin_type == new_skin_type)
847            })
848        {
849            let old_skin_id = state.character_state.character_skins.equipped.remove(pos);
850            state
851                .character_state
852                .character_skins
853                .available
854                .push(old_skin_id);
855        }
856
857        state.character_state.character_skins.equipped.push(skin_id);
858
859        EventHandleResult::ok(state)
860    }
861
862    fn handle_cheat_unequip_skin(
863        &mut self,
864        skin_id: SkinId,
865        mut state: OverlordState,
866    ) -> EventHandleResult<OverlordEvent, OverlordState> {
867        if let Some(pos) = state
868            .character_state
869            .character_skins
870            .equipped
871            .iter()
872            .position(|&s| s == skin_id)
873        {
874            state.character_state.character_skins.equipped.remove(pos);
875            state
876                .character_state
877                .character_skins
878                .available
879                .push(skin_id);
880        }
881
882        EventHandleResult::ok(state)
883    }
884
885    fn handle_cheat_new_level(
886        &mut self,
887        level: i64,
888        mut state: OverlordState,
889    ) -> EventHandleResult<OverlordEvent, OverlordState> {
890        state.character_state.character.character_level = level;
891        EventHandleResult::ok(state)
892    }
893
894    /// Native port of the dev-only `Cheat::Script` cheats.
895    ///
896    /// `BehaviorRegistry::run_event` (scoped with `CharacterState`/`Random`). The four
897    /// deployed cheat bodies use only loop-task building blocks that already have
898    /// native implementations, so we dispatch on `script_id` and reproduce the
899    /// produced. No new mechanics — purely a faithful 1:1 port of the shipped
900    /// bodies in `overlord/admin/config/scripts/content.templates.cheat_scripts.*`.
901    fn handle_cheat_script(
902        &mut self,
903        script_id: CheatScriptId,
904        rand_gen: rand::rngs::StdRng,
905        state: OverlordState,
906    ) -> EventHandleResult<OverlordEvent, OverlordState> {
907        let game_config = self.game_config.get();
908
909        let Some(_cheat_script) = game_config.cheat_script(script_id) else {
910            tracing::error!("Failed to find cheat_script with id: {script_id}");
911            return EventHandleResult::fail(state);
912        };
913
914        // The shipped UUID literals are well-formed; `expect` on a literal mirrors
915        let parse_uuid =
916            |s: &str| uuid::Uuid::parse_str(s).expect("valid cheat-script uuid literal");
917
918        let mut events: Vec<OverlordEvent> = Vec::new();
919
920        match script_id.to_string().as_str() {
921            // 019be635: give_quests([...]) + prepare_loop(RNG) + advance_loop.
922            // `give_quests([..])` maps to a single `NewQuests` carrying the whole
923            // list (matches the old `register_fn("give_quests", ..)`), then the
924            // loop-task prepare/advance pair (prepare consumes RNG).
925            "019be635-9bc8-7a67-b7df-2cc01c2001c5" => {
926                let quest_ids = [
927                    "019a4b43-be66-76d6-838f-153a717f82ff",
928                    "019a6ef3-740e-7150-b795-b0cbd681ceef",
929                    "019b561c-4f34-76c2-8a53-7b55667e7aea",
930                    "019b561e-5450-7d29-ae94-d2ddc340030d",
931                    "019b561f-bea7-71cc-834f-0909dead9d4b",
932                    "019b5621-6475-794b-b850-502d8e124422",
933                    "019bc1e6-394d-7c7e-b70c-55764b7455cd",
934                    "019bc29c-572e-7c7d-a9e3-8b10a6d28550",
935                    "019bc29c-b647-7288-a6ba-668bd37e1c5b",
936                    "019bdb53-7ed5-731c-a056-06df0b628065",
937                    "019be138-4fee-7ad6-a1a3-7b1539a7bf69",
938                    "019be13b-ba5f-71b1-8982-ca1734a7367b",
939                ]
940                .into_iter()
941                .map(parse_uuid)
942                .collect::<Vec<_>>();
943                events.push(OverlordEvent::NewQuests { quest_ids });
944
945                let rng = event_system::script::random::GameRng::new(rand_gen);
946                crate::mechanics::loop_tasks::prepare_loop(
947                    &mut events,
948                    &game_config,
949                    &state.character_state,
950                    &rng,
951                );
952                crate::mechanics::loop_tasks::advance_loop(
953                    &mut events,
954                    &game_config,
955                    self.behaviors.lookups(),
956                    &state.character_state,
957                );
958            }
959
960            // 019bea59: loop_tasks::advance_loop(Result, CharacterState).
961            "019bea59-c643-7a11-8985-f02ba314bfd6" => {
962                crate::mechanics::loop_tasks::advance_loop(
963                    &mut events,
964                    &game_config,
965                    self.behaviors.lookups(),
966                    &state.character_state,
967                );
968            }
969
970            // 019bfca8: push OverlordEventCustomEvent("CompleteAllLoopTasks", CustomEventData()).
971            "019bfca8-953e-76c5-8b33-d83662f9dbb5" => {
972                events.push(OverlordEvent::CustomEvent {
973                    event_type: "CompleteAllLoopTasks".to_string(),
974                    data: crate::event::CustomEventData(Default::default()),
975                });
976            }
977
978            // 019c1f08: push OverlordEventUpdateActiveLoopTaskId(uuid("019b561e-..")).
979            "019c1f08-d84d-7c5f-b71d-1368ac514461" => {
980                events.push(OverlordEvent::UpdateActiveLoopTaskId {
981                    quest_id: parse_uuid("019b561e-5450-7d29-ae94-d2ddc340030d"),
982                });
983            }
984
985            // branch also produced no events, so this stays a no-op.
986            other => {
987                tracing::warn!(
988                    "Cheat::Script (id={other}) has no native implementation; \
989                     producing no events"
990                );
991            }
992        }
993
994        let events = events.into_iter().map(EventPluginized::now).collect();
995        EventHandleResult::ok_events(state, events)
996    }
997}