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.
This commit is contained in:
Florian Andrew George Berthold 2026-04-25 23:18:50 +02:00
parent 9600ce35c2
commit 724ae08394
5 changed files with 2699 additions and 4 deletions

1102
data/atlasloot_extras.json Normal file

File diff suppressed because it is too large Load diff

285
tools/atlasloot_extras.py Normal file
View file

@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
Pull AtlasLootAscension's "rare" bosses (and selected interactives) into
keystone.guru pixel space so they can be rendered alongside kg's data.
Approach:
1. Match bosses between AL and kg by name (kg cls>=3 + AL dungeonskull)
2. Per (AL-dungeon, kg-tile-key) pair: fit an affine x/y transform
pixel = al * scale + offset using 2+ matched pairs.
3. Apply transform to AL entries we don't already have in kg
(pinType=None entries explicitly tagged "(Rare)" plus a curated
subset of named non-rare entries we still want graveyards, etc.).
4. Emit data/atlasloot_extras.json keyed by kg tile_key.
Notes:
- AL coords are 0-100 percent of the Atlas-addon BLP. kg pixels are
0..6144 / 0..4096 at z=4.
- Multi-wing AL dungeons (DireMaul, ScarletMonastery, BlackrockSpire)
have a single coord space; we'd need per-wing transforms keyed by
boss-presence. Handled by AL_TO_KG mapping below we point each AL
dungeon at the kg wing(s) it overlaps with and only apply matches
that fall in that wing's content area (loose check on transformed
pixel bounds).
"""
from __future__ import annotations
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
KG_DIR = ROOT / "data" / "kg"
REGISTRY = ROOT / "data" / "kg_dungeons.json"
OUT = ROOT / "data" / "atlasloot_extras.json"
ATLASLOOT_PATH = Path("/tmp/atlasloot_maps.json")
# For multi-wing kg dungeons we run the transform per wing; the same AL
# boss can plausibly land inside more than one wing's image. WING_FORCE
# pins specific bosses to one wing so we don't render duplicates.
# Keys: kg tile_key. Values: substrings of AL boss names that belong here.
WING_FORCE = {
"lower_blackrock_spire": [
"Spirestone Butcher", "Bijou", "Halycon", "Roughshod Pike",
"Human Remains", "Ghok Bashguud",
],
"upper_blackrock_spire": [
"Bannok Grimaxe", "Jed Runewatcher", "Awbee",
"Darkstone Tablet", "Blackwing Lair",
],
"dire_maul_east": ["Tsu'zee", "Pylons", "Old Ironbark"],
"dire_maul_north": ["Cho'Rush"],
"scarlet_monastery_cathedral": ["Pumpkin Shrine"],
}
# Bosses appearing in any WING_FORCE list are excluded from every OTHER wing
# of their parent dungeon. Bosses not in any list still go to all wings
# they fit (we'll see "Burning Felguard (Summon)" in both BRS wings, which
# is actually correct — there's a summon stone in each).
# AL dungeon-id → list of kg tile_keys (one or more for split dungeons).
AL_TO_KG = {
"RagefireChasm": ["ragefire_chasm"],
"TheDeadmines": ["deadmines"],
"WailingCaverns": ["wailing_caverns"],
"ShadowfangKeep": ["shadowfang_keep"],
"TheStockade": ["the_stockade"],
"BlackFathomDeeps": ["blackfathom_deeps"],
"Gnomeregan": ["gnomeregan"],
"RazorfenKraul": ["razorfen_kraul"],
"RazorfenDowns": ["razorfen_downs"],
"ScarletMonastery": ["scarlet_monastery_armory", "scarlet_monastery_cathedral",
"scarlet_monastery_graveyard", "scarlet_monastery_library"],
"Uldaman": ["uldaman"],
"ZulFarrak": ["zul_farrak"],
"Maraudon": ["maraudon"],
"BlackrockDepths": ["blackrock_depths"],
"BlackrockSpire": ["lower_blackrock_spire", "upper_blackrock_spire"],
"Stratholme": ["stratholme"],
"Scholomance": ["scholomance"],
"DireMaul": ["dire_maul_north", "dire_maul_east", "dire_maul_west"],
"MoltenCore": ["moltencore"],
"BlackwingLair": ["blackwinglair"],
"ZulGurub": ["zulgurub"],
"Naxxramas60": ["naxxramas_classic"],
}
# Names we strip when matching ("Boss Name <Title>" → "Boss Name", "(Rare)", etc.)
NAME_STRIPS = re.compile(r"\s*<[^>]+>|\s*\([^)]+\)")
def norm_name(s: str) -> str:
return NAME_STRIPS.sub("", s).strip().lower()
def parse_js_var(p: Path) -> dict:
s = p.read_text()
return json.loads(s[s.find("{"):].rstrip(" ;\n"))
def get_kg_bosses_per_floor(tile_key: str):
"""Return list of (npc_id, name, x_px, y_px, classification) for
boss-like enemies in the dungeon's first floor only — enough to fit
a transform; later floors share the same coord scale anyway."""
sf = parse_js_var(KG_DIR / tile_key / "split_floors.js")
lang = parse_js_var(KG_DIR / tile_key / "lang.js")
name_by_id = {n["id"]: n["name"] for n in lang.get("dungeonNpcs", [])}
cls_by_id = {n["id"]: n.get("classification_id") for n in lang.get("dungeonNpcs", [])}
# First floor: pick the lowest floor_id present
enemies = sf["dungeon"].get("enemies", [])
if not enemies:
return []
out = []
for e in enemies:
npc_id = e.get("npc_id")
name = name_by_id.get(npc_id, "?")
cls = cls_by_id.get(npc_id, 0)
if cls < 3:
continue # only bosses
# kg coord transform: pixel_x = lng*16, pixel_y = -lat*16 (z=4)
pix_x = e["lng"] * 16
pix_y = -e["lat"] * 16
out.append((npc_id, name, pix_x, pix_y, cls, e.get("floor_id")))
return out
def fit_transform(al_pts, kg_pts):
"""Least-squares fit of pixel = al * scale + offset, separately per axis.
Returns (sx, ox, sy, oy) or None if too few matches / degenerate."""
if len(al_pts) < 2:
return None
# Build linear system
n = len(al_pts)
sum_x = sum(p[0] for p in al_pts)
sum_y = sum(p[1] for p in al_pts)
sum_X = sum(p[0] for p in kg_pts)
sum_Y = sum(p[1] for p in kg_pts)
sum_xX = sum(a[0] * b[0] for a, b in zip(al_pts, kg_pts))
sum_yY = sum(a[1] * b[1] for a, b in zip(al_pts, kg_pts))
sum_xx = sum(p[0] ** 2 for p in al_pts)
sum_yy = sum(p[1] ** 2 for p in al_pts)
den_x = n * sum_xx - sum_x * sum_x
den_y = n * sum_yy - sum_y * sum_y
if abs(den_x) < 1e-9 or abs(den_y) < 1e-9:
return None
sx = (n * sum_xX - sum_x * sum_X) / den_x
ox = (sum_X - sx * sum_x) / n
sy = (n * sum_yY - sum_y * sum_Y) / den_y
oy = (sum_Y - sy * sum_y) / n
return sx, ox, sy, oy
def collect_al_entries(al_dungeon: dict):
"""Yield (name, x, y, is_rare, raw_name) for entries with cords."""
for k, v in al_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
is_rare = "(rare" in name.lower()
yield name, cords[0], cords[1], is_rare, name
def main() -> int:
if not ATLASLOOT_PATH.exists():
print(f"missing {ATLASLOOT_PATH}; export with bisbeard/export_atlasloot_maps.lua", file=sys.stderr)
return 1
atlasloot = json.loads(ATLASLOOT_PATH.read_text())["OriginalWoW"]
# Pre-load kg bosses per relevant tile_key
kg_bosses = {}
for kgs in AL_TO_KG.values():
for k in kgs:
try:
kg_bosses[k] = get_kg_bosses_per_floor(k)
except FileNotFoundError:
kg_bosses[k] = []
extras = defaultdict(list)
transforms = {}
summary = []
for al_id, kg_keys in AL_TO_KG.items():
al_dungeon = atlasloot.get(al_id)
if not al_dungeon:
continue
al_entries = list(collect_al_entries(al_dungeon))
# Match AL entries to kg bosses by normalized name
al_by_norm = {norm_name(e[0]): e for e in al_entries if not e[3]} # non-rare bosses for fitting
# For each kg wing, fit a transform from anchors that match
for kg_key in kg_keys:
kg_b = kg_bosses.get(kg_key, [])
if not kg_b:
continue
kg_by_norm = {norm_name(b[1]): b for b in kg_b}
common_names = set(al_by_norm) & set(kg_by_norm)
if len(common_names) < 2:
summary.append(f"{al_id}{kg_key}: only {len(common_names)} anchor(s); skipping")
continue
al_pts = []
kg_pts = []
for n in sorted(common_names):
al_e = al_by_norm[n]
kg_e = kg_by_norm[n]
al_pts.append((al_e[1], al_e[2]))
kg_pts.append((kg_e[2], kg_e[3]))
tr = fit_transform(al_pts, kg_pts)
if not tr:
summary.append(f"{al_id}{kg_key}: degenerate fit")
continue
sx, ox, sy, oy = tr
transforms[(al_id, kg_key)] = tr
summary.append(
f"{al_id}{kg_key}: {len(common_names)} anchors, "
f"scale=({sx:.2f},{sy:.2f}) offset=({ox:.0f},{oy:.0f})"
)
# Apply transform to AL entries that don't already exist as kg bosses
kg_existing_norms = {norm_name(b[1]) for b in kg_b}
# Build the set of "this dungeon's bosses pinned to a different
# wing" so we can exclude them here.
other_wings = [k for k in kg_keys if k != kg_key]
forced_elsewhere = set()
for w in other_wings:
for needle in WING_FORCE.get(w, []):
forced_elsewhere.add(needle.lower())
this_wing_pins = {n.lower() for n in WING_FORCE.get(kg_key, [])}
for e in al_entries:
name = e[0]
lname = name.lower()
if norm_name(name) in kg_existing_norms:
continue # already in kg
# If this boss is force-pinned to another wing, skip here.
if any(p in lname for p in forced_elsewhere) and \
not any(p in lname for p in this_wing_pins):
continue
px = e[1] * sx + ox
py = e[2] * sy + oy
# Reject points outside the kg image bounds (multi-wing
# dungeons: a boss in a wing is OOB on a different wing's
# image).
if not (-200 <= px <= 6344 and -200 <= py <= 4296):
continue
# Also reject points that are >1.5x the diagonal away from
# all anchors — likely a wrong-wing match.
anchor_dists = [
((px - a[0]) ** 2 + (py - a[1]) ** 2) ** 0.5 for a in kg_pts
]
if min(anchor_dists) > 4500:
continue
extras[kg_key].append({
"name": name,
"x": round(px, 1),
"y": round(py, 1),
"rare": e[3],
"source": "atlasloot",
})
OUT.write_text(json.dumps({
"_comment": "Supplemental rare bosses + interactives lifted from AtlasLootAscension and transformed into keystone.guru pixel space. Generated by tools/atlasloot_extras.py.",
"transforms": {f"{k[0]}{k[1]}": v for k, v in transforms.items()},
"extras": dict(extras),
}, indent=2))
print("transform summary:")
for s in summary:
print(f" {s}")
print(f"\ntotal extras: {sum(len(v) for v in extras.values())} across {len(extras)} dungeon-wings")
for k in sorted(extras):
rares = [e for e in extras[k] if e["rare"]]
print(f" {k:32s} {len(extras[k]):3d} entries ({len(rares)} rare)")
print(f"\nwrote {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -20,10 +20,12 @@ import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
KG_DIR = ROOT / "data" / "kg"
REGISTRY = ROOT / "data" / "kg_dungeons.json"
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.
@ -216,6 +218,11 @@ def main() -> int:
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] = {
@ -246,6 +253,17 @@ def main() -> int:
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"])

View file

@ -194,6 +194,17 @@ function renderOverlay() {
}
}
// AtlasLoot-derived extras: rare bosses + interactives kg doesn't ship.
// These are dungeon-level (not per-floor) and only render on floor 0
// unless we get richer data; for single-floor dungeons that's all that
// matters, and for multi-floor we leave them on the first floor by
// default — the user can drag a Note over them on any floor.
if (state.floorIndex === 0 && state.current?.extras) {
for (const ex of state.current.extras) {
svg.appendChild(makeExtraPin(ex));
}
}
// Route polyline
if (wps.length > 1) {
const path = document.createElementNS(SVG_NS, "polyline");
@ -419,6 +430,42 @@ function makeEnemyPin(e) {
return g;
}
function makeExtraPin(ex) {
// Silver-blue ring with skull glyph for rares; muted square for non-rare
// interactives (postboxes, summon spots, etc.). Strip the trailing
// "(Rare)" / "(Rare, Wanders)" tag from the displayed tooltip — we
// surface "rare" via the visual treatment.
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", ex.rare ? "extra rare" : "extra");
g.setAttribute("transform", `translate(${ex.pos[0]},${ex.pos[1]})`);
if (ex.rare) {
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 26);
c.setAttribute("fill", "#bfd6f0");
c.setAttribute("stroke", "#3b6db0");
c.setAttribute("stroke-width", "5");
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 11);
t.setAttribute("font-size", "30");
t.setAttribute("text-anchor", "middle");
t.setAttribute("fill", "#1a3360");
t.setAttribute("font-weight", "900");
t.textContent = "☠";
g.appendChild(t);
} else {
const r = document.createElementNS(SVG_NS, "rect");
r.setAttribute("x", -10); r.setAttribute("y", -10);
r.setAttribute("width", 20); r.setAttribute("height", 20);
r.setAttribute("fill", "#7e8290");
r.setAttribute("stroke", "#1a1208");
r.setAttribute("stroke-width", "2");
g.appendChild(r);
}
g.dataset.tooltip = ex.name + (ex.rare ? "" : "");
return g;
}
function makeIconMarker(ic) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `icon icon-${ic.type}`);
@ -438,14 +485,54 @@ function makeIconMarker(ic) {
t.setAttribute("font-weight", "700");
t.textContent = "i";
g.appendChild(t);
} else if (ic.type === "door") {
} else if (ic.type === "door" || ic.type === "door_locked") {
const r = document.createElementNS(SVG_NS, "rect");
r.setAttribute("x", -10); r.setAttribute("y", -14);
r.setAttribute("width", 20); r.setAttribute("height", 28);
r.setAttribute("fill", "#B58A3F");
r.setAttribute("fill", ic.type === "door_locked" ? "#7a4a1a" : "#B58A3F");
r.setAttribute("stroke", "#000");
r.setAttribute("stroke-width", "2");
g.appendChild(r);
} else if (ic.type === "start") {
// Green entry-flag triangle
const p = document.createElementNS(SVG_NS, "polygon");
p.setAttribute("points", "-12,-14 14,0 -12,14");
p.setAttribute("fill", "#6ad17b");
p.setAttribute("stroke", "#0a2a12");
p.setAttribute("stroke-width", "2");
g.appendChild(p);
} else if (ic.type === "graveyard") {
// Tombstone: rounded-top rect with a cross
const r = document.createElementNS(SVG_NS, "rect");
r.setAttribute("x", -12); r.setAttribute("y", -14);
r.setAttribute("width", 24); r.setAttribute("height", 28);
r.setAttribute("rx", 10); r.setAttribute("ry", 10);
r.setAttribute("fill", "#9aa1aa");
r.setAttribute("stroke", "#000");
r.setAttribute("stroke-width", "2");
g.appendChild(r);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 6);
t.setAttribute("font-size", "20");
t.setAttribute("text-anchor", "middle");
t.setAttribute("fill", "#1a1208");
t.setAttribute("font-weight", "900");
t.textContent = "✝";
g.appendChild(t);
} else if (ic.type === "dot_yellow") {
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 10);
c.setAttribute("fill", "#f0c674");
c.setAttribute("stroke", "#1a1208");
c.setAttribute("stroke-width", "2");
g.appendChild(c);
} else if (ic.type === "gateway") {
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 14);
c.setAttribute("fill", "#9b59b6");
c.setAttribute("stroke", "#000");
c.setAttribute("stroke-width", "2");
g.appendChild(c);
}
if (ic.comment) {
g.dataset.tooltip = ic.comment;

File diff suppressed because it is too large Load diff