configs/
game_config.rs

1use essences::abilities::{AbilityRarity, AbilityTemplate};
2use essences::ability_presets::AbilityPresetsSettings;
3use essences::bundles::BundleRaw;
4use essences::chats::ChatSettings;
5use essences::class;
6use essences::currency::Currency;
7use essences::dungeons::DungeonTemplate;
8use essences::effect::Effect;
9use essences::fighting::FightTemplate;
10use essences::game::{AbilitySlotsLevel, Chapter, CharacterLevel, EntityTemplate, PetSlotsLevel};
11use essences::gatings::Gatings;
12use essences::generation::{BotsSettings, UsersGeneratingSettings};
13use essences::gift::GiftTemplate;
14use essences::item_case::{InventoryLevel, ItemCasesSettingsByLevel};
15use essences::items::{Attribute, ItemRarity, ItemTemplate};
16use essences::mail::MailTemplate;
17use essences::offers::{OfferTemplate, ShopTabConfig};
18use essences::pets::{PetRarity, PetTemplate};
19use essences::quest::{QuestGroupType, QuestTemplate, QuestsProgressionSettings};
20use essences::ratings::RatingSettings;
21use essences::referrals::ReferralLevelInfo;
22use essences::skins::{ConfigSkin, SkinsSettings};
23use essences::vassals::VassalTaskTemplate;
24use schema_loader::script_schema;
25use schemars::JsonSchema;
26
27use serde::{Deserialize, Serialize};
28use tsify_next::Tsify;
29
30use crate::abilities::{AbilityCasesSettingsByLevel, AbilityLevel, Projectile};
31use crate::ads_settings::{AdsSettings, BirdVariant};
32use crate::afk_rewards::{AfkRewardBonusType, AfkRewardsByLevel, AfkRewardsSettings};
33use crate::buffs::BuffTemplate;
34use crate::cheats::{CheatScript, TestPlayerScript};
35use crate::events::EventDescription;
36use crate::fighting::{ArenaLeague, ArenaSettings, FightSettings, PvpSettings};
37use crate::game_settings::GameSettings;
38use crate::matchmaking::MatchmakingSettings;
39use crate::pets::{PetCasesSettingsByLevel, PetLevel};
40use crate::reports::ReportsSettings;
41use crate::statue::{StatueBonusTypeConfig, StatueLevelConfig, StatueSettings};
42use crate::tutorial::TutorialStep;
43use crate::validated_types::NonEmptyVec;
44use crate::vassals::VassalsSettings;
45use essences::statue::StatueBonusGrade;
46use essences::talent_tree::{TalentTemplate, TalentTreeSettings};
47
48#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
49pub struct ScriptModule {
50    #[schemars(title = "Название")]
51    pub name: String,
52    #[schemars(title = "Скрипт", schema_with = "script_schema")]
53    pub script: String,
54}
55
56#[derive(Clone, Serialize, Deserialize, JsonSchema, Tsify)]
57#[tsify(into_wasm_abi)]
58pub struct GameConfig {
59    #[schemars(title = "Скриптовые библиотеки")]
60    pub script_modules: Vec<ScriptModule>,
61
62    #[schemars(title = "Атрибуты")]
63    pub attributes: Vec<Attribute>,
64
65    #[schemars(title = "Настройки сундука-гачи-айтемов")]
66    pub item_cases_settings: Vec<ItemCasesSettingsByLevel>,
67
68    #[schemars(
69        title = "Настройки игры",
70        description = "Отдельные значения, которые используются в игре"
71    )]
72    pub game_settings: GameSettings,
73
74    #[schemars(
75        title = "Настройки действий за рекламу",
76        description = "Все настройки действий, связанные с рекламой"
77    )]
78    pub ads_settings: AdsSettings,
79
80    #[schemars(title = "Предметы")]
81    pub items: Vec<ItemTemplate>,
82
83    #[schemars(title = "Редкости предметов")]
84    pub item_rarities: Vec<ItemRarity>,
85
86    #[schemars(title = "Скины")]
87    pub skins: Vec<ConfigSkin>,
88
89    #[schemars(title = "Настройки скинов для кастомизации")]
90    pub skins_settings: SkinsSettings,
91
92    #[schemars(title = "Эффекты")]
93    pub effects: Vec<Effect>,
94
95    #[schemars(title = "Способности")]
96    pub abilities: Vec<AbilityTemplate>,
97
98    #[schemars(title = "Настройки сундука-гачи-скилов")]
99    pub ability_cases_settings: NonEmptyVec<AbilityCasesSettingsByLevel>,
100
101    #[schemars(title = "Редкости способностей")]
102    pub ability_rarities: Vec<AbilityRarity>,
103
104    #[schemars(title = "Уровни способностей")]
105    pub ability_levels: Vec<AbilityLevel>,
106
107    #[schemars(title = "Настройки боя")]
108    pub fight_settings: FightSettings,
109
110    #[schemars(title = "Враги")]
111    pub entities: Vec<EntityTemplate>,
112
113    #[schemars(title = "Шаблоны данжей")]
114    pub dungeon_templates: Vec<DungeonTemplate>,
115
116    #[schemars(title = "Шаблоны боев")]
117    pub fight_templates: Vec<FightTemplate>,
118
119    #[schemars(title = "Главы кампании")]
120    pub chapters: Vec<Chapter>,
121
122    #[schemars(title = "Уровни игрока")]
123    pub character_levels: Vec<CharacterLevel>,
124
125    #[schemars(title = "Уровни слотов способностей")]
126    pub ability_slots_levels: Vec<AbilitySlotsLevel>,
127
128    #[schemars(title = "Реферальные уровни игрока")]
129    pub patron_levels: Vec<ReferralLevelInfo>,
130
131    #[schemars(title = "Квесты")]
132    pub quests: Vec<QuestTemplate>,
133
134    #[schemars(title = "Настройки прогрессии квестов")]
135    pub quests_progression_settings: QuestsProgressionSettings,
136
137    #[schemars(title = "Настройки вассалов")]
138    pub vassals_settings: VassalsSettings,
139
140    #[schemars(title = "Поручения")]
141    pub vassal_tasks: Vec<VassalTaskTemplate>,
142
143    #[schemars(title = "Настройки PvP")]
144    pub pvp_settings: PvpSettings,
145
146    #[schemars(title = "Подарки")]
147    pub gifts: Vec<GiftTemplate>,
148
149    #[schemars(title = "Шаблоны писем")]
150    pub mail_templates: Vec<MailTemplate>,
151
152    #[schemars(title = "Валюты")]
153    pub currencies: Vec<Currency>,
154
155    #[schemars(title = "Уровни инвентаря")]
156    pub inventory_levels: Vec<InventoryLevel>,
157
158    #[schemars(title = "Настройки арены")]
159    pub arena_settings: ArenaSettings,
160
161    #[schemars(title = "Настройки лиг")]
162    pub arena_leagues: Vec<ArenaLeague>,
163
164    #[schemars(title = "Настройки матчмейкинга")]
165    pub matchmaking_settings: MatchmakingSettings,
166
167    #[schemars(title = "Описания эвентов")]
168    pub event_descriptions: Vec<EventDescription>,
169
170    #[schemars(title = "Классы персонажа")]
171    pub classes: Vec<class::Class>,
172
173    #[schemars(title = "Список проджектайлов")]
174    pub projectiles: Vec<Projectile>,
175
176    #[schemars(title = "Список бандлов")]
177    pub bundles: Vec<BundleRaw>,
178
179    #[schemars(title = "Настройки пресетов способностей")]
180    pub ability_presets_settings: AbilityPresetsSettings,
181
182    #[schemars(title = "Настройки ботов")]
183    pub bots_settings: BotsSettings,
184
185    #[schemars(title = "Настройки репортов")]
186    pub reports_settings: ReportsSettings,
187
188    #[schemars(title = "Настройки афк наград")]
189    pub afk_rewards_settings: AfkRewardsSettings,
190
191    #[schemars(title = "Уровни афк наград")]
192    pub afk_rewards_levels: Vec<AfkRewardsByLevel>,
193
194    #[schemars(title = "Настройки генерации параметров пользователя")]
195    pub users_generating_settings: UsersGeneratingSettings,
196
197    #[schemars(title = "Шаги туториала")]
198    pub tutorial_steps: Vec<TutorialStep>,
199
200    #[schemars(title = "Настройки чатов")]
201    pub chats_settings: Vec<ChatSettings>,
202
203    #[schemars(title = "Шаблоны офферов")]
204    pub offers_templates: Vec<OfferTemplate>,
205
206    #[schemars(title = "Вкладки магазина")]
207    pub shop_tabs: Vec<ShopTabConfig>,
208
209    #[schemars(title = "Настройки рейтингов")]
210    pub ratings_settings: Vec<RatingSettings>,
211
212    #[schemars(title = "Набор скриптов для читов")]
213    pub cheat_scripts: Vec<CheatScript>,
214
215    #[schemars(
216        title = "Скрипты генерации тестового игрока",
217        description = "Используется только в режиме читов. Каждый скрипт в формате TestPlayerResult — задаёт предметы, способности, петов, уровень, класс и силу тестового игрока. Выбирается по индексу."
218    )]
219    pub test_player_scripts: Vec<TestPlayerScript>,
220
221    #[schemars(title = "Гейтинги")]
222    pub gatings: Gatings,
223
224    #[schemars(title = "Шаблоны петов")]
225    pub pet_templates: Vec<PetTemplate>,
226
227    #[schemars(title = "Редкости петов")]
228    pub pet_rarities: Vec<PetRarity>,
229
230    #[schemars(title = "Уровни петов")]
231    pub pet_levels: Vec<PetLevel>,
232
233    #[schemars(title = "Уровни слотов петов")]
234    pub pet_slots_levels: Vec<PetSlotsLevel>,
235
236    #[schemars(title = "Настройки сундука-гачи-петов")]
237    pub pet_cases_settings: Vec<PetCasesSettingsByLevel>,
238
239    #[schemars(title = "Настройки дерева талантов")]
240    pub talent_tree_settings: TalentTreeSettings,
241
242    #[schemars(title = "Таланты")]
243    pub talents: Vec<TalentTemplate>,
244
245    #[schemars(title = "Грейды бонусов статуи")]
246    pub statue_bonus_grades: NonEmptyVec<StatueBonusGrade>,
247
248    #[schemars(title = "Типы бонусов статуи (стат × грейд)")]
249    pub statue_bonus_type_configs: NonEmptyVec<StatueBonusTypeConfig>,
250
251    #[schemars(title = "Таблица уровней статуи")]
252    pub statue_level_configs: NonEmptyVec<StatueLevelConfig>,
253
254    #[schemars(title = "Настройки статуи героя")]
255    pub statue_settings: StatueSettings,
256
257    #[schemars(title = "Шаблоны баффов")]
258    pub buff_templates: Vec<BuffTemplate>,
259
260    #[schemars(title = "Варианты рекламных птиц")]
261    pub bird_variants: Vec<BirdVariant>,
262}
263
264impl std::fmt::Debug for GameConfig {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        write!(f, "GameConfig")
267    }
268}
269
270impl GameConfig {
271    pub fn clone_translate(&self, translator: &i18n::translator::Translator, locale: &str) -> Self {
272        let mut config = self.clone();
273        for ability in &mut config.abilities {
274            ability.name = translator.translate(&ability.name, locale);
275            ability.description = translator.translate(&ability.description, locale);
276        }
277
278        for attribute in &mut config.attributes {
279            attribute.name = translator.translate(&attribute.name, locale);
280            attribute.description = translator.translate(&attribute.description, locale);
281            if let Some(prefix) = &attribute.prefix {
282                attribute.prefix = Some(translator.translate(prefix, locale));
283            }
284            if let Some(suffix) = &attribute.suffix {
285                attribute.suffix = Some(translator.translate(suffix, locale));
286            }
287        }
288
289        for effect in &mut config.effects {
290            effect.name = translator.translate(&effect.name, locale);
291        }
292
293        for event in &mut config.event_descriptions {
294            event.description = translator.translate(&event.description, locale);
295        }
296
297        for item in &mut config.items {
298            item.name = translator.translate(&item.name, locale);
299        }
300
301        for item_rarity in &mut config.item_rarities {
302            item_rarity.name = translator.translate(&item_rarity.name, locale);
303        }
304
305        for skin in &mut config.skins {
306            skin.name = translator.translate(&skin.name, locale);
307        }
308
309        for ability_rarity in &mut config.ability_rarities {
310            ability_rarity.name = translator.translate(&ability_rarity.name, locale);
311        }
312
313        for quest in &mut config.quests {
314            quest.title = translator.translate(&quest.title, locale);
315            quest.description = translator.translate(&quest.description, locale);
316        }
317
318        for vassal_task in &mut config.vassal_tasks {
319            vassal_task.title = translator.translate(&vassal_task.title, locale);
320        }
321
322        for currency in &mut config.currencies {
323            currency.name = translator.translate(&currency.name, locale);
324            currency.description = translator.translate(&currency.description, locale);
325        }
326
327        for mail_template in &mut config.mail_templates {
328            mail_template.title = translator.translate(&mail_template.title, locale);
329            mail_template.message = translator.translate(&mail_template.message, locale);
330        }
331
332        for league in &mut config.arena_leagues {
333            league.name = translator.translate(&league.name, locale);
334        }
335
336        for fight in &mut config.fight_templates {
337            fight.title = translator.translate(&fight.title, locale);
338        }
339
340        for class in &mut config.classes {
341            class.name = translator.translate(&class.name, locale);
342            class.description = translator.translate(&class.description, locale);
343        }
344
345        for dungeon in &mut config.dungeon_templates {
346            dungeon.title = translator.translate(&dungeon.title, locale);
347            dungeon.description = translator.translate(&dungeon.description, locale);
348
349            for tip in &mut dungeon.tips {
350                tip.tip = translator.translate(&tip.tip, locale);
351            }
352        }
353
354        for step in &mut config.tutorial_steps {
355            step.text = translator.translate(&step.text, locale);
356        }
357
358        for offer in &mut config.offers_templates {
359            offer.title = translator.translate(&offer.title, locale);
360            if let Some(limit_buy_text) = &offer.limit_buy_text {
361                offer.limit_buy_text = Some(translator.translate(limit_buy_text, locale));
362            }
363        }
364
365        for shop_tab in &mut config.shop_tabs {
366            shop_tab.name = translator.translate(&shop_tab.name, locale);
367        }
368
369        for chapter in &mut config.chapters {
370            chapter.title = translator.translate(&chapter.title, locale);
371        }
372
373        for pet_template in &mut config.pet_templates {
374            pet_template.name = translator.translate(&pet_template.name, locale);
375        }
376
377        for pet_rarity in &mut config.pet_rarities {
378            pet_rarity.name = translator.translate(&pet_rarity.name, locale);
379        }
380
381        for talent in &mut config.talents {
382            talent.name = translator.translate(&talent.name, locale);
383            talent.description = translator.translate(&talent.description, locale);
384        }
385
386        config.ability_presets_settings.default_preset_name =
387            translator.translate(&config.ability_presets_settings.default_preset_name, locale);
388
389        for grade in &mut config.statue_bonus_grades {
390            grade.name = translator.translate(&grade.name, locale);
391        }
392
393        for buff in &mut config.buff_templates {
394            buff.title = translator.translate(&buff.title, locale);
395            buff.description = translator.translate(&buff.description, locale);
396        }
397
398        for skin in &mut config.skins {
399            if let Some(unlock_description) = &skin.unlock_description {
400                skin.unlock_description = Some(translator.translate(unlock_description, locale));
401            }
402        }
403
404        config
405    }
406
407    pub fn validate(&self) {
408        // TODO: Also check empty vectors, too big numbers, invalid references, 0 div
409        if !Self::has_unique_ids(&self.items) {
410            panic!("Not unique item ids in config");
411        }
412
413        self.validate_ability_gacha_settings();
414
415        Self::validate_afk_rewards(
416            &self.afk_rewards_levels,
417            &self.afk_rewards_settings,
418            &self.currencies,
419            &self.abilities,
420            &self.items,
421            &self.bundles,
422        );
423        Self::validate_loop_task_chain(&self.quests);
424        self.validate_pet_config();
425        self.validate_statue_settings();
426    }
427
428    fn validate_loop_task_chain(quests: &[QuestTemplate]) {
429        use std::collections::{HashMap, HashSet};
430
431        let loop_quests: Vec<&QuestTemplate> = quests
432            .iter()
433            .filter(|q| q.quest_group_type == QuestGroupType::LoopTask)
434            .collect();
435
436        if loop_quests.is_empty() {
437            return;
438        }
439
440        let all_quest_ids: HashSet<_> = quests.iter().map(|q| q.id).collect();
441        let loop_quest_ids: HashSet<_> = loop_quests.iter().map(|q| q.id).collect();
442
443        // 1. No duplicate codes among LoopTask quests
444        let mut code_to_quest: HashMap<&str, &QuestTemplate> = HashMap::new();
445        for q in &loop_quests {
446            if let Some(code) = &q.code
447                && let Some(existing) = code_to_quest.insert(code.as_str(), q)
448            {
449                panic!(
450                    "LoopTask: duplicate code '{}' on quests {} and {}",
451                    code, existing.id, q.id
452                );
453            }
454        }
455
456        // 2. Every LoopTask quest must have additional_quests_script (the chain advances there)
457        for q in &loop_quests {
458            if q.additional_quests_script.is_none() {
459                panic!(
460                    "LoopTask: quest {} ('{}') has no additional_quests_script — chain will break",
461                    q.id,
462                    q.code.as_deref().unwrap_or("<no code>")
463                );
464            }
465        }
466
467        // 3. Every next_quest_ids reference must point to an existing quest
468        for q in &loop_quests {
469            for next_id in &q.next_quest_ids {
470                if !all_quest_ids.contains(next_id) {
471                    panic!(
472                        "LoopTask: quest {} ('{}') has next_quest_id {} that does not exist",
473                        q.id,
474                        q.code.as_deref().unwrap_or("<no code>"),
475                        next_id
476                    );
477                }
478            }
479        }
480
481        // 4. Discover numbered sequences (loop_task.{type}.{N}) and verify each is
482        //    a gapless 1..=max chain where N chains to N+1 via next_quest_ids.
483        let mut sequences: HashMap<String, Vec<(u32, &QuestTemplate)>> = HashMap::new();
484        for (code, quest) in &code_to_quest {
485            let parts: Vec<&str> = code.split('.').collect();
486            // codes look like "loop_task.{type}.{number_or_name}"
487            if parts.len() == 3
488                && parts[0] == "loop_task"
489                && let Ok(n) = parts[2].parse::<u32>()
490            {
491                sequences
492                    .entry(parts[1].to_string())
493                    .or_default()
494                    .push((n, quest));
495            }
496        }
497
498        for (seq_type, mut entries) in sequences {
499            entries.sort_by_key(|(n, _)| *n);
500
501            // Must start from 1
502            if entries[0].0 != 1 {
503                panic!(
504                    "LoopTask: sequence 'loop_task.{}' starts at {} instead of 1",
505                    seq_type, entries[0].0
506                );
507            }
508
509            // No gaps
510            for (i, (n, _)) in entries.iter().enumerate() {
511                let expected = (i as u32) + 1;
512                if *n != expected {
513                    panic!(
514                        "LoopTask: sequence 'loop_task.{}' has gap — expected {} but found {}",
515                        seq_type, expected, n
516                    );
517                }
518            }
519
520            // Each N must chain to N+1 via next_quest_ids (except last).
521            // Skip for "loop" sequence — those cycle via advance_loop() Rhai script.
522            if seq_type != "loop" {
523                for window in entries.windows(2) {
524                    let (n, quest) = window[0];
525                    let (_, next_quest) = window[1];
526                    if !quest.next_quest_ids.contains(&next_quest.id) {
527                        panic!(
528                            "LoopTask: 'loop_task.{}.{}' (quest {}) does not chain to 'loop_task.{}.{}' (quest {}) via next_quest_ids",
529                            seq_type,
530                            n,
531                            quest.id,
532                            seq_type,
533                            n + 1,
534                            next_quest.id
535                        );
536                    }
537                }
538            }
539        }
540
541        // 5. Every LoopTask quest must be reachable: either has a code (discoverable by scripts)
542        //    or is in the next_quest_ids graph from any quest in the config
543        let mut reachable: HashSet<essences::quest::QuestId> = HashSet::new();
544
545        // Reachable by code
546        for q in code_to_quest.values() {
547            reachable.insert(q.id);
548        }
549
550        // Reachable by next_quest_ids graph (BFS from all quests that reference LoopTask quests)
551        let mut stack: Vec<essences::quest::QuestId> = Vec::new();
552        for q in quests {
553            for next_id in &q.next_quest_ids {
554                if loop_quest_ids.contains(next_id) {
555                    stack.push(*next_id);
556                }
557            }
558        }
559        while let Some(id) = stack.pop() {
560            if !reachable.insert(id) {
561                continue;
562            }
563            if let Some(q) = loop_quests.iter().find(|q| q.id == id) {
564                for next_id in &q.next_quest_ids {
565                    if loop_quest_ids.contains(next_id) {
566                        stack.push(*next_id);
567                    }
568                }
569            }
570        }
571
572        for q in &loop_quests {
573            if !reachable.contains(&q.id) {
574                panic!(
575                    "LoopTask: quest {} ('{}') is unreachable — no code and not in next_quest_ids chain from a starting quest",
576                    q.id,
577                    q.code.as_deref().unwrap_or("<no code>")
578                );
579            }
580        }
581    }
582
583    fn validate_afk_rewards(
584        afk_levels: &[AfkRewardsByLevel],
585        afk_settings: &AfkRewardsSettings,
586        currencies: &[Currency],
587        abilities: &[AbilityTemplate],
588        items: &[ItemTemplate],
589        bundles: &[BundleRaw],
590    ) {
591        use std::collections::HashSet;
592
593        let currency_ids: HashSet<_> = currencies.iter().map(|c| c.id).collect();
594        let ability_ids: HashSet<_> = abilities.iter().map(|a| a.id).collect();
595        let item_ids: HashSet<_> = items.iter().map(|i| i.id).collect();
596        let bundle_ids: HashSet<_> = bundles.iter().map(|b| b.id).collect();
597
598        // bonus_calculation_rate_sec > 0 is enforced by NonZeroU64 type
599
600        if !bundle_ids.contains(&afk_settings.bundle_id) {
601            panic!(
602                "AfkRewardsSettings: bundle_id {} not found in bundles",
603                afk_settings.bundle_id
604            );
605        }
606
607        let chapter_levels: Vec<_> = afk_levels.iter().map(|r| r.chapter_level).collect();
608        let unique_levels: HashSet<_> = chapter_levels.iter().collect();
609        if chapter_levels.len() != unique_levels.len() {
610            panic!("AfkRewardsByLevel: Two or more afk reward levels have equal chapter_level");
611        }
612
613        for level_rewards in afk_levels {
614            for rate in &level_rewards.currency_rates {
615                if !currency_ids.contains(&rate.currency_id) {
616                    panic!(
617                        "AfkRewardsByLevel chapter_level={}: currency_id {} not found in currencies",
618                        level_rewards.chapter_level, rate.currency_id
619                    );
620                }
621                // rate_per_minute > 0 is enforced by PositiveF64 type
622            }
623
624            // bonus_weights non-empty is enforced by NonEmptyVec type
625            // bonus_weight.weight > 0 is enforced by PositiveF64 type
626            // bonus_weight.count > 0 is enforced by PositiveI64 type
627
628            for bonus_weight in &level_rewards.bonus_weights {
629                match &bonus_weight.bonus_type {
630                    AfkRewardBonusType::Currency(currency_id) => {
631                        if !currency_ids.contains(currency_id) {
632                            panic!(
633                                "AfkRewardsByLevel chapter_level={}: bonus Currency id {} not found",
634                                level_rewards.chapter_level, currency_id
635                            );
636                        }
637                    }
638                    AfkRewardBonusType::Ability(ability_id) => {
639                        if !ability_ids.contains(ability_id) {
640                            panic!(
641                                "AfkRewardsByLevel chapter_level={}: bonus Ability id {} not found",
642                                level_rewards.chapter_level, ability_id
643                            );
644                        }
645                    }
646                    AfkRewardBonusType::Item(item_id) => {
647                        if !item_ids.contains(item_id) {
648                            panic!(
649                                "AfkRewardsByLevel chapter_level={}: bonus Item id {} not found",
650                                level_rewards.chapter_level, item_id
651                            );
652                        }
653                    }
654                }
655            }
656        }
657    }
658
659    fn validate_ability_gacha_settings(&self) {
660        use std::collections::HashSet;
661
662        let gacha = &self.game_settings.ability_gacha;
663        let currency_ids: HashSet<_> = self.currencies.iter().map(|currency| currency.id).collect();
664        let rarity_ids: HashSet<_> = self
665            .ability_rarities
666            .iter()
667            .map(|rarity| rarity.id)
668            .collect();
669        let class_rarity_ids: HashSet<_> = self
670            .classes
671            .iter()
672            .map(|class| class.ability_rarity_id)
673            .collect();
674
675        // small_roll_cost > 0, big_roll_cost > 0, slot_max_level > 0 enforced by PositiveI64
676        // wishlist_weight_multiplier >= 1.0 enforced by WeightMultiplier type
677        // wishlist_slots > 0 still needs runtime check (u8, not wrapped)
678        if gacha.wishlist_slots == 0 {
679            panic!("AbilityGachaSettings: wishlist_slots must be > 0");
680        }
681        if gacha.slot_level_costs.len() != gacha.slot_max_level.get() as usize {
682            panic!(
683                "AbilityGachaSettings: slot_level_costs length ({}) must match slot_max_level ({})",
684                gacha.slot_level_costs.len(),
685                gacha.slot_max_level.get()
686            );
687        }
688        if gacha.slot_level_bonus_levels.len() != gacha.slot_max_level.get() as usize + 1 {
689            panic!(
690                "AbilityGachaSettings: slot_level_bonus_levels length ({}) must be slot_max_level + 1 ({})",
691                gacha.slot_level_bonus_levels.len(),
692                gacha.slot_max_level.get() + 1
693            );
694        }
695        // slot_level_costs values > 0 enforced by PositiveI64 type
696        for bonus in &gacha.slot_level_bonus_levels {
697            if *bonus < 0 {
698                panic!("AbilityGachaSettings: slot_level_bonus_levels values must be >= 0");
699            }
700        }
701        if !currency_ids.contains(&gacha.slot_upgrade_currency_id) {
702            panic!(
703                "AbilityGachaSettings: slot_upgrade_currency_id {} not found in currencies",
704                gacha.slot_upgrade_currency_id
705            );
706        }
707
708        // ability_cases_settings non-empty is enforced by NonEmptyVec type
709
710        let mut settings_by_level = self.ability_cases_settings.to_vec();
711        settings_by_level.sort_by_key(|level_settings| level_settings.level);
712
713        // Validate boundary levels: at most one, must be the last level.
714        let boundary_count = settings_by_level
715            .iter()
716            .filter(|s| s.is_boundary_level)
717            .count();
718        if boundary_count > 1 {
719            panic!("AbilityCasesSettings: at most one boundary level is allowed");
720        }
721        if boundary_count == 1 && !settings_by_level.last().unwrap().is_boundary_level {
722            panic!("AbilityCasesSettings: the boundary level must be the last level");
723        }
724
725        let mut previous_level = 0;
726        let mut previous_opens = -1;
727        for (idx, level_settings) in settings_by_level.iter().enumerate() {
728            if level_settings.level <= previous_level {
729                panic!("AbilityCasesSettings: levels must be unique and strictly increasing");
730            }
731            if level_settings.opens_to_upgrade < 0 {
732                panic!(
733                    "AbilityCasesSettings level {}: opens_to_upgrade must be >= 0",
734                    level_settings.level
735                );
736            }
737            if level_settings.opens_to_upgrade < previous_opens {
738                panic!(
739                    "AbilityCasesSettings level {}: opens_to_upgrade must be non-decreasing",
740                    level_settings.level
741                );
742            }
743            previous_level = level_settings.level;
744            previous_opens = level_settings.opens_to_upgrade;
745
746            // Boundary levels only need level + opens_to_upgrade; skip all other validations.
747            if level_settings.is_boundary_level {
748                continue;
749            }
750
751            let mut previous_checkpoint_opens = -1;
752            for checkpoint in &level_settings.checkpoints {
753                if checkpoint.required_opens <= 0 {
754                    panic!(
755                        "AbilityCasesSettings level {}: checkpoint required_opens must be > 0",
756                        level_settings.level
757                    );
758                }
759                if checkpoint.required_opens <= previous_checkpoint_opens {
760                    panic!(
761                        "AbilityCasesSettings level {}: checkpoint required_opens must be strictly increasing",
762                        level_settings.level
763                    );
764                }
765                previous_checkpoint_opens = checkpoint.required_opens;
766
767                // Checkpoint required_opens is relative to the current level's
768                // opens_to_upgrade. It must not exceed the range of this level
769                // (i.e. the gap to the next level's opens_to_upgrade).
770                if let Some(next) = settings_by_level.get(idx + 1) {
771                    let level_range = next.opens_to_upgrade - level_settings.opens_to_upgrade;
772                    if checkpoint.required_opens > level_range {
773                        panic!(
774                            "AbilityCasesSettings level {}: checkpoint required_opens ({}) exceeds level range ({})",
775                            level_settings.level, checkpoint.required_opens, level_range
776                        );
777                    }
778                }
779
780                for currency_reward in &checkpoint.currency_rewards {
781                    if currency_reward.amount <= 0 {
782                        panic!(
783                            "AbilityCasesSettings level {}: checkpoint currency reward must be > 0",
784                            level_settings.level
785                        );
786                    }
787                    if !currency_ids.contains(&currency_reward.currency_id) {
788                        panic!(
789                            "AbilityCasesSettings level {}: checkpoint currency {} not found",
790                            level_settings.level, currency_reward.currency_id
791                        );
792                    }
793                }
794
795                for ability_reward in &checkpoint.ability_rewards {
796                    if ability_reward.amount < 0 {
797                        panic!(
798                            "AbilityCasesSettings level {}: checkpoint ability reward amount must be >= 0",
799                            level_settings.level
800                        );
801                    }
802                    if ability_reward.amount > 0
803                        && !rarity_ids.contains(&ability_reward.min_rarity_id)
804                    {
805                        panic!(
806                            "AbilityCasesSettings level {}: checkpoint ability reward min_rarity_id {} not found",
807                            level_settings.level, ability_reward.min_rarity_id
808                        );
809                    }
810                }
811            }
812
813            for rarity_id in &level_settings.allowed_wishlist_rarity_ids {
814                if !rarity_ids.contains(rarity_id) {
815                    panic!(
816                        "AbilityCasesSettings level {}: allowed wishlist rarity {} not found",
817                        level_settings.level, rarity_id
818                    );
819                }
820                if class_rarity_ids.contains(rarity_id) {
821                    panic!(
822                        "AbilityCasesSettings level {}: class rarity {} cannot be in wishlist",
823                        level_settings.level, rarity_id
824                    );
825                }
826            }
827
828            for evolve_rule in &level_settings.evolve_rules {
829                if evolve_rule.small_roll_tries < 0 {
830                    panic!(
831                        "AbilityCasesSettings level {}: evolve small_roll_tries must be >= 0",
832                        level_settings.level
833                    );
834                }
835                if evolve_rule.big_roll_tries < 0 {
836                    panic!(
837                        "AbilityCasesSettings level {}: evolve big_roll_tries must be >= 0",
838                        level_settings.level
839                    );
840                }
841                if evolve_rule.small_roll_tries == 0 && evolve_rule.big_roll_tries == 0 {
842                    panic!(
843                        "AbilityCasesSettings level {}: evolve rule must have at least one non-zero tries",
844                        level_settings.level
845                    );
846                }
847                if !rarity_ids.contains(&evolve_rule.from_rarity_id) {
848                    panic!(
849                        "AbilityCasesSettings level {}: evolve from rarity {} not found",
850                        level_settings.level, evolve_rule.from_rarity_id
851                    );
852                }
853                // rarity_weight.weight > 0 enforced by PositiveF64 type
854                let mut sum_weight = 0.0;
855                for rarity_weight in &evolve_rule.to_rarity_weights {
856                    if !rarity_ids.contains(&rarity_weight.rarity_id) {
857                        panic!(
858                            "AbilityCasesSettings level {}: evolve to rarity {} not found",
859                            level_settings.level, rarity_weight.rarity_id
860                        );
861                    }
862                    sum_weight += rarity_weight.weight.get();
863                }
864                if sum_weight > 1.0 + f64::EPSILON {
865                    panic!(
866                        "AbilityCasesSettings level {}: evolve to_rarity_weights sum must be <= 1.0, got {}",
867                        level_settings.level, sum_weight
868                    );
869                }
870            }
871
872            for currency_reward in &level_settings.big_roll_currency_rewards {
873                if currency_reward.amount <= 0 {
874                    panic!(
875                        "AbilityCasesSettings level {}: big-roll currency reward amount must be > 0",
876                        level_settings.level
877                    );
878                }
879                if !currency_ids.contains(&currency_reward.currency_id) {
880                    panic!(
881                        "AbilityCasesSettings level {}: big-roll currency {} not found",
882                        level_settings.level, currency_reward.currency_id
883                    );
884                }
885            }
886
887            let big_roll_shards = &level_settings.big_roll_shards;
888            let shards_total: u64 = big_roll_shards.iter().map(|s| s.count as u64).sum();
889            let expected_total =
890                gacha.big_roll_cost.get() as u64 + gacha.big_roll_bonus_drops as u64;
891            if shards_total != expected_total {
892                panic!(
893                    "AbilityCasesSettings level {}: big_roll_shards total ({}) must equal big_roll_cost + bonus_drops ({})",
894                    level_settings.level, shards_total, expected_total
895                );
896            }
897            for shard in big_roll_shards {
898                if shard.count == 0 {
899                    panic!(
900                        "AbilityCasesSettings level {}: big_roll_shards count must be > 0",
901                        level_settings.level
902                    );
903                }
904                if !rarity_ids.contains(&shard.min_rarity_id) {
905                    panic!(
906                        "AbilityCasesSettings level {}: big_roll_shards rarity {} not found",
907                        level_settings.level, shard.min_rarity_id
908                    );
909                }
910            }
911        }
912
913        if settings_by_level[0].level != 1 || settings_by_level[0].opens_to_upgrade != 0 {
914            panic!("AbilityCasesSettings: level 1 with opens_to_upgrade = 0 is required");
915        }
916    }
917
918    fn validate_pet_config(&self) {
919        use std::collections::HashSet;
920
921        let pet_template_ids: HashSet<_> = self.pet_templates.iter().map(|t| t.id).collect();
922        if pet_template_ids.len() != self.pet_templates.len() {
923            panic!("PetConfig: duplicate pet template ids");
924        }
925
926        let pet_rarity_ids: HashSet<_> = self.pet_rarities.iter().map(|r| r.id).collect();
927        if pet_rarity_ids.len() != self.pet_rarities.len() {
928            panic!("PetConfig: duplicate pet rarity ids");
929        }
930
931        let ability_ids: HashSet<_> = self.abilities.iter().map(|a| a.id).collect();
932        let attribute_ids: HashSet<_> = self.attributes.iter().map(|a| a.id).collect();
933
934        for template in &self.pet_templates {
935            if let Some(ability_id) = template.active_ability_id
936                && !ability_ids.contains(&ability_id)
937            {
938                panic!(
939                    "PetConfig: pet template {} has active_ability_id {} not found in abilities",
940                    template.id, ability_id
941                );
942            }
943            if let Some(ability_id) = template.passive_ability_id
944                && !ability_ids.contains(&ability_id)
945            {
946                panic!(
947                    "PetConfig: pet template {} has passive_ability_id {} not found in abilities",
948                    template.id, ability_id
949                );
950            }
951            if !pet_rarity_ids.contains(&template.rarity_id) {
952                panic!(
953                    "PetConfig: pet template {} has rarity_id {} not found in pet_rarities",
954                    template.id, template.rarity_id
955                );
956            }
957            for stat in &template.stats {
958                if !attribute_ids.contains(&stat.attribute_id) {
959                    panic!(
960                        "PetConfig: pet template {} has stat attribute_id {} not found in attributes",
961                        template.id, stat.attribute_id
962                    );
963                }
964            }
965        }
966    }
967
968    fn validate_statue_settings(&self) {
969        use std::collections::HashSet;
970
971        let statue = &self.statue_settings;
972        let currency_ids: HashSet<_> = self.currencies.iter().map(|c| c.id).collect();
973        let attribute_ids: HashSet<_> = self.attributes.iter().map(|a| a.id).collect();
974
975        if !currency_ids.contains(&statue.currency_id) {
976            panic!(
977                "StatueSettings: currency_id {} not found in currencies",
978                statue.currency_id
979            );
980        }
981
982        // statue_bonus_grades non-empty enforced by NonEmptyVec type
983
984        let grade_ids: HashSet<_> = self.statue_bonus_grades.iter().map(|g| g.id).collect();
985        if grade_ids.len() != self.statue_bonus_grades.len() {
986            panic!("statue_bonus_grades: duplicate grade ids");
987        }
988
989        // roll_costs non-empty enforced by NonEmptyVec type
990        // roll_costs cost > 0 enforced by PositiveI64 type
991
992        // statue_bonus_type_configs non-empty enforced by NonEmptyVec type
993        for bonus_type in &self.statue_bonus_type_configs {
994            if !attribute_ids.contains(&bonus_type.attribute_id) {
995                panic!(
996                    "statue_bonus_type_configs attribute_id {} not found in attributes",
997                    bonus_type.attribute_id
998                );
999            }
1000            // grade_values non-empty enforced by NonEmptyVec type
1001            for grade_value in &bonus_type.grade_values {
1002                if !grade_ids.contains(&grade_value.grade_id) {
1003                    panic!(
1004                        "statue_bonus_type_configs attribute {} references unknown grade_id {}",
1005                        bonus_type.attribute_id, grade_value.grade_id
1006                    );
1007                }
1008                // grade_value.value > 0 enforced by PositiveI32 type
1009            }
1010        }
1011
1012        // statue_level_configs non-empty enforced by NonEmptyVec type
1013        if self.statue_level_configs[0].level != 1
1014            || self.statue_level_configs[0].required_experience != 0
1015        {
1016            panic!("statue_level_configs must start with level=1 and required_experience=0");
1017        }
1018        let mut prev_level = 0;
1019        let mut prev_exp = -1;
1020        for level_config in &self.statue_level_configs {
1021            if level_config.level <= prev_level {
1022                panic!("statue_level_configs levels must be strictly increasing");
1023            }
1024            if level_config.required_experience <= prev_exp && level_config.level > 1 {
1025                panic!(
1026                    "statue_level_configs level={} required_experience must be strictly increasing",
1027                    level_config.level
1028                );
1029            }
1030            // slot_count > 0 enforced by NonZeroU64 type
1031            // sets_count > 0 enforced by NonZeroU64 type
1032            // grade_weights non-empty enforced by NonEmptyVec type
1033            for weight in &level_config.grade_weights {
1034                if !grade_ids.contains(&weight.grade_id) {
1035                    panic!(
1036                        "statue_level_configs level={} grade_weights references unknown grade_id {}",
1037                        level_config.level, weight.grade_id
1038                    );
1039                }
1040                // weight > 0 enforced by PositiveF64 type
1041            }
1042            prev_level = level_config.level;
1043            prev_exp = level_config.required_experience;
1044        }
1045    }
1046
1047    fn has_unique_ids(items: &[ItemTemplate]) -> bool {
1048        let mut ids = std::collections::HashSet::new();
1049        items.iter().all(|item| ids.insert(item.id))
1050    }
1051}
1052
1053#[derive(Debug, Clone, Copy)]
1054pub struct NextFightInfo {
1055    pub next_chapter_level: i64,
1056    pub next_fight_number: i64,
1057    pub next_fight_id: Option<essences::fighting::FightTemplateId>,
1058}
1059
1060#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Tsify)]
1061#[tsify(into_wasm_abi, from_wasm_abi)]
1062pub enum GetConfigResponse {
1063    Ok { config: Box<GameConfig> },
1064    Error { code: String, message: String },
1065}