#!/usr/bin/env python3 """Rail-2 guard: Rust loads canonical game content from JSON, never hardcodes it. Rail-2 — "JSON game packs are the canonical content store; neither Rust nor GDScript hardcodes game content." The failure mode this gate exists to stop is the *two-path divergence*: content reaches the sim two ways — in-game the GDScript `DataLoader` reads the JSON at runtime, headless (tests, CI, AI self-play, the WASM guide) Rust falls back to a compile-time copy. If a balance table is also hardcoded in a Rust crate, the two copies drift apart silently, and the headless path is where the AI trains. (See instruction module `rust-source-of-truth.md` → "the two-path divergence", and p3-28 for the structural endgame: one host-fed `ContentRegistry` both paths read.) This is a REGISTRY-DRIVEN gate, deliberately low-false-positive — it does NOT blind-grep for "balance-looking constants" (that flags legit sim-tuning consts like `MIGRATION_RATE`). It enforces two things per registered content file: Check A — the JSON file exists and its owning module(s) actually `include_str!` it (the headless Rust path reads the canonical JSON, not a private copy). Catches a module that stops loading its content. Check B — "tombstones": const/static names that previously held now-externalized content must NOT reappear in the owning module. Exact-name match → zero false positives. Catches a re-introduced hardcode (the exact promotions regression: XP_THRESHOLDS / HEAL_ON_PROMOTE_FRACTION). Coverage is opt-in: a file is guarded once it's added to REGISTRY below. This is intentional — the gate makes a precise promise (registered content stays loaded; known-deleted hardcodes stay dead), not the unkeepable one of catching every possible future hardcode. That blanket guarantee is the ContentRegistry's job (p3-28). Grow REGISTRY as content modules are identified. Escape hatch: none needed — Check B uses exact tombstone names, so it only ever fires on a deliberate resurrection of a deleted hardcode. Usage: tools/check-no-rust-hardcoded-content.py # report; exit 1 if violations """ from __future__ import annotations import re import sys from dataclasses import dataclass, field 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.""" json: str # repo-relative path to the canonical JSON content file modules: tuple[str, ...] # repo-relative Rust module(s) that must load it # 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 # `include_str!` content sites in src/simulator/crates/*/src/. REGISTRY: tuple[ContentEntry, ...] = ( ContentEntry( json="public/resources/promotions/promotions.json", modules=("src/simulator/crates/mc-combat/src/promotions.rs",), # 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", modules=( "src/simulator/crates/mc-trade/src/rules.rs", "src/simulator/crates/mc-trade/src/tribute.rs", "src/simulator/crates/mc-trade/src/renewal.rs", ), ), ContentEntry( json="public/resources/ai/freepeople/freepeople.json", modules=("src/simulator/crates/mc-trade/src/tribute.rs",), ), ContentEntry( json="public/games/age-of-dwarves/data/score.json", modules=("src/simulator/crates/mc-score/src/lib.rs",), ), ContentEntry( json="public/resources/ecology/traits/biome_trait_weights.json", modules=("src/simulator/crates/mc-ecology/src/generation.rs",), ), ContentEntry( json="public/resources/ecology/traits/flavor.json", modules=("src/simulator/crates/mc-ecology/src/generation.rs",), ), ) def _json_suffix(json_path: str) -> str: """Last two path segments, e.g. 'promotions/promotions.json' — robust to the differing `../` depths used across include_str! sites.""" parts = json_path.split("/") return "/".join(parts[-2:]) def _const_decl(name: str) -> re.Pattern[str]: """Match a `const NAME:` / `static NAME:` (optionally `pub`) declaration.""" return re.compile(rf"^\s*(?:pub\s+)?(?:const|static)\s+{re.escape(name)}\s*:") def main() -> int: violations: list[str] = [] for entry in REGISTRY: json_path = REPO_ROOT / entry.json suffix = _json_suffix(entry.json) # Check A.0 — the canonical content file must exist. if not json_path.is_file(): violations.append( f"[A] missing canonical content file: {entry.json}\n" f" registered as game content but not present on disk" ) 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(): # 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. for name in entry.tombstones: rx = _const_decl(name) for lineno, line in enumerate(text.splitlines(), start=1): if rx.search(line): violations.append( f"[B] {mod}:{lineno} resurrects deleted hardcode `{name}`\n" f" {line.strip()}\n" f" This content lives in {entry.json}; load it, don't hardcode (Rail-2)." ) if violations: print(f"Rail-2 violation: {len(violations)} content-divergence issue(s):\n") for v in violations: print(f" {v}") print( "\nRail-2: JSON game packs are the canonical content store. A Rust crate\n" "must LOAD balance content from public/resources/** (OnceLock+include_str!,\n" "WASM/gdext-safe), never hold a second hardcoded copy that drifts from the\n" "JSON. See .claude/instructions/rust-source-of-truth.md and p3-28." ) return 1 n_files = len(REGISTRY) n_tombstones = sum(len(e.tombstones) for e in REGISTRY) print( f"OK: {n_files} registered content file(s) loaded from JSON; " f"{n_tombstones} tombstoned hardcode(s) stay dead (Rail-2)" ) return 0 if __name__ == "__main__": sys.exit(main())