fix(infra): make the DO fleet actually work on real hardware + render host

Real-DO testing surfaced bugs the mocked tests couldn't:
- ssh key: reference shared 'mc-fleet' key via data source, not a duplicate (DO 422s on dup pubkeys).
- cmd_dist_up: fail loudly on failed apply; dist:up waits for cloud-init readiness.
- snapshot cloud-init skips runcmd -> bake authorized_keys (FLEET_PUBKEY) + 'cloud-init clean' before snapshot.
- build user passwordless sudo; apt dpkg-lock race fixed (cloud-init --wait + Lock::Timeout).
- size s-8vcpu-16gb-amd (tier max); creds via PKR_VAR env not argv.
- render host: weston+Mesa baked; ./run dist:render proven (Godot->PNG on DO, no GPU). forge:dns shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 12:45:29 -04:00
parent a5d66ce477
commit 6332d47011
12 changed files with 346 additions and 29 deletions

View file

@ -1,5 +1,5 @@
{
"generated_at": "2026-06-27T13:41:30Z",
"generated_at": "2026-06-27T13:58:02Z",
"totals": {
"done": 298,
"in_progress": 0,

View file

@ -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

View file

@ -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"

View file

@ -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 ==="

View file

@ -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

View file

@ -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.

View file

@ -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" {

58
scripts/cloud-bringup.sh Normal file
View file

@ -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"

View file

@ -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() {

View file

@ -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)"

View file

@ -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/" \

View file

@ -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())