overlord_event_system/behaviors/
mod.rs

1//! The name-addressed registry of native behavior functions — every config
2//! script slot dispatches to a function registered here.
3//!
4//! ## Model
5//! Every script slot in the game config is one [`BehaviorKind`]. A category
6//! fixes the typed input scope and return type the slot expects. A config field
7//! referencing a script names a function of the matching category; the
8//! config-validator step asserts the name exists and the category matches
9//! (see [`validate::validate_game_config_refs`]).
10//!
11//! Only **config-dispatched** behaviors live here. Behaviors whose name is
12//! hardcoded in Rust (character/item power, fast-equip, opponent generation,
13//! item price, …) are plain functions on their modules, called directly.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19pub mod arena;
20pub mod combat;
21pub mod fast_equip;
22pub mod fixtures;
23pub mod items;
24pub mod opponents;
25pub mod power;
26pub mod quests;
27pub mod rewards;
28pub mod ui_values;
29pub mod validate;
30pub mod vassals;
31
32/// One config-dispatched script slot. A category fixes the slot's typed
33/// input/output contract.
34///
35/// The string form (via [`BehaviorKind::as_str`] / serde) is the stable key
36/// used in the exported catalog and in the admin JSON-schema `script_ref`
37/// annotation — keep it stable across renames.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum BehaviorKind {
41    /// Buff / entity effect behaviors → `Vec<OverlordEvent>` (`Effect::script`).
42    Event,
43    /// Quest progress (`QuestTemplate::progress_behavior`) → `i64`.
44    ConditionalProgress,
45    /// Reward / bundle-step currencies (`BundleRawStep::script` — the AFK
46    /// accrual slot).
47    Currencies,
48    /// Vassal / suzerain reward slots (`reward_fn` fields).
49    VassalReward,
50    /// Item attribute value (`Attribute::calculation_behavior`) → `i64`.
51    ItemAttribute,
52    /// Vassal-task loyalty (`VassalTaskTemplate::loyalty_fn`) → `i64`.
53    VassalLoyalty,
54    /// Default loop-task selector (`game_settings.default_loop_task_behavior`)
55    /// → quest id.
56    DefaultLoopTask,
57    /// Ability `start_behavior` (cast wind-up → attack/run results).
58    StartCastAbility,
59    /// Projectile `start_behavior` (cast wind-up).
60    StartCastProjectile,
61    /// Ability `script` — the combat effect of casting an ability.
62    CastAbility,
63    /// Projectile `script` — the combat effect of a projectile landing.
64    CastProjectile,
65    /// Fight `start_behavior` (`init_fight` + optional static buff).
66    FightStart,
67    /// Quest `additional_quests_behavior` — follow-up quests / loop-task pacing
68    /// on claim.
69    AdditionalQuests,
70}
71
72impl BehaviorKind {
73    /// Stable string key (matches serde `snake_case`). Used in the catalog and
74    /// the admin `script_ref` schema annotation.
75    pub fn as_str(self) -> &'static str {
76        match self {
77            BehaviorKind::Event => "event",
78            BehaviorKind::ConditionalProgress => "conditional_progress",
79            BehaviorKind::Currencies => "currencies",
80            BehaviorKind::VassalReward => "vassal_reward",
81            BehaviorKind::ItemAttribute => "item_attribute",
82            BehaviorKind::VassalLoyalty => "vassal_loyalty",
83            BehaviorKind::DefaultLoopTask => "default_loop_task",
84            BehaviorKind::StartCastAbility => "start_cast_ability",
85            BehaviorKind::StartCastProjectile => "start_cast_projectile",
86            BehaviorKind::CastAbility => "cast_ability",
87            BehaviorKind::CastProjectile => "cast_projectile",
88            BehaviorKind::FightStart => "fight_start",
89            BehaviorKind::AdditionalQuests => "additional_quests",
90        }
91    }
92
93    /// Every category, in stable order. Drives the catalog so the set of
94    /// categories itself is snapshot-tested.
95    pub const ALL: &'static [BehaviorKind] = &[
96        BehaviorKind::Event,
97        BehaviorKind::ConditionalProgress,
98        BehaviorKind::Currencies,
99        BehaviorKind::VassalReward,
100        BehaviorKind::ItemAttribute,
101        BehaviorKind::VassalLoyalty,
102        BehaviorKind::DefaultLoopTask,
103        BehaviorKind::StartCastAbility,
104        BehaviorKind::StartCastProjectile,
105        BehaviorKind::CastAbility,
106        BehaviorKind::CastProjectile,
107        BehaviorKind::FightStart,
108        BehaviorKind::AdditionalQuests,
109    ];
110
111    /// Stable 0-based index: the position in [`BehaviorKind::ALL`].
112    pub fn index(self) -> usize {
113        Self::ALL
114            .iter()
115            .position(|c| *c == self)
116            .expect("every variant is in ALL")
117    }
118}
119
120/// Catalog metadata for one registered native script function. This is what the
121/// admin renders as a dropdown option for a `script_ref` field, and what the
122/// validator checks config references against.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct BehaviorMeta {
125    /// Unique within its category. Config references this.
126    pub name: String,
127    pub category: BehaviorKind,
128    /// Human-readable label for the admin dropdown.
129    pub title: String,
130    /// Longer description of what the function does / its params.
131    pub description: String,
132}
133
134/// Declares the [`BehaviorRegistry`] with one typed callable map per category
135/// scope, plus the matching `register_*` / `*_fn` accessor pair for each.
136macro_rules! registry_maps {
137    ($( $map:ident : $fnty:ty, $category:expr, $register:ident, $lookup:ident; )*) => {
138        /// The name-addressed registry of config-dispatched native fns, keyed
139        /// by `(category, name)`. Built once via [`build_registry`].
140        ///
141        /// `entries` is the category-agnostic catalog/validation view; the
142        /// typed maps hold the function pointers the dispatch sites call.
143        #[derive(Debug, Default)]
144        pub struct BehaviorRegistry {
145            entries: BTreeMap<(BehaviorKind, String), BehaviorMeta>,
146            /// Content lookups (q/eff/target_type/...) extracted from the
147            /// config at init; behaviors that need data the typed schemas
148            /// don't carry read them via [`BehaviorRegistry::lookups`].
149            lookups: crate::mechanics::content_lookups::ContentLookups,
150            $( $map: BTreeMap<String, $fnty>, )*
151        }
152
153        impl BehaviorRegistry {
154            /// Register catalog metadata. Panics on a duplicate
155            /// `(category, name)` — caught at process start / in tests.
156            pub fn register(&mut self, meta: BehaviorMeta) {
157                let key = (meta.category, meta.name.clone());
158                if let Some(prev) = self.entries.insert(key, meta) {
159                    panic!(
160                        "duplicate script fn registration: category={:?} name={:?}",
161                        prev.category, prev.name
162                    );
163                }
164            }
165
166            /// Remove a behavior by name from the catalog and every typed
167            /// map (used to strip test fixtures from production registries).
168            pub fn remove(&mut self, name: &str) {
169                self.entries.retain(|(_, n), _| n != name);
170                $( self.$map.remove(name); )*
171            }
172
173            $(
174                pub fn $register(&mut self, meta: BehaviorMeta, f: $fnty) {
175                    debug_assert_eq!(meta.category, $category);
176                    let name = meta.name.clone();
177                    self.register(meta);
178                    self.$map.insert(name, f);
179                }
180
181                pub fn $lookup(&self, name: &str) -> Option<$fnty> {
182                    self.$map.get(name).copied()
183                }
184            )*
185        }
186    };
187}
188
189registry_maps! {
190    event_fns: combat::effects::EventFn,
191        BehaviorKind::Event, register_event, event_fn;
192    conditional_progress_fns: quests::progress::ConditionalProgressFn,
193        BehaviorKind::ConditionalProgress, register_conditional_progress, conditional_progress_fn;
194    currencies_fns: rewards::RewardFn,
195        BehaviorKind::Currencies, register_currencies, currencies_fn;
196    vassal_reward_fns: vassals::VassalRewardFn,
197        BehaviorKind::VassalReward, register_vassal_reward, vassal_reward_fn;
198    item_attribute_fns: items::ItemAttributeFn,
199        BehaviorKind::ItemAttribute, register_item_attribute, item_attribute_fn;
200    vassal_loyalty_fns: vassals::VassalLoyaltyFn,
201        BehaviorKind::VassalLoyalty, register_vassal_loyalty, vassal_loyalty_fn;
202    default_loop_task_fns: quests::loop_tasks::DefaultLoopTaskFn,
203        BehaviorKind::DefaultLoopTask, register_default_loop_task, default_loop_task_fn;
204    start_cast_ability_fns: combat::start_cast::StartCastAbilityFn,
205        BehaviorKind::StartCastAbility, register_start_cast_ability, start_cast_ability_fn;
206    start_cast_projectile_fns: combat::start_cast::StartCastProjectileFn,
207        BehaviorKind::StartCastProjectile, register_start_cast_projectile, start_cast_projectile_fn;
208    cast_ability_fns: combat::cast_ability::CastAbilityFn,
209        BehaviorKind::CastAbility, register_cast_ability, cast_ability_fn;
210    cast_projectile_fns: combat::cast_projectile::CastProjectileFn,
211        BehaviorKind::CastProjectile, register_cast_projectile, cast_projectile_fn;
212    fight_start_fns: combat::fight_start::FightStartFn,
213        BehaviorKind::FightStart, register_fight_start, fight_start_fn;
214    additional_quests_fns: quests::loop_tasks::AdditionalQuestsFn,
215        BehaviorKind::AdditionalQuests, register_additional_quests, additional_quests_fn;
216}
217
218impl BehaviorRegistry {
219    /// Build the full registry for a game config: registers every
220    /// config-dispatched behavior and extracts the content lookups.
221    ///
222    /// Lookup extraction MUST happen here: empty `ContentLookups` silently
223    /// breaks fight targeting (`is_valid_target` reads `target_type ""` → no
224    /// valid targets → entities run through the enemy line).
225    pub fn new(game_config: &configs::game_config::GameConfig) -> Self {
226        let mut registry = build_registry();
227        registry.lookups = crate::mechanics::content_raw_extract::extract(game_config);
228        registry
229    }
230
231    /// Content lookups extracted at init — passed into the `*Ctx` structs.
232    pub fn lookups(&self) -> &crate::mechanics::content_lookups::ContentLookups {
233        &self.lookups
234    }
235
236    /// Look up a function's metadata by category + name.
237    pub fn get(&self, category: BehaviorKind, name: &str) -> Option<&BehaviorMeta> {
238        self.entries.get(&(category, name.to_string()))
239    }
240
241    /// True if `name` is registered under `category`.
242    pub fn contains(&self, category: BehaviorKind, name: &str) -> bool {
243        self.get(category, name).is_some()
244    }
245
246    /// Validate a config `ScriptRef`: the `name` must exist under `category`.
247    /// On failure it lists the valid names for that category to make the
248    /// misconfig obvious.
249    pub fn validate_ref(&self, category: BehaviorKind, name: &str) -> Result<(), String> {
250        if self.contains(category, name) {
251            return Ok(());
252        }
253        let mut available: Vec<&str> = self
254            .entries
255            .keys()
256            .filter(|(c, _)| *c == category)
257            .map(|(_, n)| n.as_str())
258            .collect();
259        available.sort_unstable();
260        Err(format!(
261            "script_ref names a function `{name}` that is not registered under \
262             category `{}`; available: [{}]",
263            category.as_str(),
264            available.join(", ")
265        ))
266    }
267
268    /// Serialize to the stable catalog shape: `{ category -> [meta, ...] }`,
269    /// with every category present (empty array if nothing registered).
270    /// Deterministic ordering (categories in [`BehaviorKind::ALL`] order,
271    /// names sorted by the `BTreeMap`) so the snapshot test is stable.
272    pub fn to_catalog(&self) -> BehaviorCatalog {
273        let mut catalog = BTreeMap::new();
274        for &category in BehaviorKind::ALL {
275            catalog.insert(category.as_str().to_string(), Vec::new());
276        }
277        for ((category, _name), meta) in &self.entries {
278            catalog
279                .entry(category.as_str().to_string())
280                .or_default()
281                .push(meta.clone());
282        }
283        BehaviorCatalog(catalog)
284    }
285}
286
287/// Exported catalog shape (`behavior_catalog.json`). Snapshot-tested in CI.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(transparent)]
290pub struct BehaviorCatalog(pub BTreeMap<String, Vec<BehaviorMeta>>);
291
292impl BehaviorCatalog {
293    /// Pretty JSON with a trailing newline (matches the committed file format).
294    pub fn to_pretty_json(&self) -> String {
295        let mut s = serde_json::to_string_pretty(self).expect("catalog serializes");
296        s.push('\n');
297        s
298    }
299}
300
301/// Build the full registry: every config-dispatched category submodule
302/// registers its functions.
303pub fn build_registry() -> BehaviorRegistry {
304    let mut registry = BehaviorRegistry::default();
305    rewards::register(&mut registry);
306    vassals::register(&mut registry);
307    items::register(&mut registry);
308    quests::progress::register(&mut registry);
309    quests::loop_tasks::register(&mut registry);
310    combat::start_cast::register(&mut registry);
311    combat::effects::register(&mut registry);
312    combat::fight_start::register(&mut registry);
313    combat::cast_ability::register(&mut registry);
314    combat::cast_projectile::register(&mut registry);
315    #[cfg(not(any(test, feature = "fixtures")))]
316    fixtures::strip(&mut registry);
317    registry
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// Path to the committed catalog snapshot, relative to this crate.
325    fn catalog_path() -> std::path::PathBuf {
326        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("behavior_catalog.json")
327    }
328
329    #[test]
330    fn category_index_matches_all_position() {
331        for (pos, cat) in BehaviorKind::ALL.iter().enumerate() {
332            assert_eq!(cat.index(), pos);
333        }
334    }
335
336    /// CI staleness gate: the committed catalog must equal what the registry
337    /// produces. Regenerate with
338    /// `REGEN_CATALOG=1 cargo test -p overlord_event_system --lib regenerate_catalog_snapshot`.
339    #[test]
340    fn regenerate_catalog_snapshot() {
341        if std::env::var("REGEN_CATALOG").is_err() {
342            return;
343        }
344        let mut registry = build_registry();
345        fixtures::strip(&mut registry);
346        let expected = registry.to_catalog().to_pretty_json();
347        std::fs::write(catalog_path(), expected).unwrap();
348    }
349
350    #[test]
351    fn catalog_snapshot_is_current() {
352        let mut registry = build_registry();
353        fixtures::strip(&mut registry);
354        let expected = registry.to_catalog().to_pretty_json();
355        let path = catalog_path();
356        let actual = std::fs::read_to_string(&path).unwrap_or_else(|e| {
357            panic!(
358                "cannot read {}: {e}. Create it with the generated catalog.",
359                path.display()
360            )
361        });
362        assert_eq!(
363            actual, expected,
364            "behavior_catalog.json is stale — regenerate it with REGEN_CATALOG=1"
365        );
366    }
367
368    #[test]
369    fn categories_have_unique_stable_keys() {
370        let mut seen = std::collections::HashSet::new();
371        for &c in BehaviorKind::ALL {
372            assert!(
373                seen.insert(c.as_str()),
374                "duplicate category key: {}",
375                c.as_str()
376            );
377        }
378    }
379
380    #[test]
381    fn registry_lookup_roundtrips() {
382        let mut registry = BehaviorRegistry::default();
383        registry.register(BehaviorMeta {
384            name: "example".to_string(),
385            category: BehaviorKind::ConditionalProgress,
386            title: "Example".to_string(),
387            description: "test".to_string(),
388        });
389        assert!(registry.contains(BehaviorKind::ConditionalProgress, "example"));
390        assert!(!registry.contains(BehaviorKind::Currencies, "example"));
391    }
392
393    #[test]
394    fn validate_ref_reports_missing_with_available_names() {
395        let mut registry = BehaviorRegistry::default();
396        registry.register(BehaviorMeta {
397            name: "always_one".to_string(),
398            category: BehaviorKind::ConditionalProgress,
399            title: "t".to_string(),
400            description: "d".to_string(),
401        });
402        assert!(
403            registry
404                .validate_ref(BehaviorKind::ConditionalProgress, "always_one")
405                .is_ok()
406        );
407        // Wrong category for an existing name → error.
408        let err = registry
409            .validate_ref(BehaviorKind::Currencies, "always_one")
410            .unwrap_err();
411        assert!(err.contains("not registered under category `currencies`"));
412        // Unknown name → error lists the category's available names.
413        let err = registry
414            .validate_ref(BehaviorKind::ConditionalProgress, "nope")
415            .unwrap_err();
416        assert!(
417            err.contains("always_one"),
418            "should list available names: {err}"
419        );
420    }
421
422    /// Vocabulary gate: the Rhai engine is gone; the word must not creep back
423    /// into this crate's sources outside explicit pointers to the migration /
424    /// latent-bugs guideline docs.
425    #[test]
426    fn rhai_vocabulary_does_not_creep_back() {
427        const ALLOW: &[&str] = &[
428            "RHAI_REMOVAL_LATENT_BUGS",
429            "RHAI_REMOVAL_MIGRATION",
430            "POST_RHAI_CLEANUP",
431            "rhai_vocabulary_does_not_creep_back",
432            "the Rhai engine is gone",
433        ];
434        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
435        let mut offenders = Vec::new();
436        let mut stack = vec![src];
437        while let Some(dir) = stack.pop() {
438            for entry in std::fs::read_dir(&dir).unwrap() {
439                let path = entry.unwrap().path();
440                if path.is_dir() {
441                    stack.push(path);
442                    continue;
443                }
444                if path.extension().and_then(|e| e.to_str()) != Some("rs") {
445                    continue;
446                }
447                let text = std::fs::read_to_string(&path).unwrap();
448                for (i, line) in text.lines().enumerate() {
449                    if line.to_ascii_lowercase().contains(concat!("rh", "ai"))
450                        && !ALLOW.iter().any(|a| line.contains(a))
451                    {
452                        offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
453                    }
454                }
455            }
456        }
457        assert!(
458            offenders.is_empty(),
459            concat!(
460                "the word `rh",
461                "ai` crept back into src/ (allowlist: doc pointers only):\n{}"
462            ),
463            offenders.join("\n")
464        );
465    }
466
467    /// `BehaviorRegistry::new` must extract `ContentLookups` from the config,
468    /// not start empty. Empty lookups silently break fight targeting (every
469    /// ability gets `target_type ""` → no valid target → entities advance
470    /// every tick and run through the enemy line without attacking).
471    #[test]
472    fn registry_populates_lookups_from_config() {
473        let config = configs::tests_game_config::generate_game_config_for_tests();
474        assert!(!config.abilities.is_empty(), "fixture must have abilities");
475
476        let registry = BehaviorRegistry::new(&config);
477        let lookups = registry.lookups();
478
479        assert_eq!(
480            lookups.ability_target_type.len(),
481            config.abilities.len(),
482            "every ability must have a target type in lookups"
483        );
484        assert_eq!(
485            lookups.item_rarity_q.len(),
486            config.item_rarities.len(),
487            "every item rarity must have a q in lookups"
488        );
489        assert_eq!(
490            lookups.entity_template_is_boss.len(),
491            config.entities.len(),
492            "every entity must have an is_boss flag in lookups"
493        );
494    }
495}