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