Compare commits
5 commits
main
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a401701810 | ||
|
|
af91484343 | ||
|
|
69f80189c6 | ||
|
|
1c256e7db4 | ||
|
|
787f08f073 |
10 changed files with 306 additions and 56 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
//! Resource catalog WASM bridge — exposes per-tile resource metadata
|
//! Resource catalog WASM bridge — exposes per-tile resource metadata
|
||||||
//! including the 3-axis visibility schema landed in p2-54.
|
//! including the 3-axis visibility schema landed in p2-54.
|
||||||
//!
|
//!
|
||||||
//! The catalog is baked at compile time from `public/resources/resources.json`
|
//! The catalog is read from the host-fed `mc_core::content` registry (embedded
|
||||||
//! and parsed once via `OnceLock` on first access.
|
//! `public/resources/resources.json` fallback) and parsed once via `OnceLock`
|
||||||
|
//! on first access.
|
||||||
|
|
||||||
use mc_core::grid::GridState;
|
use mc_core::grid::GridState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -44,8 +45,8 @@ static CATALOG: OnceLock<HashMap<String, CatalogEntry>> = OnceLock::new();
|
||||||
|
|
||||||
fn catalog() -> &'static HashMap<String, CatalogEntry> {
|
fn catalog() -> &'static HashMap<String, CatalogEntry> {
|
||||||
CATALOG.get_or_init(|| {
|
CATALOG.get_or_init(|| {
|
||||||
const JSON: &str = include_str!("../../../../public/resources/resources.json");
|
let json = mc_core::content::get(mc_core::content::ContentKey::Resources);
|
||||||
let bundle: ResourcesJson = serde_json::from_str(JSON).unwrap_or(ResourcesJson {
|
let bundle: ResourcesJson = serde_json::from_str(&json).unwrap_or(ResourcesJson {
|
||||||
bonus: vec![],
|
bonus: vec![],
|
||||||
luxury: vec![],
|
luxury: vec![],
|
||||||
strategic: vec![],
|
strategic: vec![],
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const PROMOTIONS_JSON: &str =
|
|
||||||
include_str!("../../../../../public/resources/promotions/promotions.json");
|
|
||||||
|
|
||||||
/// Promotion tuning loaded from the canonical content store
|
/// Promotion tuning loaded from the canonical content store
|
||||||
/// (`public/resources/promotions/promotions.json`). Rail-2: neither Rust nor
|
/// (`public/resources/promotions/promotions.json`). Rail-2: neither Rust nor
|
||||||
/// GDScript hardcodes game content — the XP thresholds and heal-on-promote
|
/// GDScript hardcodes game content — the XP thresholds and heal-on-promote
|
||||||
|
|
@ -18,14 +15,16 @@ struct PromotionConfig {
|
||||||
heal_on_promote_percent: f32,
|
heal_on_promote_percent: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load + cache the promotion tuning. The JSON is compiled in via `include_str!`
|
/// Load + cache the promotion tuning. The raw JSON comes from the host-fed
|
||||||
/// (WASM- and GDExtension-safe — no filesystem access at runtime) and parsed
|
/// [`mc_core::content`] registry (embedded `include_str!` fallback when no host
|
||||||
/// once into a process-wide `OnceLock`, mirroring `mc_comms::config`.
|
/// override — WASM- and GDExtension-safe, no runtime filesystem access) and is
|
||||||
|
/// parsed once into a process-wide `OnceLock`.
|
||||||
fn promotion_config() -> &'static PromotionConfig {
|
fn promotion_config() -> &'static PromotionConfig {
|
||||||
static CELL: OnceLock<PromotionConfig> = OnceLock::new();
|
static CELL: OnceLock<PromotionConfig> = OnceLock::new();
|
||||||
CELL.get_or_init(|| {
|
CELL.get_or_init(|| {
|
||||||
|
let json = mc_core::content::get(mc_core::content::ContentKey::Promotions);
|
||||||
let value: serde_json::Value =
|
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
|
let xp_thresholds = value
|
||||||
.get("xp_thresholds")
|
.get("xp_thresholds")
|
||||||
.and_then(|v| serde_json::from_value::<Vec<i32>>(v.clone()).ok())
|
.and_then(|v| serde_json::from_value::<Vec<i32>>(v.clone()).ok())
|
||||||
|
|
@ -259,8 +258,9 @@ fn promotion_registry() -> &'static HashMap<String, PromotionDef> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_registry() -> HashMap<String, PromotionDef> {
|
fn build_registry() -> HashMap<String, PromotionDef> {
|
||||||
|
let json = mc_core::content::get(mc_core::content::ContentKey::Promotions);
|
||||||
let value: serde_json::Value =
|
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
|
let trees = value
|
||||||
.get("trees")
|
.get("trees")
|
||||||
.and_then(serde_json::Value::as_object)
|
.and_then(serde_json::Value::as_object)
|
||||||
|
|
|
||||||
|
|
@ -74,19 +74,18 @@ pub struct Phase3Cfg {
|
||||||
pub beacon_tap: BeaconTapCfg,
|
pub beacon_tap: BeaconTapCfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load Phase 3 config from the bundled `comms.json`. The JSON is
|
/// Load Phase 3 config from `comms.json`. The raw JSON comes from the host-fed
|
||||||
/// compiled in via `include_str!`, parsed once, and cached.
|
/// [`mc_core::content`] registry (embedded `include_str!` fallback when no host
|
||||||
|
/// override), parsed once, and cached.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn phase3_cfg() -> &'static Phase3Cfg {
|
pub fn phase3_cfg() -> &'static Phase3Cfg {
|
||||||
static CELL: OnceLock<Phase3Cfg> = OnceLock::new();
|
static CELL: OnceLock<Phase3Cfg> = OnceLock::new();
|
||||||
CELL.get_or_init(|| {
|
CELL.get_or_init(|| {
|
||||||
const JSON: &str = include_str!(
|
let json = mc_core::content::get(mc_core::content::ContentKey::Comms);
|
||||||
"../../../../../public/games/age-of-dwarves/data/comms.json"
|
|
||||||
);
|
|
||||||
// Tolerate field shape: parse the whole file as a generic Value,
|
// Tolerate field shape: parse the whole file as a generic Value,
|
||||||
// then pluck the two blocks we care about. Missing blocks fall
|
// then pluck the two blocks we care about. Missing blocks fall
|
||||||
// back to defaults.
|
// back to defaults.
|
||||||
let value: serde_json::Value = serde_json::from_str(JSON)
|
let value: serde_json::Value = serde_json::from_str(&json)
|
||||||
.expect("comms.json must parse as valid JSON");
|
.expect("comms.json must parse as valid JSON");
|
||||||
let capital_blackout = value
|
let capital_blackout = value
|
||||||
.get("capital_blackout")
|
.get("capital_blackout")
|
||||||
|
|
|
||||||
|
|
@ -134,10 +134,8 @@ pub fn heartbeat_interval_for_tier(tier: u8) -> Option<u32> {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
static CELL: OnceLock<Vec<(u8, Option<u32>)>> = OnceLock::new();
|
static CELL: OnceLock<Vec<(u8, Option<u32>)>> = OnceLock::new();
|
||||||
let table = CELL.get_or_init(|| {
|
let table = CELL.get_or_init(|| {
|
||||||
const JSON: &str = include_str!(
|
let json = mc_core::content::get(mc_core::content::ContentKey::Comms);
|
||||||
"../../../../../public/games/age-of-dwarves/data/comms.json"
|
let value: serde_json::Value = serde_json::from_str(&json)
|
||||||
);
|
|
||||||
let value: serde_json::Value = serde_json::from_str(JSON)
|
|
||||||
.expect("comms.json must parse as valid JSON");
|
.expect("comms.json must parse as valid JSON");
|
||||||
let mut out: Vec<(u8, Option<u32>)> = Vec::new();
|
let mut out: Vec<(u8, Option<u32>)> = Vec::new();
|
||||||
if let Some(arr) = value.get("comm_tier_table").and_then(|v| v.as_array()) {
|
if let Some(arr) = value.get("comm_tier_table").and_then(|v| v.as_array()) {
|
||||||
|
|
|
||||||
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 building_state;
|
||||||
pub mod civic;
|
pub mod civic;
|
||||||
pub mod combat_balance;
|
pub mod combat_balance;
|
||||||
|
pub mod content;
|
||||||
pub mod damage_channel;
|
pub mod damage_channel;
|
||||||
pub mod diplomacy;
|
pub mod diplomacy;
|
||||||
pub mod collectibles;
|
pub mod collectibles;
|
||||||
|
|
|
||||||
|
|
@ -938,16 +938,15 @@ fn classify_relation(rel: &mc_trade::relation::RelationState) -> String {
|
||||||
|
|
||||||
/// Canonical score weights, loaded once from
|
/// Canonical score weights, loaded once from
|
||||||
/// `public/games/age-of-dwarves/data/score.json` (Rail-2 — no hardcoded weights).
|
/// `public/games/age-of-dwarves/data/score.json` (Rail-2 — no hardcoded weights).
|
||||||
/// Compiled in via `include_str!` because the projector runs on every `view()`
|
/// The raw JSON comes from the host-fed [`mc_core::content`] registry (embedded
|
||||||
/// and per-call I/O is unacceptable.
|
/// `include_str!` fallback, no per-call I/O) and is cached for the process — the
|
||||||
|
/// projector runs on every `view()` so per-call I/O would be unacceptable.
|
||||||
fn score_weights() -> &'static mc_score::ScoreWeights {
|
fn score_weights() -> &'static mc_score::ScoreWeights {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
static WEIGHTS: OnceLock<mc_score::ScoreWeights> = OnceLock::new();
|
static WEIGHTS: OnceLock<mc_score::ScoreWeights> = OnceLock::new();
|
||||||
WEIGHTS.get_or_init(|| {
|
WEIGHTS.get_or_init(|| {
|
||||||
const JSON: &str = include_str!(
|
let json = mc_core::content::get(mc_core::content::ContentKey::ScoreWeights);
|
||||||
"../../../../../public/games/age-of-dwarves/data/score.json"
|
mc_score::ScoreWeights::from_json(&json).expect("score.json must parse")
|
||||||
);
|
|
||||||
mc_score::ScoreWeights::from_json(JSON).expect("score.json must parse")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,9 +222,7 @@ fn severable_infra_from_data() -> Option<&'static [(&'static str, u8)]> {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
static CELL: OnceLock<Vec<(&'static str, u8)>> = OnceLock::new();
|
static CELL: OnceLock<Vec<(&'static str, u8)>> = OnceLock::new();
|
||||||
let parsed = CELL.get_or_init(|| {
|
let parsed = CELL.get_or_init(|| {
|
||||||
const JSON: &str = include_str!(
|
let json = mc_core::content::get(mc_core::content::ContentKey::Couriers);
|
||||||
"../../../../../public/games/age-of-dwarves/data/couriers.json"
|
|
||||||
);
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct File {
|
struct File {
|
||||||
severable_infra: Vec<Entry>,
|
severable_infra: Vec<Entry>,
|
||||||
|
|
@ -234,7 +232,7 @@ fn severable_infra_from_data() -> Option<&'static [(&'static str, u8)]> {
|
||||||
era_tier: u8,
|
era_tier: u8,
|
||||||
improvement_id: String,
|
improvement_id: String,
|
||||||
}
|
}
|
||||||
match serde_json::from_str::<File>(JSON) {
|
match serde_json::from_str::<File>(&json) {
|
||||||
Ok(parsed) => parsed
|
Ok(parsed) => parsed
|
||||||
.severable_infra
|
.severable_infra
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
|
|
@ -326,13 +326,8 @@ pub struct TurnProcessor {
|
||||||
pub fn authored_encounter_rates() -> &'static EncounterRates {
|
pub fn authored_encounter_rates() -> &'static EncounterRates {
|
||||||
static RATES: OnceLock<EncounterRates> = OnceLock::new();
|
static RATES: OnceLock<EncounterRates> = OnceLock::new();
|
||||||
RATES.get_or_init(|| {
|
RATES.get_or_init(|| {
|
||||||
// 4 `..` from this crate's manifest dir reaches the repo root:
|
let json = mc_core::content::get(mc_core::content::ContentKey::EncounterRates);
|
||||||
// crates/mc-turn -> crates -> simulator -> src -> repo root.
|
EncounterRates::from_json(&json).expect("authored encounter_rates.json must parse")
|
||||||
const JSON: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/../../../../public/resources/ecology/encounter_rates.json"
|
|
||||||
));
|
|
||||||
EncounterRates::from_json(JSON).expect("authored encounter_rates.json must parse")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,11 +339,8 @@ pub fn authored_encounter_rates() -> &'static EncounterRates {
|
||||||
pub fn authored_lair_siege_config() -> &'static crate::lair_siege::LairSiegeConfig {
|
pub fn authored_lair_siege_config() -> &'static crate::lair_siege::LairSiegeConfig {
|
||||||
static CFG: OnceLock<crate::lair_siege::LairSiegeConfig> = OnceLock::new();
|
static CFG: OnceLock<crate::lair_siege::LairSiegeConfig> = OnceLock::new();
|
||||||
CFG.get_or_init(|| {
|
CFG.get_or_init(|| {
|
||||||
const JSON: &str = include_str!(concat!(
|
let json = mc_core::content::get(mc_core::content::ContentKey::LairCombatModes);
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
crate::lair_siege::LairSiegeConfig::from_lair_combat_modes_json(&json)
|
||||||
"/../../../../public/resources/ecology/fauna/lair_combat_modes.json"
|
|
||||||
));
|
|
||||||
crate::lair_siege::LairSiegeConfig::from_lair_combat_modes_json(JSON)
|
|
||||||
.expect("authored lair_combat_modes.json must carry a siege.siege_pressure block")
|
.expect("authored lair_combat_modes.json must carry a siege.siege_pressure block")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@ from pathlib import Path
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
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)
|
@dataclass(frozen=True)
|
||||||
class ContentEntry:
|
class ContentEntry:
|
||||||
"""One canonical JSON content file and the Rust module(s) that load it."""
|
"""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;
|
# const/static names that PREVIOUSLY hardcoded this content and were removed;
|
||||||
# the gate fails if any reappears in an owning module (a divergence regression).
|
# the gate fails if any reappears in an owning module (a divergence regression).
|
||||||
tombstones: tuple[str, ...] = field(default=())
|
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
|
# 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
|
# Removed 2026-06-27 when promotion tuning moved to promotions.json
|
||||||
# (the divergence that motivated this gate). Must not come back.
|
# (the divergence that motivated this gate). Must not come back.
|
||||||
tombstones=("XP_THRESHOLDS", "HEAL_ON_PROMOTE_FRACTION"),
|
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(
|
ContentEntry(
|
||||||
json="public/resources/diplomacy/treaty_rules.json",
|
json="public/resources/diplomacy/treaty_rules.json",
|
||||||
|
|
@ -121,6 +136,23 @@ def main() -> int:
|
||||||
)
|
)
|
||||||
continue
|
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:
|
for mod in entry.modules:
|
||||||
mod_path = REPO_ROOT / mod
|
mod_path = REPO_ROOT / mod
|
||||||
if not mod_path.is_file():
|
if not mod_path.is_file():
|
||||||
|
|
@ -130,8 +162,6 @@ def main() -> int:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
text = mod_path.read_text(encoding="utf-8", errors="replace")
|
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:
|
if "include_str!" not in text or suffix not in text:
|
||||||
violations.append(
|
violations.append(
|
||||||
f"[A] {mod} no longer loads {entry.json}\n"
|
f"[A] {mod} no longer loads {entry.json}\n"
|
||||||
|
|
@ -139,6 +169,20 @@ def main() -> int:
|
||||||
f" LOADED from the canonical JSON, not hardcoded (Rail-2)."
|
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():
|
||||||
|
# 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 B — no tombstoned hardcode may reappear.
|
# Check B — no tombstoned hardcode may reappear.
|
||||||
for name in entry.tombstones:
|
for name in entry.tombstones:
|
||||||
rx = _const_decl(name)
|
rx = _const_decl(name)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue