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:
parent
7475daa7f8
commit
787f08f073
2 changed files with 219 additions and 0 deletions
218
src/simulator/crates/mc-core/src/content.rs
Normal file
218
src/simulator/crates/mc-core/src/content.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue