#!/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
" → "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 dicts per boss-like enemy across ALL floors.
Caller groups by floor_id when fitting per-floor transforms."""
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", [])}
out = []
for e in sf["dungeon"].get("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
out.append({
"name": name,
"x": e["lng"] * 16,
"y": -e["lat"] * 16,
"cls": cls,
"floor_id": 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))
al_by_norm = {norm_name(e[0]): e for e in al_entries if not e[3]}
for kg_key in kg_keys:
kg_b = kg_bosses.get(kg_key, [])
if not kg_b:
continue
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, [])}
# Group kg bosses by floor_id and fit one transform per floor
# so a boss on floor B doesn't drag the floor-A fit off.
by_floor = defaultdict(list)
for b in kg_b:
by_floor[b["floor_id"]].append(b)
floor_transforms = {} # floor_id → (sx, ox, sy, oy, anchor_pts)
for fid, fb in by_floor.items():
kg_by_norm = {norm_name(b["name"]): b for b in fb}
common = set(al_by_norm) & set(kg_by_norm)
if len(common) < 2:
continue
al_pts, kg_pts = [], []
for n in sorted(common):
al_pts.append((al_by_norm[n][1], al_by_norm[n][2]))
kg_pts.append((kg_by_norm[n]["x"], kg_by_norm[n]["y"]))
tr = fit_transform(al_pts, kg_pts)
if not tr:
continue
floor_transforms[fid] = (*tr, al_pts, kg_pts)
transforms[(al_id, kg_key, fid)] = tr
summary.append(
f"{al_id} → {kg_key} floor={fid}: {len(common)} anchors, "
f"scale=({tr[0]:.2f},{tr[2]:.2f}) offset=({tr[1]:.0f},{tr[3]:.0f})"
)
if not floor_transforms:
summary.append(f"{al_id} → {kg_key}: no per-floor transform fit")
continue
kg_existing = set()
for fb in by_floor.values():
for b in fb:
kg_existing.add(norm_name(b["name"]))
for e in al_entries:
name = e[0]
lname = name.lower()
if norm_name(name) in kg_existing:
continue
if any(p in lname for p in forced_elsewhere) and \
not any(p in lname for p in this_wing_pins):
continue
# Pick the floor whose anchors are nearest the AL coord —
# that's the floor this entry most likely belongs to.
best_fid = None
best_dist = float("inf")
for fid, (sx, ox, sy, oy, al_pts, kg_pts) in floor_transforms.items():
d = min(((e[1] - a[0]) ** 2 + (e[2] - a[1]) ** 2) ** 0.5 for a in al_pts)
if d < best_dist:
best_dist = d
best_fid = fid
sx, ox, sy, oy, al_pts, kg_pts = floor_transforms[best_fid]
px = e[1] * sx + ox
py = e[2] * sy + oy
if not (-200 <= px <= 6344 and -200 <= py <= 4296):
continue
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],
"kg_floor_id": best_fid,
"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]}@floor{k[2]}": 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())