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.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
#!/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())
|
||||
+20
-2
@@ -20,10 +20,12 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
KG_DIR = ROOT / "data" / "kg"
|
||||
REGISTRY = ROOT / "data" / "kg_dungeons.json"
|
||||
DATA = ROOT / "data"
|
||||
KG_DIR = DATA / "kg"
|
||||
REGISTRY = DATA / "kg_dungeons.json"
|
||||
WEB_ASSETS = ROOT / "web" / "assets"
|
||||
WEB_MAPS = WEB_ASSETS / "maps"
|
||||
EXTRAS_PATH = DATA / "atlasloot_extras.json"
|
||||
OUT_PATH = WEB_ASSETS / "dungeons.json"
|
||||
|
||||
# Map kg's mapIconType names → simple labels we render in the UI.
|
||||
@@ -216,6 +218,11 @@ def main() -> int:
|
||||
registry = json.loads(REGISTRY.read_text())
|
||||
summary = json.loads((KG_DIR / "_summary.json").read_text())
|
||||
|
||||
# AtlasLoot supplemental rares + interactives, keyed by kg tile_key.
|
||||
extras_db = {}
|
||||
if EXTRAS_PATH.exists():
|
||||
extras_db = json.loads(EXTRAS_PATH.read_text()).get("extras", {})
|
||||
|
||||
# Build a global icon-type index from the static.js if present, else
|
||||
# fall back to known IDs.
|
||||
icon_index: dict[int, str] = {
|
||||
@@ -246,6 +253,17 @@ def main() -> int:
|
||||
|
||||
entry = build_one(d, summary, npc_index, icon_index)
|
||||
if entry:
|
||||
extras = extras_db.get(d["tile_key"], [])
|
||||
if extras:
|
||||
entry["extras"] = [
|
||||
{
|
||||
"name": e["name"],
|
||||
"pos": [e["x"], e["y"]],
|
||||
"rare": bool(e.get("rare")),
|
||||
"source": e.get("source", "atlasloot"),
|
||||
}
|
||||
for e in extras
|
||||
]
|
||||
dungeons.append(entry)
|
||||
|
||||
dungeons.sort(key=lambda d: d["name"])
|
||||
|
||||
Reference in New Issue
Block a user