#!/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: # Audio is shared across themes — lives in public/resources/audio/. # `theme` is retained for the warning prefix only. assets_root = REPO / "public" / "resources" 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 _read_json(path: Path, theme: str, what: str, report: Report) -> dict: if not path.exists(): report.fail(f"{theme}: {what} not found at {path}") return {} try: return json.loads(path.read_text()) except json.JSONDecodeError as e: report.fail(f"{theme}: {what} JSON parse error: {e}") return {} def _resolve_manifest(library: dict, manifest: dict) -> dict: """Mirror AudioManager._apply_subscription: filter library by includes, merge per-key overrides. Returns a synthesized full manifest dict in the legacy shape that `validate_schema` and `collect_referenced_streams` already understand. """ includes = manifest.get("includes", True) overrides = manifest.get("overrides", {}) or {} def included(key: str) -> bool: if isinstance(includes, bool): return includes if isinstance(includes, list): return key in includes return False out: dict = {"schema_version": library.get("schema_version", 2), "sfx": {}, "music": {"tracks": []}} for key, entry in (library.get("sfx", {}) or {}).items(): if not included(key): continue merged = dict(entry) if key in overrides and isinstance(overrides[key], dict): merged.update(overrides[key]) out["sfx"][key] = merged for track in (library.get("music", {}) or {}).get("tracks", []) or []: if not isinstance(track, dict): continue tid = track.get("id", "") if not tid or not included(tid): continue merged = dict(track) if tid in overrides and isinstance(overrides[tid], dict): merged.update(overrides[tid]) out["music"]["tracks"].append(merged) return out def _check_derived_manifest_drift(theme: str, report: Report) -> None: """Fail if the derived `data/audio.json` exists but is stale. `data/audio.json` is a generated artifact (tools/audio-generate-manifest.py) consumed by the guide's AssetPipeline. It must equal the resolution of library.json + audio/manifest.json + audio/pools.json byte-for-byte. A drifted copy is a second, lying source of truth — reject it. When the file is absent we say nothing here: generation is the write-side complement and its absence is reported by its own consumers, not this validator. """ out_path = REPO / "public" / "games" / theme / "data" / "audio.json" if not out_path.exists(): return try: from importlib import util as _util gen_path = REPO / "tools" / "audio-generate-manifest.py" spec = _util.spec_from_file_location("audio_generate_manifest", gen_path) if spec is None or spec.loader is None: report.warn(f"{theme}: cannot load audio-generate-manifest.py to " f"verify data/audio.json is in sync") return mod = _util.module_from_spec(spec) spec.loader.exec_module(mod) rendered = mod.render(theme) except Exception as e: # noqa: BLE001 — surface any generation failure report.fail(f"{theme}: failed to re-render data/audio.json for drift " f"check: {e}") return committed = out_path.read_text(encoding="utf-8") if committed != rendered: report.fail( f"{theme}: data/audio.json is stale relative to library.json + " f"audio/manifest.json + audio/pools.json. Run " f"`python3 tools/audio-generate-manifest.py` and commit the result." ) def validate_theme(theme: str) -> Report: report = Report(errors=[], warnings=[]) library_path = REPO / "public" / "resources" / "audio" / "library.json" manifest_path = REPO / "public" / "games" / theme / "data" / "audio" / "manifest.json" library = _read_json(library_path, theme, "audio library.json", report) manifest = _read_json(manifest_path, theme, "audio manifest.json", report) if report.errors: return report resolved = _resolve_manifest(library, manifest) validate_schema(resolved, report) refs = collect_referenced_streams(resolved) check_assets(theme, refs, report) _check_derived_manifest_drift(theme, 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())