1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum BehaviorKind {
41 Event,
43 ConditionalProgress,
45 Currencies,
48 VassalReward,
50 ItemAttribute,
52 VassalLoyalty,
54 DefaultLoopTask,
57 StartCastAbility,
59 StartCastProjectile,
61 CastAbility,
63 CastProjectile,
65 FightStart,
67 AdditionalQuests,
70}
71
72impl BehaviorKind {
73 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct BehaviorMeta {
125 pub name: String,
127 pub category: BehaviorKind,
128 pub title: String,
130 pub description: String,
132}
133
134macro_rules! registry_maps {
137 ($( $map:ident : $fnty:ty, $category:expr, $register:ident, $lookup:ident; )*) => {
138 #[derive(Debug, Default)]
144 pub struct BehaviorRegistry {
145 entries: BTreeMap<(BehaviorKind, String), BehaviorMeta>,
146 lookups: crate::mechanics::content_lookups::ContentLookups,
150 $( $map: BTreeMap<String, $fnty>, )*
151 }
152
153 impl BehaviorRegistry {
154 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 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 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 pub fn lookups(&self) -> &crate::mechanics::content_lookups::ContentLookups {
233 &self.lookups
234 }
235
236 pub fn get(&self, category: BehaviorKind, name: &str) -> Option<&BehaviorMeta> {
238 self.entries.get(&(category, name.to_string()))
239 }
240
241 pub fn contains(&self, category: BehaviorKind, name: &str) -> bool {
243 self.get(category, name).is_some()
244 }
245
246 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(transparent)]
290pub struct BehaviorCatalog(pub BTreeMap<String, Vec<BehaviorMeta>>);
291
292impl BehaviorCatalog {
293 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
301pub 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 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 #[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 let err = registry
409 .validate_ref(BehaviorKind::Currencies, "always_one")
410 .unwrap_err();
411 assert!(err.contains("not registered under category `currencies`"));
412 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 #[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 #[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}