Files
mplus-routes/tools/kg_build_data.py
T
florian.berthold 724ae08394 pull AtlasLoot rares + interactives into kg pixel space
- new tools/atlasloot_extras.py: matches AL bosses to kg cls>=3 enemies by name, fits per-(dungeon,wing) affine x/y transform, applies to AL pinType=None entries to recover ~14 rare bosses kg's spawn-bound data model omits (Blind Hunter, Stonespine, Deathsworn Captain, Spirestone Butcher, Bannok Grimaxe, Jed Runewatcher, Tsu'zee, …) plus 140+ AtlasLoot interactives (postboxes, summon stones, levers)
- WING_FORCE map disambiguates multi-wing dungeons (BRS, Dire Maul, Scarlet Monastery) where the same AL coord transforms into multiple wings
- frontend renders rare extras as silver-blue skull pins, non-rare extras as muted squares; both have hover-tooltips with their AtlasLoot name
- start, graveyard, dot_yellow, gateway, door_locked icon types from kg now render with distinct shapes (were silently empty before)
- kg_build_data.py merges atlasloot_extras.json into each dungeon's 'extras' field

Note: Ascension always-spawns rare bosses (vs retail's RNG), so they're now reliably visible on the planner.
2026-04-25 23:18:50 +02:00

282 lines
10 KiB
Python

#!/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/<key>/split_floors.js enemies, packs, patrols, mapIcons
data/kg/<key>/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"
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", {})
# 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 = {}
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"),
}
for e in extras
]
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())