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.
This commit is contained in:
+62
-52
@@ -98,28 +98,26 @@ def parse_js_var(p: Path) -> dict:
|
||||
|
||||
|
||||
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."""
|
||||
"""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", [])}
|
||||
# First floor: pick the lowest floor_id present
|
||||
enemies = sf["dungeon"].get("enemies", [])
|
||||
if not enemies:
|
||||
return []
|
||||
out = []
|
||||
for e in enemies:
|
||||
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 # 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")))
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"x": e["lng"] * 16,
|
||||
"y": -e["lat"] * 16,
|
||||
"cls": cls,
|
||||
"floor_id": e.get("floor_id"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -192,40 +190,12 @@ def main() -> int:
|
||||
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
|
||||
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
|
||||
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:
|
||||
@@ -233,24 +203,63 @@ def main() -> int:
|
||||
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_norms:
|
||||
continue # already in kg
|
||||
# If this boss is force-pinned to another wing, skip here.
|
||||
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
|
||||
# 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
|
||||
]
|
||||
@@ -261,12 +270,13 @@ def main() -> int:
|
||||
"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]}": v for k, v in transforms.items()},
|
||||
"transforms": {f"{k[0]}→{k[1]}@floor{k[2]}": v for k, v in transforms.items()},
|
||||
"extras": dict(extras),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ REGISTRY = DATA / "kg_dungeons.json"
|
||||
WEB_ASSETS = ROOT / "web" / "assets"
|
||||
WEB_MAPS = WEB_ASSETS / "maps"
|
||||
EXTRAS_PATH = DATA / "atlasloot_extras.json"
|
||||
OVERRIDES_PATH = DATA / "ascension_overrides.json"
|
||||
OUT_PATH = WEB_ASSETS / "dungeons.json"
|
||||
|
||||
# Map kg's mapIconType names → simple labels we render in the UI.
|
||||
@@ -223,6 +224,22 @@ def main() -> int:
|
||||
if EXTRAS_PATH.exists():
|
||||
extras_db = json.loads(EXTRAS_PATH.read_text()).get("extras", {})
|
||||
|
||||
overrides = []
|
||||
if OVERRIDES_PATH.exists():
|
||||
overrides = json.loads(OVERRIDES_PATH.read_text()).get("overrides", [])
|
||||
|
||||
def apply_overrides(tile_key, name, pos, floor_id):
|
||||
"""Return (pos, floor_id) possibly replaced by an Ascension override."""
|
||||
for o in overrides:
|
||||
if o["tile_key"] != tile_key:
|
||||
continue
|
||||
if o["name"].lower() not in name.lower():
|
||||
continue
|
||||
new_floor = o.get("kg_floor_id", floor_id)
|
||||
new_pos = o.get("pos", pos)
|
||||
return new_pos, new_floor
|
||||
return pos, floor_id
|
||||
|
||||
# Build a global icon-type index from the static.js if present, else
|
||||
# fall back to known IDs.
|
||||
icon_index: dict[int, str] = {
|
||||
@@ -261,9 +278,25 @@ def main() -> int:
|
||||
"pos": [e["x"], e["y"]],
|
||||
"rare": bool(e.get("rare")),
|
||||
"source": e.get("source", "atlasloot"),
|
||||
"kg_floor_id": e.get("kg_floor_id"),
|
||||
}
|
||||
for e in extras
|
||||
]
|
||||
|
||||
# Ascension overrides: swap pos / floor for a specific named entity
|
||||
for m in entry["maps"]:
|
||||
for e in m["enemies"]:
|
||||
new_pos, new_floor = apply_overrides(d["tile_key"], e["name"], e["pos"], m.get("kg_floor_id"))
|
||||
if new_pos is not e["pos"]:
|
||||
e["pos"] = list(new_pos)
|
||||
e["ascension_override"] = True
|
||||
for ex in entry.get("extras", []):
|
||||
new_pos, new_floor = apply_overrides(d["tile_key"], ex["name"], ex["pos"], ex.get("kg_floor_id"))
|
||||
if new_pos is not ex["pos"]:
|
||||
ex["pos"] = list(new_pos)
|
||||
ex["kg_floor_id"] = new_floor
|
||||
ex["ascension_override"] = True
|
||||
|
||||
dungeons.append(entry)
|
||||
|
||||
dungeons.sort(key=lambda d: d["name"])
|
||||
|
||||
Reference in New Issue
Block a user