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