#!/usr/bin/env python3 """ Combine AtlasLoot boss coords + stitched hi-res maps into web/assets/dungeons.json. Pipeline assumption: data/maps_png_hires/[_floor].png ← stitch_uprez.py web/assets/maps/[_floor].webp ← compress_for_web.py Resolution order for AtlasLoot dungeon-id → map file(s): 1. data/aliases.json explicit override (keyed by AtlasLoot id OR MapName) 2. exact basename match (case-sensitive) 3. case-insensitive basename match 4. nothing → dungeon listed without a map (still pickable) """ from __future__ import annotations import json import re import sys from pathlib import Path from PIL import Image ROOT = Path(__file__).resolve().parent.parent DATA = ROOT / "data" WEB_ASSETS = ROOT / "web" / "assets" WEB_MAPS = WEB_ASSETS / "maps" # holds the .webp files (and is also the source of truth for dimensions) ALIASES_PATH = DATA / "aliases.json" ATLASLOOT_PATH = DATA / "atlasloot_maps.json" OUT_PATH = WEB_ASSETS / "dungeons.json" FLOOR_RE = re.compile(r"^(.+?)(?:_floor\d+|_overview)$") def load_json(p: Path) -> dict: with p.open() as f: return json.load(f) def join_zone_name(zn) -> str | None: if isinstance(zn, list) and zn: return str(zn[0]) if isinstance(zn, str): return zn return None def join_location(loc) -> str | None: if isinstance(loc, list) and loc: return str(loc[0]) if isinstance(loc, str): return loc return None def collect_bosses(dungeon: dict) -> list[dict]: out: list[dict] = [] seen = set() for k, v in dungeon.items(): if not k.isdigit() or not isinstance(v, list): continue for entry in v: if not isinstance(entry, dict): continue if entry.get("SubZone"): continue cords = entry.get("cords") name = entry.get("1") if not (isinstance(cords, list) and len(cords) == 2 and name): continue key = (name, cords[0], cords[1]) if key in seen: continue seen.add(key) out.append({ "name": name, "x": cords[0], "y": cords[1], "type": entry.get("pinType", "boss"), }) return out def floor_sort_key(stem: str): """Sort _overview before any _floor; floor numbers in numeric order.""" if stem.endswith("_overview"): return (0, 0, stem) m = re.match(r".+_floor(\d+)$", stem) if m: return (1, int(m.group(1)), stem) return (2, 0, stem) def index_map_files() -> dict[str, list[str]]: """Group web .webp files by base map name → { base: [stem1, stem2, ...] }.""" if not WEB_MAPS.exists(): return {} grouped: dict[str, list[str]] = {} for p in WEB_MAPS.glob("*.webp"): stem = p.stem m = FLOOR_RE.match(stem) base = m.group(1) if m else stem grouped.setdefault(base, []).append(stem) for base in grouped: grouped[base].sort(key=floor_sort_key) return grouped def expand_bases(bases: list[str], grouped: dict[str, list[str]]) -> list[str]: """An alias entry like "Karazhan" expands to all floors; a specific stem like "Karazhan_floor1" passes through as-is.""" out: list[str] = [] for b in bases: if b in grouped: out.extend(grouped[b]) else: out.append(b) return out def resolve_basenames( dungeon_id: str, map_name: str | None, aliases: dict, grouped: dict[str, list[str]], ) -> list[str]: """Return list of webp basenames to use for this dungeon entry. Note: AtlasLoot's WotLK MapData has a known bug where every dungeon's MapName is "DireMaul"; we therefore never resolve by MapName, only by dungeon_id (with explicit aliases for the rest).""" # Explicit alias override wins if dungeon_id in aliases: return expand_bases(aliases[dungeon_id], grouped) if map_name and map_name in aliases: return expand_bases(aliases[map_name], grouped) # Auto-resolve by AtlasLoot id, exact then case-insensitive if dungeon_id in grouped: return list(grouped[dungeon_id]) lc_index = {k.lower(): k for k in grouped} if dungeon_id and dungeon_id.lower() in lc_index: return list(grouped[lc_index[dungeon_id.lower()]]) return [] def main() -> int: atlasloot = load_json(ATLASLOOT_PATH) aliases = load_json(ALIASES_PATH) if ALIASES_PATH.exists() else {} aliases = {k: v for k, v in aliases.items() if not k.startswith("_")} # Ascension M+ pool is classic-only. atlasloot = {k: v for k, v in atlasloot.items() if k == "OriginalWoW"} grouped = index_map_files() if not grouped: print(f"warning: no hi-res PNGs in {PNG_HIRES}", file=sys.stderr) dungeons: list[dict] = [] used_bases: set[str] = set() no_map: list[str] = [] for expansion, entries in atlasloot.items(): for did, dungeon in entries.items(): if not isinstance(dungeon, dict): continue map_name = dungeon.get("MapName") zone_name = join_zone_name(dungeon.get("ZoneName")) or did bosses = collect_bosses(dungeon) basenames = resolve_basenames(did, map_name, aliases, grouped) maps_out: list[dict] = [] for basename in basenames: src = WEB_MAPS / f"{basename}.webp" if not src.exists(): continue with Image.open(src) as im: w, h = im.size used_bases.add(basename) maps_out.append({ "image": f"maps/{basename}.webp", "width": w, "height": h, "label": basename, "bosses": bosses if len(basenames) == 1 else [], }) entry = { "id": did, "expansion": expansion, "name": zone_name, "acronym": dungeon.get("Acronym"), "levelRange": dungeon.get("LevelRange"), "playerLimit": dungeon.get("PlayerLimit"), "location": join_location(dungeon.get("Location")), "reputation": dungeon.get("Reputation"), "maps": maps_out, } if not maps_out: no_map.append(f"{expansion}/{did} (MapName={map_name})") elif len(maps_out) > 1: entry["unassignedBosses"] = bosses dungeons.append(entry) dungeons.sort(key=lambda d: (d["expansion"], d["name"])) OUT_PATH.parent.mkdir(parents=True, exist_ok=True) with OUT_PATH.open("w") as f: json.dump({"dungeons": dungeons}, f, indent=2) mapped = sum(1 for d in dungeons if d["maps"]) print(f"wrote {OUT_PATH} — {len(dungeons)} dungeons, {mapped} with maps") if no_map: print(f"\n{len(no_map)} AtlasLoot entries had no resolvable map:", file=sys.stderr) for s in no_map: print(f" {s}", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())