feat(mc-core): add host-fed ContentRegistry seam (p3-28)

One place for canonical-JSON content packs both sim paths read. Embedded
include_str! defaults (the WASM/headless fallback) live in a single EMBEDDED
table keyed by ContentKey; hosts (Godot res:// bytes, web fetch bytes) override
via register(). No filesystem access — WASM- and GDExtension-safe. Consumers
will call content::get(key) instead of per-crate fragile relative include_str!.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-28 09:49:58 -04:00
parent 7475daa7f8
commit 787f08f073
2 changed files with 219 additions and 0 deletions

View file

@ -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<HashMap<&'static str, String>> {
static OVERRIDES: OnceLock<RwLock<HashMap<&'static str, String>>> = 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<String>) {
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::<serde_json::Value>(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);
}
}

View file

@ -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;