magicciv/tools/validate-start-scripts.py
Natalie 269316722e feat(@projects/@magic-civilization): 🎬 declarative start-script system (p3-14)
Game opening becomes a moddable JSON script driven by mc_worldsim::StartScriptRunner
and exposed to Godot via GdStartScript. Start scripts + dwarf tribe/wanderer units
live in public/resources/start_scripts; START_SCRIPTS.md documents the contract.
Adds tools/validate-start-scripts.py + wires it into CI (stage 3b) and verify.sh
(step 0b). Marks p3-14 done and regenerates the objectives dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:56:50 -05:00

178 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Validate game-start scripts (p3-14) against their schema + semantic rules.
Scripts: public/resources/start_scripts/*.json (excluding the schema)
Schema: public/resources/start_scripts/start_scripts.schema.json
Catalogs: public/resources/units/*.json (unit ids), vocabulary.json (banner keys)
Mirrors the Rust load-path validator (mc_turn::start_script::validate) and adds
the catalog checks (unit_id exists, banner_key exists) that need the game pack.
Errors name the script id + phase id + op index so a scenario author can jump
straight to the line.
Usage:
python3 tools/validate-start-scripts.py [--root /path/to/project]
Exit code 0 = all pass, 1 = failures found.
"""
import argparse
import json
import sys
from pathlib import Path
try:
from jsonschema import Draft202012Validator
HAS_JSONSCHEMA = True
except ImportError:
HAS_JSONSCHEMA = False
# Op → the arg keys that reference a declared actor tag / a declared param.
ACTOR_REF_KEYS = {
"place_spawn_box": ["actor"],
"roll_actor_directions": ["actor"],
"step_actors": ["actor"],
"converge_actors": ["actor", "produce"],
"set_unit_actions": ["actor"],
"found_city": ["from"],
}
PARAM_REF_KEYS = {
"place_spawn_box": ["count_ref", "mode_ref", "radius_ref"],
"roll_actor_directions": ["mode_ref", "bias_ref"],
"converge_actors": ["radius_ref", "min_count_ref", "mode_ref", "cap_ref"],
"found_city": ["mode_ref", "cap_ref"],
}
def load_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8")), None
except (OSError, json.JSONDecodeError) as e:
return None, str(e)
def unit_ids(root: Path) -> set:
out = set()
udir = root / "public/resources/units"
for fp in sorted(udir.glob("*.json")):
data, _ = load_json(fp)
if data is None:
continue
for entry in data if isinstance(data, list) else [data]:
if isinstance(entry, dict) and "id" in entry:
out.add(str(entry["id"]))
return out
def vocab_keys(root: Path) -> set:
data, _ = load_json(root / "public/games/age-of-dwarves/vocabulary.json")
return set(data.keys()) if isinstance(data, dict) else set()
def semantic_errors(script: dict, units: set, vocab: set) -> list:
sid = script.get("id", "<no-id>")
errs = []
def err(loc, msg):
errs.append(f"start_script '{sid}' {loc}: {msg}")
actors = {a["tag"]: a for a in script.get("actors", []) if isinstance(a, dict) and "tag" in a}
params = set(script.get("params", {}).keys())
# unit_id of each actor must exist in the catalog.
for tag, a in actors.items():
uid = a.get("unit_id", "")
if units and uid not in units:
err("actors", f"actor '{tag}' unit_id '{uid}' not in unit catalog")
phases = script.get("phases", [])
if not any(p.get("kind") == "normal" for p in phases if isinstance(p, dict)):
err("phases", "no phase of kind 'normal' — the script never hands off to normal play")
seen_phase = set()
for p in phases:
if not isinstance(p, dict):
continue
pid = p.get("id", "<no-id>")
if pid in seen_phase:
err("phases", f"duplicate phase id '{pid}'")
seen_phase.add(pid)
# banner_key must exist in vocabulary.
bkey = p.get("banner_key")
if bkey and vocab and bkey not in vocab:
err(f"phase '{pid}'", f"banner_key '{bkey}' not in vocabulary.json")
for list_name in ("on_enter", "on_resolve"):
for i, op in enumerate(p.get(list_name, [])):
if not isinstance(op, dict):
continue
name = op.get("op", "")
loc = f"phase '{pid}' {list_name} op[{i}]"
for k in ACTOR_REF_KEYS.get(name, []):
ref = op.get(k)
if ref is not None and ref not in actors:
err(loc, f"unknown actor '{ref}' in '{k}' (declared: {sorted(actors)})")
for k in PARAM_REF_KEYS.get(name, []):
ref = op.get(k)
if ref is not None and ref not in params:
err(loc, f"unknown param '{ref}' in '{k}' (declared: {sorted(params)})")
return errs
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=str(Path(__file__).resolve().parents[1]))
args = ap.parse_args()
root = Path(args.root)
sdir = root / "public/resources/start_scripts"
schema_path = sdir / "start_scripts.schema.json"
if not sdir.is_dir():
print(f" no start_scripts dir at {sdir} — nothing to validate")
return 0
schema, serr = load_json(schema_path)
if schema is None:
print(f"FAIL: cannot load schema {schema_path}: {serr}")
return 1
validator = Draft202012Validator(schema) if HAS_JSONSCHEMA else None
if validator is None:
print(" WARNING: jsonschema not installed — schema checks skipped (semantic checks still run)")
units = unit_ids(root)
vocab = vocab_keys(root)
passed = 0
failures = []
scripts = [p for p in sorted(sdir.glob("*.json")) if p.name != "start_scripts.schema.json"]
for fp in scripts:
data, jerr = load_json(fp)
if data is None:
failures.append(f"{fp.name}: invalid JSON: {jerr}")
continue
file_errs = []
if validator is not None:
for e in validator.iter_errors(data):
loc = "/".join(str(x) for x in e.absolute_path) or "<root>"
file_errs.append(f"{fp.name} [{loc}]: {e.message}")
file_errs.extend(semantic_errors(data, units, vocab))
if file_errs:
failures.extend(file_errs)
else:
passed += 1
print("=" * 60)
if failures:
for f in failures:
print(f" FAIL {f}")
print("=" * 60)
print(f" start-scripts: {passed} passed, {len(failures)} failure(s)")
return 1
print(f" start-scripts: {passed} passed, 0 failures ({len(scripts)} script(s))")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())