2026-06-27 12:45:29 -04:00
|
|
|
#!/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
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 09:56:16 -04:00
|
|
|
# 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"
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 12:45:29 -04:00
|
|
|
@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=())
|
2026-06-28 09:56:16 -04:00
|
|
|
# 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)
|
2026-06-27 12:45:29 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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"),
|
2026-06-28 09:56:16 -04:00
|
|
|
# p3-28: embedded fallback now lives in the central ContentRegistry;
|
|
|
|
|
# mc-combat reads it via mc_core::content::get(Promotions).
|
|
|
|
|
registry_owned=True,
|
2026-06-27 12:45:29 -04:00
|
|
|
),
|
|
|
|
|
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
|
|
|
|
|
|
2026-06-28 09:56:16 -04:00
|
|
|
# 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.
|
2026-06-27 12:45:29 -04:00
|
|
|
for mod in entry.modules:
|
|
|
|
|
mod_path = REPO_ROOT / mod
|
|
|
|
|
if not mod_path.is_file():
|
2026-06-28 09:56:16 -04:00
|
|
|
# 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}"
|
|
|
|
|
)
|
2026-06-27 12:45:29 -04:00
|
|
|
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())
|