220 lines
7.1 KiB
Python
220 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Combine AtlasLoot boss coords + stitched hi-res maps into web/assets/dungeons.json.
|
|
|
|
Pipeline assumption:
|
|
data/maps_png_hires/<MapName>[_floor<n>].png ← stitch_uprez.py
|
|
web/assets/maps/<MapName>[_floor<n>].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<n>; 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())
|