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