diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index f26ea6ca..41f3fe61 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-06-27T13:41:30Z", + "generated_at": "2026-06-27T13:58:02Z", "totals": { "done": 298, "in_progress": 0, diff --git a/.project/objectives/p3-28-modular-turn-architecture.md b/.project/objectives/p3-28-modular-turn-architecture.md index b1be9363..45cf8e42 100644 --- a/.project/objectives/p3-28-modular-turn-architecture.md +++ b/.project/objectives/p3-28-modular-turn-architecture.md @@ -32,13 +32,18 @@ revealed three SOLID/DRY/DIP debts. "Foundation first" tackled the layering + ph `res://` bytes across FFI, the web guide fetches packs → bytes across bindgen — with `include_str!` surviving **only** as the headless/test fallback. So the target is a registry fed by injected bytes, not a path-reading function. -- [ ] **Rail-2 verify gate (enforcement) — catch the next hardcode before it ships.** The gate - has a Rail-1 check (`verify.sh` Step 18 → `tools/check-no-gdscript-sim-logic.py`) but **no Rail-2 - equivalent** for hardcoded game content in Rust. Add `tools/check-no-rust-hardcoded-content.py` + - a verify step that flags balance-looking `const`/`static` tables in `mc-*` crates which duplicate - a JSON file. ⚠ heuristic — needs a low-false-positive design (likely a small allowlist/registry of - `(json_file, owning_module)` pairs rather than a blind numeric-array grep). Best landed alongside - the `ContentRegistry` so the check becomes "does the registry own this?", not a regex. +- [x] **Rail-2 verify gate (enforcement, v1) — catch the next hardcode before it ships.** ✅ + 2026-06-27 — `tools/check-no-rust-hardcoded-content.py` + `verify.sh` Step 19 (parallel to the + Rail-1 Step 18 gate). Registry-driven, **zero false-positive** by design: a manifest of + `(json_file, owning_module(s), tombstones)` enforces (A) each registered content file stays + `include_str!`-loaded by its owner, and (B) tombstoned const names (e.g. promotions + `XP_THRESHOLDS`/`HEAL_ON_PROMOTE_FRACTION`) never resurrect. **Deliberately NOT** a heuristic + numeric-const grep — that wrongly flags legit sim-tuning consts (`MIGRATION_RATE`, + `DROUGHT_CARRYING_PENALTY` in `mc-ecology/generation.rs`). Proven: passes clean, fails on an + injected `XP_THRESHOLDS` revert. **Limitation:** coverage is opt-in (6 files registered today) — + a brand-new hardcode in an unregistered module isn't caught. The blanket guarantee is the + `ContentRegistry`'s job: when it lands, fold this check into "does the registry own this?" rather + than a manifest. Until then, grow `REGISTRY` as content modules are identified. - [ ] **Dedup the ad-hoc `include_str!` content sites (Opportunity A, same arc)** — verified 2026-06-27: **8 JSON-config `include_str!` sites** across simulator crates, each rolling its own `OnceLock` + a fragile relative path (6 at `../../../../../`). `treaty_rules.json` is diff --git a/infra/packer/golden-image.pkr.hcl b/infra/packer/golden-image.pkr.hcl index 73a5810e..de7a57ca 100644 --- a/infra/packer/golden-image.pkr.hcl +++ b/infra/packer/golden-image.pkr.hcl @@ -31,11 +31,12 @@ variable "region" { default = "nyc3" } -# A one-off CPU-Optimized box builds fast (cargo + godot import are CPU-heavy); -# it only exists for the duration of the build. +# A one-off box builds the image (cargo + godot import are CPU-heavy); it only +# exists for the build. Basic s-* — CPU-Optimized c-* is tier-restricted on new +# DO accounts (needs a support ticket to unlock). variable "build_size" { type = string - default = "c-8" + default = "s-8vcpu-16gb-amd" } variable "git_remote" { @@ -52,6 +53,13 @@ variable "remote_user" { default = "mc" } +variable "fleet_pubkey" { + type = string + default = "" + # The fleet SSH public key, baked into the build user's authorized_keys so the + # dispatch can ssh in without relying on cloud-init's (snapshot-flaky) key copy. +} + locals { ts = formatdate("YYYYMMDDhhmmss", timestamp()) } @@ -73,6 +81,7 @@ build { "GIT_REMOTE=${var.git_remote}", "GIT_REF=${var.git_ref}", "BUILD_USER=${var.remote_user}", + "FLEET_PUBKEY=${var.fleet_pubkey}", ] execute_command = "chmod +x {{ .Path }}; env {{ .Vars }} bash {{ .Path }}" script = "${path.root}/provision.sh" diff --git a/infra/packer/provision.sh b/infra/packer/provision.sh index 1d943349..410ac7a9 100755 --- a/infra/packer/provision.sh +++ b/infra/packer/provision.sh @@ -17,8 +17,11 @@ REPO_PATH="Code/@projects/@magic-civilization" # relative to the build user's HO echo "=== [1/7] base packages (incl. software-render stack: weston + Mesa llvmpipe) ===" export DEBIAN_FRONTEND=noninteractive -apt-get update -y -apt-get install -y --no-install-recommends \ +# Wait for cloud-init's own boot-time apt to finish, then take the dpkg lock with +# a timeout (avoids the first-boot lock race that aborts provisioning). +cloud-init status --wait >/dev/null 2>&1 || true +apt-get -o DPkg::Lock::Timeout=600 update -y +apt-get -o DPkg::Lock::Timeout=600 install -y --no-install-recommends \ git curl ca-certificates build-essential pkg-config libssl-dev \ unzip sudo python3-pip flatpak rsync \ weston libgl1-mesa-dri libegl1 libgles2 libwayland-egl1 \ @@ -26,10 +29,29 @@ apt-get install -y --no-install-recommends \ # So every worker can render proof scenes (opengl3/gl_compatibility) under a # headless weston with no GPU — see tools/capture-proof.sh + tools/autoplay-batch.sh --weston. +echo "=== [1b/7] swapfile (8 GB box needs headroom for the Rust link step) ===" +if ! swapon --show 2>/dev/null | grep -q /swapfile; then + fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile >/dev/null && swapon /swapfile + grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi + echo "=== [2/7] build user '$BUILD_USER' ===" if ! id "$BUILD_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$BUILD_USER" fi +# Passwordless sudo: scripts/dev-setup/linux.sh installs system packages (node, etc.) +# via sudo apt-get; the build user must be able to run them non-interactively. +echo "$BUILD_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/90-${BUILD_USER}" +chmod 440 "/etc/sudoers.d/90-${BUILD_USER}" +# Authorize the fleet key for the build user directly — the dispatch ssh's in as +# this user, and cloud-init's key-copy runcmd is unreliable on snapshot-booted +# droplets. Baking it means ssh works the moment sshd is up. +if [ -n "${FLEET_PUBKEY:-}" ]; then + install -d -m 700 -o "$BUILD_USER" -g "$BUILD_USER" "/home/$BUILD_USER/.ssh" + printf '%s\n' "$FLEET_PUBKEY" > "/home/$BUILD_USER/.ssh/authorized_keys" + chmod 600 "/home/$BUILD_USER/.ssh/authorized_keys" + chown "$BUILD_USER:$BUILD_USER" "/home/$BUILD_USER/.ssh/authorized_keys" +fi BUILD_UID="$(id -u "$BUILD_USER")" # Enable lingering so /run/user/$UID (and the user D-Bus flatpak needs for # headless --import) exists without an interactive login. @@ -70,4 +92,10 @@ as_user "mkdir -p ~/bin && cp ~/$REPO_PATH/scripts/autoplay/run_ap3.sh ~/bin/run as_user "cd ~/$REPO_PATH && flatpak run --user org.godotengine.Godot --path src/game --headless --import" || \ echo "WARN: headless --import did not complete cleanly — validate in the live smoke" +echo "=== reset cloud-init so fleet workers boot FRESH ===" +# Snapshot-booted droplets otherwise carry this build box's 'already ran' state and +# skip runcmd (key copy + git pull). Cleaning makes the new worker run cloud-init +# fully on first boot. +cloud-init clean --logs 2>/dev/null || true + echo "=== golden image provisioned OK ===" diff --git a/infra/terraform/test-fleet/main.tf b/infra/terraform/test-fleet/main.tf index 3dfc4a46..8151a16e 100644 --- a/infra/terraform/test-fleet/main.tf +++ b/infra/terraform/test-fleet/main.tf @@ -2,9 +2,10 @@ # No persistent volume: workers are stateless. The golden image carries the warm # clone + toolchain + prebuilt .so; results leave via the dispatch layer (scp). -resource "digitalocean_ssh_key" "fleet" { - name = "${var.name}-key" - public_key = file(pathexpand(var.ssh_public_key_path)) +# Reuse the already-registered MC fleet key (shared with the forge + packer) rather +# than creating a duplicate — DO rejects a second key with the same public_key. +data "digitalocean_ssh_key" "fleet" { + name = var.ssh_key_name } # Resolve the newest golden image by name substring. Skipped entirely when @@ -34,7 +35,7 @@ resource "digitalocean_droplet" "worker" { size = var.size region = var.region image = local.image - ssh_keys = [digitalocean_ssh_key.fleet.id] + ssh_keys = [data.digitalocean_ssh_key.fleet.id] # Thin cloud-init: copy the injected key to the build user and fast-forward # the warm clone to the requested ref. The golden image already holds the diff --git a/infra/terraform/test-fleet/tests/fleet.tftest.hcl b/infra/terraform/test-fleet/tests/fleet.tftest.hcl index 74ff526c..8619bcf4 100644 --- a/infra/terraform/test-fleet/tests/fleet.tftest.hcl +++ b/infra/terraform/test-fleet/tests/fleet.tftest.hcl @@ -15,9 +15,8 @@ mock_provider "digitalocean" { } variables { - do_token = "mock-token-unused" - git_remote = "https://example.com/magic-civilization.git" - ssh_public_key_path = "./tests/fixtures/id_test.pub" + do_token = "mock-token-unused" + git_remote = "https://example.com/magic-civilization.git" } # base_image set -> golden data source is skipped (count 0); fleet expands to N. diff --git a/infra/terraform/test-fleet/variables.tf b/infra/terraform/test-fleet/variables.tf index f833c989..f4a89b22 100644 --- a/infra/terraform/test-fleet/variables.tf +++ b/infra/terraform/test-fleet/variables.tf @@ -37,7 +37,7 @@ variable "size" { c-8 = 8 vCPU / 16 GB, c-16 = 16 vCPU / 32 GB EOT type = string - default = "s-8vcpu-16gb" + default = "s-8vcpu-16gb-amd" } variable "base_image" { @@ -58,10 +58,10 @@ variable "golden_name_match" { default = "mc-golden" } -variable "ssh_public_key_path" { - description = "Public key authorised on every worker (and used by the dispatch scripts to ssh in)." +variable "ssh_key_name" { + description = "Name of the pre-registered DigitalOcean SSH key the fleet reuses (shared with forge + packer; the dispatch ssh's in with its private half)." type = string - default = "~/.ssh/id_ed25519.pub" + default = "mc-fleet" } variable "name" { diff --git a/scripts/cloud-bringup.sh b/scripts/cloud-bringup.sh new file mode 100644 index 00000000..1d571acc --- /dev/null +++ b/scripts/cloud-bringup.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# One-shot DigitalOcean bring-up + smoke. Run it yourself so the cloud build and +# repo clone happen under your authority (the agent can't auto-clone private +# source onto cloud boxes; you can). It: +# 1. builds the golden image from the forge, +# 2. spins 1 worker, runs the test suite (timed) + a render proof, +# 3. tears the worker down (trap, even on failure). +# +# Launch and walk away: +# nohup bash scripts/cloud-bringup.sh > ~/cloud-bringup.log 2>&1 & +# # ...sleep... then on waking: less ~/cloud-bringup.log ; open ~/Desktop/mc-do-proof.png +# +# Reads all secrets from ~/.vault/ — nothing sensitive is hardcoded here. +set -uo pipefail + +REPO="$HOME/Code/@projects/@magic-civilization" +cd "$REPO" || exit 1 + +# --- auth (from vault) --- +export DIGITALOCEAN_TOKEN; DIGITALOCEAN_TOKEN="$(cat ~/.vault/do_pat_mc)" +export TF_VAR_do_token="$DIGITALOCEAN_TOKEN" +# shellcheck disable=SC1090 +. ~/.vault/mc_forge_creds # FORGE_IP ADMIN_USER ADMIN_PASS ... +GITR="http://${ADMIN_USER}:${ADMIN_PASS}@${FORGE_IP}:3000/mcadmin/magicciv.git" +export TF_VAR_git_remote="$GITR" # workers pull latest from the forge +export PKR_VAR_git_remote="$GITR" # packer reads the creds from env, not argv +PKR_VAR_fleet_pubkey="$(cat ~/.ssh/id_mc_fleet.pub)"; export PKR_VAR_fleet_pubkey # baked into worker authorized_keys +# fleet reuses the pre-registered DO key 'mc-fleet' (var ssh_key_name default); just load its private half +ssh-add ~/.ssh/id_mc_fleet 2>/dev/null || true # so the dispatch ssh (mc@worker) authenticates + +echo "########## $(date) — DO cloud bring-up starting ##########" + +_teardown() { + echo "########## teardown: ./run dist:down ##########" + ./run dist:down 2>&1 | tail -3 || true + echo "forge left UP for inspection — './run forge:down' to park it (~\$0.30/mo idle)." +} +trap _teardown EXIT + +echo "=== [1/4] packer build golden image (~20-40 min) ===" +( cd infra/packer && packer init golden-image.pkr.hcl >/dev/null && \ + packer build golden-image.pkr.hcl ) \ + || { echo "!!! PACKER BUILD FAILED — see above. Stopping."; exit 1; } + +echo "=== [2/4] dist:up 1 worker (s-8vcpu-16gb-amd — beefy, from golden snapshot) ===" +./run dist:up 1 s-8vcpu-16gb-amd || { echo "!!! dist:up FAILED"; exit 1; } +echo " waiting 75s for worker cloud-init (key + git pull) to settle ..." +sleep 75 + +echo "=== [3/4] dist:test on the worker (TIMED — the DX-win proof) ===" +time ./run dist:test || echo " (dist:test returned nonzero — see output above)" + +echo "=== [4/4] dist:render proof scene -> ~/Desktop/mc-do-proof.png ===" +./run dist:render res://engine/scenes/tests/city_proof.tscn "$HOME/Desktop/mc-do-proof.png" 240 \ + || echo " (render returned nonzero — try another scene from src/game/engine/scenes/tests/*_proof.tscn)" + +echo "########## $(date) — bring-up done. Worker will be torn down on exit. ##########" +echo "Review: this log + ~/Desktop/mc-do-proof.png" diff --git a/scripts/run/dist.sh b/scripts/run/dist.sh index bcd77a1e..967e5ca3 100755 --- a/scripts/run/dist.sh +++ b/scripts/run/dist.sh @@ -27,6 +27,26 @@ _dist_read_hosts() { grep -vE '^\s*(#|$)' "$inv" 2>/dev/null || true } +_dist_wait_ready() { + # Block until each worker's cloud-init finishes — it copies the fleet key to the + # build user and git-pulls. DO's boot agent install delays runcmd 1-3 min, so the + # build user isn't ssh-able until then. We ssh as root (authorized immediately) to wait. + local root inv host ip + root="$(_dist_repo_root)"; inv="$root/.local/fleet/inventory" + [ -f "$inv" ] || return 0 + while IFS= read -r host; do + ip="${host#*@}" + printf ' waiting for %s cloud-init... ' "$ip" + local _i + for _i in $(seq 1 36); do + ssh -n -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 -o BatchMode=yes -i ~/.ssh/id_mc_fleet "root@$ip" true 2>/dev/null && break + sleep 5 + done + ssh -n -o BatchMode=yes -i ~/.ssh/id_mc_fleet "root@$ip" 'cloud-init status --wait >/dev/null 2>&1 || true' 2>/dev/null + echo "ready" + done < <(_dist_read_hosts "$inv") +} + cmd_dist() { cat <<'EOF' Distributed test/train fleet (DigitalOcean). Set TF_VAR_do_token first. @@ -67,8 +87,10 @@ cmd_dist_up() { [ -n "${2:-}" ] && args+=(-var "size=$2") [ -n "${3:-}" ] && args+=(-var "region=$3") _dist_tf init -input=false >/dev/null - _dist_tf apply "${args[@]}" - echo "fleet up: $n worker(s). inventory: $(_dist_repo_root)/.local/fleet/inventory" + _dist_tf apply "${args[@]}" || { echo "dist:up FAILED — terraform apply errored (see above)" >&2; return 1; } + echo "fleet up: $n worker(s) — waiting for cloud-init before they're usable..." + _dist_wait_ready + echo "fleet ready. inventory: $(_dist_repo_root)/.local/fleet/inventory" } cmd_dist_down() { diff --git a/scripts/run/forge.sh b/scripts/run/forge.sh index 4ad1b20d..6f020565 100755 --- a/scripts/run/forge.sh +++ b/scripts/run/forge.sh @@ -56,9 +56,20 @@ cmd_forge() { Forgejo origin lifecycle (DigitalOcean). Needs ~/.vault/do_pat_mc. ./run forge:down stop + snapshot + destroy (~$6/mo -> ~$0.30/mo idle) ./run forge:up restore from newest snapshot, refresh vault creds + ./run forge:dns point the 'mcforge' hostname at the current forge IP (sudo; macOS /etc/hosts) EOF } +cmd_forge_dns() { + # Map a friendly hostname to the current forge IP in /etc/hosts (macOS). + # Re-run after forge:up (the IP changes). Browse the forge at http://mcforge:3000. + local name="${1:-mcforge}" ip + ip="$(grep -E '^FORGE_IP=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2)" + [ -n "$ip" ] || { echo "no FORGE_IP in $_VAULT_CREDS" >&2; return 1; } + sudo sh -c "sed -i '' '/[[:space:]]${name}\$/d' /etc/hosts 2>/dev/null; printf '%s\t%s\n' '$ip' '$name' >> /etc/hosts" + echo "/etc/hosts: $name -> $ip → http://$name:3000" +} + cmd_forge_down() { local id ip aid snap id="$(_forge_droplet_id)" diff --git a/scripts/run/verify.sh b/scripts/run/verify.sh index 78592cd2..3efe2815 100644 --- a/scripts/run/verify.sh +++ b/scripts/run/verify.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # `./run verify` — full regression-gate pipeline. # -# Split out of dev.sh. The pipeline is 15 steps covering data validation, -# i18n, objectives dashboard freshness, Rust build/test/clippy/machete/deny/docs, +# Split out of dev.sh. The pipeline covers data validation, i18n, objectives +# dashboard freshness, the Rail-1 (no sim logic in GDScript) and Rail-2 (no +# hardcoded game content in Rust) gates, Rust build/test/clippy/machete/deny/docs, # file-size cap, TS typecheck, GDScript lint (3 trees), and a headless Godot # boot check. Each step times itself; failures abort and print a summary. @@ -85,7 +86,7 @@ cmd_verify() { echo -e "${BLUE}─────────────────────────────────────────────────${NC}" } - local TOTAL=21 + local TOTAL=22 # Step 0 — Game data schema validation _verify_step 0 $TOTAL "game data JSON schemas" \ @@ -108,6 +109,14 @@ cmd_verify() { _verify_step 18 $TOTAL "Rail-1: no sim logic in GDScript" \ python3 "$REPO_ROOT/tools/check-no-gdscript-sim-logic.py" + # Step 19 — Rail-2 gate: Rust loads canonical game content from JSON, never + # hardcodes it. Registry-driven (low false-positive): registered content + # files must stay include_str!-loaded by their owning module, and tombstoned + # hardcodes (e.g. promotions XP_THRESHOLDS) must not resurrect. Stops the + # two-path divergence (in-game DataLoader vs headless include_str copy). + _verify_step 19 $TOTAL "Rail-2: no hardcoded game content in Rust" \ + python3 "$REPO_ROOT/tools/check-no-rust-hardcoded-content.py" + # Step 16 — "Build output never under src/" invariant. # Rule source: .claude/instructions/build-output-locations.md. _verify_step 16 $TOTAL "no build output under src/" \ diff --git a/tools/check-no-rust-hardcoded-content.py b/tools/check-no-rust-hardcoded-content.py new file mode 100755 index 00000000..b8ac7d31 --- /dev/null +++ b/tools/check-no-rust-hardcoded-content.py @@ -0,0 +1,175 @@ +#!/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 + + +@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=()) + + +# 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"), + ), + 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 + + 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") + + # 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) + 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())