247633546a
Same multi-floor look as every other dungeon: 4 webps with floor tabs (The Reliquary / Chamber of Summoning / The Upper Study / Headmaster's Study). Each of the 14 classic Scholo bosses hand-pinned to a specific room within its floor: f1 (Reliquary): Blood Steward of Kirtonos f2 (CoS): Kirtonos summon, Vectus, Marduk Blackpool f3 (Upper Study): Jandice, Rattlegore, Polkelt, Krastinov, Malicia f4 (Headmaster's): Illucia, Alexei, Ravenian, Ras, Gandling Schema change: dungeon_replacements now supports a 'floors' array for multi-floor manual overrides, alongside the existing single-floor 'bosses'/'atlasloot_id' modes.
488 lines
20 KiB
Python
488 lines
20 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 = []
|
|
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())
|