e11dc1eed5
- 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.
296 lines
12 KiB
Python
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())
|