magicciv/tools/validate-game-data.py
Natalie 9ed330a1ce feat(@projects/@magic-civilization): add gpu phase b1 and test coverage gates
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-16 17:51:27 -07:00

238 lines
9 KiB
Python

#!/usr/bin/env python3
"""Validate Age of Dwarves game pack JSON files against their schemas.
Schemas live in: public/games/age-of-dwarves/data/schemas/*.schema.json
Data sources:
- Split dirs: data/units/, data/buildings/, data/techs/, data/terrain/
- Single files: data/races.json, data/ai_personalities.json
- Resources: public/resources/wilds/wilds.json
- Manifested: data/improvements/ manifest references public/resources/improvements/
Usage:
python3 tools/validate-game-data.py [--root /path/to/project] [--verbose]
Exit code 0 = all pass, 1 = failures found.
"""
import argparse
import json
import sys
from pathlib import Path
try:
from jsonschema import Draft202012Validator, ValidationError
HAS_JSONSCHEMA = True
except ImportError:
HAS_JSONSCHEMA = False
def load_json(path: Path):
try:
return json.loads(path.read_text())
except json.JSONDecodeError as e:
return None, str(e)
return None, "unknown error"
def load_json_safe(path: Path):
try:
data = json.loads(path.read_text())
return data, None
except json.JSONDecodeError as e:
return None, str(e)
class GameDataValidator:
def __init__(self, root: Path, verbose: bool = False):
self.root = root
self.game_data = root / "public" / "games" / "age-of-dwarves" / "data"
self.resources = root / "public" / "resources"
self.schema_dir = self.game_data / "schemas"
self.verbose = verbose
self.passed = 0
self.failed = 0
self.errors: list[str] = []
def _ok(self, label: str):
self.passed += 1
if self.verbose:
print(f" PASS {label}")
def _fail(self, label: str, reason: str):
self.failed += 1
msg = f"FAIL {label}: {reason}"
self.errors.append(msg)
print(f" {msg}")
def _load_schema(self, name: str):
path = self.schema_dir / f"{name}.schema.json"
data, err = load_json_safe(path)
if err:
self._fail(f"schema/{name}", f"parse error: {err}")
return None
return data
def _validate_entry(self, schema, entry: dict, label: str):
if not HAS_JSONSCHEMA:
return
validator = Draft202012Validator(schema)
errs = list(validator.iter_errors(entry))
if errs:
for e in errs[:2]:
path = ".".join(str(p) for p in e.absolute_path) or "(root)"
self._fail(label, f"{path}: {e.message}")
else:
self._ok(label)
def _collect_entries_from_file(self, path: Path) -> list[tuple[str, dict]]:
"""Extract (label, entry_dict) pairs from a JSON file, handling all DataLoader shapes."""
data, err = load_json_safe(path)
if err:
self._fail(str(path.relative_to(self.root)), f"parse error: {err}")
return []
rel = path.relative_to(self.root)
if isinstance(data, list):
return [(f"{rel}[{i}]", e) for i, e in enumerate(data) if isinstance(e, dict)]
if isinstance(data, dict):
# Top-level dict with single "id" = single entry
if "id" in data and isinstance(data["id"], str):
return [(str(rel), data)]
# Keyed collection (ai_personalities shape: {clan_id: {id, name, ...}})
results = []
for key, val in data.items():
if isinstance(val, dict) and ("id" in val or "name" in val):
results.append((f"{rel}/{key}", val))
if results:
return results
# Wrapped array: {"races": [...], "terrains": [...], ...}
for wrap_key, wrap_val in data.items():
if isinstance(wrap_val, list):
return [(f"{rel}[{i}]", e) for i, e in enumerate(wrap_val) if isinstance(e, dict)]
return []
# ── Category validators ───────────────────────────────────────────
def validate_split_dir(self, category_label: str, dir_path: Path, schema_name: str):
schema = self._load_schema(schema_name)
if schema is None:
return
files = sorted(f for f in dir_path.glob("*.json")
if not f.name.endswith(".schema.json") and f.name != "manifest.json")
if not files:
print(f" (no files in {dir_path.relative_to(self.root)})")
return
print(f"\n {category_label} ({len(files)} files)")
for f in files:
for label, entry in self._collect_entries_from_file(f):
self._validate_entry(schema, entry, label)
def validate_single_file(self, label: str, path: Path, schema_name: str, wrap_key: str | None = None):
schema = self._load_schema(schema_name)
if schema is None:
return
data, err = load_json_safe(path)
if err:
self._fail(label, f"parse error: {err}")
return
rel = path.relative_to(self.root)
print(f"\n {label}")
# Unwrap a top-level key if given (e.g. {"races": [...]})
entries_data = data
if wrap_key and isinstance(data, dict) and wrap_key in data:
entries_data = data[wrap_key]
if isinstance(entries_data, list):
for i, entry in enumerate(entries_data):
if isinstance(entry, dict):
self._validate_entry(schema, entry, f"{rel}[{i}]")
elif isinstance(entries_data, dict):
# Dict-of-dicts (ai_personalities shape)
for key, val in entries_data.items():
if isinstance(val, dict):
self._validate_entry(schema, val, f"{rel}/{key}")
else:
self._fail(label, "unexpected top-level type")
def validate_wilds(self):
schema = self._load_schema("wilds")
if schema is None:
return
path = self.resources / "wilds" / "wilds.json"
data, err = load_json_safe(path)
if err:
self._fail("wilds.json", f"parse error: {err}")
return
rel = path.relative_to(self.root)
print(f"\n wilds")
# wilds.json shape: {"wilds": { ... single config object ... }}
inner = data.get("wilds", data) if isinstance(data, dict) else data
if isinstance(inner, dict):
self._validate_entry(schema, inner, str(rel))
else:
self._fail(str(rel), "unexpected shape")
def validate_improvements(self):
"""Improvements live in public/resources/improvements/ (not the game data dir)."""
schema = self._load_schema("improvement")
if schema is None:
return
imp_dir = self.resources / "improvements"
files = sorted(f for f in imp_dir.glob("*.json")
if not f.name.endswith(".schema.json") and f.name not in ("manifest.json", "improvements.json", "registry.md"))
print(f"\n improvements ({len(files)} files)")
for f in files:
for label, entry in self._collect_entries_from_file(f):
self._validate_entry(schema, entry, label)
# ── Main ─────────────────────────────────────────────────────────
def run(self):
if not HAS_JSONSCHEMA:
print("ERROR: jsonschema not installed. Run: pip install jsonschema")
sys.exit(1)
print("── Age of Dwarves game data validation ──")
self.validate_split_dir("units", self.game_data / "units", "unit")
self.validate_split_dir("buildings", self.game_data / "buildings", "building")
self.validate_split_dir("techs", self.game_data / "techs", "tech")
self.validate_split_dir("terrain", self.game_data / "terrain", "terrain")
self.validate_single_file(
"races.json", self.game_data / "races.json", "race", wrap_key="races"
)
self.validate_single_file(
"ai_personalities.json", self.game_data / "ai_personalities.json", "ai_personality"
)
self.validate_wilds()
self.validate_improvements()
def report(self) -> int:
print(f"\n{'=' * 60}")
print(f" PASSED: {self.passed} FAILED: {self.failed}")
if self.errors:
print(f"\n Failures:")
for e in self.errors[:30]:
print(f" {e}")
if len(self.errors) > 30:
print(f" ... and {len(self.errors) - 30} more")
print(f"{'=' * 60}")
return 1 if self.failed > 0 else 0
def main():
parser = argparse.ArgumentParser(description="Validate Age of Dwarves game pack JSON data")
parser.add_argument("--root", type=Path, default=Path(__file__).parent.parent,
help="Project root directory")
parser.add_argument("--verbose", action="store_true", help="Show individual pass results")
args = parser.parse_args()
v = GameDataValidator(args.root, verbose=args.verbose)
v.run()
sys.exit(v.report())
if __name__ == "__main__":
main()