"""Load and validate static scene map manifests. Manifests live at app/static/assets/maps//manifest.json. The loader parses a manifest into a SceneManifest and runs structural validation beyond what the Pydantic field constraints cover (collision dimensions, walkability of spawns/anchors, door/zone references). Loaded manifests are cached by scene_id. """ from __future__ import annotations import json import logging from pathlib import Path from backend.models.scene_map import SceneManifest log = logging.getLogger("map_loader") # app/static/assets/maps relative to the repo root (this file is backend/services/). _MAPS_DIR = Path(__file__).resolve().parents[2] / "app" / "static" / "assets" / "maps" # Scenes shown in onboarding. `available=False` => "coming soon", not playable. # `short` is the generic, spoiler-free label shown in the onboarding picker. SCENES: dict[str, dict] = { "avorio_f1": {"label": "Avorio Mansion", "short": "Mansion", "available": True}, "haunted_mansion": {"label": "Haunted Mansion", "short": "Haunted House", "available": True}, "mental_asylum": {"label": "Mental Asylum", "short": "Mental Asylum", "available": True}, "medieval_city": {"label": "Medieval City", "short": "Medieval City", "available": False}, } _cache: dict[str, SceneManifest] = {} class ManifestError(ValueError): """Raised when a manifest is missing, malformed, or fails validation.""" def manifest_path(scene_id: str) -> Path: return _MAPS_DIR / scene_id / "manifest.json" def available_scenes() -> dict[str, dict]: """Scene metadata for onboarding. A scene is only playable when it is marked available AND its manifest file exists on disk.""" out: dict[str, dict] = {} for sid, meta in SCENES.items(): playable = bool(meta.get("available")) and manifest_path(sid).exists() out[sid] = {**meta, "playable": playable} return out def _validate(m: SceneManifest) -> None: """Structural checks beyond Pydantic field constraints.""" errors: list[str] = [] # collision grid dimensions must match grid_w × grid_h if len(m.collision) != m.grid_h: errors.append(f"collision has {len(m.collision)} rows, expected grid_h={m.grid_h}") else: for ry, row in enumerate(m.collision): if len(row) != m.grid_w: errors.append(f"collision row {ry} has {len(row)} cols, expected grid_w={m.grid_w}") break for val in row: if val not in (0, 1): errors.append(f"collision row {ry} has non-binary value {val!r}") break # zone ids unique ids = m.zone_ids dupes = {i for i in ids if ids.count(i) > 1} if dupes: errors.append(f"duplicate zone ids: {sorted(dupes)}") # spawn points and clue anchors must be walkable (collision 0, in-bounds). # Only check when the collision grid is well-formed. if not errors or all("collision" not in e for e in errors): for z in m.zones: for kind, pts in (("spawn_point", z.spawn_points), ("clue_anchor", z.clue_anchors)): for p in pts: if len(p) != 2: errors.append(f"zone {z.id!r}: {kind} {p!r} must be [tx, ty]") continue tx, ty = p if not m.is_walkable(tx, ty): errors.append(f"zone {z.id!r}: {kind} ({tx},{ty}) is not a walkable tile") # doors must reference existing zones and sit in-bounds id_set = set(ids) for d in m.doors: for b in d.between: if b not in id_set: errors.append(f"door at ({d.x},{d.y}): unknown zone id {b!r}") if not m.in_bounds(d.x, d.y): errors.append(f"door at ({d.x},{d.y}): out of bounds") if errors: raise ManifestError( f"manifest {m.scene_id!r} failed validation:\n - " + "\n - ".join(errors) ) def load_manifest(scene_id: str, *, use_cache: bool = True) -> SceneManifest: """Load, validate, and cache the manifest for a scene. Raises ManifestError if the file is missing, the JSON is malformed, the Pydantic shape is wrong, or structural validation fails. """ if use_cache and scene_id in _cache: return _cache[scene_id] path = manifest_path(scene_id) if not path.exists(): raise ManifestError(f"no manifest for scene {scene_id!r} at {path}") try: raw = json.loads(path.read_text()) except json.JSONDecodeError as e: raise ManifestError(f"manifest {scene_id!r} is not valid JSON: {e}") from e try: manifest = SceneManifest.model_validate(raw) except Exception as e: # pydantic ValidationError -> uniform error type raise ManifestError(f"manifest {scene_id!r} has an invalid shape: {e}") from e _validate(manifest) if use_cache: _cache[scene_id] = manifest log.info("loaded scene manifest %r (%d zones, %dx%d tiles)", scene_id, len(manifest.zones), manifest.grid_w, manifest.grid_h) return manifest def clear_cache() -> None: _cache.clear()