overlord_event_system/logic/
items.rs

1use crate::{
2    event::OverlordEvent, game_config_helpers::GameConfigLookup, logic::handler::OverlordLogic,
3    state::OverlordState,
4};
5
6use essences::currency::check_can_decrease_currencies;
7
8use configs::buffs::{apply_buff_multiplier, currency_exp_buff_multiplier};
9use essences::{
10    autochest::{AutoChestFilter, filter_items},
11    currency::{CurrencyConsumer, CurrencySource, CurrencyUnit, increase_currencies},
12    items::{Item, ItemType},
13};
14
15use event_system::{event::EventPluginized, script::random::GameRng, system::EventHandleResult};
16
17use uuid::Uuid;
18
19impl OverlordLogic {
20    /// `max_batch` is passed in rather than read here: the manual and auto
21    /// paths share this body but are subject to DIFFERENT caps. Reading one
22    /// field for both is what let the auto ladder inherit the manual one.
23    pub(crate) fn open_item_case(
24        &self,
25        batch_size: i64,
26        max_batch: i64,
27        rng: &mut rand::rngs::StdRng,
28        state: OverlordState,
29    ) -> EventHandleResult<OverlordEvent, OverlordState> {
30        let game_config = self.game_config.get();
31
32        tracing::debug!("Open case handler !frontend");
33
34        let character_item_case_level = state.character_state.character.item_case_level;
35        let Ok(case_settings) =
36            game_config.require_item_case_settings_by_level(character_item_case_level)
37        else {
38            tracing::error!(
39                "Couldn't find item_case_settings with level = {character_item_case_level}"
40            );
41            return EventHandleResult::fail(state);
42        };
43        if batch_size > max_batch {
44            tracing::error!("Batch size {batch_size} is bigger than max allowed {max_batch}");
45            return EventHandleResult::fail(state);
46        }
47
48        use crate::{cases, gacha};
49
50        // Пачка катится по одному предмету, и каждый следующий бросок видит уже
51        // выпавшие: инвентарь обновится только позже, в `PlayerNewItems`, а
52        // гарантия на пустой слот считает по инвентарю. Без этого открытие
53        // пачкой выдало бы `batch_size` одинаковых гарантированных предметов.
54        let mut items: Vec<Item> = Vec::with_capacity(batch_size.max(0) as usize);
55        for _ in 0..batch_size {
56            // Seeded from the session stream, not OS entropy: `try_open_item_case`
57            // has always taken a seed, but the only production caller passed
58            // `None` and fell through to `SysRng`. That, plus the attribute rolls
59            // below, is why no seed reproduced a run.
60            let case_seed: u64 = rand::RngExt::random(rng);
61            let mut item = match gacha::item_case::try_open_item_case(
62                &state.character_state,
63                &game_config,
64                Some(case_seed),
65                &self.behaviors,
66                &items,
67            ) {
68                Ok(x) => x,
69                Err(e) => {
70                    tracing::error!("Couldn't open case: {e}");
71                    continue;
72                }
73            };
74
75            // Its own sub-stream, drawn from the session rng: attribute values
76            // used to come from a `GameRng::from_entropy()` minted per item, so
77            // no seed could reproduce them.
78            let attr_seed: u64 = rand::RngExt::random(rng);
79            let attr_rng = GameRng::new(rand::SeedableRng::seed_from_u64(attr_seed));
80            match cases::try_finalize_item(&mut item, &game_config, &self.behaviors, &attr_rng) {
81                Ok(()) => items.push(item),
82                Err(e) => {
83                    tracing::error!("Failed to finalize item: {}", e);
84                }
85            }
86        }
87
88        // Путь шарика решает сервер: слот определяет подпись редкости под
89        // шариком, а значит и награду. Доска берётся под уровень сундука
90        // игрока — набор выпадающих редкостей зависит от уровня. Катим путь
91        // тем же RNG, что и остальная серверная случайность этого события
92        // (сид сессии + логическое время), поэтому он воспроизводим и
93        // тестируем.
94        //
95        // Порядок событий важен: сперва `PlayerNewItems` (кладёт предмет в
96        // инвентарь), затем `PlinkoBallDropped` на каждый предмет.
97        let ball_events: Vec<_> = items
98            .iter()
99            .filter_map(|item| {
100                match crate::mechanics::plinko::roll_path_for_rarity(
101                    &game_config.plinko_settings,
102                    character_item_case_level,
103                    &case_settings.rarity_weights,
104                    item.rarity.id,
105                    rng,
106                ) {
107                    Ok(path) => Some(EventPluginized::now(OverlordEvent::PlinkoBallDropped {
108                        item_id: item.id,
109                        path,
110                    })),
111                    Err(e) => {
112                        tracing::error!("Couldn't roll plinko path for item {}: {e}", item.id);
113                        None
114                    }
115                }
116            })
117            .collect();
118
119        // Камни здесь больше не падают: их источник переехал на убийства мобов
120        // (`OverlordLogic::roll_stone_kill_drop`, вызов в `logic::fighting`).
121        // Сундуки — покупаемый канал, и привязанный к ним фаусет давал киту на
122        // два порядка больше камней, чем игроку, который их выбивает.
123        let mut events = vec![EventPluginized::now(OverlordEvent::PlayerNewItems {
124            items,
125        })];
126        events.extend(ball_events);
127
128        EventHandleResult::ok_events(state, events)
129    }
130
131    /// Начисляет постоянные прибавки к статам за колышки, задетые шариком.
132    ///
133    /// Колышки выводятся из пути детерминированно (`pins_touched_by_path`),
134    /// поэтому один и тот же путь всегда даёт один и тот же набор колышков.
135    /// Прибавки складываются в `character_state.plinko_pin_bonuses`, откуда
136    /// их подхватывает общий агрегатор атрибутов — тем же путём, что таланты
137    /// и статуя, так что бонус попадает и в бой, и в отображаемые статы, и в
138    /// Power.
139    pub fn handle_plinko_ball_dropped(
140        &self,
141        item_id: uuid::Uuid,
142        path: &[essences::plinko::PlinkoDirection],
143        mut state: OverlordState,
144    ) -> EventHandleResult<OverlordEvent, OverlordState> {
145        let game_config = self.game_config.get();
146        let settings = &game_config.plinko_settings;
147
148        if path.len() as i64 != settings.rows {
149            tracing::error!(
150                "Plinko path for item {item_id} has {} steps, expected {}",
151                path.len(),
152                settings.rows
153            );
154            return EventHandleResult::fail(state);
155        }
156
157        // The chest level is what decides which tranche the ball is filling —
158        // the board and its prices belong to the level the player is on
159        // (BAL-021).
160        let chest_level = state.character_state.character.item_case_level;
161        crate::mechanics::plinko::apply_pin_bonuses(
162            settings,
163            chest_level,
164            path,
165            &mut state.character_state.plinko_pin_bonuses,
166        );
167
168        EventHandleResult::ok(state)
169    }
170
171    pub fn handle_open_item_case(
172        &self,
173        batch_size: i64,
174        mut rng: rand::rngs::StdRng,
175        state: OverlordState,
176    ) -> EventHandleResult<OverlordEvent, OverlordState> {
177        let game_config = self.game_config.get();
178
179        if state.character_state.character.auto_chest_enabled {
180            tracing::error!("Can't open item case when auto_chest is enabled");
181            return EventHandleResult::fail(state);
182        }
183
184        let required_currency = vec![CurrencyUnit {
185            currency_id: game_config.game_settings.item_case_currency_id,
186            amount: batch_size,
187        }];
188
189        if !check_can_decrease_currencies(&state.character_state.currencies, &required_currency) {
190            tracing::error!(
191                "Not enough currency for open_item_case opening\nGot = {:?}\nRequired = {:?}",
192                state.character_state.currencies,
193                required_currency
194            );
195
196            return EventHandleResult::fail(state);
197        };
198
199        if !self.frontend {
200            // Ручная лестница: batch_size приходит от клиента, и эта проверка —
201            // единственная граница перед аллокацией и циклом гачи.
202            let max_batch = game_config
203                .item_case_settings_by_level(state.character_state.character.item_case_level)
204                .map(|s| s.auto_chest_settings.max_batch_size)
205                .unwrap_or(0);
206            return self.open_item_case(batch_size, max_batch, &mut rng, state);
207        }
208        EventHandleResult::ok(state)
209    }
210
211    pub fn handle_auto_chest_open_item_case(
212        &self,
213        batch_size: i64,
214        mut rng: rand::rngs::StdRng,
215        state: OverlordState,
216    ) -> EventHandleResult<OverlordEvent, OverlordState> {
217        let game_config = self.game_config.get();
218
219        if !state.character_state.character.auto_chest_enabled {
220            tracing::error!("Can't open auto_chest item case when auto_chest is disabled");
221            return EventHandleResult::fail(state);
222        }
223
224        let required_currency = vec![CurrencyUnit {
225            currency_id: game_config.game_settings.item_case_currency_id,
226            amount: batch_size,
227        }];
228
229        if !check_can_decrease_currencies(&state.character_state.currencies, &required_currency) {
230            tracing::error!(
231                "Not enough currency for auto_chest_item_case opening\nGot = {:?}\nRequired = {:?}",
232                state.character_state.currencies,
233                required_currency
234            );
235
236            return EventHandleResult::ok_events(
237                state,
238                vec![EventPluginized::now(OverlordEvent::DisableAutoChest {})],
239            );
240        };
241
242        if !self.frontend {
243            // Авто-лестница: отдельная и более низкая, потому что авто-режим
244            // работает непрерывно. Не заменять на max_batch_size.
245            let max_batch = game_config
246                .item_case_settings_by_level(state.character_state.character.item_case_level)
247                .map(|s| s.auto_chest_settings.auto_only_max_batch_size)
248                .unwrap_or(0);
249            return self.open_item_case(batch_size, max_batch, &mut rng, state);
250        }
251        EventHandleResult::ok(state)
252    }
253
254    pub fn handle_player_new_items(
255        &self,
256        items: &[Item],
257        mut state: OverlordState,
258    ) -> EventHandleResult<OverlordEvent, OverlordState> {
259        let game_config = self.game_config.get();
260
261        let required_currency = vec![CurrencyUnit {
262            currency_id: game_config.game_settings.item_case_currency_id,
263            amount: items.len() as i64,
264        }];
265
266        let mut events = vec![];
267
268        let Some(currency_event) =
269            Self::currency_decrease(&state, &required_currency, CurrencyConsumer::ItemCaseOpen)
270        else {
271            return EventHandleResult::fail(state);
272        };
273        events.push(currency_event);
274
275        let mut items = items.to_owned();
276
277        if state.character_state.character.auto_chest_enabled {
278            let filter = state.auto_chest.active_filter.clone();
279            let power_compare_enabled = state.auto_chest.power_compare_enabled;
280            let items_to_equip = match self.filter_new_items(
281                &filter,
282                power_compare_enabled,
283                &items,
284                &mut state,
285                &mut events,
286            ) {
287                Ok(items_to_equip) => items_to_equip,
288                Err(e) => {
289                    tracing::error!("Something went wrong while filtering = {}", e);
290                    return EventHandleResult::fail(state);
291                }
292            };
293
294            // The stack accumulates: keepers never pause the auto chain. Each
295            // logical slot banks at most one candidate — the strongest — and
296            // weaker same-slot candidates are auto-sold on arrival.
297            let items_to_equip =
298                match self.merge_auto_stack(items_to_equip, &mut state, &mut events) {
299                    Ok(winners) => winners,
300                    Err(e) => {
301                        tracing::error!(
302                            "Something went wrong while merging the auto stack = {}",
303                            e
304                        );
305                        return EventHandleResult::fail(state);
306                    }
307                };
308
309            // The client presents every rolled ball of this batch one by one
310            // (auto-sold ones included), so the pause before the next open
311            // grows with the DELIVERED batch size — otherwise the server
312            // outruns the animation and the client's launch queue grows
313            // without bound.
314            let delivered_balls = items.len() as u64;
315            let pause_ticks = game_config
316                .game_settings
317                .auto_chest_pause_duration_ticks
318                .saturating_add(
319                    delivered_balls
320                        .saturating_mul(game_config.game_settings.auto_chest_per_ball_pause_ticks),
321                );
322            events.push(EventPluginized::delayed(
323                OverlordEvent::AutoChestOpenItemCase {
324                    batch_size: state.auto_chest.batch_size,
325                },
326                pause_ticks,
327            ));
328
329            items = items_to_equip;
330        }
331
332        for item in &items {
333            if let Some(item_template) = game_config.item_template(item.item_template_id)
334                && let Some(skin_id) = item_template.skin_id
335            {
336                if game_config.require_skin(skin_id).is_err() {
337                    tracing::error!(
338                        "Skin with id={} not found in config for item_template_id={}",
339                        skin_id,
340                        item.item_template_id
341                    );
342                    return EventHandleResult::fail(state);
343                }
344
345                if !state
346                    .character_state
347                    .character_skins
348                    .is_already_unlocked(skin_id)
349                {
350                    state
351                        .character_state
352                        .character_skins
353                        .blocked
354                        .retain(|s| *s != skin_id);
355                    state
356                        .character_state
357                        .character_skins
358                        .available
359                        .push(skin_id);
360                }
361            }
362        }
363
364        state.character_state.inventory.append(&mut items);
365
366        EventHandleResult::ok_events(state, events)
367    }
368
369    fn filter_new_items(
370        &self,
371        filter: &Option<AutoChestFilter>,
372        power_compare_enabled: bool,
373        items: &[Item],
374        state: &mut OverlordState,
375        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
376    ) -> anyhow::Result<Vec<Item>> {
377        let game_config = self.game_config.get();
378
379        let (item_powers, equipped_item_powers) =
380            state.auto_chest_power_maps(items, &game_config, &self.behaviors)?;
381
382        let (items_to_sell, items_to_equip) = filter_items(
383            items,
384            filter,
385            power_compare_enabled,
386            &game_config.item_rarities,
387            &item_powers,
388            &equipped_item_powers,
389        )?;
390
391        let buff_mult = currency_exp_buff_multiplier(
392            &state.character_state.active_buffs,
393            &game_config.buff_templates,
394            ::time::utc_now(),
395        );
396
397        let mut gained_currencies = vec![];
398
399        for item in items_to_sell {
400            let scaled_price: Vec<CurrencyUnit> = item
401                .price
402                .iter()
403                .map(|c| CurrencyUnit {
404                    currency_id: c.currency_id,
405                    amount: apply_buff_multiplier(c.amount, buff_mult),
406                })
407                .collect();
408            increase_currencies(&mut gained_currencies, &scaled_price);
409            state.character_state.character.character_experience +=
410                apply_buff_multiplier(item.experience, buff_mult);
411            events.push(EventPluginized::now(OverlordEvent::ItemSold {
412                item_id: item.id,
413            }));
414        }
415
416        events.push(Self::currency_increase(
417            &gained_currencies,
418            CurrencySource::AutoItemSell,
419        ));
420
421        Ok(items_to_equip)
422    }
423
424    /// Auto-chest stack maintenance: the stack (unequipped inventory) holds at
425    /// most ONE candidate per logical slot — the strongest. Every incoming
426    /// keeper challenges the prior batch pick and the items already banked for
427    /// its slot; every loser is auto-sold with the same pricing as the
428    /// filter's rejects.
429    ///
430    /// Deterministic on purpose (this code replays on the client): candidates
431    /// are ordered prior-batch pick, then banked stack items in inventory
432    /// order, then the new drop, and a tie keeps the earliest candidate.
433    fn merge_auto_stack(
434        &self,
435        incoming: Vec<Item>,
436        state: &mut OverlordState,
437        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
438    ) -> anyhow::Result<Vec<Item>> {
439        let game_config = self.game_config.get();
440
441        let mut winners: Vec<Item> = Vec::new();
442        let mut losers: Vec<Item> = Vec::new();
443
444        for item in incoming {
445            let key = item.equipment_slot_key();
446
447            let mut candidates: Vec<Item> = Vec::new();
448            let mut i = 0;
449            while i < winners.len() {
450                if winners[i].equipment_slot_key() == key {
451                    candidates.push(winners.remove(i));
452                } else {
453                    i += 1;
454                }
455            }
456            let mut i = 0;
457            while i < state.character_state.inventory.len() {
458                let inv_item = &state.character_state.inventory[i];
459                if !inv_item.is_equipped && inv_item.equipment_slot_key() == key {
460                    candidates.push(state.character_state.inventory.remove(i));
461                } else {
462                    i += 1;
463                }
464            }
465            candidates.push(item);
466
467            // Same metric as the filter above and as the client's own compare:
468            // character power with that candidate in the slot.
469            let mut best_idx = 0;
470            let mut best_power = state.calculate_power_with_candidate_equipped(
471                &candidates[0],
472                &game_config,
473                &self.behaviors,
474            )?;
475            for (idx, candidate) in candidates.iter().enumerate().skip(1) {
476                let power = state.calculate_power_with_candidate_equipped(
477                    candidate,
478                    &game_config,
479                    &self.behaviors,
480                )?;
481                if power > best_power {
482                    best_idx = idx;
483                    best_power = power;
484                }
485            }
486
487            for (idx, candidate) in candidates.into_iter().enumerate() {
488                if idx == best_idx {
489                    winners.push(candidate);
490                } else {
491                    losers.push(candidate);
492                }
493            }
494        }
495
496        if !losers.is_empty() {
497            let buff_mult = currency_exp_buff_multiplier(
498                &state.character_state.active_buffs,
499                &game_config.buff_templates,
500                ::time::utc_now(),
501            );
502            let mut gained_currencies = vec![];
503            for item in losers {
504                let scaled_price: Vec<CurrencyUnit> = item
505                    .price
506                    .iter()
507                    .map(|c| CurrencyUnit {
508                        currency_id: c.currency_id,
509                        amount: apply_buff_multiplier(c.amount, buff_mult),
510                    })
511                    .collect();
512                increase_currencies(&mut gained_currencies, &scaled_price);
513                state.character_state.character.character_experience +=
514                    apply_buff_multiplier(item.experience, buff_mult);
515                events.push(EventPluginized::now(OverlordEvent::ItemSold {
516                    item_id: item.id,
517                }));
518            }
519            events.push(Self::currency_increase(
520                &gained_currencies,
521                CurrencySource::AutoItemSell,
522            ));
523        }
524
525        Ok(winners)
526    }
527
528    pub fn handle_player_equip_item(
529        &self,
530        item_id: Uuid,
531        mut state: OverlordState,
532    ) -> EventHandleResult<OverlordEvent, OverlordState> {
533        let game_config = self.game_config.get();
534
535        let Some(item) = state
536            .character_state
537            .inventory
538            .iter()
539            .find(|x| x.id == item_id)
540            .cloned()
541        else {
542            tracing::error!("Tried equiping non-existing item_id={}", item_id);
543            return EventHandleResult::fail(state);
544        };
545
546        let equipment_slot_key = item.equipment_slot_key();
547        let slot_is_unlocked = game_config
548            .inventory_levels
549            .iter()
550            .rev()
551            .find(|level| {
552                level.from_chapter_level <= state.character_state.character.current_chapter_level
553            })
554            .is_some_and(|level| {
555                level
556                    .slots
557                    .iter()
558                    .any(|slot| slot.equipment_slot_key() == equipment_slot_key)
559            });
560        if !slot_is_unlocked {
561            tracing::warn!(
562                item_id = %item.id,
563                item_type = %item.item_type,
564                world_side = ?item.world_side,
565                chapter_level = state.character_state.character.current_chapter_level,
566                "Rejected equip into a locked logical equipment slot"
567            );
568            return EventHandleResult::fail(state);
569        }
570
571        let prev_item = state
572            .character_state
573            .inventory
574            .iter()
575            .find(|inv_item| {
576                inv_item.equipment_slot_key() == equipment_slot_key
577                    && inv_item.id != item.id
578                    && inv_item.is_equipped
579            })
580            .cloned();
581        let displaced_item_id = prev_item.as_ref().map(|prev| prev.id);
582
583        state
584            .character_state
585            .inventory
586            .iter_mut()
587            .map(|inv_item| {
588                if inv_item.equipment_slot_key() == equipment_slot_key {
589                    inv_item.is_equipped = false;
590                }
591                if inv_item.id == item.id {
592                    inv_item.is_equipped = true;
593                }
594            })
595            .count();
596
597        let mut equip_skin_ids = vec![];
598        let mut unequip_skin_ids = vec![];
599
600        let gear_override_enabled = state
601            .character_state
602            .character_settings
603            .gear_override_enabled_item_types
604            .contains(&item.item_type);
605
606        if !gear_override_enabled {
607            let currently_equipped = &state.character_state.character_skins.equipped;
608
609            // Unequip the prev item's skin only if it is still actually equipped.
610            // If the player has customized away from it, there is nothing to unequip.
611            if let Some(prev_item) = prev_item
612                && let Some(prev_item_template) =
613                    game_config.item_template(prev_item.item_template_id)
614                && let Some(prev_skin_id) = prev_item_template.skin_id
615                && currently_equipped.contains(&prev_skin_id)
616            {
617                unequip_skin_ids.push(prev_skin_id);
618            }
619
620            // Equip the new item's skin unless it is already equipped. If it is,
621            // make sure we do not also unequip it via the prev-item path above
622            // (e.g. when two items share a skin_id and that skin is equipped).
623            if let Some(item_template) = game_config.item_template(item.item_template_id)
624                && let Some(skin_id) = item_template.skin_id
625            {
626                if game_config.require_skin(skin_id).is_err() {
627                    tracing::error!(
628                        "Skin with id={} not found in config for item_template_id={}",
629                        skin_id,
630                        item.item_template_id
631                    );
632                    return EventHandleResult::fail(state);
633                }
634
635                if currently_equipped.contains(&skin_id) {
636                    unequip_skin_ids.retain(|id| *id != skin_id);
637                } else {
638                    equip_skin_ids.push(skin_id);
639                }
640            }
641        }
642
643        let mut events = vec![];
644        if !equip_skin_ids.is_empty() || !unequip_skin_ids.is_empty() {
645            events.push(EventPluginized::now(OverlordEvent::EquipAndUnequipSkins {
646                equip_skin_ids,
647                unequip_skin_ids,
648            }));
649        }
650
651        // A2-BAL-003 §3.4: the displaced item is sold HERE, at the moment of the
652        // upgrade.
653        //
654        // This handler used to only unequip it. Auto Chest would then sell it
655        // later, whenever the next same-slot conflict happened to come along,
656        // and the manual path sold it at a different moment again — so the Gold
657        // and item XP from one roll landed at an unpredictable time, and the
658        // signed "one sale per post-fill roll" ledger could not be checked.
659        // Selling at equip time makes both paths identical and the moment exact.
660        //
661        // An equip into an empty slot displaces nothing and therefore sells
662        // nothing, which is what keeps the pre-fill phase free of sales.
663        if let Some(displaced) = displaced_item_id {
664            events.push(EventPluginized::now(OverlordEvent::SellItem {
665                item_id: displaced,
666            }));
667        }
668
669        // No auto-chest resume here: the chain never pauses on keepers (see
670        // merge_auto_stack), so re-scheduling from an equip would fork it.
671
672        EventHandleResult::ok_events(state, events)
673    }
674
675    pub fn handle_sell_item(
676        &self,
677        item_id: Uuid,
678        mut state: OverlordState,
679    ) -> EventHandleResult<OverlordEvent, OverlordState> {
680        let game_config = self.game_config.get();
681
682        let Some(inv_item_idx) = state
683            .character_state
684            .inventory
685            .iter()
686            .position(|x| x.id == item_id)
687        else {
688            tracing::error!("Tried selling undefined item: item_id={}", item_id);
689            return EventHandleResult::fail(state);
690        };
691
692        let removed_item = state.character_state.inventory.remove(inv_item_idx);
693
694        let mut events = Vec::new();
695
696        let buff_mult = currency_exp_buff_multiplier(
697            &state.character_state.active_buffs,
698            &game_config.buff_templates,
699            ::time::utc_now(),
700        );
701        let scaled_price: Vec<CurrencyUnit> = removed_item
702            .price
703            .iter()
704            .map(|c| CurrencyUnit {
705                currency_id: c.currency_id,
706                amount: apply_buff_multiplier(c.amount, buff_mult),
707            })
708            .collect();
709
710        events.push(Self::currency_increase(
711            &scaled_price,
712            CurrencySource::ItemSell,
713        ));
714        events.push(EventPluginized::now(OverlordEvent::ItemSold { item_id }));
715        state.character_state.character.character_experience +=
716            apply_buff_multiplier(removed_item.experience, buff_mult);
717
718        // No auto-chest resume here either — the chain never pauses on keepers.
719
720        EventHandleResult::ok_events(state, events)
721    }
722
723    pub fn handle_enable_auto_sell(
724        &self,
725        mut state: OverlordState,
726    ) -> EventHandleResult<OverlordEvent, OverlordState> {
727        state.character_state.character_settings.auto_sell_enabled = true;
728
729        EventHandleResult::ok(state)
730    }
731
732    pub fn handle_disable_auto_sell(
733        &self,
734        mut state: OverlordState,
735    ) -> EventHandleResult<OverlordEvent, OverlordState> {
736        state.character_state.character_settings.auto_sell_enabled = false;
737
738        EventHandleResult::ok(state)
739    }
740
741    pub fn handle_set_gear_override_enabled(
742        &self,
743        item_type: ItemType,
744        enabled: bool,
745        mut state: OverlordState,
746    ) -> EventHandleResult<OverlordEvent, OverlordState> {
747        let overrides = &mut state
748            .character_state
749            .character_settings
750            .gear_override_enabled_item_types;
751
752        if enabled {
753            if !overrides.contains(&item_type) {
754                overrides.push(item_type);
755            }
756            return EventHandleResult::ok(state);
757        }
758
759        overrides.retain(|t| *t != item_type);
760
761        let game_config = self.game_config.get();
762        let skin_ids: Vec<Uuid> = state
763            .character_state
764            .inventory
765            .iter()
766            .filter(|item| item.is_equipped && item.item_type == item_type)
767            .filter_map(|item| {
768                game_config
769                    .item_template(item.item_template_id)
770                    .and_then(|template| template.skin_id)
771            })
772            .collect();
773
774        if skin_ids.is_empty() {
775            return EventHandleResult::ok(state);
776        }
777
778        EventHandleResult::ok_events(
779            state,
780            vec![EventPluginized::now(OverlordEvent::EquipAndUnequipSkins {
781                equip_skin_ids: skin_ids,
782                unequip_skin_ids: vec![],
783            })],
784        )
785    }
786
787    pub fn handle_enable_case_upgrade_pop_up(
788        &self,
789        mut state: OverlordState,
790    ) -> EventHandleResult<OverlordEvent, OverlordState> {
791        state
792            .character_state
793            .character_settings
794            .dont_show_case_upgrade_popup_today = false;
795
796        EventHandleResult::ok(state)
797    }
798
799    pub fn handle_disable_case_upgrade_pop_up(
800        &self,
801        mut state: OverlordState,
802    ) -> EventHandleResult<OverlordEvent, OverlordState> {
803        state
804            .character_state
805            .character_settings
806            .dont_show_case_upgrade_popup_today = true;
807
808        EventHandleResult::ok(state)
809    }
810}
811
812/// Удаляет предметы с истёкшим TTL (`expires_at <= now`), возвращает
813/// `ItemsExpired` (пустой — если нечего). В отличие от продажи ничего не
814/// начисляет; экипированные тоже удаляются, силу пересчитает `compute_fields`.
815pub fn sweep_expired_items(
816    state: &mut OverlordState,
817    now: chrono::DateTime<chrono::Utc>,
818) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
819    let expired_ids: Vec<Uuid> = state
820        .character_state
821        .inventory
822        .iter()
823        .filter(|item| item.expires_at.is_some_and(|exp| exp <= now))
824        .map(|item| item.id)
825        .collect();
826
827    if expired_ids.is_empty() {
828        return Vec::new();
829    }
830
831    state
832        .character_state
833        .inventory
834        .retain(|item| !expired_ids.contains(&item.id));
835
836    vec![EventPluginized::now(OverlordEvent::ItemsExpired {
837        item_ids: expired_ids,
838    })]
839}