configs/
game_config.rs

1use essences::abilities::{AbilityRarity, AbilityTemplate};
2use essences::ability_presets::AbilityPresetsSettings;
3use essences::ability_stones::AbilityStoneTemplate;
4use essences::bundles::BundleRaw;
5use essences::chats::ChatSettings;
6use essences::class;
7use essences::cores::LawTemplate;
8use essences::currency::Currency;
9use essences::dungeons::DungeonTemplate;
10use essences::effect::Effect;
11use essences::fighting::FightTemplate;
12use essences::game::{
13    AbilitySlotsLevel, Chapter, CharacterLevel, CharacterLevelFormula, EntityTemplate,
14    PetSlotsLevel,
15};
16use essences::gatings::Gatings;
17use essences::generation::{BotsSettings, UsersGeneratingSettings};
18use essences::gift::GiftTemplate;
19use essences::item_case::{InventoryLevel, ItemCasesSettingsByLevel};
20use essences::items::{Attribute, ItemRarity, ItemTemplate};
21use essences::mail::MailTemplate;
22use essences::offers::{OfferTemplate, ShopTabConfig};
23use essences::pets::{PetRarity, PetTemplate};
24use essences::progress_pass::ProgressPassConfig;
25use essences::quest::{QuestGroupType, QuestTemplate, QuestsProgressionSettings};
26use essences::ratings::RatingSettings;
27use essences::referrals::ReferralLevelInfo;
28use essences::skins::{ConfigSkin, SkinsSettings};
29use essences::vassals::VassalTaskTemplate;
30use schemars::JsonSchema;
31
32use serde::{Deserialize, Serialize};
33use tsify_next::Tsify;
34
35use crate::abilities::{AbilityCasesSettingsByLevel, AbilityLevel, Projectile};
36use crate::ability_stones::AbilityStoneSettings;
37use crate::ads_settings::{AdsSettings, BirdVariant};
38use crate::afk_rewards::{AfkRewardBonusType, AfkRewardsByLevel, AfkRewardsSettings};
39use crate::artifacts::{ArtifactStoneTemplate, ArtifactTemplate, ArtifactsSettings};
40use crate::buffs::BuffTemplate;
41use crate::cheats::{CheatScript, TestPlayerScript};
42use crate::collection_power::CollectionPowerSettings;
43use crate::cores::CoresSettings;
44use crate::events::EventDescription;
45use crate::fighting::{ArenaLeague, ArenaSettings, FightSettings, PvpSettings};
46use crate::flip::FlipSettings;
47use crate::game_settings::GameSettings;
48use crate::kill_faucets::KillFaucetSettings;
49use crate::local_notifications::LocalNotificationsSettings;
50use crate::mana::ManaSettings;
51use crate::matchmaking::MatchmakingSettings;
52use crate::pets::{PetCasesSettingsByLevel, PetLevel};
53use crate::plinko::{MAX_PLINKO_ROWS, MIN_PLINKO_ROWS, PlinkoSettings};
54use crate::portal_rarities::PortalRarity;
55use crate::reports::ReportsSettings;
56use crate::statue::{StatueBonusTypeConfig, StatueLevelConfig, StatueSettings};
57use crate::stones::{EffectStoneTemplate, StonesSettings, TriggerStoneTemplate};
58use crate::tutorial::TutorialStep;
59use crate::validated_types::NonEmptyVec;
60use crate::vassals::VassalsSettings;
61use essences::statue::StatueBonusGrade;
62use essences::talent_tree::{TalentTemplate, TalentTreeSettings};
63
64#[derive(Clone, Serialize, Deserialize, JsonSchema, Tsify)]
65#[tsify(into_wasm_abi)]
66pub struct GameConfig {
67    #[schemars(title = "Атрибуты")]
68    pub attributes: Vec<Attribute>,
69
70    #[schemars(title = "Настройки сундука-гачи-айтемов")]
71    pub item_cases_settings: Vec<ItemCasesSettingsByLevel>,
72
73    #[schemars(
74        title = "Настройки игры",
75        description = "Отдельные значения, которые используются в игре"
76    )]
77    pub game_settings: GameSettings,
78
79    #[schemars(
80        title = "Настройки действий за рекламу",
81        description = "Все настройки действий, связанные с рекламой"
82    )]
83    pub ads_settings: AdsSettings,
84
85    #[schemars(title = "Предметы")]
86    pub items: Vec<ItemTemplate>,
87
88    #[schemars(title = "Редкости предметов")]
89    pub item_rarities: Vec<ItemRarity>,
90
91    /// Portal-specific sprites mapped onto item rarity metadata.
92    #[schemars(title = "Редкости порталов")]
93    pub portal_rarities: Vec<PortalRarity>,
94
95    #[schemars(title = "Скины")]
96    pub skins: Vec<ConfigSkin>,
97
98    #[schemars(title = "Настройки скинов для кастомизации")]
99    pub skins_settings: SkinsSettings,
100
101    #[schemars(title = "Эффекты")]
102    pub effects: Vec<Effect>,
103
104    #[schemars(title = "Способности")]
105    pub abilities: Vec<AbilityTemplate>,
106
107    #[schemars(title = "Настройки сундука-гачи-скилов")]
108    pub ability_cases_settings: NonEmptyVec<AbilityCasesSettingsByLevel>,
109
110    #[schemars(title = "Редкости способностей")]
111    pub ability_rarities: Vec<AbilityRarity>,
112
113    #[schemars(title = "Уровни способностей")]
114    pub ability_levels: Vec<AbilityLevel>,
115
116    /// Catalog of ability stones — their own collection, never mixed with item
117    /// or artifact stones.
118    #[schemars(title = "Камни способностей")]
119    pub ability_stones: Vec<AbilityStoneTemplate>,
120
121    #[schemars(title = "Настройки камней способностей")]
122    pub ability_stone_settings: AbilityStoneSettings,
123
124    #[schemars(title = "Настройки маны")]
125    pub mana_settings: ManaSettings,
126
127    #[schemars(title = "Настройки боя")]
128    pub fight_settings: FightSettings,
129
130    /// Settings for the persistent global Real/Fantasy equipment flip gauge.
131    #[schemars(title = "Настройки глобального флипа экипировки")]
132    pub flip_settings: FlipSettings,
133
134    /// Balance knobs for the twin cores, their law slots and the bridges.
135    #[schemars(title = "Настройки ядер, законов и мостов")]
136    pub cores_settings: CoresSettings,
137
138    /// Numbers of the twenty Pet Facets rolled by the Team Die.
139    #[schemars(title = "Настройки граней петов (Team Die)")]
140    pub pet_facet_settings: crate::pet_facets::PetFacetSettings,
141
142    /// Law catalog. A law lives in a slot on the core of its own side.
143    #[schemars(title = "Законы")]
144    pub laws: Vec<LawTemplate>,
145    /// Tunables of the Trigger/Effect Stone system: gauge weights, trigger
146    /// cooldown, the tier coefficient matrix, the socket unlock schedule and
147    /// the temporary drop chance.
148    #[schemars(title = "Настройки камней (триггеры/эффекты)")]
149    pub stones_settings: StonesSettings,
150
151    /// BAL-038: the shared daily anti-idle contract for every kill-driven
152    /// faucet. Core Essence is absent from this table on purpose — its `D` is
153    /// the reached chapter band's `R_value` in `cores_settings`.
154    #[schemars(title = "Дневной анти-idle контракт kill-фаунтейнов")]
155    pub kill_faucet_settings: KillFaucetSettings,
156
157    #[schemars(title = "Каталог Trigger-камней")]
158    pub trigger_stones: Vec<TriggerStoneTemplate>,
159
160    #[schemars(title = "Каталог Effect-камней")]
161    pub effect_stones: Vec<EffectStoneTemplate>,
162
163    /// Tunables of the artifact system: the ownership bonus ladder, the
164    /// duplicate ladder, the six-socket unlock schedule and the placeholder
165    /// acquisition sources.
166    #[schemars(title = "Настройки артефактов")]
167    pub artifacts_settings: ArtifactsSettings,
168
169    #[schemars(title = "Каталог артефактов")]
170    pub artifacts: Vec<ArtifactTemplate>,
171
172    /// Catalog of artifact stones. Ships with the six Aspect (left column)
173    /// stones only — the Law column needs the cores/laws vertical.
174    #[schemars(title = "Каталог камней артефакта")]
175    pub artifact_stones: Vec<ArtifactStoneTemplate>,
176
177    /// Collection Power: the passive stat bonus paid for OWNING catalog
178    /// content, across stones, laws, ability stones, gacha abilities and pets.
179    /// Artifacts are excluded — they already pay their own ownership bonus.
180    #[schemars(title = "Настройки коллекционной силы")]
181    pub collection_power: CollectionPowerSettings,
182
183    #[schemars(title = "Враги")]
184    pub entities: Vec<EntityTemplate>,
185
186    #[schemars(title = "Шаблоны данжей")]
187    pub dungeon_templates: Vec<DungeonTemplate>,
188
189    #[schemars(title = "Шаблоны боев")]
190    pub fight_templates: Vec<FightTemplate>,
191
192    #[schemars(title = "Главы кампании")]
193    pub chapters: Vec<Chapter>,
194
195    #[schemars(title = "Уровни игрока")]
196    pub character_levels: Vec<CharacterLevel>,
197
198    #[schemars(title = "Формула бесконечных уровней игрока")]
199    pub character_level_formula: CharacterLevelFormula,
200
201    #[schemars(title = "Уровни слотов способностей")]
202    pub ability_slots_levels: Vec<AbilitySlotsLevel>,
203
204    #[schemars(title = "Реферальные уровни игрока")]
205    pub patron_levels: Vec<ReferralLevelInfo>,
206
207    #[schemars(title = "Квесты")]
208    pub quests: Vec<QuestTemplate>,
209
210    #[schemars(title = "Настройки прогрессии квестов")]
211    pub quests_progression_settings: QuestsProgressionSettings,
212
213    #[schemars(title = "Настройки вассалов")]
214    pub vassals_settings: VassalsSettings,
215
216    #[schemars(title = "Поручения")]
217    pub vassal_tasks: Vec<VassalTaskTemplate>,
218
219    #[schemars(title = "Настройки PvP")]
220    pub pvp_settings: PvpSettings,
221
222    #[schemars(title = "Подарки")]
223    pub gifts: Vec<GiftTemplate>,
224
225    #[schemars(title = "Шаблоны писем")]
226    pub mail_templates: Vec<MailTemplate>,
227
228    #[schemars(title = "Валюты")]
229    pub currencies: Vec<Currency>,
230
231    #[schemars(title = "Уровни инвентаря")]
232    pub inventory_levels: Vec<InventoryLevel>,
233
234    #[schemars(title = "Настройки арены")]
235    pub arena_settings: ArenaSettings,
236
237    #[schemars(title = "Настройки лиг")]
238    pub arena_leagues: Vec<ArenaLeague>,
239
240    #[schemars(title = "Настройки матчмейкинга")]
241    pub matchmaking_settings: MatchmakingSettings,
242
243    #[schemars(title = "Описания эвентов")]
244    pub event_descriptions: Vec<EventDescription>,
245
246    #[schemars(title = "Классы персонажа")]
247    pub classes: Vec<class::Class>,
248
249    #[schemars(title = "Уровни прокачки классов")]
250    pub class_levels: Vec<class::ClassLevels>,
251
252    #[schemars(title = "Список проджектайлов")]
253    pub projectiles: Vec<Projectile>,
254
255    #[schemars(title = "Список бандлов")]
256    pub bundles: Vec<BundleRaw>,
257
258    #[schemars(title = "Настройки пресетов способностей")]
259    pub ability_presets_settings: AbilityPresetsSettings,
260
261    #[schemars(title = "Настройки ботов")]
262    pub bots_settings: BotsSettings,
263
264    #[schemars(title = "Настройки репортов")]
265    pub reports_settings: ReportsSettings,
266
267    #[schemars(title = "Настройки афк наград")]
268    pub afk_rewards_settings: AfkRewardsSettings,
269
270    #[schemars(title = "Уровни афк наград")]
271    pub afk_rewards_levels: Vec<AfkRewardsByLevel>,
272
273    #[schemars(title = "Настройки генерации параметров пользователя")]
274    pub users_generating_settings: UsersGeneratingSettings,
275
276    #[schemars(title = "Шаги туториала")]
277    pub tutorial_steps: Vec<TutorialStep>,
278
279    #[schemars(title = "Настройки чатов")]
280    pub chats_settings: Vec<ChatSettings>,
281
282    #[schemars(title = "Шаблоны офферов")]
283    pub offers_templates: Vec<OfferTemplate>,
284
285    #[schemars(title = "Вкладки магазина")]
286    pub shop_tabs: Vec<ShopTabConfig>,
287
288    #[schemars(title = "Настройки рейтингов")]
289    pub ratings_settings: Vec<RatingSettings>,
290
291    #[schemars(title = "Набор скриптов для читов")]
292    pub cheat_scripts: Vec<CheatScript>,
293
294    #[schemars(
295        title = "Скрипты генерации тестового игрока",
296        description = "Используется только в режиме читов. Каждый скрипт в формате TestPlayerResult — задаёт предметы, способности, петов, уровень, класс и силу тестового игрока. Выбирается по индексу."
297    )]
298    pub test_player_scripts: Vec<TestPlayerScript>,
299
300    #[schemars(title = "Гейтинги")]
301    pub gatings: Gatings,
302
303    #[schemars(title = "Шаблоны петов")]
304    pub pet_templates: Vec<PetTemplate>,
305
306    #[schemars(title = "Редкости петов")]
307    pub pet_rarities: Vec<PetRarity>,
308
309    #[schemars(title = "Уровни петов")]
310    pub pet_levels: Vec<PetLevel>,
311
312    #[schemars(title = "Уровни слотов петов")]
313    pub pet_slots_levels: Vec<PetSlotsLevel>,
314
315    #[schemars(title = "Настройки сундука-гачи-петов")]
316    pub pet_cases_settings: Vec<PetCasesSettingsByLevel>,
317
318    #[schemars(title = "Настройки дерева талантов")]
319    pub talent_tree_settings: TalentTreeSettings,
320
321    #[schemars(title = "Таланты")]
322    pub talents: Vec<TalentTemplate>,
323
324    #[schemars(title = "Грейды бонусов статуи")]
325    pub statue_bonus_grades: NonEmptyVec<StatueBonusGrade>,
326
327    #[schemars(title = "Типы бонусов статуи (стат × грейд)")]
328    pub statue_bonus_type_configs: NonEmptyVec<StatueBonusTypeConfig>,
329
330    #[schemars(title = "Таблица уровней статуи")]
331    pub statue_level_configs: NonEmptyVec<StatueLevelConfig>,
332
333    #[schemars(title = "Настройки статуи героя")]
334    pub statue_settings: StatueSettings,
335
336    #[schemars(title = "Шаблоны баффов")]
337    pub buff_templates: Vec<BuffTemplate>,
338
339    #[schemars(title = "Настройки прогресс пасса")]
340    pub progress_pass: ProgressPassConfig,
341
342    #[schemars(title = "Варианты рекламных птиц")]
343    pub bird_variants: Vec<BirdVariant>,
344
345    #[schemars(title = "Настройки мини-игры Plinko")]
346    pub plinko_settings: PlinkoSettings,
347
348    /// Texts and timings for OS-level notifications the client schedules on the
349    /// device itself. Nothing here is sent from the server at runtime.
350    #[schemars(title = "Настройки локальных уведомлений на телефоне")]
351    pub local_notifications: LocalNotificationsSettings,
352}
353
354impl std::fmt::Debug for GameConfig {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        write!(f, "GameConfig")
357    }
358}
359
360/// One authored Power coefficient pair (BAL-030). A `q` below `1` would make a
361/// component LOWER displayed Power, which the estimator never does — a weak or
362/// no-op component is worth exactly `1` — and a max rank below the first would
363/// mean upgrading a component made it look weaker.
364fn validate_power_q(what: &str, first_rank: f64, max_rank: f64) {
365    assert!(
366        first_rank.is_finite() && max_rank.is_finite(),
367        "{what}: power q must be finite"
368    );
369    assert!(
370        first_rank >= 1.0,
371        "{what}: power q at the first rank must be >= 1.0, got {first_rank}"
372    );
373    assert!(
374        max_rank >= first_rank,
375        "{what}: power q must not shrink with rank ({first_rank} -> {max_rank})"
376    );
377}
378
379impl GameConfig {
380    /// Whether the ability is a class active (referenced by any class's
381    /// `class_abilities`). Class abilities are level-capped below regular ones
382    /// and use their own ability-stone socket ladder.
383    pub fn is_class_ability(&self, ability_id: essences::abilities::AbilityId) -> bool {
384        self.classes
385            .iter()
386            .any(|class| class.class_abilities.contains(&ability_id))
387    }
388
389    pub fn clone_translate(&self, translator: &i18n::translator::Translator, locale: &str) -> Self {
390        let mut config = self.clone();
391        for ability in &mut config.abilities {
392            ability.name = translator.translate(&ability.name, locale);
393            ability.description = translator.translate(&ability.description, locale);
394        }
395
396        for attribute in &mut config.attributes {
397            attribute.name = translator.translate(&attribute.name, locale);
398            attribute.description = translator.translate(&attribute.description, locale);
399            if let Some(prefix) = &attribute.prefix {
400                attribute.prefix = Some(translator.translate(prefix, locale));
401            }
402            if let Some(suffix) = &attribute.suffix {
403                attribute.suffix = Some(translator.translate(suffix, locale));
404            }
405        }
406
407        for effect in &mut config.effects {
408            effect.name = translator.translate(&effect.name, locale);
409        }
410
411        for event in &mut config.event_descriptions {
412            event.description = translator.translate(&event.description, locale);
413        }
414
415        for item in &mut config.items {
416            item.name = translator.translate(&item.name, locale);
417        }
418
419        for item_rarity in &mut config.item_rarities {
420            item_rarity.name = translator.translate(&item_rarity.name, locale);
421        }
422
423        for skin in &mut config.skins {
424            skin.name = translator.translate(&skin.name, locale);
425        }
426
427        for ability_rarity in &mut config.ability_rarities {
428            ability_rarity.name = translator.translate(&ability_rarity.name, locale);
429        }
430
431        for ability_stone in &mut config.ability_stones {
432            ability_stone.name = translator.translate(&ability_stone.name, locale);
433            ability_stone.description = translator.translate(&ability_stone.description, locale);
434        }
435
436        for quest in &mut config.quests {
437            quest.title = translator.translate(&quest.title, locale);
438            quest.description = translator.translate(&quest.description, locale);
439        }
440
441        for vassal_task in &mut config.vassal_tasks {
442            vassal_task.title = translator.translate(&vassal_task.title, locale);
443        }
444
445        for currency in &mut config.currencies {
446            currency.name = translator.translate(&currency.name, locale);
447            currency.description = translator.translate(&currency.description, locale);
448        }
449
450        for mail_template in &mut config.mail_templates {
451            mail_template.title = translator.translate(&mail_template.title, locale);
452            mail_template.message = translator.translate(&mail_template.message, locale);
453        }
454
455        for league in &mut config.arena_leagues {
456            league.name = translator.translate(&league.name, locale);
457        }
458
459        for fight in &mut config.fight_templates {
460            fight.title = translator.translate(&fight.title, locale);
461        }
462
463        for class in &mut config.classes {
464            class.name = translator.translate(&class.name, locale);
465            class.description = translator.translate(&class.description, locale);
466            class.weapon_type = translator.translate(&class.weapon_type, locale);
467        }
468
469        for dungeon in &mut config.dungeon_templates {
470            dungeon.title = translator.translate(&dungeon.title, locale);
471            dungeon.description = translator.translate(&dungeon.description, locale);
472
473            for tip in &mut dungeon.tips {
474                tip.tip = translator.translate(&tip.tip, locale);
475            }
476        }
477
478        for step in &mut config.tutorial_steps {
479            step.text = translator.translate(&step.text, locale);
480        }
481
482        for offer in &mut config.offers_templates {
483            offer.title = translator.translate(&offer.title, locale);
484            if let Some(limit_buy_text) = &offer.limit_buy_text {
485                offer.limit_buy_text = Some(translator.translate(limit_buy_text, locale));
486            }
487        }
488
489        for shop_tab in &mut config.shop_tabs {
490            shop_tab.name = translator.translate(&shop_tab.name, locale);
491        }
492
493        for chapter in &mut config.chapters {
494            chapter.title = translator.translate(&chapter.title, locale);
495        }
496
497        for pet_template in &mut config.pet_templates {
498            pet_template.name = translator.translate(&pet_template.name, locale);
499        }
500
501        for pet_rarity in &mut config.pet_rarities {
502            pet_rarity.name = translator.translate(&pet_rarity.name, locale);
503        }
504
505        for talent in &mut config.talents {
506            talent.name = translator.translate(&talent.name, locale);
507            talent.description = translator.translate(&talent.description, locale);
508        }
509
510        for law in &mut config.laws {
511            law.name = translator.translate(&law.name, locale);
512            law.description = translator.translate(&law.description, locale);
513        }
514
515        config.ability_presets_settings.default_preset_name =
516            translator.translate(&config.ability_presets_settings.default_preset_name, locale);
517
518        for grade in &mut config.statue_bonus_grades {
519            grade.name = translator.translate(&grade.name, locale);
520        }
521
522        for buff in &mut config.buff_templates {
523            buff.title = translator.translate(&buff.title, locale);
524            buff.description = translator.translate(&buff.description, locale);
525        }
526
527        for skin in &mut config.skins {
528            if let Some(unlock_description) = &skin.unlock_description {
529                skin.unlock_description = Some(translator.translate(unlock_description, locale));
530            }
531        }
532
533        for trigger_stone in &mut config.trigger_stones {
534            trigger_stone.name = translator.translate(&trigger_stone.name, locale);
535            trigger_stone.description = translator.translate(&trigger_stone.description, locale);
536        }
537
538        for effect_stone in &mut config.effect_stones {
539            effect_stone.name = translator.translate(&effect_stone.name, locale);
540            effect_stone.description = translator.translate(&effect_stone.description, locale);
541        }
542
543        for artifact in &mut config.artifacts {
544            artifact.name = translator.translate(&artifact.name, locale);
545            artifact.description = translator.translate(&artifact.description, locale);
546        }
547
548        for artifact_stone in &mut config.artifact_stones {
549            artifact_stone.name = translator.translate(&artifact_stone.name, locale);
550            artifact_stone.description = translator.translate(&artifact_stone.description, locale);
551        }
552
553        {
554            let notifications = &mut config.local_notifications;
555            let translate_text = |text: &mut crate::local_notifications::LocalNotificationText| {
556                text.title = translator.translate(&text.title, locale);
557                text.body = translator.translate(&text.body, locale);
558            };
559
560            for step in &mut notifications.win_back {
561                translate_text(&mut step.text);
562            }
563            translate_text(&mut notifications.afk_rewards_capped);
564            translate_text(&mut notifications.item_case_upgrade_finished);
565            translate_text(&mut notifications.talent_upgrade_finished);
566            translate_text(&mut notifications.daily_reset);
567            translate_text(&mut notifications.boost_expiring);
568        }
569
570        config
571    }
572
573    pub fn validate(&self) {
574        // TODO: Also check empty vectors, too big numbers, invalid references, 0 div
575        if !Self::has_unique_ids(&self.items) {
576            panic!("Not unique item ids in config");
577        }
578        self.validate_portal_rarities();
579        self.validate_item_world_sides();
580        self.validate_flip_settings();
581        self.validate_local_notifications();
582        self.validate_mana_settings();
583        self.validate_ability_stone_settings();
584        self.validate_cores_settings();
585        self.validate_pet_facet_settings();
586        self.validate_stones();
587        self.validate_artifacts();
588        self.validate_collection_power();
589        self.validate_inventory_levels();
590
591        self.validate_ability_gacha_settings();
592
593        Self::validate_afk_rewards(
594            &self.afk_rewards_levels,
595            &self.afk_rewards_settings,
596            &self.currencies,
597            &self.abilities,
598            &self.items,
599            &self.bundles,
600        );
601        Self::validate_loop_task_chain(&self.quests);
602        self.validate_pet_config();
603        self.validate_pet_gacha_settings();
604        self.validate_statue_settings();
605        self.validate_class_levels();
606        self.validate_class_abilities();
607        self.validate_item_cases_settings();
608        self.validate_dungeon_templates();
609        self.validate_rating_rewards();
610        Self::validate_progress_pass_config(&self.progress_pass);
611        self.validate_progress_pass_references();
612        self.validate_plinko_settings();
613        self.validate_power_estimator_settings();
614    }
615
616    fn validate_dungeon_templates(&self) {
617        for dungeon in &self.dungeon_templates {
618            if dungeon.between_wave_spawn_delay_ticks == Some(0) {
619                panic!(
620                    "DungeonTemplate {}: between_wave_spawn_delay_ticks must be > 0 when set",
621                    dungeon.id
622                );
623            }
624        }
625    }
626
627    /// BAL-030: the three scalars the `P_ui` estimator needs beyond the
628    /// per-template coefficients.
629    fn validate_power_estimator_settings(&self) {
630        let settings = &self.game_settings.power_estimator;
631        assert!(
632            settings.reference_trigger_proc_rate.is_finite()
633                && settings.reference_trigger_proc_rate > 0.0,
634            "PowerEstimatorSettings: reference_trigger_proc_rate must be finite and > 0 — it              divides every authored trigger frequency"
635        );
636        assert!(
637            settings.trigger_only_q >= 1.0,
638            "PowerEstimatorSettings: trigger_only_q must be >= 1.0"
639        );
640        assert!(
641            (0.0..=1.0).contains(&settings.bridge_expected_fill),
642            "PowerEstimatorSettings: bridge_expected_fill is a share of capacity, so it must be              within 0..=1"
643        );
644    }
645
646    /// Инварианты доски Plinko. Сервер катит путь и начисляет статы по этой
647    /// таблице, поэтому кривая раскладка означала бы шарик, приземлившийся в
648    /// слот с чужой подписью, или бонус без потолка.
649    fn validate_plinko_settings(&self) {
650        use std::collections::HashSet;
651
652        let plinko = &self.plinko_settings;
653        if plinko.rows < MIN_PLINKO_ROWS || plinko.rows > MAX_PLINKO_ROWS {
654            panic!(
655                "plinko_settings.rows = {} is outside {MIN_PLINKO_ROWS}..={MAX_PLINKO_ROWS}",
656                plinko.rows
657            );
658        }
659
660        let mut seen_levels = HashSet::new();
661        for layout in plinko.slot_layouts.iter() {
662            if !seen_levels.insert(layout.chest_level) {
663                panic!(
664                    "plinko_settings.slot_layouts has two layouts for chest level {}",
665                    layout.chest_level
666                );
667            }
668            if !self
669                .item_cases_settings
670                .iter()
671                .any(|settings| settings.level == layout.chest_level)
672            {
673                panic!(
674                    "plinko_settings.slot_layouts has a layout for chest level {}, which has no item_cases_settings",
675                    layout.chest_level
676                );
677            }
678
679            let mut groups: Vec<_> = layout.slot_groups.iter().collect();
680            groups.sort_by_key(|group| group.from_slot);
681            let mut expected_from = 0;
682            for group in &groups {
683                if group.from_slot != expected_from || group.to_slot < group.from_slot {
684                    panic!(
685                        "plinko_settings.slot_layouts chest level {} must cover slots 0..={} without gaps or overlaps, got group {}..{}",
686                        layout.chest_level, plinko.rows, group.from_slot, group.to_slot
687                    );
688                }
689                if !self.item_rarities.iter().any(|r| r.id == group.rarity_id) {
690                    panic!(
691                        "plinko_settings.slot_layouts chest level {} references unknown item rarity {}",
692                        layout.chest_level, group.rarity_id
693                    );
694                }
695                expected_from = group.to_slot + 1;
696            }
697            if expected_from != plinko.slot_count() {
698                panic!(
699                    "plinko_settings.slot_layouts chest level {} covers {expected_from} slots, expected {}",
700                    layout.chest_level,
701                    plinko.slot_count()
702                );
703            }
704        }
705
706        self.validate_plinko_rarity_coverage();
707
708        let mut seen_pins = HashSet::new();
709        for bonus in &plinko.pin_bonuses {
710            if bonus.row < 0 || bonus.row >= plinko.rows {
711                panic!(
712                    "plinko_settings.pin_bonuses row {} is outside 0..{}",
713                    bonus.row, plinko.rows
714                );
715            }
716            if bonus.column < 0 || bonus.column > bonus.row {
717                panic!(
718                    "plinko_settings.pin_bonuses column {} is outside 0..={} for row {}",
719                    bonus.column, bonus.row, bonus.row
720                );
721            }
722            if !seen_pins.insert((bonus.row, bonus.column)) {
723                panic!(
724                    "plinko_settings.pin_bonuses has duplicate pin ({}, {})",
725                    bonus.row, bonus.column
726                );
727            }
728            if bonus.value <= 0 {
729                panic!(
730                    "plinko_settings.pin_bonuses pin ({}, {}) has non-positive value {}",
731                    bonus.row, bonus.column, bonus.value
732                );
733            }
734            if !self.attributes.iter().any(|a| a.id == bonus.attribute_id) {
735                panic!(
736                    "plinko_settings.pin_bonuses references unknown attribute {}",
737                    bonus.attribute_id
738                );
739            }
740        }
741
742        // BAL-021: every axis of the pin table needs a tranche on EVERY chest
743        // level, or a level would silently stop paying that axis.
744        let axes: HashSet<_> = plinko.pin_bonuses.iter().map(|p| p.attribute_id).collect();
745        let mut seen_levels = HashSet::new();
746        let mut previous: std::collections::HashMap<uuid::Uuid, i64> =
747            std::collections::HashMap::new();
748        let mut levels: Vec<_> = plinko.tranches.iter().collect();
749        levels.sort_by_key(|tranche| tranche.chest_level);
750        for tranche in levels {
751            if !seen_levels.insert(tranche.chest_level) {
752                panic!(
753                    "plinko_settings.tranches has two rows for chest level {}",
754                    tranche.chest_level
755                );
756            }
757            let mut seen_axes = HashSet::new();
758            for axis in &tranche.axes {
759                if !seen_axes.insert(axis.attribute_id) {
760                    panic!(
761                        "plinko_settings.tranches chest level {} lists attribute {} twice",
762                        tranche.chest_level, axis.attribute_id
763                    );
764                }
765                assert!(
766                    axis.cap_micro >= 0 && axis.scale_micro >= 0,
767                    "plinko_settings.tranches chest level {}: attribute {} has a negative cap or                      scale",
768                    tranche.chest_level,
769                    axis.attribute_id
770                );
771                let last = previous.entry(axis.attribute_id).or_insert(0);
772                assert!(
773                    axis.cap_micro >= *last,
774                    "plinko_settings.tranches chest level {}: the cap of attribute {} shrinks                      ({last} -> {}) — a chest upgrade must never take accumulated stats away",
775                    tranche.chest_level,
776                    axis.attribute_id,
777                    axis.cap_micro
778                );
779                *last = axis.cap_micro;
780            }
781            for axis in &axes {
782                assert!(
783                    seen_axes.contains(axis),
784                    "plinko_settings.tranches chest level {} has no row for attribute {}, which                      the pin table pays",
785                    tranche.chest_level,
786                    axis
787                );
788            }
789        }
790
791        for settings in &self.item_cases_settings {
792            assert!(
793                seen_levels.contains(&settings.level),
794                "plinko_settings.tranches has no row for chest level {}",
795                settings.level
796            );
797        }
798    }
799
800    /// Каждая редкость, выпадающая на уровне сундука, обязана иметь слот в
801    /// раскладке ЭТОГО уровня, и у каждого уровня сундука обязана быть
802    /// раскладка.
803    ///
804    /// Раньше это было предупреждением: раскладка была одна на всю игру, а
805    /// набор выпадающих редкостей растёт с уровнем сундука, поэтому полное
806    /// покрытие было недостижимо и шарик ронялся в слот с ближайшей меньшей
807    /// подписью. С раскладкой на уровень покрытие достижимо, поэтому
808    /// несоответствие — сломанный конфиг: сервер не сможет выбрать слот и
809    /// открытие сундука останется без шарика.
810    fn validate_plinko_rarity_coverage(&self) {
811        for settings in &self.item_cases_settings {
812            let Some(layout) = self.plinko_settings.layout_for_level(settings.level) else {
813                panic!(
814                    "plinko_settings.slot_layouts has no board for chest level {}",
815                    settings.level
816                );
817            };
818
819            for weight in &settings.rarity_weights {
820                if weight.weight <= 0.0 {
821                    continue;
822                }
823                if layout.slots_for_rarity(weight.rarity_id).is_empty() {
824                    panic!(
825                        "plinko_settings.slot_layouts chest level {} has no slot for item rarity {}, \
826                         which drops there with weight {}",
827                        settings.level, weight.rarity_id, weight.weight
828                    );
829                }
830            }
831        }
832
833        self.validate_plinko_layout_monotonicity();
834    }
835
836    /// Тир не убывает от центра доски к краям — по обеим половинам, и обе
837    /// центральные ячейки несут один и тот же тир.
838    ///
839    /// Игрок читает доску как «к краям редкость растёт», поэтому раскладка, в
840    /// которой более высокий тир оказался ближе к центру, чем более низкий,
841    /// врёт ему. Позиция слота вообще не связана с вероятностью: путь строится
842    /// к заранее выбранному слоту (`CreateToSlot`), попасть в край не
843    /// «труднее», чем в центр, — поэтому единственный ключ раскладки — тир.
844    ///
845    /// Проверка живёт в валидации, а не в тесте: `validate` вызывается при
846    /// загрузке конфига, и CI прогоняет `config_validator` по боевым
847    /// конфигам, так что немонотонная раскладка не доедет до игрока.
848    fn validate_plinko_layout_monotonicity(&self) {
849        use std::collections::HashSet;
850
851        let plinko = &self.plinko_settings;
852        let order_of = |rarity_id: uuid::Uuid| {
853            self.item_rarities
854                .iter()
855                .find(|rarity| rarity.id == rarity_id)
856                .map(|rarity| rarity.order)
857                .unwrap_or_else(|| {
858                    panic!(
859                        "plinko_settings.slot_layouts references unknown item rarity {rarity_id}"
860                    )
861                })
862        };
863
864        // При чётном числе слотов центр — пара, при нечётном обе половины
865        // растут от одной и той же средней ячейки.
866        let left_centre = (plinko.slot_count() - 1) / 2;
867        let right_centre = plinko.slot_count() / 2;
868
869        for layout in plinko.slot_layouts.iter() {
870            let tier = |slot: i64| {
871                let rarity = layout.rarity_for_slot(slot).unwrap_or_else(|| {
872                    panic!(
873                        "plinko_settings.slot_layouts chest level {} has no label for slot {slot}",
874                        layout.chest_level
875                    )
876                });
877                order_of(rarity)
878            };
879
880            // Пара в центре несёт один тир — кроме вырожденного случая, когда
881            // редкостей ровно столько же, сколько слотов: тогда пар не
882            // остаётся вовсе и каждый слот подписан своим тиром.
883            let distinct: HashSet<_> = layout
884                .slot_groups
885                .iter()
886                .map(|group| group.rarity_id)
887                .collect();
888            let has_pairs = (distinct.len() as i64) < plinko.slot_count();
889            if has_pairs && left_centre != right_centre && tier(left_centre) != tier(right_centre) {
890                panic!(
891                    "plinko_settings.slot_layouts chest level {}: the centre slots {left_centre} \
892                     and {right_centre} carry different tiers ({} and {})",
893                    layout.chest_level,
894                    tier(left_centre),
895                    tier(right_centre)
896                );
897            }
898
899            for slot in (1..=left_centre).rev() {
900                if tier(slot - 1) < tier(slot) {
901                    panic!(
902                        "plinko_settings.slot_layouts chest level {}: slot {} carries tier {}, \
903                         lower than tier {} on slot {slot} closer to the centre — the tier must \
904                         not drop towards the edge",
905                        layout.chest_level,
906                        slot - 1,
907                        tier(slot - 1),
908                        tier(slot)
909                    );
910                }
911            }
912            for slot in right_centre..plinko.rows {
913                if tier(slot + 1) < tier(slot) {
914                    panic!(
915                        "plinko_settings.slot_layouts chest level {}: slot {} carries tier {}, \
916                         lower than tier {} on slot {slot} closer to the centre — the tier must \
917                         not drop towards the edge",
918                        layout.chest_level,
919                        slot + 1,
920                        tier(slot + 1),
921                        tier(slot)
922                    );
923                }
924            }
925        }
926    }
927
928    /// Rating reward invariants keep the cron and reward-screen claim path
929    /// data-driven and safe to run without runtime fallbacks.
930    fn validate_rating_rewards(&self) {
931        use essences::{
932            bundles::BundleStepType,
933            ratings::{RatingRangeReward, RatingType},
934        };
935        use std::collections::{HashMap, HashSet};
936
937        fn validate_ranges(label: &str, ranges: &[RatingRangeReward]) {
938            let mut sorted = ranges.to_vec();
939            sorted.sort_by_key(|range| range.diapason_start);
940            let mut previous_end = 0;
941            for range in sorted {
942                assert!(
943                    range.diapason_start >= 1,
944                    "{label}: diapason_start must be at least 1"
945                );
946                let end = range.diapason_end.unwrap_or(i64::MAX);
947                assert!(
948                    end >= range.diapason_start,
949                    "{label}: diapason_end must not precede diapason_start"
950                );
951                assert!(
952                    range.diapason_start > previous_end,
953                    "{label}: reward ranges must not overlap"
954                );
955                previous_end = end;
956            }
957        }
958
959        fn fixed_currencies(
960            label: &str,
961            bundle: &essences::bundles::BundleRaw,
962            currency_only: bool,
963        ) -> HashMap<uuid::Uuid, i64> {
964            let mut totals = HashMap::new();
965            for step in &bundle.steps {
966                if step.item_type != BundleStepType::Currency {
967                    assert!(
968                        !currency_only,
969                        "{label}: daily bundle must be currency-only"
970                    );
971                    continue;
972                }
973                assert!(
974                    step.behavior.is_none() && step.currency_branch.is_none(),
975                    "{label}: rating reward currencies must be fixed"
976                );
977                for unit in &step.currencies {
978                    assert!(unit.amount > 0, "{label}: currency amount must be positive");
979                    *totals.entry(unit.currency_id).or_insert(0) += unit.amount;
980                }
981            }
982            assert!(!totals.is_empty(), "{label}: bundle must contain currency");
983            totals
984        }
985
986        let mut seen_types = HashSet::new();
987        for settings in &self.ratings_settings {
988            assert!(
989                seen_types.insert(settings.rating_type.clone()),
990                "Duplicate RatingSettings for {:?}",
991                settings.rating_type
992            );
993
994            let label = format!("{:?} weekly rating rewards", settings.rating_type);
995            validate_ranges(&label, &settings.weekly_rating_range_rewards);
996            let daily_label = format!("{:?} daily rating rewards", settings.rating_type);
997            validate_ranges(&daily_label, &settings.daily_rating_range_rewards);
998
999            if settings.rating_type == RatingType::Power {
1000                assert!(
1001                    settings.daily_rating_range_rewards.is_empty()
1002                        && settings.weekly_rating_range_rewards.is_empty(),
1003                    "Power rating rewards must be empty"
1004                );
1005            }
1006
1007            for weekly in &settings.weekly_rating_range_rewards {
1008                assert!(
1009                    self.bundles
1010                        .iter()
1011                        .any(|bundle| bundle.id == weekly.bundle_id),
1012                    "{label}: unknown bundle {}",
1013                    weekly.bundle_id
1014                );
1015                if let Some(mail_template_id) = weekly.legacy_mail_template_id {
1016                    assert!(
1017                        self.mail_templates
1018                            .iter()
1019                            .any(|template| template.id == mail_template_id),
1020                        "{label}: unknown legacy mail template {mail_template_id}"
1021                    );
1022                }
1023            }
1024
1025            for daily in &settings.daily_rating_range_rewards {
1026                let daily_bundle = self
1027                    .bundles
1028                    .iter()
1029                    .find(|bundle| bundle.id == daily.bundle_id)
1030                    .unwrap_or_else(|| panic!("{daily_label}: unknown bundle {}", daily.bundle_id));
1031                assert!(
1032                    daily.legacy_mail_template_id.is_none(),
1033                    "{daily_label}: daily rewards must not reference mail templates"
1034                );
1035                let daily_currencies = fixed_currencies(&daily_label, daily_bundle, true);
1036
1037                let weekly = settings
1038                    .weekly_rating_range_rewards
1039                    .iter()
1040                    .find(|weekly| {
1041                        weekly.diapason_start == daily.diapason_start
1042                            && weekly.diapason_end == daily.diapason_end
1043                    })
1044                    .unwrap_or_else(|| {
1045                        panic!(
1046                            "{daily_label}: range {}..{:?} has no corresponding weekly range",
1047                            daily.diapason_start, daily.diapason_end
1048                        )
1049                    });
1050                let weekly_bundle = self
1051                    .bundles
1052                    .iter()
1053                    .find(|bundle| bundle.id == weekly.bundle_id)
1054                    .expect("weekly bundle reference validated above");
1055                let weekly_currencies = fixed_currencies(&label, weekly_bundle, false);
1056
1057                assert_eq!(
1058                    weekly_currencies.len(),
1059                    daily_currencies.len(),
1060                    "{daily_label}: daily currencies must exactly match weekly currencies"
1061                );
1062                for (currency_id, weekly_amount) in weekly_currencies {
1063                    let daily_amount = daily_currencies.get(&currency_id).copied().unwrap_or(0);
1064                    assert_eq!(
1065                        weekly_amount,
1066                        daily_amount * 5,
1067                        "{daily_label}: currency {currency_id} must be exactly 1:5 of weekly"
1068                    );
1069                }
1070            }
1071        }
1072
1073        for rating_type in [RatingType::Arena, RatingType::Power, RatingType::PvE] {
1074            assert!(
1075                seen_types.contains(&rating_type),
1076                "Missing RatingSettings for {rating_type:?}"
1077            );
1078        }
1079    }
1080
1081    /// Per-class invariants for `Class.class_abilities`:
1082    ///  - disjoint from that class's `basic_abilities`;
1083    ///  - every referenced ability template exists and has `is_gacha_ability == false`
1084    ///    (class actives must never drop from the gacha pool per the design doc).
1085    fn validate_class_abilities(&self) {
1086        use std::collections::HashSet;
1087
1088        for class in &self.classes {
1089            validate_power_q(
1090                &format!("Class {} passive", class.id),
1091                class.passive_power_q_l1,
1092                class.passive_power_q_l20,
1093            );
1094            for row in &class.ability_power_q {
1095                validate_power_q(
1096                    &format!("Class {} ability {} q", class.id, row.ability_id),
1097                    row.q,
1098                    row.q,
1099                );
1100            }
1101            let basics: HashSet<_> = class.basic_abilities.iter().copied().collect();
1102            let mut seen = HashSet::new();
1103            for ability_id in &class.class_abilities {
1104                if !seen.insert(*ability_id) {
1105                    panic!(
1106                        "Class {}: duplicate ability_id {ability_id} in class_abilities",
1107                        class.id
1108                    );
1109                }
1110                if basics.contains(ability_id) {
1111                    panic!(
1112                        "Class {}: ability {ability_id} appears in both basic_abilities and class_abilities",
1113                        class.id
1114                    );
1115                }
1116                let Some(template) = self.abilities.iter().find(|a| a.id == *ability_id) else {
1117                    panic!(
1118                        "Class {}: class_abilities references unknown ability_id {ability_id}",
1119                        class.id
1120                    );
1121                };
1122                if template.is_gacha_ability {
1123                    panic!(
1124                        "Class {}: class_ability {ability_id} has is_gacha_ability=true; class actives must be excluded from the gacha pool",
1125                        class.id
1126                    );
1127                }
1128            }
1129        }
1130    }
1131
1132    fn validate_class_levels(&self) {
1133        use std::collections::HashMap;
1134
1135        let mut by_class: HashMap<class::ClassId, Vec<u64>> = HashMap::new();
1136        for row in &self.class_levels {
1137            by_class.entry(row.class_id).or_default().push(row.level);
1138        }
1139
1140        for (class_id, mut levels) in by_class {
1141            levels.sort();
1142            let mut dedup = levels.clone();
1143            dedup.dedup();
1144            if dedup.len() != levels.len() {
1145                panic!("ClassLevels for class {class_id}: duplicate level entries");
1146            }
1147            for (i, level) in levels.iter().enumerate() {
1148                let expected = (i as u64) + 1;
1149                if *level != expected {
1150                    panic!(
1151                        "ClassLevels for class {class_id}: expected contiguous levels starting at 1, got {level} at position {expected}"
1152                    );
1153                }
1154            }
1155        }
1156
1157        // BAL-034: Class Level is one shared account ladder, so a level costs
1158        // the same whatever class the player raised it from. Diverging prices
1159        // would make the shared level cheaper or dearer depending on which
1160        // screen the button was pressed on.
1161        let mut price_of_level: HashMap<u64, &essences::currency::CurrencyUnit> = HashMap::new();
1162        for row in &self.class_levels {
1163            match price_of_level.get(&row.level) {
1164                None => {
1165                    price_of_level.insert(row.level, &row.price);
1166                }
1167                Some(first) => assert!(
1168                    first.currency_id == row.price.currency_id && first.amount == row.price.amount,
1169                    "ClassLevels level {}: class {} prices it {} x{} while another class prices it \
1170                     {} x{} — the ladder is shared, so the price must be too",
1171                    row.level,
1172                    row.class_id,
1173                    row.price.currency_id,
1174                    row.price.amount,
1175                    first.currency_id,
1176                    first.amount
1177                ),
1178            }
1179        }
1180    }
1181
1182    /// The auto-chest batch cap (`auto_chest_settings.max_batch_size`) is the
1183    /// most chests a player may auto-open in a single batch at a given chest
1184    /// level. Chests only ever get upgraded, so the cap must never step down as
1185    /// the level rises — a decrease would silently shrink a capability the
1186    /// player already had. Enforce a monotonic non-decreasing sequence ordered
1187    /// by chest level.
1188    fn validate_item_cases_settings(&self) {
1189        let mut by_level: Vec<&ItemCasesSettingsByLevel> =
1190            self.item_cases_settings.iter().collect();
1191        by_level.sort_by_key(|s| s.level);
1192
1193        for pair in by_level.windows(2) {
1194            let (prev, next) = (pair[0], pair[1]);
1195            for (label, prev_cap, next_cap) in [
1196                (
1197                    "max_batch_size",
1198                    prev.auto_chest_settings.max_batch_size,
1199                    next.auto_chest_settings.max_batch_size,
1200                ),
1201                (
1202                    "auto_only_max_batch_size",
1203                    prev.auto_chest_settings.auto_only_max_batch_size,
1204                    next.auto_chest_settings.auto_only_max_batch_size,
1205                ),
1206            ] {
1207                if next_cap < prev_cap {
1208                    panic!(
1209                        "ItemCasesSettings: auto_chest {label} must never decrease with chest \
1210                         level, but drops from {prev_cap} at level {} to {next_cap} at level {}",
1211                        prev.level, next.level,
1212                    );
1213                }
1214            }
1215            // The auto ladder is deliberately the tighter of the two: auto runs
1216            // continuously, so its throughput is capped harder than a manual
1217            // tap. A config where auto exceeds manual would silently make the
1218            // automatic path the faster one.
1219            if next.auto_chest_settings.auto_only_max_batch_size
1220                > next.auto_chest_settings.max_batch_size
1221            {
1222                panic!(
1223                    "ItemCasesSettings: auto_only_max_batch_size ({}) must not exceed \
1224                     max_batch_size ({}) at level {}",
1225                    next.auto_chest_settings.auto_only_max_batch_size,
1226                    next.auto_chest_settings.max_batch_size,
1227                    next.level,
1228                );
1229            }
1230        }
1231    }
1232
1233    /// Portal rarity mappings must remain a one-to-one projection of existing
1234    /// item rarities, must cover every rarity rendered by a Plinko board, and
1235    /// must always carry a renderable Unity asset address.
1236    fn validate_portal_rarities(&self) {
1237        use std::collections::HashSet;
1238
1239        let item_rarity_ids: HashSet<_> =
1240            self.item_rarities.iter().map(|rarity| rarity.id).collect();
1241        let mut portal_rarity_ids = HashSet::new();
1242        let mut mapped_item_rarity_ids = HashSet::new();
1243
1244        for portal_rarity in &self.portal_rarities {
1245            assert!(
1246                portal_rarity_ids.insert(portal_rarity.id),
1247                "PortalRarities: duplicate id {}",
1248                portal_rarity.id
1249            );
1250            assert!(
1251                mapped_item_rarity_ids.insert(portal_rarity.item_rarity_id),
1252                "PortalRarities: duplicate item_rarity_id {}",
1253                portal_rarity.item_rarity_id
1254            );
1255            assert!(
1256                item_rarity_ids.contains(&portal_rarity.item_rarity_id),
1257                "PortalRarities: item_rarity_id {} not found in item_rarities",
1258                portal_rarity.item_rarity_id
1259            );
1260            assert!(
1261                !portal_rarity.icon_path.trim().is_empty(),
1262                "PortalRarities: icon_path must not be empty for {}",
1263                portal_rarity.id
1264            );
1265        }
1266
1267        for rarity_id in self
1268            .plinko_settings
1269            .slot_layouts
1270            .iter()
1271            .flat_map(|layout| layout.slot_groups.iter())
1272            .map(|group| group.rarity_id)
1273        {
1274            assert!(
1275                mapped_item_rarity_ids.contains(&rarity_id),
1276                "PortalRarities: missing mapping for item rarity {rarity_id} referenced by plinko_settings.slot_layouts"
1277            );
1278        }
1279    }
1280
1281    fn validate_flip_settings(&self) {
1282        assert!(
1283            self.flip_settings.unlock_chapter >= 0,
1284            "FlipSettings: unlock_chapter must be >= 0"
1285        );
1286        assert!(
1287            self.flip_settings.progress_threshold.is_finite()
1288                && self.flip_settings.progress_threshold > 0.0,
1289            "FlipSettings: progress_threshold must be finite and > 0"
1290        );
1291        assert!(
1292            self.flip_settings.damage_gauge_coefficient.is_finite()
1293                && self.flip_settings.damage_gauge_coefficient >= 0.0,
1294            "FlipSettings: damage_gauge_coefficient must be finite and >= 0"
1295        );
1296    }
1297
1298    fn validate_local_notifications(&self) {
1299        let settings = &self.local_notifications;
1300        assert!(
1301            settings.quiet_hours_start < 24,
1302            "LocalNotificationsSettings: quiet_hours_start must be 0..=23"
1303        );
1304        assert!(
1305            settings.quiet_hours_end < 24,
1306            "LocalNotificationsSettings: quiet_hours_end must be 0..=23"
1307        );
1308
1309        let mut previous = 0u64;
1310        for step in &settings.win_back {
1311            let delay = step.delay_minutes.get();
1312            assert!(
1313                delay > previous,
1314                "LocalNotificationsSettings: win_back delays must strictly increase, got {delay} after {previous}"
1315            );
1316            previous = delay;
1317        }
1318    }
1319
1320    fn validate_mana_settings(&self) {
1321        assert!(
1322            self.mana_settings.pool.is_finite() && self.mana_settings.pool > 0.0,
1323            "ManaSettings: pool must be finite and > 0"
1324        );
1325        assert!(
1326            self.mana_settings.regen_per_second.is_finite()
1327                && self.mana_settings.regen_per_second >= 0.0,
1328            "ManaSettings: regen_per_second must be finite and >= 0"
1329        );
1330        for ability in &self.abilities {
1331            assert!(
1332                ability.mana_cost >= 0,
1333                "Ability {} has a negative mana_cost",
1334                ability.id
1335            );
1336        }
1337    }
1338
1339    fn validate_ability_stone_settings(&self) {
1340        use std::collections::HashSet;
1341
1342        let settings = &self.ability_stone_settings;
1343        assert!(
1344            settings.unlock_chapter >= 0,
1345            "AbilityStoneSettings: unlock_chapter must be >= 0"
1346        );
1347        for (name, sockets) in [
1348            ("sockets", &settings.sockets),
1349            ("class_sockets", &settings.class_sockets),
1350        ] {
1351            assert!(
1352                !sockets.is_empty(),
1353                "AbilityStoneSettings: {name} must not be empty"
1354            );
1355            for (index, socket) in sockets.iter().enumerate() {
1356                assert!(
1357                    socket.unlock_ability_level >= 1,
1358                    "AbilityStoneSettings: {name}[{index}] unlock_ability_level must be >= 1"
1359                );
1360            }
1361        }
1362        assert!(
1363            !settings.upgrade_copies.is_empty(),
1364            "AbilityStoneSettings: upgrade_copies must not be empty"
1365        );
1366        for (index, copies) in settings.upgrade_copies.iter().enumerate() {
1367            assert!(
1368                *copies > 0,
1369                "AbilityStoneSettings: upgrade_copies[{index}] must be > 0"
1370            );
1371        }
1372        assert!(
1373            settings.chapter_reward_period >= 0,
1374            "AbilityStoneSettings: chapter_reward_period must be >= 0"
1375        );
1376
1377        let mut ids = HashSet::new();
1378        for stone in &self.ability_stones {
1379            assert!(
1380                ids.insert(stone.id),
1381                "Not unique ability stone id {} in config",
1382                stone.id
1383            );
1384            validate_power_q(
1385                &format!("AbilityStoneTemplate {}", stone.id),
1386                stone.power_q_first_rank,
1387                stone.power_q_max_rank,
1388            );
1389            assert!(
1390                !stone.ops.is_empty(),
1391                "Ability stone {} carries no operations",
1392                stone.id
1393            );
1394            for op in &stone.ops {
1395                assert!(
1396                    op.base_value.is_finite() && op.value_per_level.is_finite(),
1397                    "Ability stone {} has a non-finite value in op {}",
1398                    stone.id,
1399                    op.kind
1400                );
1401                assert!(
1402                    op.count >= 0 && op.interval_ms >= 0,
1403                    "Ability stone {} has a negative count/interval in op {}",
1404                    stone.id,
1405                    op.kind
1406                );
1407            }
1408        }
1409        if settings.chapter_reward_period > 0 {
1410            assert!(
1411                !self.ability_stones.is_empty(),
1412                "AbilityStoneSettings: chapter rewards are enabled but the stone catalog is empty"
1413            );
1414        }
1415    }
1416
1417    /// Pet Facets: the invariants a facet cannot survive being wrong about.
1418    ///
1419    /// Only the numbers a facet DIVIDES by, COUNTS with or schedules an expiry
1420    /// on are asserted. A magnitude of zero is a legitimate "switch this facet
1421    /// off" tuning, but a zero duration would leave a timed buff permanent
1422    /// (`apply_timed_attribute` refuses one at runtime, and this stops it
1423    /// reaching the runtime at all), and a zero charge count would arm a facet
1424    /// that can never spend.
1425    fn validate_pet_facet_settings(&self) {
1426        let settings = &self.pet_facet_settings;
1427        assert!(
1428            settings.rank_growth_permyriad >= 0,
1429            "PetFacetSettings: rank_growth_permyriad must be >= 0"
1430        );
1431
1432        // BAL-030: every facet carries a Power row, so a facet added later
1433        // cannot silently read as "worth nothing" in displayed Power.
1434        {
1435            use strum::IntoEnumIterator;
1436            for facet in essences::pet_facets::PetFacet::iter() {
1437                let row = settings.power_q_row(facet).unwrap_or_else(|| {
1438                    panic!("PetFacetSettings: facet {facet:?} has no power_q row")
1439                });
1440                validate_power_q(
1441                    &format!("PetFacetSettings::power_q[{facet:?}]"),
1442                    row.q_first_level,
1443                    row.q_max_level,
1444                );
1445            }
1446            assert_eq!(
1447                settings.power_q.len(),
1448                essences::pet_facets::PetFacet::iter().count(),
1449                "PetFacetSettings: power_q must carry exactly one row per facet"
1450            );
1451        }
1452        for (name, duration) in [
1453            ("overtime", settings.overtime_duration_ticks),
1454            ("safety_net", settings.safety_net_duration_ticks),
1455            ("wild_reading", settings.wild_reading_duration_ticks),
1456            ("life_bloom", settings.life_bloom_duration_ticks),
1457        ] {
1458            assert!(
1459                duration > 0,
1460                "PetFacetSettings: {name}_duration_ticks must be > 0 — a timed facet window with \
1461                 no expiry would last the whole fight"
1462            );
1463        }
1464        for (name, count) in [
1465            ("budget_plan_casts", settings.budget_plan_casts),
1466            ("magic_volley_bolts", settings.magic_volley_bolts),
1467            ("calibration_attacks", settings.calibration_attacks),
1468            (
1469                "lead_reading_activations",
1470                settings.lead_reading_activations,
1471            ),
1472            ("rising_gate_procs", settings.rising_gate_procs),
1473            ("souvenir_procs", settings.souvenir_procs),
1474        ] {
1475            assert!(count >= 1, "PetFacetSettings: {name} must be >= 1");
1476        }
1477        assert!(
1478            settings.budget_plan_cost_multiplier.is_finite()
1479                && (0.0..=1.0).contains(&settings.budget_plan_cost_multiplier),
1480            "PetFacetSettings: budget_plan_cost_multiplier must be in [0, 1] — Budget Plan is a \
1481             discount, never a surcharge"
1482        );
1483        assert!(
1484            settings.advance_notice_gauge_percent.is_finite()
1485                && (0.0..100.0).contains(&settings.advance_notice_gauge_percent),
1486            "PetFacetSettings: advance_notice_gauge_percent must be in [0, 100) — a phase that \
1487             opens at a full gauge would flip on its own first gain"
1488        );
1489        assert!(
1490            settings.safety_net_reduction_percent.is_finite()
1491                && (0.0..=100.0).contains(&settings.safety_net_reduction_percent),
1492            "PetFacetSettings: safety_net_reduction_percent must be in [0, 100]"
1493        );
1494        for (name, percent) in [
1495            ("second_spark", settings.second_spark_percent),
1496            ("open_tab_surcharge", settings.open_tab_surcharge_percent),
1497            (
1498                "open_tab_payload_share",
1499                settings.open_tab_payload_share_percent,
1500            ),
1501            (
1502                "overtime_attack_speed",
1503                settings.overtime_attack_speed_percent,
1504            ),
1505            ("magic_volley", settings.magic_volley_percent),
1506            ("retaliation", settings.retaliation_percent),
1507            ("calibration_crit", settings.calibration_crit_percent),
1508            ("lucky_star", settings.lucky_star_percent),
1509            (
1510                "lead_reading_resonance",
1511                settings.lead_reading_resonance_percent,
1512            ),
1513            ("wild_reading_effect", settings.wild_reading_effect_percent),
1514            (
1515                "rising_gate_multiplier_l1",
1516                settings.rising_gate_multiplier_l1,
1517            ),
1518            (
1519                "rising_gate_multiplier_l10",
1520                settings.rising_gate_multiplier_l10,
1521            ),
1522            ("prepared_slots", settings.prepared_slots_percent),
1523            ("first_spell", settings.first_spell_percent),
1524            ("souvenir", settings.souvenir_percent),
1525            ("dream_reader", settings.dream_reader_percent),
1526            ("packed_lunch_heal", settings.packed_lunch_heal_percent),
1527            ("life_bloom_l1", settings.life_bloom_percent_l1),
1528            ("life_bloom_l10", settings.life_bloom_percent_l10),
1529        ] {
1530            assert!(
1531                percent.is_finite() && percent >= 0.0,
1532                "PetFacetSettings: {name}_percent must be finite and >= 0"
1533            );
1534        }
1535    }
1536
1537    fn validate_cores_settings(&self) {
1538        use std::collections::HashSet;
1539
1540        let settings = &self.cores_settings;
1541        assert!(
1542            settings.unlock_chapter >= 0,
1543            "CoresSettings: unlock_chapter must be >= 0"
1544        );
1545        assert!(
1546            settings.unlock_essence_grant >= 0,
1547            "CoresSettings: unlock_essence_grant must be >= 0"
1548        );
1549        assert!(
1550            settings.max_core_level >= 1,
1551            "CoresSettings: max_core_level must be >= 1"
1552        );
1553        assert!(
1554            settings.max_slots_per_core >= 1,
1555            "CoresSettings: max_slots_per_core must be >= 1"
1556        );
1557
1558        // The coupled trio, now keyed to the SLOT ladder rather than to the
1559        // level cap: at `max_slot_level` the ALLOWED bridge count
1560        // (`min(level, max_slot_level) - 1`) must stay strictly below the
1561        // physically possible count, otherwise "which bridges to keep" is no
1562        // longer a choice.
1563        //
1564        // Possible count = the number of slots, NOT `2 * slots`. The doubling
1565        // was the arithmetic of "a law sits in two bridges", which laws v0.2
1566        // repealed (`mechanics::cores::MAX_BRIDGES_PER_LAW == 1`): one law, one
1567        // partner, so five slotted laws per core can carry at most five bridges.
1568        // With the old doubling this assert passed while the invariant it exists
1569        // for was broken — 5 possible against 9 allowed.
1570        //
1571        // Before the acquisition pass the left-hand side read `max_core_level`
1572        // directly. Unbounded core levels made that read false — level 9999
1573        // would have "allowed" 9998 bridges — which is exactly why
1574        // `mechanics::cores::max_bridges` now clamps at the slot level too.
1575        let max_slot_level = settings.max_slot_level();
1576        let possible_bridges = settings.max_slots_per_core;
1577        let allowed_bridges = settings.max_core_level.min(max_slot_level) - 1;
1578        assert!(
1579            allowed_bridges < possible_bridges,
1580            "CoresSettings: the slot level {max_slot_level} allows {allowed_bridges} bridges but \
1581             only {possible_bridges} are possible at {} slots — the slot cap, the slot ladder and \
1582             the bridge limit move together",
1583            settings.max_slots_per_core,
1584        );
1585
1586        assert!(
1587            settings.level_costs.iter().all(|row| row.level >= 1),
1588            "CoresSettings: level_costs may only price levels >= 1 (level 0 = no core yet)"
1589        );
1590        assert!(
1591            settings
1592                .level_costs
1593                .iter()
1594                .all(|row| row.level <= settings.max_core_level),
1595            "CoresSettings: level_costs must not price a level above max_core_level"
1596        );
1597        let mut priced_levels: Vec<i64> =
1598            settings.level_costs.iter().map(|row| row.level).collect();
1599        priced_levels.sort_unstable();
1600        priced_levels.dedup();
1601        assert_eq!(
1602            priced_levels.len(),
1603            settings.level_costs.len(),
1604            "CoresSettings: duplicate level in level_costs"
1605        );
1606        // The table no longer has to reach `max_core_level` — `level_cost_growth`
1607        // continues it — but it must be a CONTIGUOUS run, or `core_level_cost`
1608        // would hit a gap it refuses to price and the ladder would dead-end
1609        // below the cap.
1610        //
1611        // BAL-010 moved the start from 1 to 2: both cores are GRANTED at level 1
1612        // with the ch21 unlock, so level 1 is no longer purchasable and pricing
1613        // it would resurrect the removed 700 cost. Anything past 2 would leave a
1614        // level nobody can buy, which is why the start is pinned rather than
1615        // merely bounded.
1616        let first_priced = crate::cores::CORE_FIRST_PURCHASABLE_LEVEL;
1617        assert_eq!(
1618            priced_levels,
1619            (first_priced..=settings.max_tabled_core_level()).collect::<Vec<_>>(),
1620            "CoresSettings: level_costs must be a contiguous run of levels starting at {first_priced}"
1621        );
1622        // Every level that opens a slot must be priced explicitly: those are the
1623        // designed rungs, and letting the geometric extension invent their cost
1624        // would silently reprice the part of the ladder the design owns.
1625        assert!(
1626            settings.max_tabled_core_level() >= max_slot_level.min(settings.max_core_level),
1627            "CoresSettings: level_costs stops at level {} but slots keep opening until \
1628             {max_slot_level} — price every slot-opening level explicitly",
1629            settings.max_tabled_core_level(),
1630        );
1631        assert!(
1632            settings.level_cost_growth.get() > 1.0,
1633            "CoresSettings: level_cost_growth must be > 1 — a flat or shrinking cost above the \
1634             table turns the core into a free multiplier"
1635        );
1636        assert!(
1637            self.currencies
1638                .iter()
1639                .any(|currency| currency.id == settings.upgrade_currency_id),
1640            "CoresSettings: upgrade_currency_id {} is not a known currency",
1641            settings.upgrade_currency_id
1642        );
1643        assert!(
1644            (0.0..=1.0).contains(&settings.kill_drop.chance),
1645            "CoresSettings: kill_drop.chance must be in [0, 1]"
1646        );
1647        assert!(
1648            (0.0..=1.0).contains(&settings.law_drop_chance),
1649            "CoresSettings: law_drop_chance must be in [0, 1]"
1650        );
1651        assert!(
1652            !settings.multiplied_attribute_ids.is_empty(),
1653            "CoresSettings: multiplied_attribute_ids must not be empty"
1654        );
1655        for attribute_id in &settings.multiplied_attribute_ids {
1656            assert!(
1657                self.attributes.iter().any(|attr| attr.id == *attribute_id),
1658                "CoresSettings: multiplied_attribute_ids references unknown attribute \
1659                 {attribute_id}"
1660            );
1661        }
1662
1663        let ladder_levels: Vec<i64> = settings
1664            .law_upgrade_ladder
1665            .iter()
1666            .map(|row| row.level)
1667            .collect();
1668        let mut sorted_ladder = ladder_levels.clone();
1669        sorted_ladder.sort_unstable();
1670        assert_eq!(
1671            ladder_levels, sorted_ladder,
1672            "CoresSettings: law_upgrade_ladder must be sorted by level"
1673        );
1674        assert_eq!(
1675            ladder_levels,
1676            (2..=(settings.max_law_level())).collect::<Vec<_>>(),
1677            "CoresSettings: law_upgrade_ladder must be a contiguous run starting at level 2"
1678        );
1679
1680        // The charge trio became a PAIR (post-merge plan §8). The old identity
1681        // `capacity x per_unit == cap` made `BL-03 Short Span` inexpressible —
1682        // it moves capacity by -40% and the cap by -20% — so the per-unit field
1683        // is gone and amplification is a share of fullness. There is nothing
1684        // left to keep consistent here beyond the positivity the types already
1685        // guarantee.
1686
1687        for law in &self.laws {
1688            assert!(
1689                law.resonance >= 0,
1690                "Law {}: resonance must not be negative",
1691                law.id
1692            );
1693            validate_power_q(
1694                &format!("Law {}", law.id),
1695                law.power_q_first_rank,
1696                law.power_q_max_rank,
1697            );
1698            // The deterministic unlock schedule. Bounded by the slot level, not
1699            // by `max_core_level`: with an unbounded cap the latter would let a
1700            // law hide behind level 4000, and "both cores at the last
1701            // slot-opening level = every law owned" is the rule the whole
1702            // schedule exists to guarantee.
1703            assert!(
1704                law.unlock_core_level >= 1 && law.unlock_core_level <= max_slot_level,
1705                "Law {}: unlock_core_level {} must be in 1..={max_slot_level} so every law is \
1706                 granted by the time the last slot opens",
1707                law.id,
1708                law.unlock_core_level,
1709            );
1710            assert!(
1711                law.condition != essences::cores::LawCondition::SkillWithTag
1712                    || law.condition_tag.is_some(),
1713                "Law {}: condition SkillWithTag needs a condition_tag",
1714                law.id
1715            );
1716            assert!(
1717                !law.effect.is_timed() || law.effect_duration_ticks > 0,
1718                "Law {}: effect {} is timed and needs a non-zero effect_duration_ticks, or it \
1719                 would never expire",
1720                law.id,
1721                law.effect
1722            );
1723            for modifier in &law.modifiers {
1724                assert!(
1725                    self.attributes
1726                        .iter()
1727                        .any(|attr| attr.id == modifier.attribute_id),
1728                    "Law {}: unknown attribute {}",
1729                    law.id,
1730                    modifier.attribute_id
1731                );
1732            }
1733        }
1734        let mut law_ids = HashSet::new();
1735        assert!(
1736            self.laws.iter().all(|law| law_ids.insert(law.id)),
1737            "Not unique law ids in config"
1738        );
1739    }
1740
1741    /// Every rule the stone system relies on being true at load time.
1742    ///
1743    /// The three that carry real weight: the socket schedule must cover all 15
1744    /// sockets (a missing entry would silently make a socket unreachable
1745    /// forever), the coefficient matrix must cover every tier pair, including
1746    /// the reserved `Legendary` — a missing cell would read as `1.0` and hide a
1747    /// typo in a matrix whose whole point is being editable — and the upgrade
1748    /// ladder must cover `2..=max_stone_level` with no gap, because a missing
1749    /// rung reads as "already at the cap" and would quietly strand every stone
1750    /// one level below where the designer meant it to stop.
1751    fn validate_stones(&self) {
1752        use essences::stones::{StoneSocketKey, StoneTier};
1753        use std::collections::HashSet;
1754        use strum::IntoEnumIterator;
1755
1756        let settings = &self.stones_settings;
1757
1758        assert!(
1759            settings.global_trigger_cooldown_ticks > 0,
1760            "StonesSettings: global_trigger_cooldown_ticks must be > 0"
1761        );
1762        assert!(
1763            settings.max_stone_level >= 1,
1764            "StonesSettings: max_stone_level must be >= 1"
1765        );
1766
1767        // BAL-032: the Beast Token AFK rate is FLAT wherever it is paid. The
1768        // card replaced a curve that grew from 0.018/min at ch11 to 420/min at
1769        // ch401 -- orders of magnitude of drift that broke the signed pet
1770        // catalog timing -- with one rate from the Pets gate upward. Bands below
1771        // the gate omit the currency entirely rather than paying zero.
1772        //
1773        // Checked here rather than in a test because the fixture carries no
1774        // Beast Token: this has to run against real content, which `deploy`
1775        // does.
1776        {
1777            let beast = self.game_settings.pet_gacha.currency_id;
1778            let paid: Vec<f64> = self
1779                .afk_rewards_levels
1780                .iter()
1781                .flat_map(|row| row.currency_rates.iter())
1782                .filter(|rate| rate.currency_id == beast)
1783                .map(|rate| rate.rate_per_minute.get())
1784                .collect();
1785            if let Some(first) = paid.first() {
1786                for rate in &paid {
1787                    assert!(
1788                        (rate - first).abs() < 1e-9,
1789                        "AfkRewardsByLevel: the Beast Token rate must be flat across \
1790                         every band that pays it (BAL-032), found {rate} alongside {first}"
1791                    );
1792                }
1793            }
1794        }
1795
1796        // BAL-038: every authored D must be positive. A zero would make its
1797        // cap `ceil(0) = 0`, silently switching that faucet off for good rather
1798        // than merely capping it.
1799        let kf = &self.kill_faucet_settings;
1800        for (name, d) in [
1801            ("direct_cookies_d", kf.direct_cookies_d),
1802            ("law_copies_d", kf.law_copies_d),
1803            ("equipment_stones_d", kf.equipment_stones_d),
1804            ("artifact_stones_d", kf.artifact_stones_d),
1805        ] {
1806            assert!(d > 0.0, "KillFaucetSettings: {name} must be > 0, got {d}");
1807        }
1808        for (name, cap) in [
1809            (
1810                "skill_chapter_cap_with_pass",
1811                kf.skill_chapter_cap_with_pass,
1812            ),
1813            (
1814                "skill_chapter_cap_after_pass",
1815                kf.skill_chapter_cap_after_pass,
1816            ),
1817            ("boss_gems_daily_cap", kf.boss_gems_daily_cap),
1818        ] {
1819            assert!(cap > 0, "KillFaucetSettings: {name} must be > 0, got {cap}");
1820        }
1821
1822        // BAL-012: the rarity step of the drop roll needs every tier priced
1823        // exactly once. A missing row would make that tier undrawable and a
1824        // duplicate would silently double its weight.
1825        let mut weighted_tiers = HashSet::new();
1826        for row in &settings.rarity_weights {
1827            assert!(
1828                row.weight >= 0.0,
1829                "StonesSettings: rarity weight for {:?} must be >= 0",
1830                row.tier
1831            );
1832            assert!(
1833                weighted_tiers.insert(row.tier),
1834                "StonesSettings: duplicate rarity weight for {:?}",
1835                row.tier
1836            );
1837        }
1838        for tier in StoneTier::iter() {
1839            assert!(
1840                weighted_tiers.contains(&tier),
1841                "StonesSettings: missing rarity weight for {tier:?}"
1842            );
1843        }
1844        assert!(
1845            settings
1846                .rarity_weights
1847                .iter()
1848                .map(|row| row.weight.max(0.0))
1849                .sum::<f64>()
1850                > 0.0,
1851            "StonesSettings: rarity weights must not all be zero"
1852        );
1853
1854        // BAL-024/BAL-025: every guaranteed package references real catalog
1855        // entries exactly once — a dangling id would make the guarantee
1856        // silently unfulfillable.
1857        for grant in &settings.milestone_grants {
1858            assert!(
1859                grant.chapter >= 0,
1860                "StonesSettings: milestone grant chapter must be >= 0"
1861            );
1862            let mut seen = HashSet::new();
1863            for id in &grant.trigger_stones {
1864                assert!(
1865                    self.trigger_stones.iter().any(|stone| stone.id == *id),
1866                    "StonesSettings: milestone grant at chapter {} references unknown trigger stone {id}",
1867                    grant.chapter
1868                );
1869                assert!(
1870                    seen.insert(*id),
1871                    "StonesSettings: milestone grant at chapter {} lists {id} twice",
1872                    grant.chapter
1873                );
1874            }
1875            for id in &grant.effect_stones {
1876                assert!(
1877                    self.effect_stones.iter().any(|stone| stone.id == *id),
1878                    "StonesSettings: milestone grant at chapter {} references unknown effect stone {id}",
1879                    grant.chapter
1880                );
1881                assert!(
1882                    seen.insert(*id),
1883                    "StonesSettings: milestone grant at chapter {} lists {id} twice",
1884                    grant.chapter
1885                );
1886            }
1887        }
1888
1889        // The ladder must price every level a stone can reach: a level with no
1890        // rung reads as "already at the cap", so a gap silently lowers the cap
1891        // instead of failing loudly.
1892        let mut priced = HashSet::new();
1893        for step in &settings.upgrade_ladder {
1894            assert!(
1895                (2..=settings.max_stone_level).contains(&step.level),
1896                "StonesSettings: upgrade_ladder prices level {}, outside 2..={}",
1897                step.level,
1898                settings.max_stone_level
1899            );
1900            assert!(
1901                step.copies >= 1,
1902                "StonesSettings: level {} must cost at least one copy",
1903                step.level
1904            );
1905            assert!(
1906                priced.insert(step.level),
1907                "StonesSettings: level {} is priced twice in upgrade_ladder",
1908                step.level
1909            );
1910        }
1911        for level in 2..=settings.max_stone_level {
1912            assert!(
1913                priced.contains(&level),
1914                "StonesSettings: upgrade_ladder has no cost for level {level}"
1915            );
1916        }
1917        // BAL-027: the gauge award is per template now; the tier ladder is gone.
1918        for stone in &self.trigger_stones {
1919            assert!(
1920                stone.gauge_gain.is_finite() && stone.gauge_gain >= 0.0,
1921                "TriggerStoneTemplate {}: gauge_gain must be finite and >= 0",
1922                stone.id
1923            );
1924            // BAL-030: the estimator scales an equipment package by how often
1925            // its trigger fires, so a zero rate would price a working package
1926            // at nothing.
1927            assert!(
1928                stone.power_proc_rate.is_finite() && stone.power_proc_rate > 0.0,
1929                "TriggerStoneTemplate {}: power_proc_rate must be finite and > 0",
1930                stone.id
1931            );
1932            if stone.condition == crate::stones::TriggerCondition::OnInterval {
1933                assert!(
1934                    stone.condition_value > 0,
1935                    "TriggerStoneTemplate {}: an interval condition needs a positive period",
1936                    stone.id
1937                );
1938            }
1939        }
1940        assert!(
1941            (0.0..=1.0).contains(&settings.kill_drop.chance),
1942            "StonesSettings: kill_drop.chance must be in [0, 1]"
1943        );
1944        assert!(
1945            (0.0..=1.0).contains(&settings.kill_drop.trigger_share),
1946            "StonesSettings: kill_drop.trigger_share must be in [0, 1]"
1947        );
1948
1949        let scheduled: HashSet<(essences::items::ItemType, essences::stones::StoneSocketSlot)> =
1950            settings
1951                .socket_unlocks
1952                .iter()
1953                .map(|unlock| (unlock.item_type, unlock.socket))
1954                .collect();
1955        assert_eq!(
1956            scheduled.len(),
1957            settings.socket_unlocks.len(),
1958            "StonesSettings: duplicate socket in the unlock schedule"
1959        );
1960        for key in StoneSocketKey::all() {
1961            assert!(
1962                scheduled.contains(&(key.item_type, key.socket)),
1963                "StonesSettings: no unlock scheduled for {:?}/{:?}",
1964                key.item_type,
1965                key.socket
1966            );
1967        }
1968        for unlock in &settings.socket_unlocks {
1969            assert!(
1970                unlock.item_type.supports_world_side(),
1971                "StonesSettings: {:?} is not a two-sided slot and cannot carry sockets",
1972                unlock.item_type
1973            );
1974            assert!(
1975                unlock.unlock_chapter >= 0,
1976                "StonesSettings: unlock_chapter must be >= 0"
1977            );
1978        }
1979
1980        let mut cells = HashSet::new();
1981        for cell in &settings.tier_coefficients {
1982            assert!(
1983                cell.multiplier.is_finite() && cell.multiplier > 0.0,
1984                "StonesSettings: tier coefficient must be finite and > 0"
1985            );
1986            assert!(
1987                cells.insert((cell.trigger_tier, cell.effect_tier)),
1988                "StonesSettings: duplicate tier coefficient cell"
1989            );
1990        }
1991        for trigger_tier in StoneTier::iter() {
1992            for effect_tier in StoneTier::iter() {
1993                assert!(
1994                    cells.contains(&(trigger_tier, effect_tier)),
1995                    "StonesSettings: no coefficient for {trigger_tier}x{effect_tier}"
1996                );
1997            }
1998        }
1999
2000        let mut template_ids = HashSet::new();
2001        for stone in &self.trigger_stones {
2002            assert!(
2003                template_ids.insert(stone.id),
2004                "Not unique stone template ids in config"
2005            );
2006        }
2007        for stone in &self.effect_stones {
2008            assert!(
2009                template_ids.insert(stone.id),
2010                "Not unique stone template ids in config"
2011            );
2012            validate_power_q(
2013                &format!("EffectStoneTemplate {}", stone.id),
2014                stone.power_q_base_first_rank,
2015                stone.power_q_base_max_rank,
2016            );
2017        }
2018
2019        // A stat pointing at a missing attribute would be silently dropped by
2020        // the aggregation, so the stone would quietly grant less than its UI
2021        // promises. Catch it at load instead.
2022        let mut seen_attrs = HashSet::new();
2023        for (stone_id, stats) in self
2024            .trigger_stones
2025            .iter()
2026            .map(|s| (s.id, &s.stats))
2027            .chain(self.effect_stones.iter().map(|s| (s.id, &s.stats)))
2028        {
2029            // The stone-detail UI promises "the stats it gives" for every
2030            // stone, and a socketed stone is meant to be worth something before
2031            // its trigger ever fires — so an empty block is a content bug, not
2032            // a valid design choice.
2033            assert!(
2034                !stats.is_empty(),
2035                "Stone {stone_id}: must grant at least one stat"
2036            );
2037            // Cleared per stone: two DIFFERENT stones may of course grant the
2038            // same attribute; only one stone listing it twice is the bug.
2039            seen_attrs.clear();
2040            for stat in stats {
2041                assert!(
2042                    self.attributes.iter().any(|a| a.id == stat.attribute_id),
2043                    "Stone {stone_id}: unknown stat attribute id {}",
2044                    stat.attribute_id
2045                );
2046                assert!(
2047                    seen_attrs.insert(stat.attribute_id),
2048                    "Stone {stone_id}: duplicate stat attribute id {} — merge the two entries",
2049                    stat.attribute_id
2050                );
2051            }
2052        }
2053        // Per-trigger invariants the panel cannot show: a windowed condition
2054        // with no window would never open, a window on an instant condition
2055        // would silently narrow it. Every condition is implementable today
2056        // (the mana pair got its mechanic when the pool shipped), so `active`
2057        // carries no code-side requirement any more — it is purely the
2058        // designer's switch for content that should wait.
2059        for stone in &self.trigger_stones {
2060            assert_eq!(
2061                stone.condition_window_ticks > 0,
2062                stone.condition.needs_window(),
2063                "Trigger stone {}: condition_window_ticks must be positive for a windowed \
2064                 condition and zero otherwise",
2065                stone.id
2066            );
2067            assert!(
2068                stone.condition_value >= 0,
2069                "Trigger stone {}: condition_value must not be negative",
2070                stone.id
2071            );
2072        }
2073
2074        // Per-effect invariants, same reasoning: a timed action with no
2075        // duration would never expire, and a charge action with no charges is
2076        // a no-op the panel renders as a real effect.
2077        for stone in &self.effect_stones {
2078            assert_eq!(
2079                stone.duration_ticks > 0,
2080                stone.action.needs_duration(),
2081                "Effect stone {}: duration_ticks must be positive for a timed action and zero \
2082                 otherwise",
2083                stone.id
2084            );
2085            assert_eq!(
2086                stone.charges > 0,
2087                stone.action.needs_charges(),
2088                "Effect stone {}: charges must be positive for a charge-based action and zero \
2089                 otherwise",
2090                stone.id
2091            );
2092            for value in [
2093                stone.magnitude,
2094                stone.magnitude_per_level,
2095                stone.secondary_magnitude,
2096            ] {
2097                assert!(
2098                    value.is_finite() && value >= 0.0,
2099                    "Effect stone {}: magnitudes must be finite and >= 0",
2100                    stone.id
2101                );
2102            }
2103        }
2104
2105        // BAL-027: every ACTIVE trigger must feed the gauge with its own
2106        // authored gain, or the stone fires for nothing.
2107        for stone in &self.trigger_stones {
2108            assert!(
2109                !stone.active || stone.gauge_gain > 0.0,
2110                "TriggerStoneTemplate {}: an active trigger needs a positive gauge_gain",
2111                stone.id
2112            );
2113        }
2114    }
2115
2116    /// Collection Power is a real stat multiplier, so a mis-authored constant
2117    /// is a silent balance change rather than a crash. These checks catch the
2118    /// three ways that can happen: a weight table with a hole in it, a bonus
2119    /// authored on an attribute nothing composes, and a negative coefficient.
2120    fn validate_collection_power(&self) {
2121        use essences::stones::StoneTier;
2122        use strum::IntoEnumIterator;
2123
2124        let settings = &self.collection_power;
2125
2126        assert!(
2127            (0.0..=1.0).contains(&settings.first_ownership_share),
2128            "CollectionPowerSettings: first_ownership_share must be in 0..=1, got {}",
2129            settings.first_ownership_share
2130        );
2131
2132        for (name, family) in [
2133            ("equipment_stones", &settings.equipment_stones),
2134            ("laws", &settings.laws),
2135            ("artifact_stones", &settings.artifact_stones),
2136            ("ability_stones", &settings.ability_stones),
2137            ("gacha_abilities", &settings.gacha_abilities),
2138            ("pets", &settings.pets),
2139        ] {
2140            assert!(
2141                family.k >= 0.0 && family.k.is_finite(),
2142                "CollectionPowerSettings: {name}.k must be finite and >= 0, got {}",
2143                family.k
2144            );
2145        }
2146
2147        assert!(
2148            !settings.multiplied_attribute_ids.is_empty(),
2149            "CollectionPowerSettings: multiplied_attribute_ids must not be empty"
2150        );
2151        for attribute_id in &settings.multiplied_attribute_ids {
2152            let attribute = self
2153                .attributes
2154                .iter()
2155                .find(|attr| attr.id == *attribute_id)
2156                .unwrap_or_else(|| {
2157                    panic!(
2158                        "CollectionPowerSettings: multiplied attribute {attribute_id} is not in the catalog"
2159                    )
2160                });
2161            // The bonus is applied as `<code>.mod`. An attribute nothing
2162            // composes that way would take the budget and pay nothing.
2163            assert!(
2164                crate::artifacts::MOD_CAPABLE_ATTRIBUTE_CODES.contains(&attribute.code.as_str()),
2165                "CollectionPowerSettings: attribute '{}' has no `.mod` composition, so a collection bonus on it would be silently dropped",
2166                attribute.code
2167            );
2168        }
2169
2170        // A missing tier or rarity row is worth catching here: it does not
2171        // crash, it just makes every template of that rarity pay nothing.
2172        for tier in StoneTier::iter() {
2173            assert!(
2174                settings
2175                    .stone_tier_weights
2176                    .iter()
2177                    .filter(|row| row.tier == tier)
2178                    .count()
2179                    == 1,
2180                "CollectionPowerSettings: stone_tier_weights needs exactly one row for {tier:?}"
2181            );
2182        }
2183        for rarity in &self.pet_rarities {
2184            assert!(
2185                settings
2186                    .pet_rarity_weights
2187                    .iter()
2188                    .filter(|row| row.rarity_id == rarity.id)
2189                    .count()
2190                    == 1,
2191                "CollectionPowerSettings: pet_rarity_weights needs exactly one row for pet rarity {}",
2192                rarity.id
2193            );
2194        }
2195        for row in settings
2196            .stone_tier_weights
2197            .iter()
2198            .map(|row| row.weight)
2199            .chain(settings.pet_rarity_weights.iter().map(|row| row.weight))
2200        {
2201            assert!(
2202                row > 0.0 && row.is_finite(),
2203                "CollectionPowerSettings: collection weights must be finite and > 0, got {row}"
2204            );
2205        }
2206    }
2207
2208    /// Every rule the artifact system relies on being true at load time.
2209    ///
2210    /// The ones that carry real weight:
2211    ///
2212    /// * **all six sockets are scheduled** — a missing entry makes a socket
2213    ///   unreachable forever, and the whole point of the Law column shipping
2214    ///   early is that its sockets *exist*;
2215    /// * **the Aspect column opens strictly before the Law column** — a Law
2216    ///   stone has nothing to retune until the player owns cores, so the order
2217    ///   is design, not taste;
2218    /// * **both ladders price every level with no gap** — a missing rung reads
2219    ///   as "already at the cap" and would silently strand the item one level
2220    ///   below where the designer meant it to stop;
2221    /// * **every artifact level has an ownership row** — a missing row pays
2222    ///   nothing, which would look like a balance decision rather than a typo;
2223    /// * **a stone's declared socket matches its rule** — otherwise a Flip rule
2224    ///   could ship in a Visible socket, where nothing would ever run it.
2225    fn validate_artifacts(&self) {
2226        use essences::artifacts::ArtifactSocketSlot;
2227        use std::collections::HashSet;
2228
2229        let settings = &self.artifacts_settings;
2230
2231        assert!(
2232            settings.unlock_chapter >= 0,
2233            "ArtifactsSettings: unlock_chapter must be >= 0"
2234        );
2235        assert_eq!(
2236            settings.unlock_chapter, self.cores_settings.unlock_chapter,
2237            "ArtifactsSettings and CoresSettings must share the same unlock_chapter"
2238        );
2239        assert!(
2240            settings.max_artifact_level >= 1,
2241            "ArtifactsSettings: max_artifact_level must be >= 1"
2242        );
2243        assert!(
2244            settings.max_stone_level >= 1,
2245            "ArtifactsSettings: max_stone_level must be >= 1"
2246        );
2247
2248        let mut artifact_ids = HashSet::new();
2249        for artifact in &self.artifacts {
2250            assert!(
2251                artifact_ids.insert(artifact.id),
2252                "Not unique artifact template ids in config"
2253            );
2254
2255            validate_power_q(
2256                &format!("Artifact {}", artifact.id),
2257                artifact.power_q,
2258                artifact.power_q,
2259            );
2260
2261            let mut bonus_attributes = HashSet::new();
2262            for bonus in &artifact.ownership_bonuses {
2263                let Some(attribute) = self
2264                    .attributes
2265                    .iter()
2266                    .find(|attr| attr.id == bonus.attribute_id)
2267                else {
2268                    panic!(
2269                        "Artifact {}: ownership bonus references unknown attribute {}",
2270                        artifact.id, bonus.attribute_id
2271                    );
2272                };
2273                // An ownership bonus is written as `<code>.mod`, and only the
2274                // codes the runtime composes are ever read back. Anything else
2275                // would be inert content that looks configured — refuse it here
2276                // rather than ship a bonus that does nothing.
2277                assert!(
2278                    crate::artifacts::MOD_CAPABLE_ATTRIBUTE_CODES
2279                        .contains(&attribute.code.as_str()),
2280                    "Artifact {}: ownership bonus on attribute '{}', whose `.mod` is composed by \
2281                     nothing — the bonus would be silently inert. Mod-capable codes: {:?}",
2282                    artifact.id,
2283                    attribute.code,
2284                    crate::artifacts::MOD_CAPABLE_ATTRIBUTE_CODES,
2285                );
2286                assert!(
2287                    bonus_attributes.insert(bonus.attribute_id),
2288                    "Artifact {}: attribute {} listed twice in ownership_bonuses — merge the rows, \
2289                     or the intent of the second one is invisible",
2290                    artifact.id,
2291                    bonus.attribute_id
2292                );
2293                for percent in [bonus.percent, bonus.percent_per_level] {
2294                    assert!(
2295                        percent.is_finite() && percent >= 0.0,
2296                        "Artifact {}: ownership bonus percentages must be finite and >= 0",
2297                        artifact.id
2298                    );
2299                }
2300            }
2301        }
2302        assert!(
2303            artifact_ids.contains(&settings.starter_artifact_id),
2304            "ArtifactsSettings: starter_artifact_id {} is not in the artifact catalog",
2305            settings.starter_artifact_id
2306        );
2307        assert!(
2308            self.artifacts
2309                .iter()
2310                .find(|artifact| artifact.id == settings.starter_artifact_id)
2311                .is_some_and(|artifact| artifact.world_law.is_none()),
2312            "ArtifactsSettings: the starter artifact must carry no World Law"
2313        );
2314
2315        assert!(
2316            settings.halfway_bell_threshold_share.is_finite()
2317                && settings.halfway_bell_threshold_share > 0.0
2318                && settings.halfway_bell_threshold_share <= 1.0,
2319            "ArtifactsSettings: halfway_bell_threshold_share must be in (0, 1] — it is a share of \
2320             the configured flip threshold, not a threshold"
2321        );
2322        assert!(
2323            settings.thin_mirror_share.is_finite()
2324                && settings.thin_mirror_share > 0.0
2325                && settings.thin_mirror_share <= 1.0,
2326            "ArtifactsSettings: thin_mirror_share must be in (0, 1] — a hidden law runs at a \
2327             SHARE of Law Power, never above it"
2328        );
2329
2330        let mut stone_ids = HashSet::new();
2331        let mut stone_rules = HashSet::new();
2332        for stone in &self.artifact_stones {
2333            assert!(
2334                stone_ids.insert(stone.id),
2335                "Not unique artifact stone template ids in config"
2336            );
2337            validate_power_q(
2338                &format!("Artifact stone {}", stone.id),
2339                stone.power_q_first_rank,
2340                stone.power_q_max_rank,
2341            );
2342            // One stone per rule: two entries carrying one rule would be the same
2343            // stone twice, and the socket that holds one of them would offer a
2344            // choice that is not a choice.
2345            assert!(
2346                stone_rules.insert(stone.rule),
2347                "Artifact stone {}: rule {:?} is already carried by another stone",
2348                stone.id,
2349                stone.rule
2350            );
2351            assert_eq!(
2352                stone.socket,
2353                stone.rule.socket(),
2354                "Artifact stone {}: rule {:?} belongs in socket {:?}, not {:?}",
2355                stone.id,
2356                stone.rule,
2357                stone.rule.socket(),
2358                stone.socket
2359            );
2360            assert!(
2361                stone.magnitude.is_finite()
2362                    && stone.magnitude_per_level.is_finite()
2363                    && stone.secondary_magnitude.is_finite(),
2364                "Artifact stone {}: magnitudes must be finite",
2365                stone.id
2366            );
2367            assert!(
2368                stone.rule_param >= 0,
2369                "Artifact stone {}: rule_param must be >= 0",
2370                stone.id
2371            );
2372            // The load-time half of the "switched off" guard: a rule whose
2373            // mechanic is not on this branch cannot be turned on by a config
2374            // edit alone, and the reason travels with the refusal.
2375            if let Some(dependency) = stone.rule.unavailable_dependency() {
2376                assert!(
2377                    !stone.active,
2378                    "Artifact stone {}: rule {:?} needs {dependency} — it must ship with \
2379                     active: false",
2380                    stone.id, stone.rule
2381                );
2382            }
2383            let mut tiers = HashSet::new();
2384            for entry in &stone.tier_magnitudes {
2385                assert!(
2386                    tiers.insert(entry.tier),
2387                    "Artifact stone {}: tier {:?} listed twice in tier_magnitudes",
2388                    stone.id,
2389                    entry.tier
2390                );
2391                assert!(
2392                    entry.magnitude.is_finite() && entry.magnitude >= 0.0,
2393                    "Artifact stone {}: tier magnitudes must be finite and >= 0",
2394                    stone.id
2395                );
2396            }
2397            assert!(
2398                stone.tier_magnitudes.is_empty()
2399                    || stone.rule == crate::artifacts::ArtifactStoneRule::SmallGears,
2400                "Artifact stone {}: only «Мелкие шестерни» reads tier_magnitudes",
2401                stone.id
2402            );
2403        }
2404        // The two catalogs are addressed by template id from the same client
2405        // calls, so a shared id would make one of them unreachable.
2406        for stone in &self.artifact_stones {
2407            assert!(
2408                !artifact_ids.contains(&stone.id),
2409                "Artifact stone {} shares its id with an artifact",
2410                stone.id
2411            );
2412        }
2413        // Both columns ship now that the cores vertical landed. What still has
2414        // to hold is that every socket offers a real choice: the left column has
2415        // five stones per socket, the right has five, five and four — `BL-04
2416        // Priority Gate` was cut (post-merge plan §8), so the Bridge Law socket
2417        // is the one place a socket offers four.
2418        for socket in ArtifactSocketSlot::all() {
2419            let offered = self
2420                .artifact_stones
2421                .iter()
2422                .filter(|stone| stone.socket == socket)
2423                .count();
2424            assert!(
2425                offered >= 4,
2426                "ArtifactsSettings: socket {socket} offers only {offered} stones — a socket is a \
2427                 choice between readings, and fewer than four is not one"
2428            );
2429        }
2430
2431        let scheduled: HashSet<ArtifactSocketSlot> = settings
2432            .socket_unlocks
2433            .iter()
2434            .map(|unlock| unlock.socket)
2435            .collect();
2436        assert_eq!(
2437            scheduled.len(),
2438            settings.socket_unlocks.len(),
2439            "ArtifactsSettings: duplicate socket in the unlock schedule"
2440        );
2441        for socket in ArtifactSocketSlot::all() {
2442            assert!(
2443                scheduled.contains(&socket),
2444                "ArtifactsSettings: no unlock scheduled for {socket}"
2445            );
2446        }
2447        for unlock in &settings.socket_unlocks {
2448            assert!(
2449                unlock.unlock_chapter >= 0,
2450                "ArtifactsSettings: unlock_chapter must be >= 0"
2451            );
2452        }
2453        // Fixed order: the whole Aspect column, then the whole Law column.
2454        let mut previous = -1_i64;
2455        for socket in ArtifactSocketSlot::all() {
2456            let chapter = settings
2457                .socket_unlocks
2458                .iter()
2459                .find(|unlock| unlock.socket == socket)
2460                .map(|unlock| unlock.unlock_chapter)
2461                .expect("every socket is scheduled");
2462            assert!(
2463                chapter >= previous,
2464                "ArtifactsSettings: {socket} opens before an earlier socket — the order is \
2465                 fixed: the Aspect column first, then the Law column"
2466            );
2467            previous = chapter;
2468        }
2469
2470        Self::validate_artifact_ladder(
2471            "upgrade_ladder",
2472            &settings.upgrade_ladder,
2473            settings.max_artifact_level,
2474        );
2475        Self::validate_artifact_ladder(
2476            "stone_upgrade_ladder",
2477            &settings.stone_upgrade_ladder,
2478            settings.max_stone_level,
2479        );
2480
2481        for chance in [
2482            settings.stone_drop.chapter_boss_chance,
2483            settings.stone_drop.dungeon_chance,
2484            settings.stone_drop.mob_kill_chance,
2485        ] {
2486            assert!(
2487                (0.0..=1.0).contains(&chance),
2488                "ArtifactsSettings: stone drop chances must be in [0, 1]"
2489            );
2490        }
2491    }
2492
2493    /// Shared shape check for both artifact ladders: rungs `2..=max`, no gaps,
2494    /// no duplicates, at least one copy each.
2495    fn validate_artifact_ladder(
2496        name: &str,
2497        ladder: &[crate::artifacts::ArtifactUpgradeStep],
2498        max_level: i64,
2499    ) {
2500        use std::collections::HashSet;
2501
2502        let mut priced = HashSet::new();
2503        for step in ladder {
2504            assert!(
2505                (2..=max_level).contains(&step.level),
2506                "ArtifactsSettings: {name} prices level {}, outside 2..={max_level}",
2507                step.level
2508            );
2509            assert!(
2510                step.copies >= 1,
2511                "ArtifactsSettings: {name} level {} must cost at least one copy",
2512                step.level
2513            );
2514            assert!(
2515                priced.insert(step.level),
2516                "ArtifactsSettings: {name} prices level {} twice",
2517                step.level
2518            );
2519        }
2520        for level in 2..=max_level {
2521            assert!(
2522                priced.contains(&level),
2523                "ArtifactsSettings: {name} has no cost for level {level}"
2524            );
2525        }
2526    }
2527
2528    fn validate_inventory_levels(&self) {
2529        use std::collections::HashSet;
2530
2531        assert!(
2532            !self.inventory_levels.is_empty(),
2533            "InventoryLevels must not be empty"
2534        );
2535        assert!(
2536            self.inventory_levels[0].from_chapter_level == 0,
2537            "InventoryLevels must start at chapter 0"
2538        );
2539
2540        for pair in self.inventory_levels.windows(2) {
2541            let (previous, next) = (&pair[0], &pair[1]);
2542            assert!(
2543                next.from_chapter_level > previous.from_chapter_level,
2544                "InventoryLevels chapters must be strictly increasing"
2545            );
2546            assert!(
2547                next.slots.starts_with(&previous.slots),
2548                "InventoryLevels slots must be cumulative and preserve their order"
2549            );
2550        }
2551
2552        for level in &self.inventory_levels {
2553            assert!(
2554                !level.slots.is_empty(),
2555                "InventoryLevel chapter {} must contain slots",
2556                level.from_chapter_level
2557            );
2558
2559            let mut unique_slots = HashSet::new();
2560            for slot in &level.slots {
2561                assert!(
2562                    unique_slots.insert(*slot),
2563                    "InventoryLevel chapter {} contains duplicate slot {} {:?}",
2564                    level.from_chapter_level,
2565                    slot.item_type,
2566                    slot.world_side
2567                );
2568
2569                match (slot.item_type.supports_world_side(), slot.world_side) {
2570                    (true, None) => panic!(
2571                        "InventoryLevel chapter {} flip slot {} must define world_side",
2572                        level.from_chapter_level, slot.item_type
2573                    ),
2574                    (false, Some(side)) => panic!(
2575                        "InventoryLevel chapter {} fixed slot {} must not define world_side ({side})",
2576                        level.from_chapter_level, slot.item_type
2577                    ),
2578                    _ => {}
2579                }
2580
2581                assert!(
2582                    self.items
2583                        .iter()
2584                        .any(|item| item.equipment_slot_key() == slot.equipment_slot_key()),
2585                    "InventoryLevel chapter {} slot {} {:?} has no item templates",
2586                    level.from_chapter_level,
2587                    slot.item_type,
2588                    slot.world_side
2589                );
2590            }
2591        }
2592
2593        for level in self
2594            .inventory_levels
2595            .iter()
2596            .filter(|level| level.from_chapter_level < self.flip_settings.unlock_chapter)
2597        {
2598            assert!(
2599                level
2600                    .slots
2601                    .iter()
2602                    .all(|slot| slot.world_side != Some(essences::flip::WorldSide::Real)),
2603                "InventoryLevels must not unlock Real slots before FlipSettings.unlock_chapter"
2604            );
2605        }
2606
2607        let unlock_level = self
2608            .inventory_levels
2609            .iter()
2610            .find(|level| level.from_chapter_level == self.flip_settings.unlock_chapter)
2611            .unwrap_or_else(|| {
2612                panic!(
2613                    "InventoryLevels must contain a level at FlipSettings.unlock_chapter ({})",
2614                    self.flip_settings.unlock_chapter
2615                )
2616            });
2617        assert!(
2618            unlock_level
2619                .slots
2620                .iter()
2621                .any(|slot| { slot.world_side == Some(essences::flip::WorldSide::Real) }),
2622            "InventoryLevel at flip unlock must contain Real slots"
2623        );
2624    }
2625
2626    fn validate_item_world_sides(&self) {
2627        for item in &self.items {
2628            match (item.item_type.supports_world_side(), item.world_side) {
2629                (true, None) => panic!(
2630                    "ItemTemplate {} ({}) must define world_side",
2631                    item.id, item.item_type
2632                ),
2633                (false, Some(side)) => panic!(
2634                    "ItemTemplate {} ({}) must not define world_side ({side})",
2635                    item.id, item.item_type
2636                ),
2637                _ => {}
2638            }
2639        }
2640    }
2641
2642    pub fn class_level(&self, class_id: class::ClassId, level: u64) -> Option<&class::ClassLevels> {
2643        self.class_levels
2644            .iter()
2645            .find(|row| row.class_id == class_id && row.level == level)
2646    }
2647
2648    fn validate_progress_pass_config(progress_pass: &essences::progress_pass::ProgressPassConfig) {
2649        use std::collections::HashSet;
2650
2651        let mut tier_values: HashSet<u32> = HashSet::new();
2652        for tier in &progress_pass.tiers {
2653            if tier.quest_template_ids.is_empty() {
2654                panic!(
2655                    "ProgressPassConfig: tier {} has no quest_template_ids",
2656                    tier.tier
2657                );
2658            }
2659            if !tier_values.insert(tier.tier) {
2660                panic!("ProgressPassConfig: duplicate tier value {}", tier.tier);
2661            }
2662        }
2663    }
2664
2665    fn validate_progress_pass_references(&self) {
2666        let pp = &self.progress_pass;
2667
2668        if pp.tiers.is_empty() {
2669            return;
2670        }
2671
2672        if !self
2673            .offers_templates
2674            .iter()
2675            .any(|o| o.id == pp.premium_offer_template_id)
2676        {
2677            panic!(
2678                "ProgressPassConfig: premium_offer_template_id {} does not match any offer template",
2679                pp.premium_offer_template_id
2680            );
2681        }
2682
2683        for tier in &pp.tiers {
2684            for quest_id in &tier.quest_template_ids {
2685                let Some(quest) = self.quests.iter().find(|q| q.id == *quest_id) else {
2686                    panic!(
2687                        "ProgressPassConfig: tier {} references unknown quest template {quest_id}",
2688                        tier.tier
2689                    );
2690                };
2691                if quest.quest_group_type != QuestGroupType::ProgressPass {
2692                    panic!(
2693                        "ProgressPassConfig: tier {} references quest {quest_id} whose group is {:?}, expected ProgressPass",
2694                        tier.tier, quest.quest_group_type
2695                    );
2696                }
2697            }
2698
2699            if !self
2700                .bundles
2701                .iter()
2702                .any(|b| b.id == tier.free_reward_bundle_id)
2703            {
2704                panic!(
2705                    "ProgressPassConfig: tier {} free_reward_bundle_id {} does not match any bundle",
2706                    tier.tier, tier.free_reward_bundle_id
2707                );
2708            }
2709            if !self
2710                .bundles
2711                .iter()
2712                .any(|b| b.id == tier.paid_reward_bundle_id)
2713            {
2714                panic!(
2715                    "ProgressPassConfig: tier {} paid_reward_bundle_id {} does not match any bundle",
2716                    tier.tier, tier.paid_reward_bundle_id
2717                );
2718            }
2719        }
2720    }
2721
2722    fn validate_loop_task_chain(quests: &[QuestTemplate]) {
2723        use std::collections::{HashMap, HashSet};
2724
2725        let loop_quests: Vec<&QuestTemplate> = quests
2726            .iter()
2727            .filter(|q| q.quest_group_type == QuestGroupType::LoopTask)
2728            .collect();
2729
2730        if loop_quests.is_empty() {
2731            return;
2732        }
2733
2734        let all_quest_ids: HashSet<_> = quests.iter().map(|q| q.id).collect();
2735        let loop_quest_ids: HashSet<_> = loop_quests.iter().map(|q| q.id).collect();
2736
2737        // 1. No duplicate codes among LoopTask quests
2738        let mut code_to_quest: HashMap<&str, &QuestTemplate> = HashMap::new();
2739        for q in &loop_quests {
2740            if let Some(code) = &q.code
2741                && let Some(existing) = code_to_quest.insert(code.as_str(), q)
2742            {
2743                panic!(
2744                    "LoopTask: duplicate code '{}' on quests {} and {}",
2745                    code, existing.id, q.id
2746                );
2747            }
2748        }
2749
2750        // 2. Every next_quest_ids reference must point to an existing quest
2751        for q in &loop_quests {
2752            for next_id in &q.next_quest_ids {
2753                if !all_quest_ids.contains(next_id) {
2754                    panic!(
2755                        "LoopTask: quest {} ('{}') has next_quest_id {} that does not exist",
2756                        q.id,
2757                        q.code.as_deref().unwrap_or("<no code>"),
2758                        next_id
2759                    );
2760                }
2761            }
2762        }
2763
2764        // 3. Discover numbered sequences (loop_task.{type}.{N}) and verify each is
2765        //    a gapless 1..=max chain where N chains to N+1 via next_quest_ids.
2766        let mut sequences: HashMap<String, Vec<(u32, &QuestTemplate)>> = HashMap::new();
2767        for (code, quest) in &code_to_quest {
2768            let parts: Vec<&str> = code.split('.').collect();
2769            // codes look like "loop_task.{type}.{number_or_name}"
2770            if parts.len() == 3
2771                && parts[0] == "loop_task"
2772                && let Ok(n) = parts[2].parse::<u32>()
2773            {
2774                sequences
2775                    .entry(parts[1].to_string())
2776                    .or_default()
2777                    .push((n, quest));
2778            }
2779        }
2780
2781        for (seq_type, mut entries) in sequences {
2782            entries.sort_by_key(|(n, _)| *n);
2783
2784            // Must start from 1
2785            if entries[0].0 != 1 {
2786                panic!(
2787                    "LoopTask: sequence 'loop_task.{}' starts at {} instead of 1",
2788                    seq_type, entries[0].0
2789                );
2790            }
2791
2792            // No gaps
2793            for (i, (n, _)) in entries.iter().enumerate() {
2794                let expected = (i as u32) + 1;
2795                if *n != expected {
2796                    panic!(
2797                        "LoopTask: sequence 'loop_task.{}' has gap — expected {} but found {}",
2798                        seq_type, expected, n
2799                    );
2800                }
2801            }
2802
2803            // Each N must chain to N+1 via next_quest_ids (except last).
2804            if seq_type != "loop" {
2805                for window in entries.windows(2) {
2806                    let (n, quest) = window[0];
2807                    let (_, next_quest) = window[1];
2808                    if !quest.next_quest_ids.contains(&next_quest.id) {
2809                        panic!(
2810                            "LoopTask: 'loop_task.{}.{}' (quest {}) does not chain to 'loop_task.{}.{}' (quest {}) via next_quest_ids",
2811                            seq_type,
2812                            n,
2813                            quest.id,
2814                            seq_type,
2815                            n + 1,
2816                            next_quest.id
2817                        );
2818                    }
2819                }
2820            }
2821        }
2822
2823        // 4. Every LoopTask quest must be reachable: either has a code (discoverable by scripts)
2824        //    or is in the next_quest_ids graph from any quest in the config
2825        let mut reachable: HashSet<essences::quest::QuestId> = HashSet::new();
2826
2827        // Reachable by code
2828        for q in code_to_quest.values() {
2829            reachable.insert(q.id);
2830        }
2831
2832        // Reachable by next_quest_ids graph (BFS from all quests that reference LoopTask quests)
2833        let mut stack: Vec<essences::quest::QuestId> = Vec::new();
2834        for q in quests {
2835            for next_id in &q.next_quest_ids {
2836                if loop_quest_ids.contains(next_id) {
2837                    stack.push(*next_id);
2838                }
2839            }
2840        }
2841        while let Some(id) = stack.pop() {
2842            if !reachable.insert(id) {
2843                continue;
2844            }
2845            if let Some(q) = loop_quests.iter().find(|q| q.id == id) {
2846                for next_id in &q.next_quest_ids {
2847                    if loop_quest_ids.contains(next_id) {
2848                        stack.push(*next_id);
2849                    }
2850                }
2851            }
2852        }
2853
2854        for q in &loop_quests {
2855            if !reachable.contains(&q.id) {
2856                panic!(
2857                    "LoopTask: quest {} ('{}') is unreachable — no code and not in next_quest_ids chain from a starting quest",
2858                    q.id,
2859                    q.code.as_deref().unwrap_or("<no code>")
2860                );
2861            }
2862        }
2863    }
2864
2865    fn validate_afk_rewards(
2866        afk_levels: &[AfkRewardsByLevel],
2867        afk_settings: &AfkRewardsSettings,
2868        currencies: &[Currency],
2869        abilities: &[AbilityTemplate],
2870        items: &[ItemTemplate],
2871        bundles: &[BundleRaw],
2872    ) {
2873        use std::collections::HashSet;
2874
2875        let currency_ids: HashSet<_> = currencies.iter().map(|c| c.id).collect();
2876        let ability_ids: HashSet<_> = abilities.iter().map(|a| a.id).collect();
2877        let item_ids: HashSet<_> = items.iter().map(|i| i.id).collect();
2878        let bundle_ids: HashSet<_> = bundles.iter().map(|b| b.id).collect();
2879
2880        // bonus_calculation_rate_sec > 0 is enforced by NonZeroU64 type
2881
2882        if !bundle_ids.contains(&afk_settings.bundle_id) {
2883            panic!(
2884                "AfkRewardsSettings: bundle_id {} not found in bundles",
2885                afk_settings.bundle_id
2886            );
2887        }
2888
2889        let chapter_levels: Vec<_> = afk_levels.iter().map(|r| r.chapter_level).collect();
2890        let unique_levels: HashSet<_> = chapter_levels.iter().collect();
2891        if chapter_levels.len() != unique_levels.len() {
2892            panic!("AfkRewardsByLevel: Two or more afk reward levels have equal chapter_level");
2893        }
2894
2895        for level_rewards in afk_levels {
2896            for rate in &level_rewards.currency_rates {
2897                if !currency_ids.contains(&rate.currency_id) {
2898                    panic!(
2899                        "AfkRewardsByLevel chapter_level={}: currency_id {} not found in currencies",
2900                        level_rewards.chapter_level, rate.currency_id
2901                    );
2902                }
2903                // rate_per_minute > 0 is enforced by PositiveF64 type
2904            }
2905
2906            // bonus_weights non-empty is enforced by NonEmptyVec type
2907            // bonus_weight.weight > 0 is enforced by PositiveF64 type
2908            // bonus_weight.count > 0 is enforced by PositiveI64 type
2909
2910            for bonus_weight in &level_rewards.bonus_weights {
2911                match &bonus_weight.bonus_type {
2912                    AfkRewardBonusType::Currency(currency_id) => {
2913                        if !currency_ids.contains(currency_id) {
2914                            panic!(
2915                                "AfkRewardsByLevel chapter_level={}: bonus Currency id {} not found",
2916                                level_rewards.chapter_level, currency_id
2917                            );
2918                        }
2919                    }
2920                    AfkRewardBonusType::Ability(ability_id) => {
2921                        if !ability_ids.contains(ability_id) {
2922                            panic!(
2923                                "AfkRewardsByLevel chapter_level={}: bonus Ability id {} not found",
2924                                level_rewards.chapter_level, ability_id
2925                            );
2926                        }
2927                    }
2928                    AfkRewardBonusType::Item(item_id) => {
2929                        if !item_ids.contains(item_id) {
2930                            panic!(
2931                                "AfkRewardsByLevel chapter_level={}: bonus Item id {} not found",
2932                                level_rewards.chapter_level, item_id
2933                            );
2934                        }
2935                    }
2936                }
2937            }
2938        }
2939    }
2940
2941    fn validate_ability_gacha_settings(&self) {
2942        use std::collections::HashSet;
2943
2944        let gacha = &self.game_settings.ability_gacha;
2945        let currency_ids: HashSet<_> = self.currencies.iter().map(|currency| currency.id).collect();
2946        let rarity_ids: HashSet<_> = self
2947            .ability_rarities
2948            .iter()
2949            .map(|rarity| rarity.id)
2950            .collect();
2951        let class_rarity_ids: HashSet<_> = self
2952            .classes
2953            .iter()
2954            .map(|class| class.ability_rarity_id)
2955            .collect();
2956
2957        // small_roll_cost > 0, big_roll_cost > 0, slot_max_level > 0 enforced by PositiveI64
2958        // wishlist_weight_multiplier >= 1.0 enforced by WeightMultiplier type
2959        // wishlist_slots > 0 still needs runtime check (u8, not wrapped)
2960        if gacha.wishlist_slots == 0 {
2961            panic!("AbilityGachaSettings: wishlist_slots must be > 0");
2962        }
2963        if gacha.slot_level_costs.len() != gacha.slot_max_level.get() as usize {
2964            panic!(
2965                "AbilityGachaSettings: slot_level_costs length ({}) must match slot_max_level ({})",
2966                gacha.slot_level_costs.len(),
2967                gacha.slot_max_level.get()
2968            );
2969        }
2970        if gacha.slot_level_bonus_levels.len() != gacha.slot_max_level.get() as usize + 1 {
2971            panic!(
2972                "AbilityGachaSettings: slot_level_bonus_levels length ({}) must be slot_max_level + 1 ({})",
2973                gacha.slot_level_bonus_levels.len(),
2974                gacha.slot_max_level.get() + 1
2975            );
2976        }
2977        // slot_level_costs values > 0 enforced by PositiveI64 type
2978        for bonus in &gacha.slot_level_bonus_levels {
2979            if *bonus < 0 {
2980                panic!("AbilityGachaSettings: slot_level_bonus_levels values must be >= 0");
2981            }
2982        }
2983        if !currency_ids.contains(&gacha.slot_upgrade_currency_id) {
2984            panic!(
2985                "AbilityGachaSettings: slot_upgrade_currency_id {} not found in currencies",
2986                gacha.slot_upgrade_currency_id
2987            );
2988        }
2989
2990        // ability_cases_settings non-empty is enforced by NonEmptyVec type
2991
2992        let mut settings_by_level = self.ability_cases_settings.to_vec();
2993        settings_by_level.sort_by_key(|level_settings| level_settings.level);
2994
2995        // Validate boundary levels: at most one, must be the last level.
2996        let boundary_count = settings_by_level
2997            .iter()
2998            .filter(|s| s.is_boundary_level)
2999            .count();
3000        if boundary_count > 1 {
3001            panic!("AbilityCasesSettings: at most one boundary level is allowed");
3002        }
3003        if boundary_count == 1 && !settings_by_level.last().unwrap().is_boundary_level {
3004            panic!("AbilityCasesSettings: the boundary level must be the last level");
3005        }
3006
3007        let mut previous_level = 0;
3008        let mut previous_opens = -1;
3009        for (idx, level_settings) in settings_by_level.iter().enumerate() {
3010            if level_settings.level <= previous_level {
3011                panic!("AbilityCasesSettings: levels must be unique and strictly increasing");
3012            }
3013            if level_settings.opens_to_upgrade < 0 {
3014                panic!(
3015                    "AbilityCasesSettings level {}: opens_to_upgrade must be >= 0",
3016                    level_settings.level
3017                );
3018            }
3019            if level_settings.opens_to_upgrade < previous_opens {
3020                panic!(
3021                    "AbilityCasesSettings level {}: opens_to_upgrade must be non-decreasing",
3022                    level_settings.level
3023                );
3024            }
3025            previous_level = level_settings.level;
3026            previous_opens = level_settings.opens_to_upgrade;
3027
3028            // Boundary levels only need level + opens_to_upgrade; skip all other validations.
3029            if level_settings.is_boundary_level {
3030                continue;
3031            }
3032
3033            let mut previous_checkpoint_opens = -1;
3034            for checkpoint in &level_settings.checkpoints {
3035                if checkpoint.required_opens <= 0 {
3036                    panic!(
3037                        "AbilityCasesSettings level {}: checkpoint required_opens must be > 0",
3038                        level_settings.level
3039                    );
3040                }
3041                if checkpoint.required_opens <= previous_checkpoint_opens {
3042                    panic!(
3043                        "AbilityCasesSettings level {}: checkpoint required_opens must be strictly increasing",
3044                        level_settings.level
3045                    );
3046                }
3047                previous_checkpoint_opens = checkpoint.required_opens;
3048
3049                // Checkpoint required_opens is relative to the current level's
3050                // opens_to_upgrade. It must not exceed the range of this level
3051                // (i.e. the gap to the next level's opens_to_upgrade).
3052                if let Some(next) = settings_by_level.get(idx + 1) {
3053                    let level_range = next.opens_to_upgrade - level_settings.opens_to_upgrade;
3054                    if checkpoint.required_opens > level_range {
3055                        panic!(
3056                            "AbilityCasesSettings level {}: checkpoint required_opens ({}) exceeds level range ({})",
3057                            level_settings.level, checkpoint.required_opens, level_range
3058                        );
3059                    }
3060                }
3061
3062                for currency_reward in &checkpoint.currency_rewards {
3063                    if currency_reward.amount <= 0 {
3064                        panic!(
3065                            "AbilityCasesSettings level {}: checkpoint currency reward must be > 0",
3066                            level_settings.level
3067                        );
3068                    }
3069                    if !currency_ids.contains(&currency_reward.currency_id) {
3070                        panic!(
3071                            "AbilityCasesSettings level {}: checkpoint currency {} not found",
3072                            level_settings.level, currency_reward.currency_id
3073                        );
3074                    }
3075                }
3076
3077                for ability_reward in &checkpoint.ability_rewards {
3078                    if ability_reward.amount < 0 {
3079                        panic!(
3080                            "AbilityCasesSettings level {}: checkpoint ability reward amount must be >= 0",
3081                            level_settings.level
3082                        );
3083                    }
3084                    if ability_reward.amount > 0
3085                        && !rarity_ids.contains(&ability_reward.min_rarity_id)
3086                    {
3087                        panic!(
3088                            "AbilityCasesSettings level {}: checkpoint ability reward min_rarity_id {} not found",
3089                            level_settings.level, ability_reward.min_rarity_id
3090                        );
3091                    }
3092                }
3093            }
3094
3095            for rarity_id in &level_settings.allowed_wishlist_rarity_ids {
3096                if !rarity_ids.contains(rarity_id) {
3097                    panic!(
3098                        "AbilityCasesSettings level {}: allowed wishlist rarity {} not found",
3099                        level_settings.level, rarity_id
3100                    );
3101                }
3102                if class_rarity_ids.contains(rarity_id) {
3103                    panic!(
3104                        "AbilityCasesSettings level {}: class rarity {} cannot be in wishlist",
3105                        level_settings.level, rarity_id
3106                    );
3107                }
3108            }
3109
3110            for evolve_rule in &level_settings.evolve_rules {
3111                if evolve_rule.small_roll_tries < 0 {
3112                    panic!(
3113                        "AbilityCasesSettings level {}: evolve small_roll_tries must be >= 0",
3114                        level_settings.level
3115                    );
3116                }
3117                if evolve_rule.big_roll_tries < 0 {
3118                    panic!(
3119                        "AbilityCasesSettings level {}: evolve big_roll_tries must be >= 0",
3120                        level_settings.level
3121                    );
3122                }
3123                if evolve_rule.small_roll_tries == 0 && evolve_rule.big_roll_tries == 0 {
3124                    panic!(
3125                        "AbilityCasesSettings level {}: evolve rule must have at least one non-zero tries",
3126                        level_settings.level
3127                    );
3128                }
3129                if !rarity_ids.contains(&evolve_rule.from_rarity_id) {
3130                    panic!(
3131                        "AbilityCasesSettings level {}: evolve from rarity {} not found",
3132                        level_settings.level, evolve_rule.from_rarity_id
3133                    );
3134                }
3135                // rarity_weight.weight > 0 enforced by PositiveF64 type
3136                let mut sum_weight = 0.0;
3137                for rarity_weight in &evolve_rule.to_rarity_weights {
3138                    if !rarity_ids.contains(&rarity_weight.rarity_id) {
3139                        panic!(
3140                            "AbilityCasesSettings level {}: evolve to rarity {} not found",
3141                            level_settings.level, rarity_weight.rarity_id
3142                        );
3143                    }
3144                    sum_weight += rarity_weight.weight.get();
3145                }
3146                if sum_weight > 1.0 + f64::EPSILON {
3147                    panic!(
3148                        "AbilityCasesSettings level {}: evolve to_rarity_weights sum must be <= 1.0, got {}",
3149                        level_settings.level, sum_weight
3150                    );
3151                }
3152            }
3153
3154            for currency_reward in &level_settings.big_roll_currency_rewards {
3155                if currency_reward.amount <= 0 {
3156                    panic!(
3157                        "AbilityCasesSettings level {}: big-roll currency reward amount must be > 0",
3158                        level_settings.level
3159                    );
3160                }
3161                if !currency_ids.contains(&currency_reward.currency_id) {
3162                    panic!(
3163                        "AbilityCasesSettings level {}: big-roll currency {} not found",
3164                        level_settings.level, currency_reward.currency_id
3165                    );
3166                }
3167            }
3168
3169            let big_roll_shards = &level_settings.big_roll_shards;
3170            let shards_total: u64 = big_roll_shards.iter().map(|s| s.count as u64).sum();
3171            let expected_total =
3172                gacha.big_roll_cost.get() as u64 + gacha.big_roll_bonus_drops as u64;
3173            if shards_total != expected_total {
3174                panic!(
3175                    "AbilityCasesSettings level {}: big_roll_shards total ({}) must equal big_roll_cost + bonus_drops ({})",
3176                    level_settings.level, shards_total, expected_total
3177                );
3178            }
3179            for shard in big_roll_shards {
3180                if shard.count == 0 {
3181                    panic!(
3182                        "AbilityCasesSettings level {}: big_roll_shards count must be > 0",
3183                        level_settings.level
3184                    );
3185                }
3186                if !rarity_ids.contains(&shard.min_rarity_id) {
3187                    panic!(
3188                        "AbilityCasesSettings level {}: big_roll_shards rarity {} not found",
3189                        level_settings.level, shard.min_rarity_id
3190                    );
3191                }
3192            }
3193        }
3194
3195        if settings_by_level[0].level != 1 || settings_by_level[0].opens_to_upgrade != 0 {
3196            panic!("AbilityCasesSettings: level 1 with opens_to_upgrade = 0 is required");
3197        }
3198    }
3199
3200    fn validate_pet_config(&self) {
3201        use std::collections::HashSet;
3202
3203        let pet_template_ids: HashSet<_> = self.pet_templates.iter().map(|t| t.id).collect();
3204        if pet_template_ids.len() != self.pet_templates.len() {
3205            panic!("PetConfig: duplicate pet template ids");
3206        }
3207
3208        let pet_rarity_ids: HashSet<_> = self.pet_rarities.iter().map(|r| r.id).collect();
3209        if pet_rarity_ids.len() != self.pet_rarities.len() {
3210            panic!("PetConfig: duplicate pet rarity ids");
3211        }
3212
3213        let attribute_ids: HashSet<_> = self.attributes.iter().map(|a| a.id).collect();
3214
3215        for template in &self.pet_templates {
3216            if !pet_rarity_ids.contains(&template.rarity_id) {
3217                panic!(
3218                    "PetConfig: pet template {} has rarity_id {} not found in pet_rarities",
3219                    template.id, template.rarity_id
3220                );
3221            }
3222            for stat in &template.stats {
3223                if !attribute_ids.contains(&stat.attribute_id) {
3224                    panic!(
3225                        "PetConfig: pet template {} has stat attribute_id {} not found in attributes",
3226                        template.id, stat.attribute_id
3227                    );
3228                }
3229            }
3230        }
3231
3232        for pet_level in &self.pet_levels {
3233            if !pet_rarity_ids.contains(&pet_level.rarity_id) {
3234                panic!(
3235                    "PetConfig: pet level {} has rarity_id {} not found in pet_rarities",
3236                    pet_level.level, pet_level.rarity_id
3237                );
3238            }
3239            if pet_level.required_shards < 0 {
3240                panic!(
3241                    "PetConfig: pet level {} (rarity {}) required_shards must be >= 0",
3242                    pet_level.level, pet_level.rarity_id
3243                );
3244            }
3245        }
3246    }
3247
3248    /// Mirror of `validate_ability_gacha_settings` for the pet gacha:
3249    /// same level/checkpoint/big-roll invariants on `pet_cases_settings`,
3250    /// minus the ability-only parts (slot upgrades, evolve rules, class rarities).
3251    fn validate_pet_gacha_settings(&self) {
3252        use std::collections::HashSet;
3253
3254        let gacha = &self.game_settings.pet_gacha;
3255        let currency_ids: HashSet<_> = self.currencies.iter().map(|currency| currency.id).collect();
3256        let rarity_ids: HashSet<_> = self.pet_rarities.iter().map(|rarity| rarity.id).collect();
3257
3258        // roll_price_in_diamonds > 0, small_roll_cost > 0, big_roll_cost > 0 enforced by PositiveI64
3259        // wishlist_weight_multiplier >= 1.0 enforced by WeightMultiplier type
3260        // wishlist_slots > 0 still needs runtime check (u8, not wrapped)
3261        if gacha.wishlist_slots == 0 {
3262            panic!("PetGachaSettings: wishlist_slots must be > 0");
3263        }
3264        if !currency_ids.contains(&gacha.currency_id) {
3265            panic!(
3266                "PetGachaSettings: currency_id {} not found in currencies",
3267                gacha.currency_id
3268            );
3269        }
3270
3271        if self.pet_cases_settings.is_empty() {
3272            panic!("PetCasesSettings: must not be empty");
3273        }
3274
3275        let mut settings_by_level = self.pet_cases_settings.clone();
3276        settings_by_level.sort_by_key(|level_settings| level_settings.level);
3277
3278        // Validate boundary levels: at most one, must be the last level.
3279        let boundary_count = settings_by_level
3280            .iter()
3281            .filter(|s| s.is_boundary_level)
3282            .count();
3283        if boundary_count > 1 {
3284            panic!("PetCasesSettings: at most one boundary level is allowed");
3285        }
3286        if boundary_count == 1 && !settings_by_level.last().unwrap().is_boundary_level {
3287            panic!("PetCasesSettings: the boundary level must be the last level");
3288        }
3289
3290        let mut previous_level = 0;
3291        let mut previous_opens = -1;
3292        for (idx, level_settings) in settings_by_level.iter().enumerate() {
3293            if level_settings.level <= previous_level {
3294                panic!("PetCasesSettings: levels must be unique and strictly increasing");
3295            }
3296            if level_settings.opens_to_upgrade < 0 {
3297                panic!(
3298                    "PetCasesSettings level {}: opens_to_upgrade must be >= 0",
3299                    level_settings.level
3300                );
3301            }
3302            if level_settings.opens_to_upgrade < previous_opens {
3303                panic!(
3304                    "PetCasesSettings level {}: opens_to_upgrade must be non-decreasing",
3305                    level_settings.level
3306                );
3307            }
3308            previous_level = level_settings.level;
3309            previous_opens = level_settings.opens_to_upgrade;
3310
3311            // Boundary levels only need level + opens_to_upgrade; skip all other validations.
3312            if level_settings.is_boundary_level {
3313                continue;
3314            }
3315
3316            // The drop pool: every roll on this level draws a rarity from these weights.
3317            if level_settings.rarity_weights.is_empty() {
3318                panic!(
3319                    "PetCasesSettings level {}: rarity_weights must not be empty",
3320                    level_settings.level
3321                );
3322            }
3323            // rarity_weight.weight > 0 enforced by PositiveF64 type
3324            for rarity_weight in &level_settings.rarity_weights {
3325                if !rarity_ids.contains(&rarity_weight.rarity_id) {
3326                    panic!(
3327                        "PetCasesSettings level {}: rarity weight rarity {} not found",
3328                        level_settings.level, rarity_weight.rarity_id
3329                    );
3330                }
3331            }
3332
3333            let mut previous_checkpoint_opens = -1;
3334            for checkpoint in &level_settings.checkpoints {
3335                if checkpoint.required_opens <= 0 {
3336                    panic!(
3337                        "PetCasesSettings level {}: checkpoint required_opens must be > 0",
3338                        level_settings.level
3339                    );
3340                }
3341                if checkpoint.required_opens <= previous_checkpoint_opens {
3342                    panic!(
3343                        "PetCasesSettings level {}: checkpoint required_opens must be strictly increasing",
3344                        level_settings.level
3345                    );
3346                }
3347                previous_checkpoint_opens = checkpoint.required_opens;
3348
3349                // Checkpoint required_opens is relative to the current level's
3350                // opens_to_upgrade. It must not exceed the range of this level
3351                // (i.e. the gap to the next level's opens_to_upgrade).
3352                if let Some(next) = settings_by_level.get(idx + 1) {
3353                    let level_range = next.opens_to_upgrade - level_settings.opens_to_upgrade;
3354                    if checkpoint.required_opens > level_range {
3355                        panic!(
3356                            "PetCasesSettings level {}: checkpoint required_opens ({}) exceeds level range ({})",
3357                            level_settings.level, checkpoint.required_opens, level_range
3358                        );
3359                    }
3360                }
3361
3362                for currency_reward in &checkpoint.currency_rewards {
3363                    if currency_reward.amount <= 0 {
3364                        panic!(
3365                            "PetCasesSettings level {}: checkpoint currency reward must be > 0",
3366                            level_settings.level
3367                        );
3368                    }
3369                    if !currency_ids.contains(&currency_reward.currency_id) {
3370                        panic!(
3371                            "PetCasesSettings level {}: checkpoint currency {} not found",
3372                            level_settings.level, currency_reward.currency_id
3373                        );
3374                    }
3375                }
3376
3377                for pet_reward in &checkpoint.pet_rewards {
3378                    if pet_reward.amount < 0 {
3379                        panic!(
3380                            "PetCasesSettings level {}: checkpoint pet reward amount must be >= 0",
3381                            level_settings.level
3382                        );
3383                    }
3384                    if pet_reward.amount > 0 && !rarity_ids.contains(&pet_reward.min_rarity_id) {
3385                        panic!(
3386                            "PetCasesSettings level {}: checkpoint pet reward min_rarity_id {} not found",
3387                            level_settings.level, pet_reward.min_rarity_id
3388                        );
3389                    }
3390                }
3391            }
3392
3393            for rarity_id in &level_settings.allowed_wishlist_rarity_ids {
3394                if !rarity_ids.contains(rarity_id) {
3395                    panic!(
3396                        "PetCasesSettings level {}: allowed wishlist rarity {} not found",
3397                        level_settings.level, rarity_id
3398                    );
3399                }
3400            }
3401
3402            for currency_reward in &level_settings.big_roll_currency_rewards {
3403                if currency_reward.amount <= 0 {
3404                    panic!(
3405                        "PetCasesSettings level {}: big-roll currency reward amount must be > 0",
3406                        level_settings.level
3407                    );
3408                }
3409                if !currency_ids.contains(&currency_reward.currency_id) {
3410                    panic!(
3411                        "PetCasesSettings level {}: big-roll currency {} not found",
3412                        level_settings.level, currency_reward.currency_id
3413                    );
3414                }
3415            }
3416
3417            let big_roll_shards = &level_settings.big_roll_shards;
3418            let shards_total: u64 = big_roll_shards.iter().map(|s| s.count as u64).sum();
3419            let expected_total =
3420                gacha.big_roll_cost.get() as u64 + gacha.big_roll_bonus_drops as u64;
3421            if shards_total != expected_total {
3422                panic!(
3423                    "PetCasesSettings level {}: big_roll_shards total ({}) must equal big_roll_cost + bonus_drops ({})",
3424                    level_settings.level, shards_total, expected_total
3425                );
3426            }
3427            for shard in big_roll_shards {
3428                if shard.count == 0 {
3429                    panic!(
3430                        "PetCasesSettings level {}: big_roll_shards count must be > 0",
3431                        level_settings.level
3432                    );
3433                }
3434                if !rarity_ids.contains(&shard.min_rarity_id) {
3435                    panic!(
3436                        "PetCasesSettings level {}: big_roll_shards rarity {} not found",
3437                        level_settings.level, shard.min_rarity_id
3438                    );
3439                }
3440            }
3441        }
3442
3443        if settings_by_level[0].level != 1 || settings_by_level[0].opens_to_upgrade != 0 {
3444            panic!("PetCasesSettings: level 1 with opens_to_upgrade = 0 is required");
3445        }
3446    }
3447
3448    fn validate_statue_settings(&self) {
3449        use std::collections::HashSet;
3450
3451        let statue = &self.statue_settings;
3452        let currency_ids: HashSet<_> = self.currencies.iter().map(|c| c.id).collect();
3453        let attribute_ids: HashSet<_> = self.attributes.iter().map(|a| a.id).collect();
3454
3455        if !currency_ids.contains(&statue.currency_id) {
3456            panic!(
3457                "StatueSettings: currency_id {} not found in currencies",
3458                statue.currency_id
3459            );
3460        }
3461
3462        // statue_bonus_grades non-empty enforced by NonEmptyVec type
3463
3464        let grade_ids: HashSet<_> = self.statue_bonus_grades.iter().map(|g| g.id).collect();
3465        if grade_ids.len() != self.statue_bonus_grades.len() {
3466            panic!("statue_bonus_grades: duplicate grade ids");
3467        }
3468
3469        // roll_costs non-empty enforced by NonEmptyVec type
3470        // roll_costs cost > 0 enforced by PositiveI64 type
3471
3472        // statue_bonus_type_configs non-empty enforced by NonEmptyVec type
3473        for bonus_type in &self.statue_bonus_type_configs {
3474            if !attribute_ids.contains(&bonus_type.attribute_id) {
3475                panic!(
3476                    "statue_bonus_type_configs attribute_id {} not found in attributes",
3477                    bonus_type.attribute_id
3478                );
3479            }
3480            // grade_values non-empty enforced by NonEmptyVec type
3481            for grade_value in &bonus_type.grade_values {
3482                if !grade_ids.contains(&grade_value.grade_id) {
3483                    panic!(
3484                        "statue_bonus_type_configs attribute {} references unknown grade_id {}",
3485                        bonus_type.attribute_id, grade_value.grade_id
3486                    );
3487                }
3488                // grade_value.value > 0 enforced by PositiveI32 type
3489            }
3490        }
3491
3492        // statue_level_configs non-empty enforced by NonEmptyVec type
3493        if self.statue_level_configs[0].level != 1
3494            || self.statue_level_configs[0].required_experience != 0
3495        {
3496            panic!("statue_level_configs must start with level=1 and required_experience=0");
3497        }
3498        let mut prev_level = 0;
3499        let mut prev_exp = -1;
3500        for level_config in &self.statue_level_configs {
3501            if level_config.level <= prev_level {
3502                panic!("statue_level_configs levels must be strictly increasing");
3503            }
3504            if level_config.required_experience <= prev_exp && level_config.level > 1 {
3505                panic!(
3506                    "statue_level_configs level={} required_experience must be strictly increasing",
3507                    level_config.level
3508                );
3509            }
3510            // slot_count > 0 enforced by NonZeroU64 type
3511            // sets_count > 0 enforced by NonZeroU64 type
3512            // grade_weights non-empty enforced by NonEmptyVec type
3513            for weight in &level_config.grade_weights {
3514                if !grade_ids.contains(&weight.grade_id) {
3515                    panic!(
3516                        "statue_level_configs level={} grade_weights references unknown grade_id {}",
3517                        level_config.level, weight.grade_id
3518                    );
3519                }
3520                // weight > 0 enforced by PositiveF64 type
3521            }
3522            prev_level = level_config.level;
3523            prev_exp = level_config.required_experience;
3524        }
3525    }
3526
3527    fn has_unique_ids(items: &[ItemTemplate]) -> bool {
3528        let mut ids = std::collections::HashSet::new();
3529        items.iter().all(|item| ids.insert(item.id))
3530    }
3531}
3532
3533#[derive(Debug, Clone, Copy)]
3534pub struct NextFightInfo {
3535    pub next_chapter_level: i64,
3536    pub next_fight_number: i64,
3537    pub next_fight_id: Option<essences::fighting::FightTemplateId>,
3538}
3539
3540#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Tsify)]
3541#[tsify(into_wasm_abi, from_wasm_abi)]
3542pub enum GetConfigResponse {
3543    Ok { config: Box<GameConfig> },
3544    Error { code: String, message: String },
3545}
3546
3547#[cfg(test)]
3548mod tests {
3549    use super::*;
3550    use essences::offers::OfferTemplateId;
3551    use essences::progress_pass::{ProgressPassConfig, ProgressPassTierTemplate};
3552    use essences::ratings::RatingType;
3553    use uuid::uuid;
3554
3555    fn tier(tier: u32, quest_template_ids: Vec<uuid::Uuid>) -> ProgressPassTierTemplate {
3556        ProgressPassTierTemplate {
3557            tier,
3558            quest_template_ids,
3559            free_reward_bundle_id: uuid::Uuid::nil(),
3560            paid_reward_bundle_id: uuid::Uuid::nil(),
3561        }
3562    }
3563
3564    fn pp_config(tiers: Vec<ProgressPassTierTemplate>) -> ProgressPassConfig {
3565        ProgressPassConfig {
3566            tiers,
3567            premium_offer_template_id: OfferTemplateId::nil(),
3568        }
3569    }
3570
3571    #[test]
3572    fn validate_dungeon_templates_accepts_missing_and_positive_overrides() {
3573        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3574        config.validate_dungeon_templates();
3575
3576        config.dungeon_templates[0].between_wave_spawn_delay_ticks = Some(100);
3577        config.dungeon_templates[0].advance_formation_between_waves = Some(false);
3578        config.validate_dungeon_templates();
3579    }
3580
3581    #[test]
3582    #[should_panic(expected = "between_wave_spawn_delay_ticks must be > 0 when set")]
3583    fn validate_dungeon_templates_rejects_zero_spawn_delay() {
3584        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3585        config.dungeon_templates[0].between_wave_spawn_delay_ticks = Some(0);
3586        config.validate_dungeon_templates();
3587    }
3588
3589    #[test]
3590    fn validate_portal_rarities_accepts_valid_config() {
3591        let config = crate::tests_game_config::generate_game_config_for_tests();
3592        config.validate_portal_rarities();
3593    }
3594
3595    #[test]
3596    #[should_panic(expected = "PortalRarities: duplicate id")]
3597    fn validate_portal_rarities_rejects_duplicate_ids() {
3598        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3599        let mut duplicate = config.portal_rarities[1].clone();
3600        duplicate.id = config.portal_rarities[0].id;
3601        config.portal_rarities.push(duplicate);
3602
3603        config.validate_portal_rarities();
3604    }
3605
3606    #[test]
3607    #[should_panic(expected = "PortalRarities: duplicate item_rarity_id")]
3608    fn validate_portal_rarities_rejects_duplicate_item_rarity_mappings() {
3609        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3610        config.portal_rarities[1].item_rarity_id = config.portal_rarities[0].item_rarity_id;
3611
3612        config.validate_portal_rarities();
3613    }
3614
3615    #[test]
3616    #[should_panic(expected = "not found in item_rarities")]
3617    fn validate_portal_rarities_rejects_missing_item_rarity_links() {
3618        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3619        config.portal_rarities[0].item_rarity_id = uuid::Uuid::nil();
3620
3621        config.validate_portal_rarities();
3622    }
3623
3624    #[test]
3625    #[should_panic(expected = "icon_path must not be empty")]
3626    fn validate_portal_rarities_rejects_empty_icon_paths() {
3627        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3628        config.portal_rarities[0].icon_path = "  ".to_string();
3629
3630        config.validate_portal_rarities();
3631    }
3632
3633    #[test]
3634    fn validate_portal_rarities_rejects_missing_plinko_rarity_mapping() {
3635        let config = crate::tests_game_config::generate_game_config_for_tests();
3636        let rarity_ids: std::collections::HashSet<_> = config
3637            .plinko_settings
3638            .slot_layouts
3639            .iter()
3640            .flat_map(|layout| layout.slot_groups.iter())
3641            .map(|group| group.rarity_id)
3642            .collect();
3643
3644        for rarity_id in rarity_ids {
3645            let mut config = crate::tests_game_config::generate_game_config_for_tests();
3646            config
3647                .portal_rarities
3648                .retain(|portal_rarity| portal_rarity.item_rarity_id != rarity_id);
3649
3650            let panic = std::panic::catch_unwind(|| config.validate_portal_rarities())
3651                .expect_err("every Plinko-used rarity must have a portal mapping");
3652            let message = panic
3653                .downcast_ref::<String>()
3654                .expect("validator panics with an owned message");
3655            assert_eq!(
3656                message,
3657                &format!(
3658                    "PortalRarities: missing mapping for item rarity {rarity_id} referenced by plinko_settings.slot_layouts"
3659                )
3660            );
3661        }
3662    }
3663
3664    #[test]
3665    fn validate_progress_pass_accepts_valid_config() {
3666        let config = pp_config(vec![
3667            tier(1, vec![uuid!("aa000000-0000-0000-0000-000000000001")]),
3668            tier(
3669                2,
3670                vec![
3671                    uuid!("aa000000-0000-0000-0000-000000000001"),
3672                    uuid!("bb000000-0000-0000-0000-000000000002"),
3673                ],
3674            ),
3675            tier(3, vec![uuid!("cc000000-0000-0000-0000-000000000003")]),
3676        ]);
3677        GameConfig::validate_progress_pass_config(&config);
3678    }
3679
3680    #[test]
3681    fn validate_progress_pass_accepts_empty_tiers() {
3682        let config = pp_config(vec![]);
3683        GameConfig::validate_progress_pass_config(&config);
3684    }
3685
3686    #[test]
3687    #[should_panic(expected = "ProgressPassConfig: tier 2 has no quest_template_ids")]
3688    fn validate_progress_pass_rejects_empty_quest_template_ids() {
3689        let config = pp_config(vec![
3690            tier(1, vec![uuid!("aa000000-0000-0000-0000-000000000001")]),
3691            tier(2, vec![]),
3692        ]);
3693        GameConfig::validate_progress_pass_config(&config);
3694    }
3695
3696    #[test]
3697    #[should_panic(expected = "ProgressPassConfig: duplicate tier value 1")]
3698    fn validate_progress_pass_rejects_duplicate_tier_numbers() {
3699        let config = pp_config(vec![
3700            tier(1, vec![uuid!("aa000000-0000-0000-0000-000000000001")]),
3701            tier(1, vec![uuid!("bb000000-0000-0000-0000-000000000002")]),
3702        ]);
3703        GameConfig::validate_progress_pass_config(&config);
3704    }
3705
3706    #[test]
3707    fn validate_cores_settings_accepts_test_config() {
3708        let config = crate::tests_game_config::generate_game_config_for_tests();
3709        config.validate_cores_settings();
3710    }
3711
3712    #[test]
3713    #[should_panic(expected = "unlock_essence_grant must be >= 0")]
3714    fn validate_cores_settings_rejects_negative_unlock_grant() {
3715        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3716        config.cores_settings.unlock_essence_grant = -1;
3717        config.validate_cores_settings();
3718    }
3719
3720    /// The coupled trio (slot cap / slot ladder / bridge limit). Stretching the
3721    /// slot ladder to a slot every TWO levels pushes the allowed bridge count
3722    /// (`min(level, max_slot_level) - 1` = 9) past the physically possible one
3723    /// (5 slots), and the "which bridges do I keep" choice disappears — the
3724    /// validator must refuse it.
3725    ///
3726    /// That two-levels-per-slot pair is exactly the pre-v0.2 configuration the
3727    /// artifact right column refitted away from, so this is a real regression
3728    /// guard rather than an invented one.
3729    ///
3730    /// It no longer trips by raising `max_core_level`: core levels are unbounded
3731    /// now, and `max_bridges` clamps at the slot level precisely so that a level
3732    /// past the slot ladder cannot buy a bridge. Raising the cap alone leaving
3733    /// this assert quiet IS the fix working.
3734    #[test]
3735    #[should_panic(expected = "the slot cap, the slot ladder and the bridge limit move together")]
3736    fn validate_cores_settings_rejects_slot_ladder_that_kills_the_bridge_choice() {
3737        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3738        config.cores_settings.core_levels_per_slot = crate::validated_types::PositiveI64::new(2);
3739        config.validate_cores_settings();
3740    }
3741
3742    /// ...and the complement: an unbounded level cap on its own is legal, which
3743    /// is the whole point of the clamp. Without it this config would report 49
3744    /// allowed bridges against 5 possible.
3745    #[test]
3746    fn validate_cores_settings_accepts_a_level_cap_far_above_the_slot_ladder() {
3747        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3748        config.cores_settings.max_core_level = 9_999;
3749        config.validate_cores_settings();
3750    }
3751
3752    /// A HOLE in the cost table. `core_level_cost` refuses to price a gap rather
3753    /// than inventing a neighbour's value, so a ladder with one missing rung
3754    /// would dead-end below the cap.
3755    #[test]
3756    #[should_panic(expected = "level_costs must be a contiguous run of levels starting at 2")]
3757    fn validate_cores_settings_rejects_gap_in_the_level_ladder() {
3758        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3759        let levels: Vec<_> = config
3760            .cores_settings
3761            .level_costs
3762            .iter()
3763            .filter(|row| row.level != 3)
3764            .cloned()
3765            .collect();
3766        config.cores_settings.level_costs = NonEmptyVec::new(levels);
3767        config.validate_cores_settings();
3768    }
3769
3770    /// Truncating the table below the last SLOT-opening level is a different
3771    /// failure from a hole: the run stays contiguous, but a level that opens a
3772    /// slot would be priced by the geometric extension instead of by the
3773    /// designer. The extension exists for the levels past the ladder, not for
3774    /// the ones the design owns.
3775    #[test]
3776    #[should_panic(expected = "slots keep opening until")]
3777    fn validate_cores_settings_rejects_an_unpriced_slot_level() {
3778        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3779        let levels: Vec<_> = config
3780            .cores_settings
3781            .level_costs
3782            .iter()
3783            .filter(|row| row.level != 5)
3784            .cloned()
3785            .collect();
3786        config.cores_settings.level_costs = NonEmptyVec::new(levels);
3787        config.validate_cores_settings();
3788    }
3789
3790    #[test]
3791    #[should_panic(expected = "law_upgrade_ladder must be a contiguous run starting at level 2")]
3792    fn validate_cores_settings_rejects_ladder_with_a_hole() {
3793        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3794        let ladder: Vec<_> = config
3795            .cores_settings
3796            .law_upgrade_ladder
3797            .iter()
3798            .filter(|row| row.level != 3)
3799            .cloned()
3800            .collect();
3801        config.cores_settings.law_upgrade_ladder = NonEmptyVec::new(ladder);
3802        config.validate_cores_settings();
3803    }
3804
3805    /// The charge trio is a PAIR since the artifact right column: the cap and
3806    /// the capacity move independently, because `BL-03 Short Span` moves them by
3807    /// different amounts. Any pair of positive numbers is now legal, and the
3808    /// amplification a given charge is worth follows from the two.
3809    #[test]
3810    fn a_bridge_cap_no_longer_has_to_match_the_capacity() {
3811        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3812        config
3813            .cores_settings
3814            .bridge_charge
3815            .amplification_cap_permyriad = crate::validated_types::PositiveI64::new(9_000);
3816        config.validate_cores_settings();
3817
3818        // And the pair prices amplification as a share of fullness.
3819        let settings = &config.cores_settings;
3820        assert!((settings.law_power_multiplier(10) - 1.90).abs() < 1e-9);
3821        assert!((settings.law_power_multiplier(5) - 1.45).abs() < 1e-9);
3822        assert!(
3823            (settings.law_power_multiplier(40) - 1.90).abs() < 1e-9,
3824            "past capacity the multiplier must not keep climbing"
3825        );
3826    }
3827
3828    /// A timed effect with no duration would never expire and would become a
3829    /// permanent stat instead of a law fire.
3830    #[test]
3831    #[should_panic(expected = "is timed and needs a non-zero effect_duration_ticks")]
3832    fn validate_cores_settings_rejects_a_timed_effect_with_no_duration() {
3833        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3834        for law in &mut config.laws {
3835            if law.effect.is_timed() {
3836                law.effect_duration_ticks = 0;
3837            }
3838        }
3839        config.validate_cores_settings();
3840    }
3841
3842    #[test]
3843    #[should_panic(expected = "multiplied_attribute_ids references unknown attribute")]
3844    fn validate_cores_settings_rejects_unknown_multiplied_attribute() {
3845        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3846        config.cores_settings.multiplied_attribute_ids =
3847            vec![uuid!("ffffffff-0000-0000-0000-000000000001")];
3848        config.validate_cores_settings();
3849    }
3850
3851    #[test]
3852    fn validate_pet_gacha_settings_accepts_test_config() {
3853        let config = crate::tests_game_config::generate_game_config_for_tests();
3854        config.validate_pet_gacha_settings();
3855    }
3856
3857    #[test]
3858    fn validate_item_cases_settings_accepts_test_config() {
3859        let config = crate::tests_game_config::generate_game_config_for_tests();
3860        config.validate_item_cases_settings();
3861    }
3862
3863    #[test]
3864    fn validate_flip_settings_accepts_test_config() {
3865        let config = crate::tests_game_config::generate_game_config_for_tests();
3866        config.validate_flip_settings();
3867    }
3868
3869    #[test]
3870    #[should_panic(expected = "unlock_chapter must be >= 0")]
3871    fn validate_flip_settings_rejects_negative_unlock_chapter() {
3872        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3873        config.flip_settings.unlock_chapter = -1;
3874        config.validate_flip_settings();
3875    }
3876
3877    #[test]
3878    #[should_panic(expected = "progress_threshold must be finite and > 0")]
3879    fn validate_flip_settings_rejects_non_positive_threshold() {
3880        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3881        config.flip_settings.progress_threshold = 0.0;
3882        config.validate_flip_settings();
3883    }
3884
3885    #[test]
3886    fn validate_artifacts_accepts_test_config() {
3887        let config = crate::tests_game_config::generate_game_config_for_tests();
3888        config.validate_artifacts();
3889    }
3890
3891    #[test]
3892    #[should_panic(expected = "must share the same unlock_chapter")]
3893    fn validate_artifacts_rejects_a_gate_that_drifts_from_cores() {
3894        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3895        config.artifacts_settings.unlock_chapter = config.cores_settings.unlock_chapter + 1;
3896        config.validate_artifacts();
3897    }
3898
3899    #[test]
3900    #[should_panic(expected = "ownership bonus references unknown attribute")]
3901    fn validate_artifacts_rejects_an_ownership_bonus_on_a_missing_attribute() {
3902        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3903        config.artifacts[0].ownership_bonuses[0].attribute_id = uuid::Uuid::from_u128(0xDEAD);
3904        config.validate_artifacts();
3905    }
3906
3907    /// A bonus is written as `<code>.mod`, and only the codes the runtime
3908    /// composes are ever read back. `strength` is in the fixture catalog but
3909    /// nothing composes `strength.mod`, so a bonus on it would be stored,
3910    /// displayed nowhere and read by nothing — inert content that looks
3911    /// configured. The validator refuses it instead of shipping it.
3912    #[test]
3913    #[should_panic(expected = "whose `.mod` is composed by nothing")]
3914    fn validate_artifacts_rejects_an_ownership_bonus_on_a_non_mod_capable_attribute() {
3915        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3916        let strength = config
3917            .attributes
3918            .iter()
3919            .find(|attribute| attribute.code == "strength")
3920            .expect("the fixture ships a non-mod-capable attribute")
3921            .id;
3922        assert!(
3923            !crate::artifacts::MOD_CAPABLE_ATTRIBUTE_CODES.contains(&"strength"),
3924            "this test is only meaningful while `strength` stays non-mod-capable"
3925        );
3926        config.artifacts[0].ownership_bonuses[0].attribute_id = strength;
3927        config.validate_artifacts();
3928    }
3929
3930    /// Two rows on one attribute inside ONE artifact: the second one's intent is
3931    /// invisible (they would silently add), so the designer is told to merge them.
3932    #[test]
3933    #[should_panic(expected = "listed twice in ownership_bonuses")]
3934    fn validate_artifacts_rejects_two_ownership_bonuses_on_one_attribute() {
3935        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3936        let duplicate = config.artifacts[0].ownership_bonuses[0];
3937        config.artifacts[0].ownership_bonuses.push(duplicate);
3938        config.validate_artifacts();
3939    }
3940
3941    /// ...but two DIFFERENT artifacts naming the same attribute is legal and
3942    /// meaningful — they add up over the collection, which is what makes a
3943    /// second artifact worth owning.
3944    #[test]
3945    fn validate_artifacts_accepts_two_artifacts_sharing_an_attribute() {
3946        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3947        let shared = config.artifacts[0].ownership_bonuses[0];
3948        config.artifacts[1].ownership_bonuses = vec![shared];
3949        config.validate_artifacts();
3950    }
3951
3952    #[test]
3953    #[should_panic(expected = "ownership bonus percentages must be finite and >= 0")]
3954    fn validate_artifacts_rejects_a_negative_ownership_percent() {
3955        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3956        config.artifacts[0].ownership_bonuses[0].percent = -1.0;
3957        config.validate_artifacts();
3958    }
3959
3960    #[test]
3961    #[should_panic(expected = "ownership bonus percentages must be finite and >= 0")]
3962    fn validate_artifacts_rejects_a_non_finite_ownership_percent_per_level() {
3963        let mut config = crate::tests_game_config::generate_game_config_for_tests();
3964        config.artifacts[0].ownership_bonuses[0].percent_per_level = f64::NAN;
3965        config.validate_artifacts();
3966    }
3967
3968    /// The guard the `MOD_CAPABLE_ATTRIBUTE_CODES` doc comment promises: every
3969    /// non-`.mod` attribute the fixture catalog ships is either mod-capable (so
3970    /// a designer may put a bonus on it) or deliberately absent from the list.
3971    ///
3972    /// It cannot assert full coverage — the fixture carries test-only attributes
3973    /// (`strength`, `agility`, `damage`) that the shipped game does not — so it
3974    /// asserts the direction that actually matters: the codes the SHIPPED game
3975    /// composes are all listed. `hp` and `armor` are the two the fixture and the
3976    /// shipped catalog share, and both must be usable.
3977    #[test]
3978    fn mod_capable_codes_cover_the_attributes_the_runtime_composes() {
3979        for code in ["hp", "armor", "attack"] {
3980            assert!(
3981                crate::artifacts::MOD_CAPABLE_ATTRIBUTE_CODES.contains(&code),
3982                "'{code}' is composed as `{code}.mod` by combat or the power scalar, so an \
3983                 ownership bonus on it must be allowed"
3984            );
3985        }
3986        let mut seen = std::collections::HashSet::new();
3987        for code in crate::artifacts::MOD_CAPABLE_ATTRIBUTE_CODES {
3988            assert!(seen.insert(code), "'{code}' listed twice");
3989            assert!(
3990                !code.ends_with(".mod"),
3991                "'{code}' is already a `.mod` key — the list holds BASE codes"
3992            );
3993        }
3994    }
3995
3996    #[test]
3997    fn validate_stones_accepts_test_config() {
3998        let config = crate::tests_game_config::generate_game_config_for_tests();
3999        config.validate_stones();
4000    }
4001
4002    #[test]
4003    #[should_panic(expected = "unknown stat attribute id")]
4004    fn validate_stones_rejects_a_stat_on_a_missing_attribute() {
4005        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4006        config.trigger_stones[0].stats[0].attribute_id = uuid::Uuid::from_u128(0xDEAD);
4007        config.validate_stones();
4008    }
4009
4010    #[test]
4011    #[should_panic(expected = "unknown stat attribute id")]
4012    fn validate_stones_checks_effect_stone_stats_too() {
4013        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4014        config.effect_stones[0].stats[0].attribute_id = uuid::Uuid::from_u128(0xDEAD);
4015        config.validate_stones();
4016    }
4017
4018    #[test]
4019    #[should_panic(expected = "duplicate stat attribute id")]
4020    fn validate_stones_rejects_two_stats_on_one_attribute() {
4021        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4022        let duplicate = config.trigger_stones[0].stats[0];
4023        config.trigger_stones[0].stats.push(duplicate);
4024        config.validate_stones();
4025    }
4026
4027    #[test]
4028    #[should_panic(expected = "must grant at least one stat")]
4029    fn validate_stones_rejects_an_empty_stat_block() {
4030        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4031        config.effect_stones[0].stats.clear();
4032        config.validate_stones();
4033    }
4034
4035    /// The duplicate rule is per stone: two different stones sharing an
4036    /// attribute is the normal case (every trigger in the shipped catalog
4037    /// grants `hp`), and pins the `seen_attrs.clear()` in `validate_stones`.
4038    #[test]
4039    fn validate_stones_allows_two_stones_to_share_an_attribute() {
4040        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4041        let shared = config.trigger_stones[0].stats[0];
4042        config.trigger_stones[1].stats = vec![shared];
4043        config.effect_stones[0].stats = vec![shared];
4044        config.validate_stones();
4045    }
4046
4047    /// A gap in the ladder reads as "already at the cap" at runtime, so it must
4048    /// not survive a deploy.
4049    #[test]
4050    #[should_panic(expected = "upgrade_ladder has no cost for level")]
4051    fn validate_stones_rejects_a_gap_in_the_upgrade_ladder() {
4052        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4053        config
4054            .stones_settings
4055            .upgrade_ladder
4056            .retain(|step| step.level != 3);
4057        config.validate_stones();
4058    }
4059
4060    /// The ladder must reach `max_stone_level`: raising the cap without pricing
4061    /// the new levels is the same gap by another route.
4062    #[test]
4063    #[should_panic(expected = "upgrade_ladder has no cost for level")]
4064    fn validate_stones_rejects_a_ladder_short_of_the_maximum_level() {
4065        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4066        config.stones_settings.max_stone_level += 1;
4067        config.validate_stones();
4068    }
4069
4070    #[test]
4071    #[should_panic(expected = "outside 2..=")]
4072    fn validate_stones_rejects_a_rung_past_the_maximum_level() {
4073        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4074        let max = config.stones_settings.max_stone_level;
4075        config
4076            .stones_settings
4077            .upgrade_ladder
4078            .push(crate::stones::StoneUpgradeStep {
4079                level: max + 1,
4080                copies: 2,
4081            });
4082        config.validate_stones();
4083    }
4084
4085    #[test]
4086    #[should_panic(expected = "is priced twice")]
4087    fn validate_stones_rejects_a_level_priced_twice() {
4088        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4089        config
4090            .stones_settings
4091            .upgrade_ladder
4092            .push(crate::stones::StoneUpgradeStep {
4093                level: 2,
4094                copies: 7,
4095            });
4096        config.validate_stones();
4097    }
4098
4099    #[test]
4100    #[should_panic(expected = "must cost at least one copy")]
4101    fn validate_stones_rejects_a_free_level() {
4102        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4103        config.stones_settings.upgrade_ladder[0].copies = 0;
4104        config.validate_stones();
4105    }
4106
4107    #[test]
4108    fn validate_item_world_sides_accepts_test_catalog() {
4109        let config = crate::tests_game_config::generate_game_config_for_tests();
4110        config.validate_item_world_sides();
4111    }
4112
4113    #[test]
4114    #[should_panic(expected = "must define world_side")]
4115    fn validate_item_world_sides_rejects_missing_side_on_flip_type() {
4116        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4117        let item = config
4118            .items
4119            .iter_mut()
4120            .find(|item| item.item_type.supports_world_side())
4121            .unwrap();
4122        item.world_side = None;
4123        config.validate_item_world_sides();
4124    }
4125
4126    #[test]
4127    #[should_panic(expected = "must not define world_side")]
4128    fn validate_item_world_sides_rejects_side_on_fixed_type() {
4129        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4130        let item = config
4131            .items
4132            .iter_mut()
4133            .find(|item| !item.item_type.supports_world_side())
4134            .unwrap();
4135        item.world_side = Some(essences::flip::WorldSide::Real);
4136        config.validate_item_world_sides();
4137    }
4138
4139    #[test]
4140    fn validate_inventory_levels_accepts_test_config() {
4141        let config = crate::tests_game_config::generate_game_config_for_tests();
4142        config.validate_inventory_levels();
4143    }
4144
4145    #[test]
4146    #[should_panic(expected = "must define world_side")]
4147    fn validate_inventory_levels_rejects_flip_slot_without_side() {
4148        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4149        let slot = config.inventory_levels[0]
4150            .slots
4151            .iter_mut()
4152            .find(|slot| slot.item_type.supports_world_side())
4153            .unwrap();
4154        slot.world_side = None;
4155        config.validate_inventory_levels();
4156    }
4157
4158    #[test]
4159    #[should_panic(expected = "must not define world_side")]
4160    fn validate_inventory_levels_rejects_side_on_fixed_slot() {
4161        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4162        let slot = config.inventory_levels[0]
4163            .slots
4164            .iter_mut()
4165            .find(|slot| !slot.item_type.supports_world_side())
4166            .unwrap();
4167        slot.world_side = Some(essences::flip::WorldSide::Real);
4168        config.validate_inventory_levels();
4169    }
4170
4171    #[test]
4172    #[should_panic(expected = "must not unlock Real slots before")]
4173    fn validate_inventory_levels_rejects_unlock_drift() {
4174        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4175        config.flip_settings.unlock_chapter = 3;
4176        config.validate_inventory_levels();
4177    }
4178
4179    #[test]
4180    fn validate_rating_rewards_accepts_test_config() {
4181        let config = crate::tests_game_config::generate_game_config_for_tests();
4182        config.validate_rating_rewards();
4183    }
4184
4185    #[test]
4186    #[should_panic(expected = "Power rating rewards must be empty")]
4187    fn validate_rating_rewards_rejects_daily_power_rewards() {
4188        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4189        let daily = config
4190            .ratings_settings
4191            .iter()
4192            .find(|settings| settings.rating_type == RatingType::Arena)
4193            .unwrap()
4194            .daily_rating_range_rewards[0]
4195            .clone();
4196        config
4197            .ratings_settings
4198            .iter_mut()
4199            .find(|settings| settings.rating_type == RatingType::Power)
4200            .unwrap()
4201            .daily_rating_range_rewards
4202            .push(daily);
4203        config.validate_rating_rewards();
4204    }
4205
4206    #[test]
4207    #[should_panic(expected = "Power rating rewards must be empty")]
4208    fn validate_rating_rewards_rejects_weekly_power_rewards() {
4209        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4210        let weekly = config
4211            .ratings_settings
4212            .iter()
4213            .find(|settings| settings.rating_type == RatingType::Arena)
4214            .unwrap()
4215            .weekly_rating_range_rewards[0]
4216            .clone();
4217        config
4218            .ratings_settings
4219            .iter_mut()
4220            .find(|settings| settings.rating_type == RatingType::Power)
4221            .unwrap()
4222            .weekly_rating_range_rewards
4223            .push(weekly);
4224        config.validate_rating_rewards();
4225    }
4226
4227    #[test]
4228    #[should_panic(expected = "reward ranges must not overlap")]
4229    fn validate_rating_rewards_rejects_overlapping_ranges() {
4230        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4231        let arena = config
4232            .ratings_settings
4233            .iter_mut()
4234            .find(|settings| settings.rating_type == RatingType::Arena)
4235            .unwrap();
4236        let mut overlapping = arena.daily_rating_range_rewards[0].clone();
4237        overlapping.diapason_end = Some(overlapping.diapason_start);
4238        arena.daily_rating_range_rewards.push(overlapping);
4239        config.validate_rating_rewards();
4240    }
4241
4242    #[test]
4243    #[should_panic(expected = "must be exactly 1:5 of weekly")]
4244    fn validate_rating_rewards_rejects_wrong_daily_weekly_ratio() {
4245        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4246        let daily_bundle_id = config
4247            .ratings_settings
4248            .iter()
4249            .find(|settings| settings.rating_type == RatingType::Arena)
4250            .unwrap()
4251            .daily_rating_range_rewards[0]
4252            .bundle_id;
4253        let bundle = config
4254            .bundles
4255            .iter_mut()
4256            .find(|bundle| bundle.id == daily_bundle_id)
4257            .unwrap();
4258        bundle.steps[0].currencies[0].amount += 1;
4259        config.validate_rating_rewards();
4260    }
4261
4262    #[test]
4263    fn validate_plinko_settings_accepts_test_config() {
4264        let config = crate::tests_game_config::generate_game_config_for_tests();
4265        config.validate_plinko_settings();
4266    }
4267
4268    /// Каждый уровень сундука обязан подписать все свои редкости: без слота
4269    /// сервер не сможет уронить шарик под выданный предмет.
4270    #[test]
4271    fn every_chest_level_labels_every_rarity_it_drops() {
4272        let config = crate::tests_game_config::generate_game_config_for_tests();
4273
4274        for settings in &config.item_cases_settings {
4275            let layout = config
4276                .plinko_settings
4277                .layout_for_level(settings.level)
4278                .unwrap_or_else(|| panic!("chest level {} has no plinko board", settings.level));
4279
4280            for weight in &settings.rarity_weights {
4281                if weight.weight <= 0.0 {
4282                    continue;
4283                }
4284                assert!(
4285                    layout
4286                        .slot_groups
4287                        .iter()
4288                        .any(|group| group.rarity_id == weight.rarity_id),
4289                    "chest level {} drops rarity {} but its board has no slot for it",
4290                    settings.level,
4291                    weight.rarity_id
4292                );
4293            }
4294        }
4295    }
4296
4297    /// Тир редкости, которой подписан слот. Читается ПРЯМО из `slot_groups`,
4298    /// а не через хелперы поиска слота, — иначе утверждение проверяло бы само
4299    /// себя.
4300    fn plinko_slot_tier(config: &GameConfig, chest_level: i64, slot: i64) -> i64 {
4301        let layout = config
4302            .plinko_settings
4303            .layout_for_level(chest_level)
4304            .unwrap_or_else(|| panic!("chest level {chest_level} has no plinko board"));
4305        let rarity_id = layout
4306            .slot_groups
4307            .iter()
4308            .find(|group| slot >= group.from_slot && slot <= group.to_slot)
4309            .map(|group| group.rarity_id)
4310            .unwrap_or_else(|| panic!("chest level {chest_level} slot {slot} carries no label"));
4311        config
4312            .item_rarities
4313            .iter()
4314            .find(|rarity| rarity.id == rarity_id)
4315            .map(|rarity| rarity.order)
4316            .expect("the label names a known rarity")
4317    }
4318
4319    /// Тир не убывает от центра к краю по обеим половинам доски — на каждом
4320    /// уровне сундука. Немонотонная раскладка врёт игроку, который читает
4321    /// доску как «к краям редкость растёт».
4322    #[test]
4323    fn every_board_grows_in_tier_from_the_centre_outwards() {
4324        let config = crate::tests_game_config::generate_game_config_for_tests();
4325        let rows = config.plinko_settings.rows;
4326        let left_centre = (config.plinko_settings.slot_count() - 1) / 2;
4327        let right_centre = config.plinko_settings.slot_count() / 2;
4328
4329        for settings in &config.item_cases_settings {
4330            let level = settings.level;
4331            for slot in (1..=left_centre).rev() {
4332                assert!(
4333                    plinko_slot_tier(&config, level, slot - 1)
4334                        >= plinko_slot_tier(&config, level, slot),
4335                    "chest level {level}: tier drops from slot {slot} to the outer slot {}",
4336                    slot - 1
4337                );
4338            }
4339            for slot in right_centre..rows {
4340                assert!(
4341                    plinko_slot_tier(&config, level, slot + 1)
4342                        >= plinko_slot_tier(&config, level, slot),
4343                    "chest level {level}: tier drops from slot {slot} to the outer slot {}",
4344                    slot + 1
4345                );
4346            }
4347        }
4348    }
4349
4350    /// Редкостей столько же, сколько пар слотов → доска полностью симметрична:
4351    /// каждая редкость держит пару, самый низкий тир — центр.
4352    #[test]
4353    fn a_board_with_one_rarity_per_pair_is_symmetric() {
4354        let config = crate::tests_game_config::generate_game_config_for_tests();
4355        let last_slot = config.plinko_settings.rows;
4356        let pairs = config.plinko_settings.slot_count() / 2;
4357
4358        let level = config
4359            .item_cases_settings
4360            .iter()
4361            .find(|settings| {
4362                settings
4363                    .rarity_weights
4364                    .iter()
4365                    .filter(|weight| weight.weight > 0.0)
4366                    .count() as i64
4367                    == pairs
4368            })
4369            .map(|settings| settings.level)
4370            .expect("the fixture has a chest level dropping exactly one rarity per slot pair");
4371
4372        for slot in 0..=last_slot {
4373            assert_eq!(
4374                plinko_slot_tier(&config, level, slot),
4375                plinko_slot_tier(&config, level, last_slot - slot),
4376                "chest level {level}: slot {slot} and its mirror carry different tiers"
4377            );
4378        }
4379        // Каждый тир занимает ровно пару слотов.
4380        for pair in 0..pairs {
4381            let inner = last_slot / 2 - pair;
4382            assert_eq!(
4383                plinko_slot_tier(&config, level, inner),
4384                plinko_slot_tier(&config, level, last_slot - inner),
4385                "chest level {level}: pair {pair} is not one tier"
4386            );
4387        }
4388    }
4389
4390    /// Редкостей больше, чем пар слотов → лишние занимают крайние слоты по
4391    /// одной, и самый высокий тир достаётся последнему слоту, предыдущий по
4392    /// тиру — слоту 0. Именно эта форма живёт на боевых уровнях с пятью
4393    /// редкостями.
4394    #[test]
4395    fn a_board_with_more_rarities_than_pairs_splits_the_outer_slots() {
4396        let config = crate::tests_game_config::generate_game_config_for_tests();
4397        let last_slot = config.plinko_settings.rows;
4398        let pairs = config.plinko_settings.slot_count() / 2;
4399
4400        let levels: Vec<_> = config
4401            .item_cases_settings
4402            .iter()
4403            .filter(|settings| {
4404                let dropped = settings
4405                    .rarity_weights
4406                    .iter()
4407                    .filter(|weight| weight.weight > 0.0)
4408                    .count() as i64;
4409                dropped > pairs && dropped < config.plinko_settings.slot_count()
4410            })
4411            .collect();
4412        assert!(
4413            !levels.is_empty(),
4414            "the fixture must have a chest level dropping more rarities than there are pairs"
4415        );
4416
4417        for settings in levels {
4418            let level = settings.level;
4419
4420            let mut tiers: Vec<i64> = settings
4421                .rarity_weights
4422                .iter()
4423                .filter(|weight| weight.weight > 0.0)
4424                .map(|weight| {
4425                    config
4426                        .item_rarities
4427                        .iter()
4428                        .find(|rarity| rarity.id == weight.rarity_id)
4429                        .map(|rarity| rarity.order)
4430                        .expect("rarity_weights name known rarities")
4431                })
4432                .collect();
4433            tiers.sort_unstable();
4434
4435            assert_eq!(
4436                plinko_slot_tier(&config, level, last_slot),
4437                *tiers.last().expect("the level drops something"),
4438                "chest level {level}: the highest tier must own the last slot alone"
4439            );
4440            assert_ne!(
4441                plinko_slot_tier(&config, level, last_slot),
4442                plinko_slot_tier(&config, level, 0),
4443                "chest level {level}: the outer slots must not be one pair here"
4444            );
4445            assert_eq!(
4446                plinko_slot_tier(&config, level, 0),
4447                tiers[tiers.len() - 2],
4448                "chest level {level}: slot 0 must carry the tier just below the highest"
4449            );
4450            assert_eq!(
4451                plinko_slot_tier(&config, level, last_slot / 2),
4452                plinko_slot_tier(&config, level, last_slot / 2 + 1),
4453                "chest level {level}: the centre is still a pair"
4454            );
4455            assert_eq!(
4456                plinko_slot_tier(&config, level, last_slot / 2),
4457                tiers[0],
4458                "chest level {level}: the centre pair carries the lowest tier"
4459            );
4460        }
4461    }
4462
4463    #[test]
4464    #[should_panic(expected = "the tier must not drop towards the edge")]
4465    fn validate_plinko_settings_rejects_a_non_monotonic_board() {
4466        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4467        // Меняем местами центральную пару и соседнюю: более высокий тир
4468        // оказывается ближе к центру, чем более низкий.
4469        let level = config.item_cases_settings[0].level;
4470        let rows = config.plinko_settings.rows;
4471        let layout = (&mut config.plinko_settings.slot_layouts)
4472            .into_iter()
4473            .find(|layout| layout.chest_level == level)
4474            .expect("the fixture has a board for every chest level");
4475
4476        let centre = rows / 2;
4477        let inner = layout.rarity_for_slot(centre).expect("centre is labelled");
4478        let outer = layout
4479            .rarity_for_slot(centre - 1)
4480            .expect("the slot next to the centre is labelled");
4481        let swapped: Vec<_> = layout
4482            .slot_groups
4483            .iter()
4484            .map(|group| {
4485                let mut group = group.clone();
4486                if group.rarity_id == inner {
4487                    group.rarity_id = outer;
4488                } else if group.rarity_id == outer {
4489                    group.rarity_id = inner;
4490                }
4491                group
4492            })
4493            .collect();
4494        layout.slot_groups = crate::validated_types::NonEmptyVec::new(swapped);
4495
4496        config.validate_plinko_settings();
4497    }
4498
4499    #[test]
4500    #[should_panic(expected = "has no slot for item rarity")]
4501    fn validate_plinko_settings_rejects_an_uncovered_rarity() {
4502        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4503        // Отдаём весь первый уровень доски одной редкости: остальные редкости
4504        // этого уровня остаются без слота.
4505        let level = config.item_cases_settings[0].level;
4506        let rows = config.plinko_settings.rows;
4507        let layout = (&mut config.plinko_settings.slot_layouts)
4508            .into_iter()
4509            .find(|layout| layout.chest_level == level)
4510            .expect("the fixture has a board for every chest level");
4511        let rarity_id = layout.slot_groups.first().rarity_id;
4512        layout.slot_groups =
4513            crate::validated_types::NonEmptyVec::new(vec![crate::plinko::PlinkoSlotGroup {
4514                from_slot: 0,
4515                to_slot: rows,
4516                rarity_id,
4517                is_locked: false,
4518            }]);
4519        config.validate_plinko_settings();
4520    }
4521
4522    #[test]
4523    #[should_panic(expected = "has no board for chest level")]
4524    fn validate_plinko_settings_rejects_a_chest_level_without_a_board() {
4525        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4526        let level = config.item_cases_settings[0].level;
4527        let kept: Vec<_> = config
4528            .plinko_settings
4529            .slot_layouts
4530            .iter()
4531            .filter(|layout| layout.chest_level != level)
4532            .cloned()
4533            .collect();
4534        config.plinko_settings.slot_layouts = crate::validated_types::NonEmptyVec::new(kept);
4535        config.validate_plinko_settings();
4536    }
4537
4538    #[test]
4539    #[should_panic(expected = "auto_chest max_batch_size must never decrease with chest level")]
4540    fn validate_item_cases_settings_rejects_decreasing_batch_size() {
4541        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4542        // Force the highest chest level's cap below its predecessor's.
4543        let last = config.item_cases_settings.len() - 1;
4544        config.item_cases_settings[last]
4545            .auto_chest_settings
4546            .max_batch_size = 0;
4547        config.validate_item_cases_settings();
4548    }
4549
4550    /// The auto ladder is its own sequence and must be monotone in its own
4551    /// right — it used to ride the manual field, so nothing checked it.
4552    #[test]
4553    #[should_panic(expected = "auto_chest auto_only_max_batch_size must never decrease")]
4554    fn validate_item_cases_settings_rejects_decreasing_auto_batch_size() {
4555        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4556        let last = config.item_cases_settings.len() - 1;
4557        config.item_cases_settings[last]
4558            .auto_chest_settings
4559            .auto_only_max_batch_size = -1;
4560        config.validate_item_cases_settings();
4561    }
4562
4563    /// Auto must stay the tighter ladder. Auto opens continuously, so a config
4564    /// where it exceeds the manual cap would make the automatic path the faster
4565    /// one — the opposite of the intended shape.
4566    #[test]
4567    #[should_panic(expected = "must not exceed")]
4568    fn validate_item_cases_settings_rejects_auto_above_manual() {
4569        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4570        let last = config.item_cases_settings.len() - 1;
4571        let manual = config.item_cases_settings[last]
4572            .auto_chest_settings
4573            .max_batch_size;
4574        config.item_cases_settings[last]
4575            .auto_chest_settings
4576            .auto_only_max_batch_size = manual + 1;
4577        config.validate_item_cases_settings();
4578    }
4579
4580    #[test]
4581    #[should_panic(expected = "PetCasesSettings: must not be empty")]
4582    fn validate_pet_gacha_settings_rejects_empty_cases_settings() {
4583        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4584        config.pet_cases_settings.clear();
4585        config.validate_pet_gacha_settings();
4586    }
4587
4588    #[test]
4589    #[should_panic(expected = "PetCasesSettings: level 1 with opens_to_upgrade = 0 is required")]
4590    fn validate_pet_gacha_settings_rejects_missing_level_one() {
4591        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4592        config.pet_cases_settings.remove(0);
4593        config.validate_pet_gacha_settings();
4594    }
4595
4596    #[test]
4597    #[should_panic(expected = "rarity weight rarity")]
4598    fn validate_pet_gacha_settings_rejects_unknown_rarity_in_weights() {
4599        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4600        config.pet_cases_settings[0].rarity_weights[0].rarity_id =
4601            uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
4602        config.validate_pet_gacha_settings();
4603    }
4604
4605    #[test]
4606    #[should_panic(expected = "rarity_weights must not be empty")]
4607    fn validate_pet_gacha_settings_rejects_empty_rarity_weights() {
4608        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4609        config.pet_cases_settings[0].rarity_weights.clear();
4610        config.validate_pet_gacha_settings();
4611    }
4612
4613    #[test]
4614    #[should_panic(expected = "must equal big_roll_cost + bonus_drops")]
4615    fn validate_pet_gacha_settings_rejects_shard_total_above_big_roll_drops() {
4616        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4617        config.pet_cases_settings[0].big_roll_shards[0].count = 200;
4618        config.validate_pet_gacha_settings();
4619    }
4620
4621    // Regression: live pet content shipped shard totals of 5 against a
4622    // big_roll_cost 5 + bonus 1, silently dropping the "+1" from every big roll.
4623    #[test]
4624    #[should_panic(expected = "must equal big_roll_cost + bonus_drops")]
4625    fn validate_pet_gacha_settings_rejects_shard_total_below_big_roll_drops() {
4626        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4627        config.pet_cases_settings[0].big_roll_shards[0].count -= 1;
4628        config.validate_pet_gacha_settings();
4629    }
4630
4631    #[test]
4632    #[should_panic(expected = "big_roll_shards rarity")]
4633    fn validate_pet_gacha_settings_rejects_unknown_shard_rarity() {
4634        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4635        config.pet_cases_settings[0].big_roll_shards[0].min_rarity_id =
4636            uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
4637        config.validate_pet_gacha_settings();
4638    }
4639
4640    #[test]
4641    #[should_panic(expected = "checkpoint required_opens must be strictly increasing")]
4642    fn validate_pet_gacha_settings_rejects_non_increasing_checkpoints() {
4643        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4644        let checkpoint = config.pet_cases_settings[0].checkpoints[0].clone();
4645        config.pet_cases_settings[0].checkpoints.push(checkpoint);
4646        config.validate_pet_gacha_settings();
4647    }
4648
4649    #[test]
4650    #[should_panic(expected = "PetGachaSettings: wishlist_slots must be > 0")]
4651    fn validate_pet_gacha_settings_rejects_zero_wishlist_slots() {
4652        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4653        config.game_settings.pet_gacha.wishlist_slots = 0;
4654        config.validate_pet_gacha_settings();
4655    }
4656
4657    #[test]
4658    #[should_panic(expected = "not found in pet_rarities")]
4659    fn validate_pet_config_rejects_unknown_pet_level_rarity() {
4660        let mut config = crate::tests_game_config::generate_game_config_for_tests();
4661        config.pet_levels[0].rarity_id = uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
4662        config.validate_pet_config();
4663    }
4664}