#!/usr/bin/env python3 """Audio manifest + asset coherence validator. Checks (per pack under `public/games//data/audio.json`): 1. JSON validates against the schema at `public/games//data/schemas/audio.schema.json`. 2. Every `stream` / `streams[]` path resolves to a real file under `public/games//assets/audio/...`. Missing files warn (the runtime fails loud at playback time via `audio_asset_missing`). 3. No orphan `.ogg` files: every `.ogg` under `assets/audio/**` is referenced by at least one manifest entry. Orphans warn; not fatal. 4. `streams[]` arrays are non-empty (no silent-sentinel pattern). Exit status: non-zero iff a structural problem (1, 4) is found. Missing files (2) and orphans (3) only warn — they're a normal intermediate state while assets land. Wire: `./run validate` calls this; it also runs in CI before the GUT pass. """ from __future__ import annotations import argparse import json import sys from dataclasses import dataclass from pathlib import Path REPO = Path(__file__).resolve().parent.parent DEFAULT_THEMES = ["age-of-dwarves"] @dataclass class Report: errors: list[str] warnings: list[str] def fail(self, msg: str) -> None: self.errors.append(msg) def warn(self, msg: str) -> None: self.warnings.append(msg) # ────────────────────────────────────────────────────────────────────────── # Schema validation (lightweight — no jsonschema dep) # ────────────────────────────────────────────────────────────────────────── def _entry_streams(entry: dict) -> list[str]: if "streams" in entry: raw = entry["streams"] if not isinstance(raw, list): return [] return [str(s) for s in raw] single = entry.get("stream") if single: return [str(single)] return [] def validate_schema(manifest: dict, report: Report) -> None: if not isinstance(manifest, dict): report.fail("audio.json root is not a JSON object") return if "schema_version" not in manifest: report.fail("missing required field: schema_version") sfx = manifest.get("sfx") if not isinstance(sfx, dict): report.fail("missing or non-object `sfx` block") sfx = {} music = manifest.get("music") if not isinstance(music, dict): report.fail("missing or non-object `music` block") music = {"tracks": []} # Each SFX entry shape allowed_entry_keys = { "stream", "streams", "volume_db", "bus", "pitch_jitter", "description", } for key, entry in sfx.items(): if not isinstance(entry, dict): report.fail(f"sfx[{key!r}] is not an object") continue extra = set(entry.keys()) - allowed_entry_keys if extra: report.fail(f"sfx[{key!r}] has unknown fields: {sorted(extra)}") if "stream" in entry and "streams" in entry: report.warn( f"sfx[{key!r}] declares both `stream` and `streams[]` — " f"streams[] takes precedence at runtime; drop `stream`" ) if "streams" in entry: raw = entry["streams"] if isinstance(raw, list) and len(raw) == 0: report.fail( f"sfx[{key!r}] declares empty `streams: []` — fail-loud " f"policy forbids silent sentinels; remove the entry or " f"ship at least one stream" ) if "stream" not in entry and "streams" not in entry: report.fail( f"sfx[{key!r}] declares neither `stream` nor `streams[]` — " f"every entry must point at at least one path" ) if "pitch_jitter" in entry: j = entry["pitch_jitter"] if not isinstance(j, (int, float)) or j < 0.0 or j > 0.5: report.fail( f"sfx[{key!r}].pitch_jitter must be a number in [0.0, 0.5]" ) # Music tracks tracks = music.get("tracks", []) if not isinstance(tracks, list): report.fail("music.tracks is not an array") tracks = [] seen_ids: set[str] = set() for i, track in enumerate(tracks): if not isinstance(track, dict): report.fail(f"music.tracks[{i}] is not an object") continue tid = track.get("id") if not tid: report.fail(f"music.tracks[{i}] missing required `id`") elif tid in seen_ids: report.fail(f"music.tracks[{i}] duplicate id {tid!r}") else: seen_ids.add(tid) if not track.get("stream"): report.fail(f"music.tracks[{i}] ({tid}) missing required `stream`") # ────────────────────────────────────────────────────────────────────────── # Asset existence + orphan check # ────────────────────────────────────────────────────────────────────────── def collect_referenced_streams(manifest: dict) -> set[str]: refs: set[str] = set() for entry in manifest.get("sfx", {}).values(): if not isinstance(entry, dict): continue for s in _entry_streams(entry): refs.add(s) for track in manifest.get("music", {}).get("tracks", []): if isinstance(track, dict) and track.get("stream"): refs.add(str(track["stream"])) return refs def check_assets(theme: str, refs: set[str], report: Report) -> None: assets_root = REPO / "public" / "games" / theme / "assets" missing: list[str] = [] for rel in sorted(refs): if not rel: continue p = assets_root / rel if not p.exists(): missing.append(rel) if missing: report.warn( f"{theme}: {len(missing)} referenced files do not yet exist " f"(asset acquisition tracked by p2-16). First few: " f"{missing[:3]}" ) # Orphan scan audio_dir = assets_root / "audio" if not audio_dir.exists(): return orphans: list[str] = [] for f in audio_dir.rglob("*.ogg"): rel = str(f.relative_to(assets_root)) if rel not in refs: orphans.append(rel) if orphans: report.warn( f"{theme}: {len(orphans)} orphan .ogg files not referenced by " f"audio.json. First few: {orphans[:3]}" ) # ────────────────────────────────────────────────────────────────────────── # Driver # ────────────────────────────────────────────────────────────────────────── def validate_theme(theme: str) -> Report: report = Report(errors=[], warnings=[]) manifest_path = REPO / "public" / "games" / theme / "data" / "audio.json" if not manifest_path.exists(): report.fail(f"{theme}: audio.json not found at {manifest_path}") return report try: manifest = json.loads(manifest_path.read_text()) except json.JSONDecodeError as e: report.fail(f"{theme}: JSON parse error: {e}") return report validate_schema(manifest, report) refs = collect_referenced_streams(manifest) check_assets(theme, refs, report) return report def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--theme", action="append", default=None, help="Theme id (e.g. 'age-of-dwarves'). Repeatable. " "Default: every theme listed in DEFAULT_THEMES.", ) parser.add_argument( "--strict", action="store_true", help="Treat warnings as errors (raises exit code on missing assets / orphans).", ) args = parser.parse_args() themes = args.theme if args.theme else DEFAULT_THEMES total_errors = 0 total_warnings = 0 for theme in themes: report = validate_theme(theme) if report.errors: print(f"[{theme}] ERRORS:") for e in report.errors: print(f" ✗ {e}") total_errors += len(report.errors) if report.warnings: print(f"[{theme}] warnings:") for w in report.warnings: print(f" · {w}") total_warnings += len(report.warnings) if not report.errors and not report.warnings: print(f"[{theme}] OK") if total_errors > 0: print(f"\nFAIL: {total_errors} structural error(s)") return 1 if args.strict and total_warnings > 0: print(f"\nFAIL (--strict): {total_warnings} warning(s)") return 1 if total_warnings > 0: print(f"\nOK with {total_warnings} warning(s)") else: print("\nOK") return 0 if __name__ == "__main__": sys.exit(main())