1use configs::game_config::GameConfig;
10use uuid::Uuid;
11
12use crate::behaviors::{BehaviorKind, BehaviorRegistry, quests};
13
14pub fn validate_game_config_refs(
19 config: &GameConfig,
20 registry: &BehaviorRegistry,
21) -> Result<(), Vec<String>> {
22 let mut checker = RefChecker {
23 registry,
24 errors: Vec::new(),
25 };
26
27 for effect in &config.effects {
28 if let Some(name) = effect.behavior.as_deref() {
29 checker.check(
30 format!("effects[{}].script", effect.id),
31 BehaviorKind::Event,
32 name,
33 registry.event_fn(name).is_some(),
34 );
35 }
36 }
37
38 for quest in &config.quests {
39 if let Some(name) = quest.progress_behavior.as_deref() {
40 checker.check(
41 format!("quests[{}].progress_behavior", quest.id),
42 BehaviorKind::ConditionalProgress,
43 name,
44 registry.conditional_progress_fn(name).is_some(),
45 );
46 checker.check_required_content(
47 config,
48 format!("quests[{}].progress_behavior", quest.id),
49 name,
50 );
51 }
52 if let Some(name) = quest.additional_quests_behavior.as_deref() {
53 checker.check(
54 format!("quests[{}].additional_quests_behavior", quest.id),
55 BehaviorKind::AdditionalQuests,
56 name,
57 registry.additional_quests_fn(name).is_some(),
58 );
59 checker.check_required_content(
60 config,
61 format!("quests[{}].additional_quests_behavior", quest.id),
62 name,
63 );
64 }
65 }
66
67 for bundle in &config.bundles {
68 for (i, step) in bundle.steps.iter().enumerate() {
69 if let Some(name) = step.behavior.as_deref() {
70 checker.check(
71 format!("bundles[{}].steps[{i}].script", bundle.id),
72 BehaviorKind::Currencies,
73 name,
74 registry.currencies_fn(name).is_some(),
75 );
76 }
77 }
78 }
79
80 for ability in &config.abilities {
81 if let Some(name) = ability.start_behavior.as_deref() {
82 checker.check(
83 format!("abilities[{}].start_behavior", ability.id),
84 BehaviorKind::StartCastAbility,
85 name,
86 registry.start_cast_ability_fn(name).is_some(),
87 );
88 }
89 if let Some(name) = ability.behavior.as_deref() {
90 checker.check(
91 format!("abilities[{}].script", ability.id),
92 BehaviorKind::CastAbility,
93 name,
94 registry.cast_ability_fn(name).is_some(),
95 );
96 }
97 }
98
99 for projectile in &config.projectiles {
100 if let Some(name) = projectile.start_behavior.as_deref() {
101 checker.check(
102 format!("projectiles[{}].start_behavior", projectile.id),
103 BehaviorKind::StartCastProjectile,
104 name,
105 registry.start_cast_projectile_fn(name).is_some(),
106 );
107 }
108 if let Some(name) = projectile.behavior.as_deref() {
109 checker.check(
110 format!("projectiles[{}].script", projectile.id),
111 BehaviorKind::CastProjectile,
112 name,
113 registry.cast_projectile_fn(name).is_some(),
114 );
115 }
116 }
117
118 for fight_template in &config.fight_templates {
119 if let Some(name) = fight_template.start_behavior.as_deref() {
120 checker.check(
121 format!("fight_templates[{}].start_behavior", fight_template.id),
122 BehaviorKind::FightStart,
123 name,
124 registry.fight_start_fn(name).is_some(),
125 );
126 }
127 }
128
129 for attribute in &config.attributes {
130 if let Some(name) = attribute.calculation_behavior.as_deref() {
131 checker.check(
132 format!("attributes[{}].calculation_behavior", attribute.id),
133 BehaviorKind::ItemAttribute,
134 name,
135 registry.item_attribute_fn(name).is_some(),
136 );
137 }
138 }
139
140 checker.check(
141 "game_settings.default_loop_task_behavior".to_string(),
142 BehaviorKind::DefaultLoopTask,
143 &config.game_settings.default_loop_task_behavior,
144 registry
145 .default_loop_task_fn(&config.game_settings.default_loop_task_behavior)
146 .is_some(),
147 );
148
149 let vassals = &config.vassals_settings;
150 checker.check(
151 "vassals_settings.suzerain_reward_fn".to_string(),
152 BehaviorKind::VassalReward,
153 &vassals.suzerain_reward_fn,
154 registry
155 .vassal_reward_fn(&vassals.suzerain_reward_fn)
156 .is_some(),
157 );
158 checker.check(
159 "vassals_settings.vassal_reward_fn".to_string(),
160 BehaviorKind::VassalReward,
161 &vassals.vassal_reward_fn,
162 registry
163 .vassal_reward_fn(&vassals.vassal_reward_fn)
164 .is_some(),
165 );
166
167 for task in &config.vassal_tasks {
168 checker.check(
169 format!("vassal_tasks[{}].reward_fn", task.id),
170 BehaviorKind::VassalReward,
171 &task.reward_fn,
172 registry.vassal_reward_fn(&task.reward_fn).is_some(),
173 );
174 checker.check(
175 format!("vassal_tasks[{}].loyalty_fn", task.id),
176 BehaviorKind::VassalLoyalty,
177 &task.loyalty_fn,
178 registry.vassal_loyalty_fn(&task.loyalty_fn).is_some(),
179 );
180 }
181
182 if checker.errors.is_empty() {
183 Ok(())
184 } else {
185 Err(checker.errors)
186 }
187}
188
189#[derive(Clone, Copy)]
191enum ContentKind {
192 Dungeon,
193 Currency,
194 ItemRarity,
195 Quest,
196}
197
198const REQUIRED_CONTENT: &[(&str, ContentKind, u128)] = &[
203 (
204 "raid_dungeon_1",
205 ContentKind::Dungeon,
206 quests::progress::DUNGEON_1,
207 ),
208 (
209 "raid_dungeon_2",
210 ContentKind::Dungeon,
211 quests::progress::DUNGEON_2,
212 ),
213 (
214 "raid_dungeon_3",
215 ContentKind::Dungeon,
216 quests::progress::DUNGEON_3,
217 ),
218 (
219 "loop_task_raid_dungeon_1",
220 ContentKind::Dungeon,
221 quests::progress::DUNGEON_1,
222 ),
223 (
224 "loop_task_raid_dungeon_2",
225 ContentKind::Dungeon,
226 quests::progress::DUNGEON_2,
227 ),
228 (
229 "loop_task_raid_dungeon_3",
230 ContentKind::Dungeon,
231 quests::progress::DUNGEON_3,
232 ),
233 (
234 "loop_task_collect_currency",
235 ContentKind::Currency,
236 quests::progress::COLLECT_CURRENCY,
237 ),
238 (
239 "count_equipped_rarity_a",
240 ContentKind::ItemRarity,
241 quests::progress::RARITY_A,
242 ),
243 (
244 "count_equipped_rarity_b",
245 ContentKind::ItemRarity,
246 quests::progress::RARITY_B,
247 ),
248 (
249 "count_equipped_rarity_c",
250 ContentKind::ItemRarity,
251 quests::progress::RARITY_C,
252 ),
253 (
254 "log_in_today",
255 ContentKind::Quest,
256 quests::progress::LOG_IN_QUEST,
257 ),
258 (
259 "aq_redirect_to_kill_3_enemies",
260 ContentKind::Quest,
261 quests::loop_tasks::REDIRECT_KILL_3_ENEMIES,
262 ),
263 (
264 "aq_redirect_to_open_chest_6",
265 ContentKind::Quest,
266 quests::loop_tasks::REDIRECT_OPEN_CHEST_6,
267 ),
268 (
269 "aq_redirect_to_sell_5_gear",
270 ContentKind::Quest,
271 quests::loop_tasks::REDIRECT_SELL_5_GEAR,
272 ),
273 (
274 "aq_redirect_to_reach_level_3",
275 ContentKind::Quest,
276 quests::loop_tasks::REDIRECT_REACH_LEVEL_3,
277 ),
278];
279
280struct RefChecker<'a> {
281 registry: &'a BehaviorRegistry,
282 errors: Vec<String>,
283}
284
285impl RefChecker<'_> {
286 fn check(&mut self, slot: String, category: BehaviorKind, name: &str, callable: bool) {
293 if callable {
294 return;
295 }
296 let detail = match self.registry.validate_ref(category, name) {
297 Err(e) => e,
298 Ok(()) => format!(
299 "script_ref names `{name}`, which is registered under category `{}` \
300 but not callable for this slot's input scope",
301 category.as_str()
302 ),
303 };
304 self.errors.push(format!("{slot}: {detail}"));
305 }
306
307 fn check_required_content(&mut self, config: &GameConfig, slot: String, name: &str) {
310 for (behavior, kind, raw_id) in REQUIRED_CONTENT {
311 if *behavior != name {
312 continue;
313 }
314 let id = Uuid::from_u128(*raw_id);
315 let (exists, kind_name) = match kind {
316 ContentKind::Dungeon => (
317 config.dungeon_templates.iter().any(|d| d.id == id),
318 "dungeon",
319 ),
320 ContentKind::Currency => (config.currencies.iter().any(|c| c.id == id), "currency"),
321 ContentKind::ItemRarity => (
322 config.item_rarities.iter().any(|r| r.id == id),
323 "item rarity",
324 ),
325 ContentKind::Quest => (config.quests.iter().any(|q| q.id == id), "quest"),
326 };
327 if !exists {
328 self.errors.push(format!(
329 "{slot}: behavior `{name}` requires {kind_name} `{id}` which is not in \
330 the config"
331 ));
332 }
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::behaviors::build_registry;
341
342 #[test]
343 fn test_fixture_config_passes_validation() {
344 let config = configs::tests_game_config::generate_game_config_for_tests();
345 let registry = build_registry();
346 let result = validate_game_config_refs(&config, ®istry);
347 assert!(
348 result.is_ok(),
349 "test fixture config has invalid script refs:\n{}",
350 result.unwrap_err().join("\n")
351 );
352 }
353
354 #[test]
355 fn typoed_quest_progress_ref_is_reported_with_slot_and_names() {
356 let mut config = configs::tests_game_config::generate_game_config_for_tests();
357 let quest = config.quests.first_mut().expect("fixture has quests");
358 quest.progress_behavior = Some("definitely_not_registered".to_string());
359 let quest_id = quest.id;
360
361 let errors = validate_game_config_refs(&config, &build_registry()).unwrap_err();
362 let err = errors
363 .iter()
364 .find(|e| e.contains(&quest_id.to_string()))
365 .expect("error names the quest");
366 assert!(err.contains("progress_behavior"), "{err}");
367 assert!(err.contains("conditional_progress"), "{err}");
368 }
369
370 #[test]
371 fn wrong_category_name_is_rejected() {
372 let mut config = configs::tests_game_config::generate_game_config_for_tests();
376 let attribute = config
377 .attributes
378 .first_mut()
379 .expect("fixture has attributes");
380 attribute.calculation_behavior = Some("vassal_task_loyalty_const".to_string());
381
382 let errors = validate_game_config_refs(&config, &build_registry()).unwrap_err();
383 assert!(
384 errors.iter().any(|e| e.contains("calculation_behavior")
385 && e.contains("vassal_task_loyalty_const")
386 && e.contains("item_attribute")),
387 "expected a wrong-category error, got: {errors:?}"
388 );
389 }
390}