Files
mplus-routes/tools/atlasloot_extras.py
T
florian.berthold e11dc1eed5 per-floor extras + Ascension overrides + layer toggles
- atlasloot_extras: fit one transform per (kg_dungeon, floor_id) instead of mixing all kg bosses into one fit. Each AL extra is assigned to whichever floor's anchors it's nearest to (in AL coord space). Strat's Stonespine now correctly lands on floor 235 (Undead) instead of being hidden because the mixed-floor fit pushed it off.
- new data/ascension_overrides.json: per-name position/floor patches for places where Ascension diverges from retail. Seeded with Magistrate Barthilas → moved to the southern courtyard (3498, 3300 on Undead Side) per Ascension spawns.
- frontend renders extras only on their assigned floor; previously hard-coded to floor 0.
- new layer-toggle checkboxes (Enemies / Packs / Patrols / Icons) in the toolbar — flip patrols off if mob routes are noise for your route.
2026-04-25 23:26:24 +02:00

296 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 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())