| """Load and validate static scene map manifests. |
| |
| Manifests live at app/static/assets/maps/<scene_id>/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") |
|
|
| |
| _MAPS_DIR = Path(__file__).resolve().parents[2] / "app" / "static" / "assets" / "maps" |
|
|
| |
| |
| 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] = [] |
|
|
| |
| 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 |
|
|
| |
| 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)}") |
|
|
| |
| |
| 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") |
|
|
| |
| 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: |
| 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() |
|
|