#!/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())