diff --git a/src/simulator/crates/mc-core/src/content.rs b/src/simulator/crates/mc-core/src/content.rs new file mode 100644 index 00000000..72d5c11d --- /dev/null +++ b/src/simulator/crates/mc-core/src/content.rs @@ -0,0 +1,218 @@ +//! Host-fed content registry — the single seam through which simulation crates +//! read canonical JSON game content (Rail-2). +//! +//! # Why this exists (p3-28, "boot-config DRY") +//! +//! Content reaches the simulation two ways and they must never drift: +//! - **In-game** (Godot drives): the GDScript `DataLoader` reads packs at +//! runtime and the host injects the bytes across FFI. +//! - **Headless / WASM / tests** (Rust drives alone): no `DataLoader`, so Rust +//! falls back to a compile-time `include_str!` copy. +//! +//! Before this registry, every content-owning crate rolled its *own* +//! `OnceLock` + a fragile `../../../../../public/...` relative `include_str!`. +//! That scattered the embedded fallbacks across ~7 modules and made the +//! two-path divergence (a hardcode drifting from the JSON) easy to reintroduce. +//! +//! This module collapses that into one seam: +//! - **Embedded defaults** live in exactly ONE place — the [`EMBEDDED`] table +//! below (the only `include_str!` content sites for these packs). This is the +//! headless / WASM / test fallback. It is filesystem-free, so it is +//! WASM-safe and GDExtension-safe. +//! - **Host injection** — [`register`] lets the host (Godot from `res://` +//! bytes, the web guide from `fetch`ed bytes) override a pack at engine init. +//! A file-reading `boot_from_resources(path)` would be *wrong* here: the +//! shared sim compiles to WASM, which has no filesystem. The host feeds +//! bytes; the registry never touches the filesystem. +//! - **Consumers** call [`get`] for the raw JSON of a pack, keyed by a stable +//! [`ContentKey`], and keep their own typed `OnceLock` parse cache as before +//! — only the *source of the bytes* moves here. +//! +//! Hosts are expected to inject (if at all) at engine init, **before** any +//! consumer first reads a pack: a consumer's typed cache is a `OnceLock`, so +//! the first read wins for the process lifetime. Injecting after a pack has +//! been read has no effect on that pack's already-parsed cache — same lifetime +//! contract the per-crate `OnceLock`s always had. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +/// A canonical content pack the simulation reads. The string value is the +/// stable registry key the host injects against and consumers read by. +/// +/// Adding a pack: add a variant, its key, and its embedded `include_str!` +/// default in [`EMBEDDED`]. That keeps the embedded-fallback list in one place. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ContentKey { + /// `public/resources/promotions/promotions.json` — promotion XP tuning. + Promotions, + /// `public/games/age-of-dwarves/data/comms.json` — communications config. + Comms, + /// `public/games/age-of-dwarves/data/couriers.json` — courier / severable infra. + Couriers, + /// `public/resources/ecology/encounter_rates.json` — authored encounter rates. + EncounterRates, + /// `public/resources/ecology/fauna/lair_combat_modes.json` — lair-siege config. + LairCombatModes, + /// `public/games/age-of-dwarves/data/score.json` — score / award weights. + ScoreWeights, + /// `public/resources/resources.json` — resource catalog (web guide). + Resources, +} + +impl ContentKey { + /// The stable string key. Hosts inject against this; the Rail-2 verify gate + /// keys its manifest on the embedded-default file path, not on this. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + ContentKey::Promotions => "promotions", + ContentKey::Comms => "comms", + ContentKey::Couriers => "couriers", + ContentKey::EncounterRates => "encounter_rates", + ContentKey::LairCombatModes => "lair_combat_modes", + ContentKey::ScoreWeights => "score_weights", + ContentKey::Resources => "resources", + } + } +} + +/// The embedded fallback for each pack — the ONLY `include_str!` content sites +/// for these packs. Filesystem-free (WASM- and GDExtension-safe). The host may +/// override any of these at init via [`register`]. +/// +/// Paths are relative to this file (`crates/mc-core/src/content.rs`); five `..` +/// reach the repo root: `content.rs` → `src` → `mc-core` → `crates` → +/// `simulator` → `src` → repo root. +const EMBEDDED: &[(ContentKey, &str)] = &[ + ( + ContentKey::Promotions, + include_str!("../../../../../public/resources/promotions/promotions.json"), + ), + ( + ContentKey::Comms, + include_str!("../../../../../public/games/age-of-dwarves/data/comms.json"), + ), + ( + ContentKey::Couriers, + include_str!("../../../../../public/games/age-of-dwarves/data/couriers.json"), + ), + ( + ContentKey::EncounterRates, + include_str!("../../../../../public/resources/ecology/encounter_rates.json"), + ), + ( + ContentKey::LairCombatModes, + include_str!("../../../../../public/resources/ecology/fauna/lair_combat_modes.json"), + ), + ( + ContentKey::ScoreWeights, + include_str!("../../../../../public/games/age-of-dwarves/data/score.json"), + ), + ( + ContentKey::Resources, + include_str!("../../../../../public/resources/resources.json"), + ), +]; + +/// Look up the embedded fallback for a key. Always present — the table is the +/// compile-time source of truth and is verified non-empty by tests. +fn embedded(key: ContentKey) -> &'static str { + EMBEDDED + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| *v) + .expect("every ContentKey has an embedded default") +} + +/// Host-injected overrides, keyed by [`ContentKey::as_str`]. Empty until a host +/// calls [`register`]. `RwLock` because reads vastly outnumber the init-time writes. +fn overrides() -> &'static RwLock> { + static OVERRIDES: OnceLock>> = OnceLock::new(); + OVERRIDES.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Inject host-supplied bytes for a content pack, overriding the embedded +/// default. Call at engine init, before any consumer first reads the pack +/// (consumer caches are `OnceLock` — first read wins). +/// +/// Godot passes `res://` bytes here; the web guide passes `fetch`ed bytes. No +/// filesystem access happens in this crate — that is the host's job, keeping +/// the shared sim WASM-safe. +pub fn register(key: ContentKey, json: impl Into) { + let mut map = overrides() + .write() + .expect("content override lock poisoned"); + map.insert(key.as_str(), json.into()); +} + +/// Clear all host overrides, reverting every pack to its embedded default. +/// Primarily for tests that exercise injection without leaking across cases. +pub fn clear_overrides() { + overrides() + .write() + .expect("content override lock poisoned") + .clear(); +} + +/// Raw JSON for a content pack: the host-injected override if present, else the +/// embedded default. The returned `Cow` borrows the embedded `&'static str` in +/// the common (no-override) path, allocating only when an override exists. +#[must_use] +pub fn get(key: ContentKey) -> Cow<'static, str> { + let map = overrides().read().expect("content override lock poisoned"); + match map.get(key.as_str()) { + Some(s) => Cow::Owned(s.clone()), + None => Cow::Borrowed(embedded(key)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_key_has_an_embedded_default_that_parses() { + for key in [ + ContentKey::Promotions, + ContentKey::Comms, + ContentKey::Couriers, + ContentKey::EncounterRates, + ContentKey::LairCombatModes, + ContentKey::ScoreWeights, + ContentKey::Resources, + ] { + let raw = embedded(key); + assert!(!raw.is_empty(), "{:?} embedded default is empty", key); + serde_json::from_str::(raw) + .unwrap_or_else(|e| panic!("{:?} embedded default must be valid JSON: {e}", key)); + } + } + + #[test] + fn keys_are_unique() { + let mut seen = std::collections::HashSet::new(); + for (k, _) in EMBEDDED { + assert!(seen.insert(k.as_str()), "duplicate key {:?}", k); + } + } + + #[test] + fn get_returns_embedded_without_override() { + clear_overrides(); + let raw = get(ContentKey::Promotions); + assert!(raw.contains("xp_thresholds"), "embedded promotions JSON expected"); + } + + #[test] + fn register_overrides_embedded_then_clear_reverts() { + clear_overrides(); + let custom = r#"{"xp_thresholds":[1,2,3],"heal_on_promote_percent":0.0}"#; + register(ContentKey::Promotions, custom); + assert_eq!(get(ContentKey::Promotions).as_ref(), custom); + clear_overrides(); + assert!(get(ContentKey::Promotions).contains("xp_thresholds")); + assert_ne!(get(ContentKey::Promotions).as_ref(), custom); + } +} diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index c1a80f9e..8a821913 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod derived_stats; pub mod building_state; pub mod civic; pub mod combat_balance; +pub mod content; pub mod damage_channel; pub mod diplomacy; pub mod collectibles;