#!/usr/bin/env python3 """ Build web/assets/dungeons.json from keystone.guru's mapping data. Reads: data/kg_dungeons.json dungeon registry data/kg/_summary.json stitch grids per floor data/kg//split_floors.js enemies, packs, patrols, mapIcons data/kg//lang.js NPC names + classifications Coordinate transform: Each kg map is rendered by Leaflet CRS.Simple with native zoom = max_zoom. pixel_x = lng * 2^zoom pixel_y = -lat * 2^zoom """ from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DATA = ROOT / "data" KG_DIR = DATA / "kg" REGISTRY = DATA / "kg_dungeons.json" WEB_ASSETS = ROOT / "web" / "assets" WEB_MAPS = WEB_ASSETS / "maps" EXTRAS_PATH = DATA / "atlasloot_extras.json" OVERRIDES_PATH = DATA / "ascension_overrides.json" OUT_PATH = WEB_ASSETS / "dungeons.json" # Map kg's mapIconType names → simple labels we render in the UI. ICON_TYPE_OVERRIDES = { "comment": "comment", "door": "door", "door_down": "door", "door_left": "door", "door_locked": "door", "door_right": "door", "door_up": "door", "graveyard": "graveyard", "dungeon_start": "start", "skip_walk": "skip", "skip_flight": "skip", "skip_teleport": "skip", "raid_marker_skull": "boss", } def parse_js_var(path: Path) -> dict: content = path.read_text() start = content.find("{") return json.loads(content[start:].rstrip(" ;\n")) def vertices_from(json_str: str) -> list[dict]: if not json_str: return [] try: return json.loads(json_str) except Exception: return [] def build_one(d: dict, summary: dict, npc_index: dict | None, icon_index: dict[int, str]) -> dict: """Return the dungeons.json entry for one kg dungeon.""" tile_key = d["tile_key"] name = d["name"] floor_summary = next((x for x in summary["dungeons"] if x["tile_key"] == tile_key), None) if not floor_summary or not floor_summary["floors"]: return None floors_per_dungeon = len(floor_summary["floors"]) zoom = floor_summary.get("max_zoom", 4) scale = 2 ** zoom data_path = KG_DIR / tile_key / "split_floors.js" has_data = data_path.exists() raw = parse_js_var(data_path) if has_data else None # Group enemies / packs / patrols / mapIcons by floor_id, since one # dungeon may have multiple floors and each entry says which floor. by_floor: dict[int, dict] = {} if has_data: for e in raw["dungeon"].get("enemies", []): fid = e.get("floor_id") by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []}) by_floor[fid]["enemies"].append(e) for p in raw["dungeon"].get("enemyPacks", []): fid = p.get("floor_id") by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []}) by_floor[fid]["packs"].append(p) for pa in raw["dungeon"].get("enemyPatrols", []): fid = pa.get("floor_id") by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []}) by_floor[fid]["patrols"].append(pa) for ic in raw["dungeon"].get("mapIcons", []): fid = ic.get("floor_id") by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []}) by_floor[fid]["icons"].append(ic) # We need to map kg's floor_id → the index we stitched. The split_floors # data doesn't include floor metadata directly, so use ordered alignment: # kg's floors come back in `index` order; our stitched floors_summary uses # the same ordering (we discovered them sequentially). # Build a mapping kg_floor_id → stitched index. Without a definitive # floor list in split_floors.js we lean on the ordering of floor_summary. kg_floor_ids = sorted({fid for fid in by_floor}) floor_id_to_index = {fid: i + 1 for i, fid in enumerate(kg_floor_ids)} if kg_floor_ids else {} maps_out = [] for floor_info in floor_summary["floors"]: idx = floor_info["index"] cols, rows = floor_info["cols"], floor_info["rows"] suffix = f"_floor{idx}" if floors_per_dungeon > 1 else "" image_basename = f"{tile_key}{suffix}" W = cols * 384 # tileWidth = 384 H = rows * 256 # tileHeight = 256 # Find the kg floor_id for this index (best-effort) kg_floor_id = None for fid, ix in floor_id_to_index.items(): if ix == idx: kg_floor_id = fid break f_data = by_floor.get(kg_floor_id, {"enemies": [], "packs": [], "patrols": [], "icons": []}) def to_pixel(lat, lng): return [lng * scale, -lat * scale] # Enemies enemies_out = [] for e in f_data["enemies"]: npc_id = e.get("npc_id") npc = npc_index.get(npc_id) if npc_index else None classification = npc.get("classification_id") if npc else None enemies_out.append({ "id": e["id"], "npc_id": npc_id, "name": (npc or {}).get("name") or "Unknown", "pos": to_pixel(e["lat"], e["lng"]), "classification": classification, # 1=trash, 2=elite, 3=boss-ish, 4=boss? "skippable": bool(e.get("skippable")), "required": bool(e.get("required")), "kill_priority": e.get("kill_priority"), "pack_id": e.get("enemy_pack_id"), "patrol_id": e.get("enemy_patrol_id"), }) # Packs (polygon vertices) packs_out = [] for p in f_data["packs"]: verts = vertices_from(p.get("vertices_json")) if not verts: continue packs_out.append({ "id": p["id"], "color": p.get("color") or "#5993D2", "label": p.get("label"), "vertices": [to_pixel(v["lat"], v["lng"]) for v in verts], "enemy_ids": [en["id"] for en in p.get("enemies", []) if "id" in en], }) # Patrols (polylines) patrols_out = [] for pa in f_data["patrols"]: line = pa.get("polyline", {}) verts = vertices_from(line.get("vertices_json")) if not verts: continue patrols_out.append({ "id": pa["id"], "color": line.get("color") or "#003280", "weight": line.get("weight") or 3, "vertices": [to_pixel(v["lat"], v["lng"]) for v in verts], }) # Map icons — bosses, doors, comments, etc. icons_out = [] for ic in f_data["icons"]: icon_type = icon_index.get(ic.get("map_icon_type_id"), "unknown") icons_out.append({ "id": ic["id"], "type": icon_type, "pos": to_pixel(ic["lat"], ic["lng"]), "comment": ic.get("comment"), }) maps_out.append({ "image": f"maps/{image_basename}.webp", "width": W, "height": H, "label": image_basename, "kg_floor_id": kg_floor_id, "enemies": enemies_out, "packs": packs_out, "patrols": patrols_out, "icons": icons_out, }) return { "id": tile_key, "expansion": "OriginalWoW", "name": name, "acronym": d.get("acronym"), "tile_key": tile_key, "data_slug": d.get("data_slug"), "mapping_id": d.get("mapping_id"), "maps": maps_out, } def main() -> int: if not REGISTRY.exists() or not (KG_DIR / "_summary.json").exists(): print("missing kg_dungeons.json or _summary.json", file=sys.stderr) return 1 registry = json.loads(REGISTRY.read_text()) summary = json.loads((KG_DIR / "_summary.json").read_text()) # AtlasLoot supplemental rares + interactives, keyed by kg tile_key. extras_db = {} if EXTRAS_PATH.exists(): extras_db = json.loads(EXTRAS_PATH.read_text()).get("extras", {}) overrides = [] dungeon_replacements = {} map_image_swaps = {} if OVERRIDES_PATH.exists(): ov_doc = json.loads(OVERRIDES_PATH.read_text()) overrides = ov_doc.get("overrides", []) dungeon_replacements = ov_doc.get("dungeon_replacements", {}) map_image_swaps = ov_doc.get("map_image_swaps", {}) # Load AtlasLoot map data on demand for dungeon replacements. atlasloot_data = None al_path = Path("/tmp/atlasloot_maps.json") if al_path.exists(): try: atlasloot_data = json.loads(al_path.read_text()) except Exception: atlasloot_data = None def replacement_entry(tile_key, repl, registry_entry): """Build a complete dungeon record from a manual-override map. Three shapes are supported: 1. `floors`: list of per-floor maps each with their own bosses. For multi-floor dungeons we want to ship at parity with the rest of the picker (4 webps with floor-tabs). 2. `bosses` list at top level: single-floor + explicit pins. 3. `atlasloot_id`: single-floor + pull from AtlasLoot. Cords are 0-100 percent of the AL subzone frame — only roughly correct on full-map images.""" # --- multi-floor case --- if "floors" in repl: maps_out = [] for f in repl["floors"]: fW, fH = f["width"], f["height"] enemies = [] for b in f.get("bosses", []): enemies.append({ "id": None, "npc_id": None, "name": b["name"], "pos": [round(b["pos"][0], 1), round(b["pos"][1], 1)], "classification": b.get("cls", 3), "skippable": False, "required": False, "kill_priority": None, "pack_id": None, "patrol_id": None, "ascension_pinned": True, }) maps_out.append({ "image": f["image"], "width": fW, "height": fH, "label": f.get("label", tile_key), "kg_floor_id": f.get("kg_floor_id"), "enemies": enemies, "packs": [], "patrols": [], "icons": [], }) return { "id": tile_key, "expansion": "OriginalWoW", "name": registry_entry.get("name", tile_key), "acronym": registry_entry.get("acronym"), "tile_key": tile_key, "data_slug": registry_entry.get("data_slug"), "mapping_id": registry_entry.get("mapping_id"), "maps": maps_out, "ascension_replaced": True, "replacement_note": repl.get("note"), } W, H = repl["width"], repl["height"] enemies = [] if "bosses" in repl: for b in repl["bosses"]: enemies.append({ "id": None, "npc_id": None, "name": b["name"], "pos": [round(b["pos"][0], 1), round(b["pos"][1], 1)], "classification": b.get("cls", 3), "skippable": False, "required": False, "kill_priority": None, "pack_id": None, "patrol_id": None, "ascension_pinned": True, }) elif "atlasloot_id" in repl: al_id = repl["atlasloot_id"] al = (atlasloot_data or {}).get("OriginalWoW", {}).get(al_id, {}) seen = set() for k, v in al.items(): if not k.isdigit() or not isinstance(v, list): continue for ent in v: if not isinstance(ent, dict): continue if ent.get("SubZone"): continue cords = ent.get("cords") name = ent.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) pin = ent.get("pinType") lname = name.lower() if pin == "dungeonskull": cls = 3 elif pin is None and "rare" in lname: cls = 5 else: cls = 2 enemies.append({ "id": None, "npc_id": None, "name": name, "pos": [round(cords[0] / 100 * W, 1), round(cords[1] / 100 * H, 1)], "classification": cls, "skippable": False, "required": False, "kill_priority": None, "pack_id": None, "patrol_id": None, }) map_obj = { "image": repl["image"], "width": W, "height": H, "label": repl.get("label", tile_key), "kg_floor_id": None, "enemies": enemies, "packs": [], "patrols": [], "icons": [], } return { "id": tile_key, "expansion": "OriginalWoW", "name": registry_entry.get("name", tile_key), "acronym": registry_entry.get("acronym"), "tile_key": tile_key, "data_slug": registry_entry.get("data_slug"), "mapping_id": registry_entry.get("mapping_id"), "maps": [map_obj], "ascension_replaced": True, "replacement_note": repl.get("note"), } def apply_overrides(tile_key, name, pos, floor_id): """Return (pos, floor_id) possibly replaced by an Ascension override.""" for o in overrides: if o["tile_key"] != tile_key: continue if o["name"].lower() not in name.lower(): continue new_floor = o.get("kg_floor_id", floor_id) new_pos = o.get("pos", pos) return new_pos, new_floor return pos, floor_id # Build a global icon-type index from the static.js if present, else # fall back to known IDs. icon_index: dict[int, str] = { 1: "unknown", 2: "comment", 3: "door", 4: "door", 5: "door", 6: "door_locked", 7: "door", 8: "door", 9: "dot_yellow", 10: "start", 11: "gateway", 12: "graveyard", 26: "boss", } static_path = KG_DIR / "_static.js" if static_path.exists(): try: static_data = parse_js_var(static_path)["static"] for it in static_data.get("mapIconTypes", []): key = ICON_TYPE_OVERRIDES.get(it.get("key"), it.get("key")) icon_index[it["id"]] = key or "unknown" except Exception: pass dungeons = [] for d in registry["dungeons"]: npc_index = None lang_path = KG_DIR / d["tile_key"] / "lang.js" if lang_path.exists(): try: lang = parse_js_var(lang_path) npc_index = {n["id"]: n for n in lang.get("dungeonNpcs", [])} except Exception: npc_index = {} # Dungeon replacement: skip kg entirely for this one. if d["tile_key"] in dungeon_replacements: dungeons.append(replacement_entry(d["tile_key"], dungeon_replacements[d["tile_key"]], d)) continue entry = build_one(d, summary, npc_index, icon_index) if entry: extras = extras_db.get(d["tile_key"], []) if extras: entry["extras"] = [ { "name": e["name"], "pos": [e["x"], e["y"]], "rare": bool(e.get("rare")), "source": e.get("source", "atlasloot"), "kg_floor_id": e.get("kg_floor_id"), } for e in extras ] # Ascension overrides: change pos and/or relocate to a different # floor. We collect relocations first, then apply them in a # second pass so iteration semantics stay clean. relocations = [] # list of (enemy_dict, source_map, target_map) for m in entry["maps"]: for e in list(m["enemies"]): new_pos, new_floor = apply_overrides(d["tile_key"], e["name"], e["pos"], m.get("kg_floor_id")) if new_pos is e["pos"] and new_floor == m.get("kg_floor_id"): continue e["pos"] = list(new_pos) e["ascension_override"] = True if new_floor is not None and new_floor != m.get("kg_floor_id"): target = next((mm for mm in entry["maps"] if mm.get("kg_floor_id") == new_floor), None) if target: relocations.append((e, m, target)) for e, src, tgt in relocations: src["enemies"].remove(e) tgt["enemies"].append(e) for ex in entry.get("extras", []): new_pos, new_floor = apply_overrides(d["tile_key"], ex["name"], ex["pos"], ex.get("kg_floor_id")) if new_pos is not ex["pos"] or new_floor != ex.get("kg_floor_id"): ex["pos"] = list(new_pos) ex["kg_floor_id"] = new_floor ex["ascension_override"] = True # Map image swap: rescale every coord from kg's pixel space # (image dimensions in floor_summary) into the new image space. swap = map_image_swaps.get(d["tile_key"]) if swap: new_w, new_h = swap["width"], swap["height"] for m in entry["maps"]: sx = new_w / m["width"] sy = new_h / m["height"] m["width"], m["height"] = new_w, new_h for e in m["enemies"]: e["pos"] = [round(e["pos"][0] * sx, 1), round(e["pos"][1] * sy, 1)] for p in m["packs"]: p["vertices"] = [[round(v[0] * sx, 1), round(v[1] * sy, 1)] for v in p["vertices"]] for pa in m["patrols"]: pa["vertices"] = [[round(v[0] * sx, 1), round(v[1] * sy, 1)] for v in pa["vertices"]] for ic in m["icons"]: ic["pos"] = [round(ic["pos"][0] * sx, 1), round(ic["pos"][1] * sy, 1)] # Extras already use the kg pixel space; rescale too. # Use the first map's pre-swap factor — extras are dungeon-level. for ex in entry.get("extras", []): ex["pos"] = [round(ex["pos"][0] * (new_w / 6144), 1), round(ex["pos"][1] * (new_h / 4096), 1)] dungeons.append(entry) dungeons.sort(key=lambda d: d["name"]) OUT_PATH.write_text(json.dumps({"dungeons": dungeons}, indent=2)) print(f"wrote {OUT_PATH} — {len(dungeons)} dungeons") for d in dungeons: n_enemies = sum(len(m["enemies"]) for m in d["maps"]) n_packs = sum(len(m["packs"]) for m in d["maps"]) n_icons = sum(len(m["icons"]) for m in d["maps"]) print(f" {d['name']:30s} maps={len(d['maps'])} enemies={n_enemies} packs={n_packs} icons={n_icons}") return 0 if __name__ == "__main__": raise SystemExit(main())