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