midnight_mystery / backend /services /map_loader.py
lcpizani's picture
Add full app: pages, assets, backend, config
811d960
Raw
History Blame Contribute Delete
5.18 kB
"""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")
# 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()