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::progress_pass::ProgressPassConfig;
20use essences::quest::{QuestGroupType, QuestTemplate, QuestsProgressionSettings};
21use essences::ratings::RatingSettings;
22use essences::referrals::ReferralLevelInfo;
23use essences::skins::{ConfigSkin, SkinsSettings};
24use essences::vassals::VassalTaskTemplate;
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, Serialize, Deserialize, JsonSchema, Tsify)]
49#[tsify(into_wasm_abi)]
50pub struct GameConfig {
51    #[schemars(title = "Атрибуты")]
52    pub attributes: Vec<Attribute>,
53
54    #[schemars(title = "Настройки сундука-гачи-айтемов")]
55    pub item_cases_settings: Vec<ItemCasesSettingsByLevel>,
56
57    #[schemars(
58        title = "Настройки игры",
59        description = "Отдельные значения, которые используются в игре"
60    )]
61    pub game_settings: GameSettings,
62
63    #[schemars(
64        title = "Настройки действий за рекламу",
65        description = "Все настройки действий, связанные с рекламой"
66    )]
67    pub ads_settings: AdsSettings,
68
69    #[schemars(title = "Предметы")]
70    pub items: Vec<ItemTemplate>,
71
72    #[schemars(title = "Редкости предметов")]
73    pub item_rarities: Vec<ItemRarity>,
74
75    #[schemars(title = "Скины")]
76    pub skins: Vec<ConfigSkin>,
77
78    #[schemars(title = "Настройки скинов для кастомизации")]
79    pub skins_settings: SkinsSettings,
80
81    #[schemars(title = "Эффекты")]
82    pub effects: Vec<Effect>,
83
84    #[schemars(title = "Способности")]
85    pub abilities: Vec<AbilityTemplate>,
86
87    #[schemars(title = "Настройки сундука-гачи-скилов")]
88    pub ability_cases_settings: NonEmptyVec<AbilityCasesSettingsByLevel>,
89
90    #[schemars(title = "Редкости способностей")]
91    pub ability_rarities: Vec<AbilityRarity>,
92
93    #[schemars(title = "Уровни способностей")]
94    pub ability_levels: Vec<AbilityLevel>,
95
96    #[schemars(title = "Настройки боя")]
97    pub fight_settings: FightSettings,
98
99    #[schemars(title = "Враги")]
100    pub entities: Vec<EntityTemplate>,
101
102    #[schemars(title = "Шаблоны данжей")]
103    pub dungeon_templates: Vec<DungeonTemplate>,
104
105    #[schemars(title = "Шаблоны боев")]
106    pub fight_templates: Vec<FightTemplate>,
107
108    #[schemars(title = "Главы кампании")]
109    pub chapters: Vec<Chapter>,
110
111    #[schemars(title = "Уровни игрока")]
112    pub character_levels: Vec<CharacterLevel>,
113
114    #[schemars(title = "Уровни слотов способностей")]
115    pub ability_slots_levels: Vec<AbilitySlotsLevel>,
116
117    #[schemars(title = "Реферальные уровни игрока")]
118    pub patron_levels: Vec<ReferralLevelInfo>,
119
120    #[schemars(title = "Квесты")]
121    pub quests: Vec<QuestTemplate>,
122
123    #[schemars(title = "Настройки прогрессии квестов")]
124    pub quests_progression_settings: QuestsProgressionSettings,
125
126    #[schemars(title = "Настройки вассалов")]
127    pub vassals_settings: VassalsSettings,
128
129    #[schemars(title = "Поручения")]
130    pub vassal_tasks: Vec<VassalTaskTemplate>,
131
132    #[schemars(title = "Настройки PvP")]
133    pub pvp_settings: PvpSettings,
134
135    #[schemars(title = "Подарки")]
136    pub gifts: Vec<GiftTemplate>,
137
138    #[schemars(title = "Шаблоны писем")]
139    pub mail_templates: Vec<MailTemplate>,
140
141    #[schemars(title = "Валюты")]
142    pub currencies: Vec<Currency>,
143
144    #[schemars(title = "Уровни инвентаря")]
145    pub inventory_levels: Vec<InventoryLevel>,
146
147    #[schemars(title = "Настройки арены")]
148    pub arena_settings: ArenaSettings,
149
150    #[schemars(title = "Настройки лиг")]
151    pub arena_leagues: Vec<ArenaLeague>,
152
153    #[schemars(title = "Настройки матчмейкинга")]
154    pub matchmaking_settings: MatchmakingSettings,
155
156    #[schemars(title = "Описания эвентов")]
157    pub event_descriptions: Vec<EventDescription>,
158
159    #[schemars(title = "Классы персонажа")]
160    pub classes: Vec<class::Class>,
161
162    #[schemars(title = "Уровни прокачки классов")]
163    pub class_levels: Vec<class::ClassLevels>,
164
165    #[schemars(title = "Список проджектайлов")]
166    pub projectiles: Vec<Projectile>,
167
168    #[schemars(title = "Список бандлов")]
169    pub bundles: Vec<BundleRaw>,
170
171    #[schemars(title = "Настройки пресетов способностей")]
172    pub ability_presets_settings: AbilityPresetsSettings,
173
174    #[schemars(title = "Настройки ботов")]
175    pub bots_settings: BotsSettings,
176
177    #[schemars(title = "Настройки репортов")]
178    pub reports_settings: ReportsSettings,
179
180    #[schemars(title = "Настройки афк наград")]
181    pub afk_rewards_settings: AfkRewardsSettings,
182
183    #[schemars(title = "Уровни афк наград")]
184    pub afk_rewards_levels: Vec<AfkRewardsByLevel>,
185
186    #[schemars(title = "Настройки генерации параметров пользователя")]
187    pub users_generating_settings: UsersGeneratingSettings,
188
189    #[schemars(title = "Шаги туториала")]
190    pub tutorial_steps: Vec<TutorialStep>,
191
192    #[schemars(title = "Настройки чатов")]
193    pub chats_settings: Vec<ChatSettings>,
194
195    #[schemars(title = "Шаблоны офферов")]
196    pub offers_templates: Vec<OfferTemplate>,
197
198    #[schemars(title = "Вкладки магазина")]
199    pub shop_tabs: Vec<ShopTabConfig>,
200
201    #[schemars(title = "Настройки рейтингов")]
202    pub ratings_settings: Vec<RatingSettings>,
203
204    #[schemars(title = "Набор скриптов для читов")]
205    pub cheat_scripts: Vec<CheatScript>,
206
207    #[schemars(
208        title = "Скрипты генерации тестового игрока",
209        description = "Используется только в режиме читов. Каждый скрипт в формате TestPlayerResult — задаёт предметы, способности, петов, уровень, класс и силу тестового игрока. Выбирается по индексу."
210    )]
211    pub test_player_scripts: Vec<TestPlayerScript>,
212
213    #[schemars(title = "Гейтинги")]
214    pub gatings: Gatings,
215
216    #[schemars(title = "Шаблоны петов")]
217    pub pet_templates: Vec<PetTemplate>,
218
219    #[schemars(title = "Редкости петов")]
220    pub pet_rarities: Vec<PetRarity>,
221
222    #[schemars(title = "Уровни петов")]
223    pub pet_levels: Vec<PetLevel>,
224
225    #[schemars(title = "Уровни слотов петов")]
226    pub pet_slots_levels: Vec<PetSlotsLevel>,
227
228    #[schemars(title = "Настройки сундука-гачи-петов")]
229    pub pet_cases_settings: Vec<PetCasesSettingsByLevel>,
230
231    #[schemars(title = "Настройки дерева талантов")]
232    pub talent_tree_settings: TalentTreeSettings,
233
234    #[schemars(title = "Таланты")]
235    pub talents: Vec<TalentTemplate>,
236
237    #[schemars(title = "Грейды бонусов статуи")]
238    pub statue_bonus_grades: NonEmptyVec<StatueBonusGrade>,
239
240    #[schemars(title = "Типы бонусов статуи (стат × грейд)")]
241    pub statue_bonus_type_configs: NonEmptyVec<StatueBonusTypeConfig>,
242
243    #[schemars(title = "Таблица уровней статуи")]
244    pub statue_level_configs: NonEmptyVec<StatueLevelConfig>,
245
246    #[schemars(title = "Настройки статуи героя")]
247    pub statue_settings: StatueSettings,
248
249    #[schemars(title = "Шаблоны баффов")]
250    pub buff_templates: Vec<BuffTemplate>,
251
252    #[schemars(title = "Настройки прогресс пасса")]
253    pub progress_pass: ProgressPassConfig,
254
255    #[schemars(title = "Варианты рекламных птиц")]
256    pub bird_variants: Vec<BirdVariant>,
257}
258
259impl std::fmt::Debug for GameConfig {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        write!(f, "GameConfig")
262    }
263}
264
265impl GameConfig {
266    pub fn clone_translate(&self, translator: &i18n::translator::Translator, locale: &str) -> Self {
267        let mut config = self.clone();
268        for ability in &mut config.abilities {
269            ability.name = translator.translate(&ability.name, locale);
270            ability.description = translator.translate(&ability.description, locale);
271        }
272
273        for attribute in &mut config.attributes {
274            attribute.name = translator.translate(&attribute.name, locale);
275            attribute.description = translator.translate(&attribute.description, locale);
276            if let Some(prefix) = &attribute.prefix {
277                attribute.prefix = Some(translator.translate(prefix, locale));
278            }
279            if let Some(suffix) = &attribute.suffix {
280                attribute.suffix = Some(translator.translate(suffix, locale));
281            }
282        }
283
284        for effect in &mut config.effects {
285            effect.name = translator.translate(&effect.name, locale);
286        }
287
288        for event in &mut config.event_descriptions {
289            event.description = translator.translate(&event.description, locale);
290        }
291
292        for item in &mut config.items {
293            item.name = translator.translate(&item.name, locale);
294        }
295
296        for item_rarity in &mut config.item_rarities {
297            item_rarity.name = translator.translate(&item_rarity.name, locale);
298        }
299
300        for skin in &mut config.skins {
301            skin.name = translator.translate(&skin.name, locale);
302        }
303
304        for ability_rarity in &mut config.ability_rarities {
305            ability_rarity.name = translator.translate(&ability_rarity.name, locale);
306        }
307
308        for quest in &mut config.quests {
309            quest.title = translator.translate(&quest.title, locale);
310            quest.description = translator.translate(&quest.description, locale);
311        }
312
313        for vassal_task in &mut config.vassal_tasks {
314            vassal_task.title = translator.translate(&vassal_task.title, locale);
315        }
316
317        for currency in &mut config.currencies {
318            currency.name = translator.translate(&currency.name, locale);
319            currency.description = translator.translate(&currency.description, locale);
320        }
321
322        for mail_template in &mut config.mail_templates {
323            mail_template.title = translator.translate(&mail_template.title, locale);
324            mail_template.message = translator.translate(&mail_template.message, locale);
325        }
326
327        for league in &mut config.arena_leagues {
328            league.name = translator.translate(&league.name, locale);
329        }
330
331        for fight in &mut config.fight_templates {
332            fight.title = translator.translate(&fight.title, locale);
333        }
334
335        for class in &mut config.classes {
336            class.name = translator.translate(&class.name, locale);
337            class.description = translator.translate(&class.description, locale);
338            class.weapon_type = translator.translate(&class.weapon_type, locale);
339        }
340
341        for dungeon in &mut config.dungeon_templates {
342            dungeon.title = translator.translate(&dungeon.title, locale);
343            dungeon.description = translator.translate(&dungeon.description, locale);
344
345            for tip in &mut dungeon.tips {
346                tip.tip = translator.translate(&tip.tip, locale);
347            }
348        }
349
350        for step in &mut config.tutorial_steps {
351            step.text = translator.translate(&step.text, locale);
352        }
353
354        for offer in &mut config.offers_templates {
355            offer.title = translator.translate(&offer.title, locale);
356            if let Some(limit_buy_text) = &offer.limit_buy_text {
357                offer.limit_buy_text = Some(translator.translate(limit_buy_text, locale));
358            }
359        }
360
361        for shop_tab in &mut config.shop_tabs {
362            shop_tab.name = translator.translate(&shop_tab.name, locale);
363        }
364
365        for chapter in &mut config.chapters {
366            chapter.title = translator.translate(&chapter.title, locale);
367        }
368
369        for pet_template in &mut config.pet_templates {
370            pet_template.name = translator.translate(&pet_template.name, locale);
371        }
372
373        for pet_rarity in &mut config.pet_rarities {
374            pet_rarity.name = translator.translate(&pet_rarity.name, locale);
375        }
376
377        for talent in &mut config.talents {
378            talent.name = translator.translate(&talent.name, locale);
379            talent.description = translator.translate(&talent.description, locale);
380        }
381
382        config.ability_presets_settings.default_preset_name =
383            translator.translate(&config.ability_presets_settings.default_preset_name, locale);
384
385        for grade in &mut config.statue_bonus_grades {
386            grade.name = translator.translate(&grade.name, locale);
387        }
388
389        for buff in &mut config.buff_templates {
390            buff.title = translator.translate(&buff.title, locale);
391            buff.description = translator.translate(&buff.description, locale);
392        }
393
394        for skin in &mut config.skins {
395            if let Some(unlock_description) = &skin.unlock_description {
396                skin.unlock_description = Some(translator.translate(unlock_description, locale));
397            }
398        }
399
400        config
401    }
402
403    pub fn validate(&self) {
404        // TODO: Also check empty vectors, too big numbers, invalid references, 0 div
405        if !Self::has_unique_ids(&self.items) {
406            panic!("Not unique item ids in config");
407        }
408
409        self.validate_ability_gacha_settings();
410
411        Self::validate_afk_rewards(
412            &self.afk_rewards_levels,
413            &self.afk_rewards_settings,
414            &self.currencies,
415            &self.abilities,
416            &self.items,
417            &self.bundles,
418        );
419        Self::validate_loop_task_chain(&self.quests);
420        self.validate_pet_config();
421        self.validate_pet_gacha_settings();
422        self.validate_statue_settings();
423        self.validate_class_levels();
424        self.validate_class_abilities();
425        Self::validate_progress_pass_config(&self.progress_pass);
426        self.validate_progress_pass_references();
427    }
428
429    /// Per-class invariants for `Class.class_abilities`:
430    ///  - disjoint from that class's `basic_abilities`;
431    ///  - every referenced ability template exists and has `is_gacha_ability == false`
432    ///    (class actives must never drop from the gacha pool per the design doc).
433    fn validate_class_abilities(&self) {
434        use std::collections::HashSet;
435
436        for class in &self.classes {
437            let basics: HashSet<_> = class.basic_abilities.iter().copied().collect();
438            let mut seen = HashSet::new();
439            for ability_id in &class.class_abilities {
440                if !seen.insert(*ability_id) {
441                    panic!(
442                        "Class {}: duplicate ability_id {ability_id} in class_abilities",
443                        class.id
444                    );
445                }
446                if basics.contains(ability_id) {
447                    panic!(
448                        "Class {}: ability {ability_id} appears in both basic_abilities and class_abilities",
449                        class.id
450                    );
451                }
452                let Some(template) = self.abilities.iter().find(|a| a.id == *ability_id) else {
453                    panic!(
454                        "Class {}: class_abilities references unknown ability_id {ability_id}",
455                        class.id
456                    );
457                };
458                if template.is_gacha_ability {
459                    panic!(
460                        "Class {}: class_ability {ability_id} has is_gacha_ability=true; class actives must be excluded from the gacha pool",
461                        class.id
462                    );
463                }
464            }
465        }
466    }
467
468    fn validate_class_levels(&self) {
469        use std::collections::HashMap;
470
471        let mut by_class: HashMap<class::ClassId, Vec<u64>> = HashMap::new();
472        for row in &self.class_levels {
473            by_class.entry(row.class_id).or_default().push(row.level);
474        }
475
476        for (class_id, mut levels) in by_class {
477            levels.sort();
478            let mut dedup = levels.clone();
479            dedup.dedup();
480            if dedup.len() != levels.len() {
481                panic!("ClassLevels for class {class_id}: duplicate level entries");
482            }
483            for (i, level) in levels.iter().enumerate() {
484                let expected = (i as u64) + 1;
485                if *level != expected {
486                    panic!(
487                        "ClassLevels for class {class_id}: expected contiguous levels starting at 1, got {level} at position {expected}"
488                    );
489                }
490            }
491        }
492    }
493
494    pub fn class_level(&self, class_id: class::ClassId, level: u64) -> Option<&class::ClassLevels> {
495        self.class_levels
496            .iter()
497            .find(|row| row.class_id == class_id && row.level == level)
498    }
499
500    fn validate_progress_pass_config(progress_pass: &essences::progress_pass::ProgressPassConfig) {
501        use std::collections::HashSet;
502
503        let mut tier_values: HashSet<u32> = HashSet::new();
504        for tier in &progress_pass.tiers {
505            if tier.quest_template_ids.is_empty() {
506                panic!(
507                    "ProgressPassConfig: tier {} has no quest_template_ids",
508                    tier.tier
509                );
510            }
511            if !tier_values.insert(tier.tier) {
512                panic!("ProgressPassConfig: duplicate tier value {}", tier.tier);
513            }
514        }
515    }
516
517    fn validate_progress_pass_references(&self) {
518        let pp = &self.progress_pass;
519
520        if pp.tiers.is_empty() {
521            return;
522        }
523
524        if !self
525            .offers_templates
526            .iter()
527            .any(|o| o.id == pp.premium_offer_template_id)
528        {
529            panic!(
530                "ProgressPassConfig: premium_offer_template_id {} does not match any offer template",
531                pp.premium_offer_template_id
532            );
533        }
534
535        for tier in &pp.tiers {
536            for quest_id in &tier.quest_template_ids {
537                let Some(quest) = self.quests.iter().find(|q| q.id == *quest_id) else {
538                    panic!(
539                        "ProgressPassConfig: tier {} references unknown quest template {quest_id}",
540                        tier.tier
541                    );
542                };
543                if quest.quest_group_type != QuestGroupType::ProgressPass {
544                    panic!(
545                        "ProgressPassConfig: tier {} references quest {quest_id} whose group is {:?}, expected ProgressPass",
546                        tier.tier, quest.quest_group_type
547                    );
548                }
549            }
550
551            if !self
552                .bundles
553                .iter()
554                .any(|b| b.id == tier.free_reward_bundle_id)
555            {
556                panic!(
557                    "ProgressPassConfig: tier {} free_reward_bundle_id {} does not match any bundle",
558                    tier.tier, tier.free_reward_bundle_id
559                );
560            }
561            if !self
562                .bundles
563                .iter()
564                .any(|b| b.id == tier.paid_reward_bundle_id)
565            {
566                panic!(
567                    "ProgressPassConfig: tier {} paid_reward_bundle_id {} does not match any bundle",
568                    tier.tier, tier.paid_reward_bundle_id
569                );
570            }
571        }
572    }
573
574    fn validate_loop_task_chain(quests: &[QuestTemplate]) {
575        use std::collections::{HashMap, HashSet};
576
577        let loop_quests: Vec<&QuestTemplate> = quests
578            .iter()
579            .filter(|q| q.quest_group_type == QuestGroupType::LoopTask)
580            .collect();
581
582        if loop_quests.is_empty() {
583            return;
584        }
585
586        let all_quest_ids: HashSet<_> = quests.iter().map(|q| q.id).collect();
587        let loop_quest_ids: HashSet<_> = loop_quests.iter().map(|q| q.id).collect();
588
589        // 1. No duplicate codes among LoopTask quests
590        let mut code_to_quest: HashMap<&str, &QuestTemplate> = HashMap::new();
591        for q in &loop_quests {
592            if let Some(code) = &q.code
593                && let Some(existing) = code_to_quest.insert(code.as_str(), q)
594            {
595                panic!(
596                    "LoopTask: duplicate code '{}' on quests {} and {}",
597                    code, existing.id, q.id
598                );
599            }
600        }
601
602        // 2. Every next_quest_ids reference must point to an existing quest
603        for q in &loop_quests {
604            for next_id in &q.next_quest_ids {
605                if !all_quest_ids.contains(next_id) {
606                    panic!(
607                        "LoopTask: quest {} ('{}') has next_quest_id {} that does not exist",
608                        q.id,
609                        q.code.as_deref().unwrap_or("<no code>"),
610                        next_id
611                    );
612                }
613            }
614        }
615
616        // 3. Discover numbered sequences (loop_task.{type}.{N}) and verify each is
617        //    a gapless 1..=max chain where N chains to N+1 via next_quest_ids.
618        let mut sequences: HashMap<String, Vec<(u32, &QuestTemplate)>> = HashMap::new();
619        for (code, quest) in &code_to_quest {
620            let parts: Vec<&str> = code.split('.').collect();
621            // codes look like "loop_task.{type}.{number_or_name}"
622            if parts.len() == 3
623                && parts[0] == "loop_task"
624                && let Ok(n) = parts[2].parse::<u32>()
625            {
626                sequences
627                    .entry(parts[1].to_string())
628                    .or_default()
629                    .push((n, quest));
630            }
631        }
632
633        for (seq_type, mut entries) in sequences {
634            entries.sort_by_key(|(n, _)| *n);
635
636            // Must start from 1
637            if entries[0].0 != 1 {
638                panic!(
639                    "LoopTask: sequence 'loop_task.{}' starts at {} instead of 1",
640                    seq_type, entries[0].0
641                );
642            }
643
644            // No gaps
645            for (i, (n, _)) in entries.iter().enumerate() {
646                let expected = (i as u32) + 1;
647                if *n != expected {
648                    panic!(
649                        "LoopTask: sequence 'loop_task.{}' has gap — expected {} but found {}",
650                        seq_type, expected, n
651                    );
652                }
653            }
654
655            // Each N must chain to N+1 via next_quest_ids (except last).
656            if seq_type != "loop" {
657                for window in entries.windows(2) {
658                    let (n, quest) = window[0];
659                    let (_, next_quest) = window[1];
660                    if !quest.next_quest_ids.contains(&next_quest.id) {
661                        panic!(
662                            "LoopTask: 'loop_task.{}.{}' (quest {}) does not chain to 'loop_task.{}.{}' (quest {}) via next_quest_ids",
663                            seq_type,
664                            n,
665                            quest.id,
666                            seq_type,
667                            n + 1,
668                            next_quest.id
669                        );
670                    }
671                }
672            }
673        }
674
675        // 4. Every LoopTask quest must be reachable: either has a code (discoverable by scripts)
676        //    or is in the next_quest_ids graph from any quest in the config
677        let mut reachable: HashSet<essences::quest::QuestId> = HashSet::new();
678
679        // Reachable by code
680        for q in code_to_quest.values() {
681            reachable.insert(q.id);
682        }
683
684        // Reachable by next_quest_ids graph (BFS from all quests that reference LoopTask quests)
685        let mut stack: Vec<essences::quest::QuestId> = Vec::new();
686        for q in quests {
687            for next_id in &q.next_quest_ids {
688                if loop_quest_ids.contains(next_id) {
689                    stack.push(*next_id);
690                }
691            }
692        }
693        while let Some(id) = stack.pop() {
694            if !reachable.insert(id) {
695                continue;
696            }
697            if let Some(q) = loop_quests.iter().find(|q| q.id == id) {
698                for next_id in &q.next_quest_ids {
699                    if loop_quest_ids.contains(next_id) {
700                        stack.push(*next_id);
701                    }
702                }
703            }
704        }
705
706        for q in &loop_quests {
707            if !reachable.contains(&q.id) {
708                panic!(
709                    "LoopTask: quest {} ('{}') is unreachable — no code and not in next_quest_ids chain from a starting quest",
710                    q.id,
711                    q.code.as_deref().unwrap_or("<no code>")
712                );
713            }
714        }
715    }
716
717    fn validate_afk_rewards(
718        afk_levels: &[AfkRewardsByLevel],
719        afk_settings: &AfkRewardsSettings,
720        currencies: &[Currency],
721        abilities: &[AbilityTemplate],
722        items: &[ItemTemplate],
723        bundles: &[BundleRaw],
724    ) {
725        use std::collections::HashSet;
726
727        let currency_ids: HashSet<_> = currencies.iter().map(|c| c.id).collect();
728        let ability_ids: HashSet<_> = abilities.iter().map(|a| a.id).collect();
729        let item_ids: HashSet<_> = items.iter().map(|i| i.id).collect();
730        let bundle_ids: HashSet<_> = bundles.iter().map(|b| b.id).collect();
731
732        // bonus_calculation_rate_sec > 0 is enforced by NonZeroU64 type
733
734        if !bundle_ids.contains(&afk_settings.bundle_id) {
735            panic!(
736                "AfkRewardsSettings: bundle_id {} not found in bundles",
737                afk_settings.bundle_id
738            );
739        }
740
741        let chapter_levels: Vec<_> = afk_levels.iter().map(|r| r.chapter_level).collect();
742        let unique_levels: HashSet<_> = chapter_levels.iter().collect();
743        if chapter_levels.len() != unique_levels.len() {
744            panic!("AfkRewardsByLevel: Two or more afk reward levels have equal chapter_level");
745        }
746
747        for level_rewards in afk_levels {
748            for rate in &level_rewards.currency_rates {
749                if !currency_ids.contains(&rate.currency_id) {
750                    panic!(
751                        "AfkRewardsByLevel chapter_level={}: currency_id {} not found in currencies",
752                        level_rewards.chapter_level, rate.currency_id
753                    );
754                }
755                // rate_per_minute > 0 is enforced by PositiveF64 type
756            }
757
758            // bonus_weights non-empty is enforced by NonEmptyVec type
759            // bonus_weight.weight > 0 is enforced by PositiveF64 type
760            // bonus_weight.count > 0 is enforced by PositiveI64 type
761
762            for bonus_weight in &level_rewards.bonus_weights {
763                match &bonus_weight.bonus_type {
764                    AfkRewardBonusType::Currency(currency_id) => {
765                        if !currency_ids.contains(currency_id) {
766                            panic!(
767                                "AfkRewardsByLevel chapter_level={}: bonus Currency id {} not found",
768                                level_rewards.chapter_level, currency_id
769                            );
770                        }
771                    }
772                    AfkRewardBonusType::Ability(ability_id) => {
773                        if !ability_ids.contains(ability_id) {
774                            panic!(
775                                "AfkRewardsByLevel chapter_level={}: bonus Ability id {} not found",
776                                level_rewards.chapter_level, ability_id
777                            );
778                        }
779                    }
780                    AfkRewardBonusType::Item(item_id) => {
781                        if !item_ids.contains(item_id) {
782                            panic!(
783                                "AfkRewardsByLevel chapter_level={}: bonus Item id {} not found",
784                                level_rewards.chapter_level, item_id
785                            );
786                        }
787                    }
788                }
789            }
790        }
791    }
792
793    fn validate_ability_gacha_settings(&self) {
794        use std::collections::HashSet;
795
796        let gacha = &self.game_settings.ability_gacha;
797        let currency_ids: HashSet<_> = self.currencies.iter().map(|currency| currency.id).collect();
798        let rarity_ids: HashSet<_> = self
799            .ability_rarities
800            .iter()
801            .map(|rarity| rarity.id)
802            .collect();
803        let class_rarity_ids: HashSet<_> = self
804            .classes
805            .iter()
806            .map(|class| class.ability_rarity_id)
807            .collect();
808
809        // small_roll_cost > 0, big_roll_cost > 0, slot_max_level > 0 enforced by PositiveI64
810        // wishlist_weight_multiplier >= 1.0 enforced by WeightMultiplier type
811        // wishlist_slots > 0 still needs runtime check (u8, not wrapped)
812        if gacha.wishlist_slots == 0 {
813            panic!("AbilityGachaSettings: wishlist_slots must be > 0");
814        }
815        if gacha.slot_level_costs.len() != gacha.slot_max_level.get() as usize {
816            panic!(
817                "AbilityGachaSettings: slot_level_costs length ({}) must match slot_max_level ({})",
818                gacha.slot_level_costs.len(),
819                gacha.slot_max_level.get()
820            );
821        }
822        if gacha.slot_level_bonus_levels.len() != gacha.slot_max_level.get() as usize + 1 {
823            panic!(
824                "AbilityGachaSettings: slot_level_bonus_levels length ({}) must be slot_max_level + 1 ({})",
825                gacha.slot_level_bonus_levels.len(),
826                gacha.slot_max_level.get() + 1
827            );
828        }
829        // slot_level_costs values > 0 enforced by PositiveI64 type
830        for bonus in &gacha.slot_level_bonus_levels {
831            if *bonus < 0 {
832                panic!("AbilityGachaSettings: slot_level_bonus_levels values must be >= 0");
833            }
834        }
835        if !currency_ids.contains(&gacha.slot_upgrade_currency_id) {
836            panic!(
837                "AbilityGachaSettings: slot_upgrade_currency_id {} not found in currencies",
838                gacha.slot_upgrade_currency_id
839            );
840        }
841
842        // ability_cases_settings non-empty is enforced by NonEmptyVec type
843
844        let mut settings_by_level = self.ability_cases_settings.to_vec();
845        settings_by_level.sort_by_key(|level_settings| level_settings.level);
846
847        // Validate boundary levels: at most one, must be the last level.
848        let boundary_count = settings_by_level
849            .iter()
850            .filter(|s| s.is_boundary_level)
851            .count();
852        if boundary_count > 1 {
853            panic!("AbilityCasesSettings: at most one boundary level is allowed");
854        }
855        if boundary_count == 1 && !settings_by_level.last().unwrap().is_boundary_level {
856            panic!("AbilityCasesSettings: the boundary level must be the last level");
857        }
858
859        let mut previous_level = 0;
860        let mut previous_opens = -1;
861        for (idx, level_settings) in settings_by_level.iter().enumerate() {
862            if level_settings.level <= previous_level {
863                panic!("AbilityCasesSettings: levels must be unique and strictly increasing");
864            }
865            if level_settings.opens_to_upgrade < 0 {
866                panic!(
867                    "AbilityCasesSettings level {}: opens_to_upgrade must be >= 0",
868                    level_settings.level
869                );
870            }
871            if level_settings.opens_to_upgrade < previous_opens {
872                panic!(
873                    "AbilityCasesSettings level {}: opens_to_upgrade must be non-decreasing",
874                    level_settings.level
875                );
876            }
877            previous_level = level_settings.level;
878            previous_opens = level_settings.opens_to_upgrade;
879
880            // Boundary levels only need level + opens_to_upgrade; skip all other validations.
881            if level_settings.is_boundary_level {
882                continue;
883            }
884
885            let mut previous_checkpoint_opens = -1;
886            for checkpoint in &level_settings.checkpoints {
887                if checkpoint.required_opens <= 0 {
888                    panic!(
889                        "AbilityCasesSettings level {}: checkpoint required_opens must be > 0",
890                        level_settings.level
891                    );
892                }
893                if checkpoint.required_opens <= previous_checkpoint_opens {
894                    panic!(
895                        "AbilityCasesSettings level {}: checkpoint required_opens must be strictly increasing",
896                        level_settings.level
897                    );
898                }
899                previous_checkpoint_opens = checkpoint.required_opens;
900
901                // Checkpoint required_opens is relative to the current level's
902                // opens_to_upgrade. It must not exceed the range of this level
903                // (i.e. the gap to the next level's opens_to_upgrade).
904                if let Some(next) = settings_by_level.get(idx + 1) {
905                    let level_range = next.opens_to_upgrade - level_settings.opens_to_upgrade;
906                    if checkpoint.required_opens > level_range {
907                        panic!(
908                            "AbilityCasesSettings level {}: checkpoint required_opens ({}) exceeds level range ({})",
909                            level_settings.level, checkpoint.required_opens, level_range
910                        );
911                    }
912                }
913
914                for currency_reward in &checkpoint.currency_rewards {
915                    if currency_reward.amount <= 0 {
916                        panic!(
917                            "AbilityCasesSettings level {}: checkpoint currency reward must be > 0",
918                            level_settings.level
919                        );
920                    }
921                    if !currency_ids.contains(&currency_reward.currency_id) {
922                        panic!(
923                            "AbilityCasesSettings level {}: checkpoint currency {} not found",
924                            level_settings.level, currency_reward.currency_id
925                        );
926                    }
927                }
928
929                for ability_reward in &checkpoint.ability_rewards {
930                    if ability_reward.amount < 0 {
931                        panic!(
932                            "AbilityCasesSettings level {}: checkpoint ability reward amount must be >= 0",
933                            level_settings.level
934                        );
935                    }
936                    if ability_reward.amount > 0
937                        && !rarity_ids.contains(&ability_reward.min_rarity_id)
938                    {
939                        panic!(
940                            "AbilityCasesSettings level {}: checkpoint ability reward min_rarity_id {} not found",
941                            level_settings.level, ability_reward.min_rarity_id
942                        );
943                    }
944                }
945            }
946
947            for rarity_id in &level_settings.allowed_wishlist_rarity_ids {
948                if !rarity_ids.contains(rarity_id) {
949                    panic!(
950                        "AbilityCasesSettings level {}: allowed wishlist rarity {} not found",
951                        level_settings.level, rarity_id
952                    );
953                }
954                if class_rarity_ids.contains(rarity_id) {
955                    panic!(
956                        "AbilityCasesSettings level {}: class rarity {} cannot be in wishlist",
957                        level_settings.level, rarity_id
958                    );
959                }
960            }
961
962            for evolve_rule in &level_settings.evolve_rules {
963                if evolve_rule.small_roll_tries < 0 {
964                    panic!(
965                        "AbilityCasesSettings level {}: evolve small_roll_tries must be >= 0",
966                        level_settings.level
967                    );
968                }
969                if evolve_rule.big_roll_tries < 0 {
970                    panic!(
971                        "AbilityCasesSettings level {}: evolve big_roll_tries must be >= 0",
972                        level_settings.level
973                    );
974                }
975                if evolve_rule.small_roll_tries == 0 && evolve_rule.big_roll_tries == 0 {
976                    panic!(
977                        "AbilityCasesSettings level {}: evolve rule must have at least one non-zero tries",
978                        level_settings.level
979                    );
980                }
981                if !rarity_ids.contains(&evolve_rule.from_rarity_id) {
982                    panic!(
983                        "AbilityCasesSettings level {}: evolve from rarity {} not found",
984                        level_settings.level, evolve_rule.from_rarity_id
985                    );
986                }
987                // rarity_weight.weight > 0 enforced by PositiveF64 type
988                let mut sum_weight = 0.0;
989                for rarity_weight in &evolve_rule.to_rarity_weights {
990                    if !rarity_ids.contains(&rarity_weight.rarity_id) {
991                        panic!(
992                            "AbilityCasesSettings level {}: evolve to rarity {} not found",
993                            level_settings.level, rarity_weight.rarity_id
994                        );
995                    }
996                    sum_weight += rarity_weight.weight.get();
997                }
998                if sum_weight > 1.0 + f64::EPSILON {
999                    panic!(
1000                        "AbilityCasesSettings level {}: evolve to_rarity_weights sum must be <= 1.0, got {}",
1001                        level_settings.level, sum_weight
1002                    );
1003                }
1004            }
1005
1006            for currency_reward in &level_settings.big_roll_currency_rewards {
1007                if currency_reward.amount <= 0 {
1008                    panic!(
1009                        "AbilityCasesSettings level {}: big-roll currency reward amount must be > 0",
1010                        level_settings.level
1011                    );
1012                }
1013                if !currency_ids.contains(&currency_reward.currency_id) {
1014                    panic!(
1015                        "AbilityCasesSettings level {}: big-roll currency {} not found",
1016                        level_settings.level, currency_reward.currency_id
1017                    );
1018                }
1019            }
1020
1021            let big_roll_shards = &level_settings.big_roll_shards;
1022            let shards_total: u64 = big_roll_shards.iter().map(|s| s.count as u64).sum();
1023            let expected_total =
1024                gacha.big_roll_cost.get() as u64 + gacha.big_roll_bonus_drops as u64;
1025            if shards_total != expected_total {
1026                panic!(
1027                    "AbilityCasesSettings level {}: big_roll_shards total ({}) must equal big_roll_cost + bonus_drops ({})",
1028                    level_settings.level, shards_total, expected_total
1029                );
1030            }
1031            for shard in big_roll_shards {
1032                if shard.count == 0 {
1033                    panic!(
1034                        "AbilityCasesSettings level {}: big_roll_shards count must be > 0",
1035                        level_settings.level
1036                    );
1037                }
1038                if !rarity_ids.contains(&shard.min_rarity_id) {
1039                    panic!(
1040                        "AbilityCasesSettings level {}: big_roll_shards rarity {} not found",
1041                        level_settings.level, shard.min_rarity_id
1042                    );
1043                }
1044            }
1045        }
1046
1047        if settings_by_level[0].level != 1 || settings_by_level[0].opens_to_upgrade != 0 {
1048            panic!("AbilityCasesSettings: level 1 with opens_to_upgrade = 0 is required");
1049        }
1050    }
1051
1052    fn validate_pet_config(&self) {
1053        use std::collections::HashSet;
1054
1055        let pet_template_ids: HashSet<_> = self.pet_templates.iter().map(|t| t.id).collect();
1056        if pet_template_ids.len() != self.pet_templates.len() {
1057            panic!("PetConfig: duplicate pet template ids");
1058        }
1059
1060        let pet_rarity_ids: HashSet<_> = self.pet_rarities.iter().map(|r| r.id).collect();
1061        if pet_rarity_ids.len() != self.pet_rarities.len() {
1062            panic!("PetConfig: duplicate pet rarity ids");
1063        }
1064
1065        let ability_ids: HashSet<_> = self.abilities.iter().map(|a| a.id).collect();
1066        let attribute_ids: HashSet<_> = self.attributes.iter().map(|a| a.id).collect();
1067
1068        for template in &self.pet_templates {
1069            if let Some(ability_id) = template.active_ability_id
1070                && !ability_ids.contains(&ability_id)
1071            {
1072                panic!(
1073                    "PetConfig: pet template {} has active_ability_id {} not found in abilities",
1074                    template.id, ability_id
1075                );
1076            }
1077            if let Some(ability_id) = template.passive_ability_id
1078                && !ability_ids.contains(&ability_id)
1079            {
1080                panic!(
1081                    "PetConfig: pet template {} has passive_ability_id {} not found in abilities",
1082                    template.id, ability_id
1083                );
1084            }
1085            if !pet_rarity_ids.contains(&template.rarity_id) {
1086                panic!(
1087                    "PetConfig: pet template {} has rarity_id {} not found in pet_rarities",
1088                    template.id, template.rarity_id
1089                );
1090            }
1091            for stat in &template.stats {
1092                if !attribute_ids.contains(&stat.attribute_id) {
1093                    panic!(
1094                        "PetConfig: pet template {} has stat attribute_id {} not found in attributes",
1095                        template.id, stat.attribute_id
1096                    );
1097                }
1098            }
1099        }
1100
1101        for pet_level in &self.pet_levels {
1102            if !pet_rarity_ids.contains(&pet_level.rarity_id) {
1103                panic!(
1104                    "PetConfig: pet level {} has rarity_id {} not found in pet_rarities",
1105                    pet_level.level, pet_level.rarity_id
1106                );
1107            }
1108            if pet_level.required_shards < 0 {
1109                panic!(
1110                    "PetConfig: pet level {} (rarity {}) required_shards must be >= 0",
1111                    pet_level.level, pet_level.rarity_id
1112                );
1113            }
1114        }
1115    }
1116
1117    /// Mirror of `validate_ability_gacha_settings` for the pet gacha:
1118    /// same level/checkpoint/big-roll invariants on `pet_cases_settings`,
1119    /// minus the ability-only parts (slot upgrades, evolve rules, class rarities).
1120    fn validate_pet_gacha_settings(&self) {
1121        use std::collections::HashSet;
1122
1123        let gacha = &self.game_settings.pet_gacha;
1124        let currency_ids: HashSet<_> = self.currencies.iter().map(|currency| currency.id).collect();
1125        let rarity_ids: HashSet<_> = self.pet_rarities.iter().map(|rarity| rarity.id).collect();
1126
1127        // roll_price_in_diamonds > 0, small_roll_cost > 0, big_roll_cost > 0 enforced by PositiveI64
1128        // wishlist_weight_multiplier >= 1.0 enforced by WeightMultiplier type
1129        // wishlist_slots > 0 still needs runtime check (u8, not wrapped)
1130        if gacha.wishlist_slots == 0 {
1131            panic!("PetGachaSettings: wishlist_slots must be > 0");
1132        }
1133        if !currency_ids.contains(&gacha.currency_id) {
1134            panic!(
1135                "PetGachaSettings: currency_id {} not found in currencies",
1136                gacha.currency_id
1137            );
1138        }
1139
1140        if self.pet_cases_settings.is_empty() {
1141            panic!("PetCasesSettings: must not be empty");
1142        }
1143
1144        let mut settings_by_level = self.pet_cases_settings.clone();
1145        settings_by_level.sort_by_key(|level_settings| level_settings.level);
1146
1147        // Validate boundary levels: at most one, must be the last level.
1148        let boundary_count = settings_by_level
1149            .iter()
1150            .filter(|s| s.is_boundary_level)
1151            .count();
1152        if boundary_count > 1 {
1153            panic!("PetCasesSettings: at most one boundary level is allowed");
1154        }
1155        if boundary_count == 1 && !settings_by_level.last().unwrap().is_boundary_level {
1156            panic!("PetCasesSettings: the boundary level must be the last level");
1157        }
1158
1159        let mut previous_level = 0;
1160        let mut previous_opens = -1;
1161        for (idx, level_settings) in settings_by_level.iter().enumerate() {
1162            if level_settings.level <= previous_level {
1163                panic!("PetCasesSettings: levels must be unique and strictly increasing");
1164            }
1165            if level_settings.opens_to_upgrade < 0 {
1166                panic!(
1167                    "PetCasesSettings level {}: opens_to_upgrade must be >= 0",
1168                    level_settings.level
1169                );
1170            }
1171            if level_settings.opens_to_upgrade < previous_opens {
1172                panic!(
1173                    "PetCasesSettings level {}: opens_to_upgrade must be non-decreasing",
1174                    level_settings.level
1175                );
1176            }
1177            previous_level = level_settings.level;
1178            previous_opens = level_settings.opens_to_upgrade;
1179
1180            // Boundary levels only need level + opens_to_upgrade; skip all other validations.
1181            if level_settings.is_boundary_level {
1182                continue;
1183            }
1184
1185            // The drop pool: every roll on this level draws a rarity from these weights.
1186            if level_settings.rarity_weights.is_empty() {
1187                panic!(
1188                    "PetCasesSettings level {}: rarity_weights must not be empty",
1189                    level_settings.level
1190                );
1191            }
1192            // rarity_weight.weight > 0 enforced by PositiveF64 type
1193            for rarity_weight in &level_settings.rarity_weights {
1194                if !rarity_ids.contains(&rarity_weight.rarity_id) {
1195                    panic!(
1196                        "PetCasesSettings level {}: rarity weight rarity {} not found",
1197                        level_settings.level, rarity_weight.rarity_id
1198                    );
1199                }
1200            }
1201
1202            let mut previous_checkpoint_opens = -1;
1203            for checkpoint in &level_settings.checkpoints {
1204                if checkpoint.required_opens <= 0 {
1205                    panic!(
1206                        "PetCasesSettings level {}: checkpoint required_opens must be > 0",
1207                        level_settings.level
1208                    );
1209                }
1210                if checkpoint.required_opens <= previous_checkpoint_opens {
1211                    panic!(
1212                        "PetCasesSettings level {}: checkpoint required_opens must be strictly increasing",
1213                        level_settings.level
1214                    );
1215                }
1216                previous_checkpoint_opens = checkpoint.required_opens;
1217
1218                // Checkpoint required_opens is relative to the current level's
1219                // opens_to_upgrade. It must not exceed the range of this level
1220                // (i.e. the gap to the next level's opens_to_upgrade).
1221                if let Some(next) = settings_by_level.get(idx + 1) {
1222                    let level_range = next.opens_to_upgrade - level_settings.opens_to_upgrade;
1223                    if checkpoint.required_opens > level_range {
1224                        panic!(
1225                            "PetCasesSettings level {}: checkpoint required_opens ({}) exceeds level range ({})",
1226                            level_settings.level, checkpoint.required_opens, level_range
1227                        );
1228                    }
1229                }
1230
1231                for currency_reward in &checkpoint.currency_rewards {
1232                    if currency_reward.amount <= 0 {
1233                        panic!(
1234                            "PetCasesSettings level {}: checkpoint currency reward must be > 0",
1235                            level_settings.level
1236                        );
1237                    }
1238                    if !currency_ids.contains(&currency_reward.currency_id) {
1239                        panic!(
1240                            "PetCasesSettings level {}: checkpoint currency {} not found",
1241                            level_settings.level, currency_reward.currency_id
1242                        );
1243                    }
1244                }
1245
1246                for pet_reward in &checkpoint.pet_rewards {
1247                    if pet_reward.amount < 0 {
1248                        panic!(
1249                            "PetCasesSettings level {}: checkpoint pet reward amount must be >= 0",
1250                            level_settings.level
1251                        );
1252                    }
1253                    if pet_reward.amount > 0 && !rarity_ids.contains(&pet_reward.min_rarity_id) {
1254                        panic!(
1255                            "PetCasesSettings level {}: checkpoint pet reward min_rarity_id {} not found",
1256                            level_settings.level, pet_reward.min_rarity_id
1257                        );
1258                    }
1259                }
1260            }
1261
1262            for rarity_id in &level_settings.allowed_wishlist_rarity_ids {
1263                if !rarity_ids.contains(rarity_id) {
1264                    panic!(
1265                        "PetCasesSettings level {}: allowed wishlist rarity {} not found",
1266                        level_settings.level, rarity_id
1267                    );
1268                }
1269            }
1270
1271            for currency_reward in &level_settings.big_roll_currency_rewards {
1272                if currency_reward.amount <= 0 {
1273                    panic!(
1274                        "PetCasesSettings level {}: big-roll currency reward amount must be > 0",
1275                        level_settings.level
1276                    );
1277                }
1278                if !currency_ids.contains(&currency_reward.currency_id) {
1279                    panic!(
1280                        "PetCasesSettings level {}: big-roll currency {} not found",
1281                        level_settings.level, currency_reward.currency_id
1282                    );
1283                }
1284            }
1285
1286            let big_roll_shards = &level_settings.big_roll_shards;
1287            let shards_total: u64 = big_roll_shards.iter().map(|s| s.count as u64).sum();
1288            let expected_total =
1289                gacha.big_roll_cost.get() as u64 + gacha.big_roll_bonus_drops as u64;
1290            if shards_total > expected_total {
1291                panic!(
1292                    "PetCasesSettings level {}: big_roll_shards total ({}) must not exceed big_roll_cost + bonus_drops ({})",
1293                    level_settings.level, shards_total, expected_total
1294                );
1295            }
1296            if shards_total != expected_total {
1297                // The ability gacha enforces strict equality here. Current live
1298                // pet content ships shard totals below big_roll_cost + bonus_drops,
1299                // so a hard panic would fail config load on the existing config.
1300                // Warn until the content is fixed, then promote this to a panic.
1301                tracing::warn!(
1302                    "PetCasesSettings level {}: big_roll_shards total ({}) does not match big_roll_cost + bonus_drops ({})",
1303                    level_settings.level,
1304                    shards_total,
1305                    expected_total
1306                );
1307            }
1308            for shard in big_roll_shards {
1309                if shard.count == 0 {
1310                    panic!(
1311                        "PetCasesSettings level {}: big_roll_shards count must be > 0",
1312                        level_settings.level
1313                    );
1314                }
1315                if !rarity_ids.contains(&shard.min_rarity_id) {
1316                    panic!(
1317                        "PetCasesSettings level {}: big_roll_shards rarity {} not found",
1318                        level_settings.level, shard.min_rarity_id
1319                    );
1320                }
1321            }
1322        }
1323
1324        if settings_by_level[0].level != 1 || settings_by_level[0].opens_to_upgrade != 0 {
1325            panic!("PetCasesSettings: level 1 with opens_to_upgrade = 0 is required");
1326        }
1327    }
1328
1329    fn validate_statue_settings(&self) {
1330        use std::collections::HashSet;
1331
1332        let statue = &self.statue_settings;
1333        let currency_ids: HashSet<_> = self.currencies.iter().map(|c| c.id).collect();
1334        let attribute_ids: HashSet<_> = self.attributes.iter().map(|a| a.id).collect();
1335
1336        if !currency_ids.contains(&statue.currency_id) {
1337            panic!(
1338                "StatueSettings: currency_id {} not found in currencies",
1339                statue.currency_id
1340            );
1341        }
1342
1343        // statue_bonus_grades non-empty enforced by NonEmptyVec type
1344
1345        let grade_ids: HashSet<_> = self.statue_bonus_grades.iter().map(|g| g.id).collect();
1346        if grade_ids.len() != self.statue_bonus_grades.len() {
1347            panic!("statue_bonus_grades: duplicate grade ids");
1348        }
1349
1350        // roll_costs non-empty enforced by NonEmptyVec type
1351        // roll_costs cost > 0 enforced by PositiveI64 type
1352
1353        // statue_bonus_type_configs non-empty enforced by NonEmptyVec type
1354        for bonus_type in &self.statue_bonus_type_configs {
1355            if !attribute_ids.contains(&bonus_type.attribute_id) {
1356                panic!(
1357                    "statue_bonus_type_configs attribute_id {} not found in attributes",
1358                    bonus_type.attribute_id
1359                );
1360            }
1361            // grade_values non-empty enforced by NonEmptyVec type
1362            for grade_value in &bonus_type.grade_values {
1363                if !grade_ids.contains(&grade_value.grade_id) {
1364                    panic!(
1365                        "statue_bonus_type_configs attribute {} references unknown grade_id {}",
1366                        bonus_type.attribute_id, grade_value.grade_id
1367                    );
1368                }
1369                // grade_value.value > 0 enforced by PositiveI32 type
1370            }
1371        }
1372
1373        // statue_level_configs non-empty enforced by NonEmptyVec type
1374        if self.statue_level_configs[0].level != 1
1375            || self.statue_level_configs[0].required_experience != 0
1376        {
1377            panic!("statue_level_configs must start with level=1 and required_experience=0");
1378        }
1379        let mut prev_level = 0;
1380        let mut prev_exp = -1;
1381        for level_config in &self.statue_level_configs {
1382            if level_config.level <= prev_level {
1383                panic!("statue_level_configs levels must be strictly increasing");
1384            }
1385            if level_config.required_experience <= prev_exp && level_config.level > 1 {
1386                panic!(
1387                    "statue_level_configs level={} required_experience must be strictly increasing",
1388                    level_config.level
1389                );
1390            }
1391            // slot_count > 0 enforced by NonZeroU64 type
1392            // sets_count > 0 enforced by NonZeroU64 type
1393            // grade_weights non-empty enforced by NonEmptyVec type
1394            for weight in &level_config.grade_weights {
1395                if !grade_ids.contains(&weight.grade_id) {
1396                    panic!(
1397                        "statue_level_configs level={} grade_weights references unknown grade_id {}",
1398                        level_config.level, weight.grade_id
1399                    );
1400                }
1401                // weight > 0 enforced by PositiveF64 type
1402            }
1403            prev_level = level_config.level;
1404            prev_exp = level_config.required_experience;
1405        }
1406    }
1407
1408    fn has_unique_ids(items: &[ItemTemplate]) -> bool {
1409        let mut ids = std::collections::HashSet::new();
1410        items.iter().all(|item| ids.insert(item.id))
1411    }
1412}
1413
1414#[derive(Debug, Clone, Copy)]
1415pub struct NextFightInfo {
1416    pub next_chapter_level: i64,
1417    pub next_fight_number: i64,
1418    pub next_fight_id: Option<essences::fighting::FightTemplateId>,
1419}
1420
1421#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Tsify)]
1422#[tsify(into_wasm_abi, from_wasm_abi)]
1423pub enum GetConfigResponse {
1424    Ok { config: Box<GameConfig> },
1425    Error { code: String, message: String },
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430    use super::*;
1431    use essences::offers::OfferTemplateId;
1432    use essences::progress_pass::{ProgressPassConfig, ProgressPassTierTemplate};
1433    use uuid::uuid;
1434
1435    fn tier(tier: u32, quest_template_ids: Vec<uuid::Uuid>) -> ProgressPassTierTemplate {
1436        ProgressPassTierTemplate {
1437            tier,
1438            quest_template_ids,
1439            free_reward_bundle_id: uuid::Uuid::nil(),
1440            paid_reward_bundle_id: uuid::Uuid::nil(),
1441        }
1442    }
1443
1444    fn pp_config(tiers: Vec<ProgressPassTierTemplate>) -> ProgressPassConfig {
1445        ProgressPassConfig {
1446            tiers,
1447            premium_offer_template_id: OfferTemplateId::nil(),
1448        }
1449    }
1450
1451    #[test]
1452    fn validate_progress_pass_accepts_valid_config() {
1453        let config = pp_config(vec![
1454            tier(1, vec![uuid!("aa000000-0000-0000-0000-000000000001")]),
1455            tier(
1456                2,
1457                vec![
1458                    uuid!("aa000000-0000-0000-0000-000000000001"),
1459                    uuid!("bb000000-0000-0000-0000-000000000002"),
1460                ],
1461            ),
1462            tier(3, vec![uuid!("cc000000-0000-0000-0000-000000000003")]),
1463        ]);
1464        GameConfig::validate_progress_pass_config(&config);
1465    }
1466
1467    #[test]
1468    fn validate_progress_pass_accepts_empty_tiers() {
1469        let config = pp_config(vec![]);
1470        GameConfig::validate_progress_pass_config(&config);
1471    }
1472
1473    #[test]
1474    #[should_panic(expected = "ProgressPassConfig: tier 2 has no quest_template_ids")]
1475    fn validate_progress_pass_rejects_empty_quest_template_ids() {
1476        let config = pp_config(vec![
1477            tier(1, vec![uuid!("aa000000-0000-0000-0000-000000000001")]),
1478            tier(2, vec![]),
1479        ]);
1480        GameConfig::validate_progress_pass_config(&config);
1481    }
1482
1483    #[test]
1484    #[should_panic(expected = "ProgressPassConfig: duplicate tier value 1")]
1485    fn validate_progress_pass_rejects_duplicate_tier_numbers() {
1486        let config = pp_config(vec![
1487            tier(1, vec![uuid!("aa000000-0000-0000-0000-000000000001")]),
1488            tier(1, vec![uuid!("bb000000-0000-0000-0000-000000000002")]),
1489        ]);
1490        GameConfig::validate_progress_pass_config(&config);
1491    }
1492
1493    #[test]
1494    fn validate_pet_gacha_settings_accepts_test_config() {
1495        let config = crate::tests_game_config::generate_game_config_for_tests();
1496        config.validate_pet_gacha_settings();
1497    }
1498
1499    #[test]
1500    #[should_panic(expected = "PetCasesSettings: must not be empty")]
1501    fn validate_pet_gacha_settings_rejects_empty_cases_settings() {
1502        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1503        config.pet_cases_settings.clear();
1504        config.validate_pet_gacha_settings();
1505    }
1506
1507    #[test]
1508    #[should_panic(expected = "PetCasesSettings: level 1 with opens_to_upgrade = 0 is required")]
1509    fn validate_pet_gacha_settings_rejects_missing_level_one() {
1510        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1511        config.pet_cases_settings.remove(0);
1512        config.validate_pet_gacha_settings();
1513    }
1514
1515    #[test]
1516    #[should_panic(expected = "rarity weight rarity")]
1517    fn validate_pet_gacha_settings_rejects_unknown_rarity_in_weights() {
1518        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1519        config.pet_cases_settings[0].rarity_weights[0].rarity_id =
1520            uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
1521        config.validate_pet_gacha_settings();
1522    }
1523
1524    #[test]
1525    #[should_panic(expected = "rarity_weights must not be empty")]
1526    fn validate_pet_gacha_settings_rejects_empty_rarity_weights() {
1527        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1528        config.pet_cases_settings[0].rarity_weights.clear();
1529        config.validate_pet_gacha_settings();
1530    }
1531
1532    #[test]
1533    #[should_panic(expected = "must not exceed big_roll_cost + bonus_drops")]
1534    fn validate_pet_gacha_settings_rejects_shard_total_above_big_roll_drops() {
1535        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1536        config.pet_cases_settings[0].big_roll_shards[0].count = 200;
1537        config.validate_pet_gacha_settings();
1538    }
1539
1540    #[test]
1541    #[should_panic(expected = "big_roll_shards rarity")]
1542    fn validate_pet_gacha_settings_rejects_unknown_shard_rarity() {
1543        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1544        config.pet_cases_settings[0].big_roll_shards[0].min_rarity_id =
1545            uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
1546        config.validate_pet_gacha_settings();
1547    }
1548
1549    #[test]
1550    #[should_panic(expected = "checkpoint required_opens must be strictly increasing")]
1551    fn validate_pet_gacha_settings_rejects_non_increasing_checkpoints() {
1552        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1553        let checkpoint = config.pet_cases_settings[0].checkpoints[0].clone();
1554        config.pet_cases_settings[0].checkpoints.push(checkpoint);
1555        config.validate_pet_gacha_settings();
1556    }
1557
1558    #[test]
1559    #[should_panic(expected = "PetGachaSettings: wishlist_slots must be > 0")]
1560    fn validate_pet_gacha_settings_rejects_zero_wishlist_slots() {
1561        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1562        config.game_settings.pet_gacha.wishlist_slots = 0;
1563        config.validate_pet_gacha_settings();
1564    }
1565
1566    #[test]
1567    #[should_panic(expected = "not found in pet_rarities")]
1568    fn validate_pet_config_rejects_unknown_pet_level_rarity() {
1569        let mut config = crate::tests_game_config::generate_game_config_for_tests();
1570        config.pet_levels[0].rarity_id = uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
1571        config.validate_pet_config();
1572    }
1573}