test(@projects/@magic-civilization): 🚦 Rail-1 verify gate — no game-data transform logic in GDScript

Add tools/check-no-gdscript-sim-logic.py and wire it as verify step 18 (TOTAL
20→21). Fails if presentation GDScript (src/game/engine/src/**/*.gd) re-introduces
catalog yield aggregation (`yield_production += …`) or hand-built spec dicts
(`"yield_production": …`) — the exact drift class just moved to Rust. Verified to
flag the pre-7e2baa25d aggregation and pass clean on the current tree. Logic
belongs in the mc-* crates, reached via the GDExtension bridge (Rail 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 23:55:39 -04:00
parent 5f3e02af5e
commit d49993e3dd
2 changed files with 88 additions and 1 deletions

View file

@ -85,7 +85,7 @@ cmd_verify() {
echo -e "${BLUE}─────────────────────────────────────────────────${NC}"
}
local TOTAL=20
local TOTAL=21
# Step 0 — Game data schema validation
_verify_step 0 $TOTAL "game data JSON schemas" \
@ -102,6 +102,12 @@ cmd_verify() {
_verify_step 17 $TOTAL "no hardcoded applied UI colours" \
python3 "$REPO_ROOT/tools/check-ui-color-sources.py"
# Step 18 — Rail-1 gate: no game-data transform/aggregation logic in
# presentation GDScript (catalog yield aggregation / hand-built spec dicts).
# Logic belongs in the mc-* Rust crates, reached via the GDExtension bridge.
_verify_step 18 $TOTAL "Rail-1: no sim logic in GDScript" \
python3 "$REPO_ROOT/tools/check-no-gdscript-sim-logic.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,81 @@
#!/usr/bin/env python3
"""Rail-1 guard: no game-data transform/aggregation logic in GDScript.
GDScript is presentation only. Simulation logic stat aggregation, catalog
construction, balance math lives in Rust (the `mc-*` crates) and is reached
through the GDExtension bridge. This gate fails if presentation GDScript
re-introduces the kind of game-data transform that was deliberately moved to
Rust, so the single-source-of-truth (Rail 1 + Rail 2) cannot silently regress
the way the unit/building catalogs did.
Flagged in `src/game/engine/src/**/*.gd` (presentation; tests are excluded):
* yield aggregation `yield_production += ...` (summing sim yields)
* spec construction `"yield_production": ...` (hand-building a catalog spec)
Reach for the canonical Rust transform instead:
`mc_ai::tactical::parse_unit_catalog` / `parse_building_catalog`, exposed via
`GdItemSystem.{parse_unit_catalog_json, aggregate_building_catalog_json}`, and
delegate from GDScript.
Usage:
tools/check-no-gdscript-sim-logic.py # report; exit 1 if violations
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SRC = REPO_ROOT / "src" / "game" / "engine" / "src"
# The scalar yield channels the tactical catalogs aggregate. Kept in sync with
# `TacticalBuildingSpec` (mc-core/tactical_types.rs).
_YIELD = r"(food|production|gold|science|culture|defense|happiness|gpp|great_work_slots)"
_PATTERNS: list[tuple[re.Pattern[str], str]] = [
# `yield_production += ...` or `item["yield_production"] += ...`
(re.compile(rf"yield_{_YIELD}\b[\"\]\s]*\+="),
"yield aggregation — sum sim yields in Rust, not GDScript"),
# `"yield_production": ...` — a catalog spec dict built by hand.
(re.compile(rf'"yield_{_YIELD}"\s*:'),
"catalog spec dict constructed in GDScript — use the Rust transform"),
]
def main() -> int:
if not SRC.is_dir():
print(f"error: GDScript source dir not found: {SRC}", file=sys.stderr)
return 2
violations: list[tuple[str, int, str, str]] = []
for path in sorted(SRC.rglob("*.gd")):
text = path.read_text(encoding="utf-8", errors="replace")
for lineno, line in enumerate(text.splitlines(), start=1):
stripped = line.strip()
# Skip comment lines (doc comments legitimately name these fields).
if stripped.startswith("#"):
continue
for rx, why in _PATTERNS:
if rx.search(line):
rel = path.relative_to(REPO_ROOT)
violations.append((str(rel), lineno, why, stripped))
if violations:
print(f"Rail-1 violation: {len(violations)} game-data transform site(s) in presentation GDScript:")
for rel, lineno, why, stripped in violations:
print(f" {rel}:{lineno} [{why}]")
print(f" {stripped}")
print(
"\nGDScript is presentation only (Rail 1). Move the logic into a Rust\n"
"transform (mc_ai::tactical::parse_unit_catalog / parse_building_catalog)\n"
"and delegate from GDScript via GdItemSystem."
)
return 1
print("OK: no game-data transform logic in presentation GDScript (Rail 1)")
return 0
if __name__ == "__main__":
sys.exit(main())