overlord_event_system/behaviors/
validate.rs

1//! Deploy-time validation of every script-ref field in the game config.
2//!
3//! A config field referencing a native fn is a plain string; a typo'd name
4//! makes the runtime lookup return `None` mid-gameplay (silently skipped slot
5//! or failed event). [`validate_game_config_refs`] walks every ref-bearing
6//! field and checks the name resolves to a callable registered for that exact
7//! slot, so the admin deploy step / config validator fails loudly instead.
8
9use configs::game_config::GameConfig;
10use uuid::Uuid;
11
12use crate::behaviors::{BehaviorKind, BehaviorRegistry, quests};
13
14/// Validate every script-ref field in `config` against `registry`.
15///
16/// Returns all failures, one message per bad ref, each naming the config slot
17/// (entity id + field) and listing the registered names for the category.
18pub 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/// What kind of config content a behavior's baked-in uuid must resolve to.
190#[derive(Clone, Copy)]
191enum ContentKind {
192    Dungeon,
193    Currency,
194    ItemRarity,
195    Quest,
196}
197
198/// Behaviors that carry a content uuid as a const parameter: when a quest
199/// references one of these names, the named content must exist in the same
200/// config, otherwise the behavior silently never matches (or errors) at
201/// runtime.
202const 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    /// Record an error for `slot` unless the name resolved to a callable.
287    ///
288    /// `callable` is the result of the exact typed lookup the runtime performs
289    /// for this slot — some categories hold more than one callable map (e.g.
290    /// `item_ids` has chest and bundle scopes), so category membership alone
291    /// is not enough.
292    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    /// If `name` is a behavior with a baked-in content uuid, assert that
308    /// content exists in `config`.
309    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, &registry);
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        // `vassal_task_loyalty_const` is registered under `vassal_loyalty`,
373        // not `item_attribute` — referencing it from an attribute slot must
374        // fail with the category's available names listed.
375        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}