e11dc1eed5
- atlasloot_extras: fit one transform per (kg_dungeon, floor_id) instead of mixing all kg bosses into one fit. Each AL extra is assigned to whichever floor's anchors it's nearest to (in AL coord space). Strat's Stonespine now correctly lands on floor 235 (Undead) instead of being hidden because the mixed-floor fit pushed it off. - new data/ascension_overrides.json: per-name position/floor patches for places where Ascension diverges from retail. Seeded with Magistrate Barthilas → moved to the southern courtyard (3498, 3300 on Undead Side) per Ascension spawns. - frontend renders extras only on their assigned floor; previously hard-coded to floor 0. - new layer-toggle checkboxes (Enemies / Packs / Patrols / Icons) in the toolbar — flip patrols off if mob routes are noise for your route.
315 lines
12 KiB
Python
315 lines
12 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"
|
|
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 = []
|
|
if OVERRIDES_PATH.exists():
|
|
overrides = json.loads(OVERRIDES_PATH.read_text()).get("overrides", [])
|
|
|
|
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 = {}
|
|
|
|
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: swap pos / floor for a specific named entity
|
|
for m in entry["maps"]:
|
|
for e in m["enemies"]:
|
|
new_pos, new_floor = apply_overrides(d["tile_key"], e["name"], e["pos"], m.get("kg_floor_id"))
|
|
if new_pos is not e["pos"]:
|
|
e["pos"] = list(new_pos)
|
|
e["ascension_override"] = True
|
|
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"]:
|
|
ex["pos"] = list(new_pos)
|
|
ex["kg_floor_id"] = new_floor
|
|
ex["ascension_override"] = True
|
|
|
|
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())
|