diff --git a/src/simulator/crates/mc-combat/src/promotions.rs b/src/simulator/crates/mc-combat/src/promotions.rs index 01f5ff9b..45f40896 100644 --- a/src/simulator/crates/mc-combat/src/promotions.rs +++ b/src/simulator/crates/mc-combat/src/promotions.rs @@ -2,9 +2,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::OnceLock; -const PROMOTIONS_JSON: &str = - include_str!("../../../../../public/resources/promotions/promotions.json"); - /// Promotion tuning loaded from the canonical content store /// (`public/resources/promotions/promotions.json`). Rail-2: neither Rust nor /// GDScript hardcodes game content — the XP thresholds and heal-on-promote @@ -18,14 +15,16 @@ struct PromotionConfig { heal_on_promote_percent: f32, } -/// Load + cache the promotion tuning. The JSON is compiled in via `include_str!` -/// (WASM- and GDExtension-safe — no filesystem access at runtime) and parsed -/// once into a process-wide `OnceLock`, mirroring `mc_comms::config`. +/// Load + cache the promotion tuning. The raw JSON comes from the host-fed +/// [`mc_core::content`] registry (embedded `include_str!` fallback when no host +/// override — WASM- and GDExtension-safe, no runtime filesystem access) and is +/// parsed once into a process-wide `OnceLock`. fn promotion_config() -> &'static PromotionConfig { static CELL: OnceLock = OnceLock::new(); CELL.get_or_init(|| { + let json = mc_core::content::get(mc_core::content::ContentKey::Promotions); let value: serde_json::Value = - serde_json::from_str(PROMOTIONS_JSON).expect("promotions.json must parse as valid JSON"); + serde_json::from_str(&json).expect("promotions.json must parse as valid JSON"); let xp_thresholds = value .get("xp_thresholds") .and_then(|v| serde_json::from_value::>(v.clone()).ok()) @@ -259,8 +258,9 @@ fn promotion_registry() -> &'static HashMap { } fn build_registry() -> HashMap { + let json = mc_core::content::get(mc_core::content::ContentKey::Promotions); let value: serde_json::Value = - serde_json::from_str(PROMOTIONS_JSON).expect("promotions.json must parse as valid JSON"); + serde_json::from_str(&json).expect("promotions.json must parse as valid JSON"); let trees = value .get("trees") .and_then(serde_json::Value::as_object) diff --git a/tools/check-no-rust-hardcoded-content.py b/tools/check-no-rust-hardcoded-content.py index b8ac7d31..d1e3454d 100755 --- a/tools/check-no-rust-hardcoded-content.py +++ b/tools/check-no-rust-hardcoded-content.py @@ -46,6 +46,14 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent +# The central host-fed content registry (p3-28). When a content pack's embedded +# `include_str!` fallback is centralized here, Check A is satisfied by THIS file +# carrying the include_str! rather than each consumer module — the consumers now +# read the bytes via `mc_core::content::get(...)`. Tombstones still apply to the +# original consumer modules (a hardcode resurrection is caught wherever it lands). +CONTENT_REGISTRY_MODULE = "src/simulator/crates/mc-core/src/content.rs" + + @dataclass(frozen=True) class ContentEntry: """One canonical JSON content file and the Rust module(s) that load it.""" @@ -55,6 +63,10 @@ class ContentEntry: # const/static names that PREVIOUSLY hardcoded this content and were removed; # the gate fails if any reappears in an owning module (a divergence regression). tombstones: tuple[str, ...] = field(default=()) + # True once the embedded include_str! fallback for this pack has moved into the + # central ContentRegistry (CONTENT_REGISTRY_MODULE). Check A then verifies the + # registry loads the JSON; `modules` are kept only for the tombstone scan. + registry_owned: bool = field(default=False) # The registry. Each entry was verified (file:line) at authoring time against the @@ -66,6 +78,9 @@ REGISTRY: tuple[ContentEntry, ...] = ( # Removed 2026-06-27 when promotion tuning moved to promotions.json # (the divergence that motivated this gate). Must not come back. tombstones=("XP_THRESHOLDS", "HEAL_ON_PROMOTE_FRACTION"), + # p3-28: embedded fallback now lives in the central ContentRegistry; + # mc-combat reads it via mc_core::content::get(Promotions). + registry_owned=True, ), ContentEntry( json="public/resources/diplomacy/treaty_rules.json", @@ -121,24 +136,53 @@ def main() -> int: ) continue + # Check A — the canonical JSON must be include_str!-loaded somewhere the + # headless/WASM path reaches. For registry-owned packs that is the central + # ContentRegistry; otherwise it is each owning consumer module. + if entry.registry_owned: + reg_path = REPO_ROOT / CONTENT_REGISTRY_MODULE + reg_text = ( + reg_path.read_text(encoding="utf-8", errors="replace") + if reg_path.is_file() + else "" + ) + if "include_str!" not in reg_text or suffix not in reg_text: + violations.append( + f"[A] {CONTENT_REGISTRY_MODULE} no longer embeds {entry.json}\n" + f" this pack is registry_owned — the central ContentRegistry must\n" + f" carry an include_str!(... {suffix}) embedded fallback (Rail-2)." + ) + else: + for mod in entry.modules: + mod_path = REPO_ROOT / mod + if not mod_path.is_file(): + violations.append( + f"[A] owning module not found: {mod}\n" + f" registered as loader of {entry.json}" + ) + continue + text = mod_path.read_text(encoding="utf-8", errors="replace") + if "include_str!" not in text or suffix not in text: + violations.append( + f"[A] {mod} no longer loads {entry.json}\n" + f" expected an include_str!(... {suffix}) — content must be\n" + f" LOADED from the canonical JSON, not hardcoded (Rail-2)." + ) + + # Check B — no tombstoned hardcode may reappear in any consumer module. for mod in entry.modules: mod_path = REPO_ROOT / mod if not mod_path.is_file(): - violations.append( - f"[A] owning module not found: {mod}\n" - f" registered as loader of {entry.json}" - ) + # Only matters for tombstone scanning; a missing non-registry + # loader was already reported above. + if entry.registry_owned and entry.tombstones: + violations.append( + f"[A] tombstone-owner module not found: {mod}\n" + f" registered for tombstone scan of {entry.json}" + ) continue text = mod_path.read_text(encoding="utf-8", errors="replace") - # Check A — the module must include_str! its registered content. - if "include_str!" not in text or suffix not in text: - violations.append( - f"[A] {mod} no longer loads {entry.json}\n" - f" expected an include_str!(... {suffix}) — content must be\n" - f" LOADED from the canonical JSON, not hardcoded (Rail-2)." - ) - # Check B — no tombstoned hardcode may reappear. for name in entry.tombstones: rx = _const_decl(name)