From d49993e3dd023c18817eccb20317c31203de41fc Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 24 Jun 2026 23:55:39 -0400 Subject: [PATCH] =?UTF-8?q?test(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=9A=A6=20Rail-1=20verify=20gate=20=E2=80=94=20no=20game-d?= =?UTF-8?q?ata=20transform=20logic=20in=20GDScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/run/verify.sh | 8 ++- tools/check-no-gdscript-sim-logic.py | 81 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tools/check-no-gdscript-sim-logic.py diff --git a/scripts/run/verify.sh b/scripts/run/verify.sh index 9de05593..78592cd2 100644 --- a/scripts/run/verify.sh +++ b/scripts/run/verify.sh @@ -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/" \ diff --git a/tools/check-no-gdscript-sim-logic.py b/tools/check-no-gdscript-sim-logic.py new file mode 100644 index 00000000..78cf4e87 --- /dev/null +++ b/tools/check-no-gdscript-sim-logic.py @@ -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())