238 lines
9 KiB
Python
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()
|