"""Single source of truth for the level list. Built-in levels come from ``assets/levels/manifest.json``. Custom levels written by the in-game editor are discovered as ``*.txt`` files in ``~/.the-way-out/custom_levels/`` and appended at the end of the list. Each level has a stable string id (``"level_1"`` for built-ins, ``"custom_"`` for user-built). The id is what :mod:`save` records as completed and what :meth:`LevelManager.load_level` takes — so adding a built-in level or saving one in the editor needs no code changes anywhere else. """ import json import os from dataclasses import dataclass from pathlib import Path import tileset from settings import SAVE_DIR CUSTOM_DIR = SAVE_DIR / "custom_levels" MANIFEST_PATH = Path("assets/levels/manifest.json") @dataclass(frozen=True) class LevelEntry: """One playable level — built-in or user-built. ``id`` stable handle used by save.py and the level menu. ``file`` path (working-dir relative for built-ins, absolute for custom) to the level's .txt. ``title`` short header (e.g. "LEVEL 1"). ``tagline`` one-liner under the title. ``custom`` True for editor-written levels; the level menu uses this to mark them visually so a player can tell them apart. ``music`` optional background-track name under ``assets/audio/music/`` (no extension); None = silent. ``floor_tile`` / ``wall_tile`` optional per-level tileset PNG names (under ``assets/tileset/tiles/``); None = the global ``tileset.FLOOR_TILE`` / ``WALL_TILE`` default. """ id: str file: str title: str tagline: str custom: bool music: str | None = None floor_tile: str | None = None wall_tile: str | None = None def _load_manifest(): """Built-in levels from manifest.json. Empty list on any IO error so a missing/corrupt manifest never crashes the menu.""" try: with open(MANIFEST_PATH) as f: data = json.load(f) except (FileNotFoundError, json.JSONDecodeError, OSError): return [] # Valid JSON that isn't the expected shape (top level not an object, # "levels" not a list, an entry not a dict) must also degrade to an # empty list, not raise — the docstring promises the menu never # crashes on a corrupt manifest, and AttributeError/TypeError from # the wrong shape aren't caught above. if not isinstance(data, dict): return [] levels = data.get("levels", []) if not isinstance(levels, list): return [] out = [] for raw in levels: try: out.append(LevelEntry( id=raw["id"], file=os.path.join("assets/levels", raw["file"]), title=raw.get("title", raw["id"]), tagline=raw.get("tagline", ""), custom=False, music=raw.get("music"), floor_tile=raw.get("floor_tile"), wall_tile=raw.get("wall_tile"))) except (KeyError, TypeError): continue return out def read_custom_theme(txt_path): """Theme id from a custom map's ``.json`` sidecar. Returns ``tileset.DEFAULT_THEME`` when there is no sidecar, it can't be read, or it isn't the expected shape — defensive in the same spirit as :func:`_load_manifest`, so a stray/corrupt sidecar never breaks the level list.""" sidecar = Path(txt_path).with_suffix(".json") try: with open(sidecar) as f: data = json.load(f) except (FileNotFoundError, json.JSONDecodeError, OSError): return tileset.DEFAULT_THEME if not isinstance(data, dict): return tileset.DEFAULT_THEME theme_id = data.get("theme", tileset.DEFAULT_THEME) return theme_id if isinstance(theme_id, str) else tileset.DEFAULT_THEME def _scan_custom(): """Levels saved by the editor under ``~/.the-way-out/custom_levels/``. The file name (without extension) becomes the human-readable title and is also part of the id, so renaming a file = a "new" level for the save system. A map's visual theme comes from its ``.json`` sidecar (see :func:`read_custom_theme`); an un-themed map resolves to the default tiles and looks unchanged.""" if not CUSTOM_DIR.exists(): return [] out = [] for path in sorted(CUSTOM_DIR.glob("*.txt")): name = path.stem floor_tile, wall_tile = tileset.theme_tiles(read_custom_theme(path)) out.append(LevelEntry( id=f"custom_{name}", file=str(path), title=name.replace("_", " ").title(), tagline="Custom", custom=True, music="default", floor_tile=floor_tile, wall_tile=wall_tile)) return out def load_catalog(): """Return every playable level in display order: built-ins (manifest order) followed by custom levels (alphabetical).""" return _load_manifest() + _scan_custom() def find(level_id): """Look up a level by id. Returns ``None`` if it's not in the catalog (e.g. user deleted a custom file).""" for entry in load_catalog(): if entry.id == level_id: return entry return None def ensure_custom_dir(): """Make sure the custom-level directory exists; safe to call any time.""" try: CUSTOM_DIR.mkdir(parents=True, exist_ok=True) except OSError: pass