179 lines
6.1 KiB
Python
179 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())
|