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