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