Files
mplus-routes/tools/atlasloot_extras.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

286 lines
12 KiB
Python

#!/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())