Compare commits

...

10 commits

Author SHA1 Message Date
738605b6c0 Commit web/assets/maps/ WebPs so deploys are self-contained
121 MB across 66 dungeon-floor WebPs (~150 KB–6 MB each). Previously
gitignored on the assumption LFS would be needed; plain git handles this
fine and it removes the kg_fetch → kg_stitch dependency from the deploy
path. README and Ansible bring-up step updated.
2026-04-26 12:54:38 +02:00
c2ae7a7218 Scholomance: revert to single-floor Atlas map
The 4-floor upreza experiment placed bosses on Cata-redesign rooms which don't match Ascension's classic boss spawns. Going back to commit 58b3f74's single-floor approach: Real-ESRGAN-upscaled Atlas BLP (2048x2048) with 14 hand-pinned bosses.

The 'floors' schema added to dungeon_replacements stays in tools/kg_build_data.py — useful if a future high-quality classic-layout multi-floor map shows up.
2026-04-26 01:02:43 +02:00
247633546a Scholomance: switch to upreza 4-floor 4096x3072 maps for parity with the rest of the picker
Same multi-floor look as every other dungeon: 4 webps with floor tabs (The Reliquary / Chamber of Summoning / The Upper Study / Headmaster's Study).

Each of the 14 classic Scholo bosses hand-pinned to a specific room within its floor:
  f1 (Reliquary):     Blood Steward of Kirtonos
  f2 (CoS):           Kirtonos summon, Vectus, Marduk Blackpool
  f3 (Upper Study):   Jandice, Rattlegore, Polkelt, Krastinov, Malicia
  f4 (Headmaster's):  Illucia, Alexei, Ravenian, Ras, Gandling

Schema change: dungeon_replacements now supports a 'floors' array for multi-floor manual overrides, alongside the existing single-floor 'bosses'/'atlasloot_id' modes.
2026-04-26 00:57:54 +02:00
58b3f74292 Scholomance: Real-ESRGAN-upscaled Atlas classic map + hand-pinned bosses
Real path forward for Scholomance after exhausting the upreza ecosystem (no classic-layout dungeon interior maps exist there because vanilla/TBC clients didn't ship dungeon interior maps — confirmed from WoWMapUprezClassic README + WoWMapUprezTBC contents).

Solution:
- Atlas-addon Scholomance.blp (512x512, classic single-room layout) upscaled to 2048x2048 with Real-ESRGAN x4plus. Numbers 1-14 are crisp and readable.
- New 'bosses' field in dungeon_replacements lets us hand-pin every enemy to a specific (x,y) on the new map. 14 classic Scholo bosses placed on their Atlas-numbered rooms.
- Single-floor dungeon (kg's 4-floor split is bypassed entirely).
2026-04-26 00:43:20 +02:00
ad511d54e1 scholo: revert image swap experiment, keep kg's native 6144x4096 maps
Tried replacing kg's 4 scholo floors with the upreza Dungeons stitched maps (which have room labels baked in: Reliquary, Chamber of Summoning, Butcher's Sanctum, etc.). But kg renders to 3:2 aspect (6144x4096) while upreza is 4:3 (4096x3072) and the WoW client's source content is positioned differently in each — linear rescale of kg's enemy coords lands bosses outside the rooms.

Better to keep kg's lower-quality but coord-aligned maps. dungeon_replacements / map_image_swaps mechanisms are still there for future cases where we DO have a coord-compatible replacement.

WoWMapUprezClassic doesn't help here — the README notes that vanilla never had in-game dungeon interior maps.
2026-04-26 00:15:02 +02:00
15b7fdeead revert scholo dungeon-replacement; restore kg 4-floor maps
The Atlas single-room override was too low-res and too lossy (no kg packs/patrols, only 14 hand-curated bosses). Better to keep kg's 4-floor layout and figure out per-floor edge cases as we go.

dungeon_replacements is left in place as a mechanism but with no entries; we'll use it only when we have a high-resolution alternative map.
2026-04-26 00:02:20 +02:00
5b1c198757 Scholomance: replace kg's Cata 4-floor map with classic single-room layout
kg only ships the post-Cataclysm Scholomance (4 floors split as you ascend the school) but Ascension uses the classic single-floor layout where every wing branches off the central rooms.

Fix: new dungeon_replacements section in ascension_overrides.json. When a tile_key is listed there, build_data skips kg entirely for that dungeon and synthesises a single-floor entry from a manually-supplied map (Atlas-addon Scholomance.blp upscaled to 2048x2048) and AtlasLoot's per-dungeon boss list.

Coords come from AtlasLoot's 0..100 percent space scaled to the new map's pixel space directly. Pin classification: dungeonskull → boss (cls=3), None+name-contains-Rare → rare elite (cls=5), other interactives → cls=2.
2026-04-25 23:56:40 +02:00
65ae0158f3 Ascension override: Lorgus Jett moves to BFD floor 2 (Moonshrine Sanctum)
kg pinned Lorgus on floor 1 (entrance Pool of Ask'ar) but he actually spawns on floor 2 in Moonshrine Sanctum on Ascension. AtlasLoot's subzone tagging confirms this — Lorgus sits with Twilight Lord Kelris and Aku'mai, not the floor-1 lake bosses.

ascension_overrides.json now also supports relocating an enemy across floors (not just position-changing). build_data.py removes the enemy from its source floor and appends it to the target.
2026-04-25 23:41:57 +02:00
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
48c401909e fix: pin click should not bubble to canvas (was placing waypoints under notes)
Clicking a Note (or any pin) in Route mode used to:
  1. fire the canvas click handler → drop a waypoint at the note's spot
  2. only THEN run the pin's own click/dblclick handlers

So a single click on a note added a stray waypoint, and a double-click added two and may not have reliably reached the dblclick handler. Edit-via-dblclick therefore felt broken.

Fix: every interactive pin (note, text label, route waypoint, pull marker) now stops 'click' propagation explicitly. Click and dblclick on a pin no longer affect the active tool. Drag detection bumped to a 3px threshold and now only persists history when the pin actually moved.
2026-04-25 23:22:04 +02:00
76 changed files with 1405 additions and 9284 deletions

4
.gitignore vendored
View file

@ -13,11 +13,9 @@ Thumbs.db
.idea/
*.swp
# Large data assets — not tracked in git (no LFS yet)
# Large data assets
# legacy hires PNG dump (gone, but keep ignored)
data/maps_png_hires/
# 336 MB of WebP map tiles — exclude until LFS is set up
web/assets/maps/
# Build output
output/

View file

@ -14,7 +14,8 @@ courtesy of RaiderIO (verbal permission, see attribution below).
comments, gateways), NPC names from companion `en_US.js`.
- **Frontend:** vanilla HTML/CSS/JS, single page, no build step. Pan/zoom
via CSS transform on the canvas stage; SVG overlay in image-pixel space.
- **Hosting:** static. ~470 MB of WebPs total. Drop behind nginx; no backend.
- **Hosting:** static. ~120 MB of WebPs total, committed to the repo. Drop
behind nginx; no backend.
## Layout
@ -40,7 +41,7 @@ mplus-routes/
├── app.js
└── assets/
├── dungeons.json
└── maps/ per-dungeon WebPs (gitignored — too big without LFS)
└── maps/ per-dungeon WebPs (~120 MB, committed)
```
## Build
@ -50,7 +51,7 @@ python3 -m venv .venv
.venv/bin/pip install Pillow
.venv/bin/python tools/kg_fetch.py --workers 32 --zoom 4 # fetch tiles + data (~1.3 GB raw)
.venv/bin/python tools/kg_stitch.py --workers 4 # → web/assets/maps/*.webp (~470 MB)
.venv/bin/python tools/kg_stitch.py --workers 4 # → web/assets/maps/*.webp (~120 MB)
.venv/bin/python tools/kg_build_data.py # → web/assets/dungeons.json
```
@ -71,8 +72,8 @@ TLS automatically.
First-time bring-up:
1. Add `mplus.exil.es` A/AAAA records in NetBox (→ public HAProxy VIPs)
2. Push the Ansible commit, trigger `Ansible_DeployPublicHaproxy`
3. Materialize `web/assets/maps/` on the deploy host (run the build pipeline above)
4. Run `playbooks/setup_mplus_routes.yml`
3. Run `playbooks/setup_mplus_routes.yml``web/assets/maps/` ships in the
repo, no pre-build step needed.
## Coverage

View file

@ -0,0 +1,51 @@
{
"_comment": "Ascension-specific corrections to the upstream kg / AtlasLoot data. Each entry overrides where an enemy/extra is rendered. Match by tile_key + name (case-insensitive substring). pos is in kg pixel space (z=4 stitched image, 6144x4096). kg_floor_id picks the floor (omit if the dungeon is single-floor).",
"_dungeon_replacements_comment": "When the kg map is the wrong layout for Ascension (e.g. retail/Cata layout vs classic), replace the entire dungeon's map with one we author manually. Coords are in the new map's pixel space (0..width, 0..height). Bosses come from AtlasLoot's per-dungeon entries with cords.",
"dungeon_replacements": {
"scholomance": {
"image": "maps/scholomance.webp",
"width": 2048,
"height": 2048,
"label": "Scholomance",
"note": "kg + upreza both ship the post-Cata 4-floor redesign; Ascension uses the original classic single-room layout. Map is the Atlas-addon Scholomance.blp (512x512) upscaled to 2048x2048 with Real-ESRGAN x4plus. Boss positions are hand-pinned to the numbered rooms drawn on the Atlas map.",
"_room_position_comment": "Room centers read off the upscaled Atlas map (2048x2048). Numbers refer to the Atlas-addon room numbers visible on the map texture. If a boss is rendered in the wrong room, edit the pos here.",
"bosses": [
{"name": "Blood Steward of Kirtonos", "pos": [1046, 637], "cls": 3, "room": 1},
{"name": "Kirtonos the Herald", "pos": [555, 264], "cls": 3, "room": 2},
{"name": "Vectus", "pos": [327, 746], "cls": 3, "room": 4},
{"name": "Marduk Blackpool", "pos": [391, 928], "cls": 3, "room": 5},
{"name": "Rattlegore", "pos": [564, 974], "cls": 3, "room": 6},
{"name": "Lorekeeper Polkelt", "pos": [928, 1174], "cls": 3, "room": 10},
{"name": "Doctor Theolen Krastinov", "pos": [1328, 1456], "cls": 3, "room": 9},
{"name": "Ras Frostwhisper", "pos": [937, 1729], "cls": 3, "room": 8},
{"name": "Lord Alexei Barov", "pos": [1483, 1702], "cls": 3, "room": 11},
{"name": "Lady Illucia Barov", "pos": [1856, 1456], "cls": 3, "room": 12},
{"name": "The Ravenian", "pos": [1502, 1092], "cls": 3, "room": 13},
{"name": "Jandice Barov", "pos": [1665, 355], "cls": 3, "room": 3},
{"name": "Instructor Malicia", "pos": [391, 1401], "cls": 3, "room": 7},
{"name": "Darkmaster Gandling", "pos": [1529, 1456], "cls": 4, "room": 14}
]
}
},
"_map_image_swaps_comment": "Swap the rendered image for one or more floors WITHOUT changing the kg enemy/pack/patrol data. Coords stay in kg's pixel space; the swapped image is force-stretched to those dims by the browser. Use this when a different render of the SAME WoW texture is sharper but has a different aspect ratio.",
"map_image_swaps": {},
"overrides": [
{
"tile_key": "stratholme",
"name": "Magistrate Barthilas",
"kg_floor_id": 235,
"pos": [3498, 3300],
"note": "Ascension moved Barthilas to the southern courtyard of the Undead Side"
},
{
"tile_key": "blackfathom_deeps",
"name": "Lorgus Jett",
"kg_floor_id": 192,
"pos": [2893, 2827],
"note": "kg places Lorgus on floor 1 (entrance pool) but he's actually in Moonshrine Sanctum on floor 2 — confirmed by AtlasLoot subzone tag"
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -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))

View file

@ -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,155 @@ def main() -> int:
if EXTRAS_PATH.exists():
extras_db = json.loads(EXTRAS_PATH.read_text()).get("extras", {})
overrides = []
dungeon_replacements = {}
map_image_swaps = {}
if OVERRIDES_PATH.exists():
ov_doc = json.loads(OVERRIDES_PATH.read_text())
overrides = ov_doc.get("overrides", [])
dungeon_replacements = ov_doc.get("dungeon_replacements", {})
map_image_swaps = ov_doc.get("map_image_swaps", {})
# Load AtlasLoot map data on demand for dungeon replacements.
atlasloot_data = None
al_path = Path("/tmp/atlasloot_maps.json")
if al_path.exists():
try:
atlasloot_data = json.loads(al_path.read_text())
except Exception:
atlasloot_data = None
def replacement_entry(tile_key, repl, registry_entry):
"""Build a complete dungeon record from a manual-override map.
Three shapes are supported:
1. `floors`: list of per-floor maps each with their own bosses.
For multi-floor dungeons we want to ship at parity with the
rest of the picker (4 webps with floor-tabs).
2. `bosses` list at top level: single-floor + explicit pins.
3. `atlasloot_id`: single-floor + pull from AtlasLoot. Cords
are 0-100 percent of the AL subzone frame only roughly
correct on full-map images."""
# --- multi-floor case ---
if "floors" in repl:
maps_out = []
for f in repl["floors"]:
fW, fH = f["width"], f["height"]
enemies = []
for b in f.get("bosses", []):
enemies.append({
"id": None, "npc_id": None,
"name": b["name"],
"pos": [round(b["pos"][0], 1), round(b["pos"][1], 1)],
"classification": b.get("cls", 3),
"skippable": False, "required": False,
"kill_priority": None, "pack_id": None, "patrol_id": None,
"ascension_pinned": True,
})
maps_out.append({
"image": f["image"],
"width": fW, "height": fH,
"label": f.get("label", tile_key),
"kg_floor_id": f.get("kg_floor_id"),
"enemies": enemies,
"packs": [], "patrols": [], "icons": [],
})
return {
"id": tile_key,
"expansion": "OriginalWoW",
"name": registry_entry.get("name", tile_key),
"acronym": registry_entry.get("acronym"),
"tile_key": tile_key,
"data_slug": registry_entry.get("data_slug"),
"mapping_id": registry_entry.get("mapping_id"),
"maps": maps_out,
"ascension_replaced": True,
"replacement_note": repl.get("note"),
}
W, H = repl["width"], repl["height"]
enemies = []
if "bosses" in repl:
for b in repl["bosses"]:
enemies.append({
"id": None, "npc_id": None,
"name": b["name"],
"pos": [round(b["pos"][0], 1), round(b["pos"][1], 1)],
"classification": b.get("cls", 3),
"skippable": False, "required": False,
"kill_priority": None, "pack_id": None, "patrol_id": None,
"ascension_pinned": True,
})
elif "atlasloot_id" in repl:
al_id = repl["atlasloot_id"]
al = (atlasloot_data or {}).get("OriginalWoW", {}).get(al_id, {})
seen = set()
for k, v in al.items():
if not k.isdigit() or not isinstance(v, list):
continue
for ent in v:
if not isinstance(ent, dict):
continue
if ent.get("SubZone"):
continue
cords = ent.get("cords")
name = ent.get("1")
if not (isinstance(cords, list) and len(cords) == 2 and name):
continue
key = (name, cords[0], cords[1])
if key in seen:
continue
seen.add(key)
pin = ent.get("pinType")
lname = name.lower()
if pin == "dungeonskull":
cls = 3
elif pin is None and "rare" in lname:
cls = 5
else:
cls = 2
enemies.append({
"id": None, "npc_id": None,
"name": name,
"pos": [round(cords[0] / 100 * W, 1), round(cords[1] / 100 * H, 1)],
"classification": cls,
"skippable": False, "required": False,
"kill_priority": None, "pack_id": None, "patrol_id": None,
})
map_obj = {
"image": repl["image"],
"width": W, "height": H,
"label": repl.get("label", tile_key),
"kg_floor_id": None,
"enemies": enemies,
"packs": [], "patrols": [], "icons": [],
}
return {
"id": tile_key,
"expansion": "OriginalWoW",
"name": registry_entry.get("name", tile_key),
"acronym": registry_entry.get("acronym"),
"tile_key": tile_key,
"data_slug": registry_entry.get("data_slug"),
"mapping_id": registry_entry.get("mapping_id"),
"maps": [map_obj],
"ascension_replaced": True,
"replacement_note": repl.get("note"),
}
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] = {
@ -251,6 +401,11 @@ def main() -> int:
except Exception:
npc_index = {}
# Dungeon replacement: skip kg entirely for this one.
if d["tile_key"] in dungeon_replacements:
dungeons.append(replacement_entry(d["tile_key"], dungeon_replacements[d["tile_key"]], d))
continue
entry = build_one(d, summary, npc_index, icon_index)
if entry:
extras = extras_db.get(d["tile_key"], [])
@ -261,9 +416,60 @@ 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: change pos and/or relocate to a different
# floor. We collect relocations first, then apply them in a
# second pass so iteration semantics stay clean.
relocations = [] # list of (enemy_dict, source_map, target_map)
for m in entry["maps"]:
for e in list(m["enemies"]):
new_pos, new_floor = apply_overrides(d["tile_key"], e["name"], e["pos"], m.get("kg_floor_id"))
if new_pos is e["pos"] and new_floor == m.get("kg_floor_id"):
continue
e["pos"] = list(new_pos)
e["ascension_override"] = True
if new_floor is not None and new_floor != m.get("kg_floor_id"):
target = next((mm for mm in entry["maps"] if mm.get("kg_floor_id") == new_floor), None)
if target:
relocations.append((e, m, target))
for e, src, tgt in relocations:
src["enemies"].remove(e)
tgt["enemies"].append(e)
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"] or new_floor != ex.get("kg_floor_id"):
ex["pos"] = list(new_pos)
ex["kg_floor_id"] = new_floor
ex["ascension_override"] = True
# Map image swap: rescale every coord from kg's pixel space
# (image dimensions in floor_summary) into the new image space.
swap = map_image_swaps.get(d["tile_key"])
if swap:
new_w, new_h = swap["width"], swap["height"]
for m in entry["maps"]:
sx = new_w / m["width"]
sy = new_h / m["height"]
m["width"], m["height"] = new_w, new_h
for e in m["enemies"]:
e["pos"] = [round(e["pos"][0] * sx, 1), round(e["pos"][1] * sy, 1)]
for p in m["packs"]:
p["vertices"] = [[round(v[0] * sx, 1), round(v[1] * sy, 1)] for v in p["vertices"]]
for pa in m["patrols"]:
pa["vertices"] = [[round(v[0] * sx, 1), round(v[1] * sy, 1)] for v in pa["vertices"]]
for ic in m["icons"]:
ic["pos"] = [round(ic["pos"][0] * sx, 1), round(ic["pos"][1] * sy, 1)]
# Extras already use the kg pixel space; rescale too.
# Use the first map's pre-swap factor — extras are dungeon-level.
for ex in entry.get("extras", []):
ex["pos"] = [round(ex["pos"][0] * (new_w / 6144), 1),
round(ex["pos"][1] * (new_h / 4096), 1)]
dungeons.append(entry)
dungeons.sort(key=lambda d: d["name"])

View file

@ -194,13 +194,17 @@ function renderOverlay() {
}
}
// AtlasLoot-derived extras: rare bosses + interactives kg doesn't ship.
// These are dungeon-level (not per-floor) and only render on floor 0
// unless we get richer data; for single-floor dungeons that's all that
// matters, and for multi-floor we leave them on the first floor by
// default — the user can drag a Note over them on any floor.
if (state.floorIndex === 0 && state.current?.extras) {
// AtlasLoot-derived extras: render only ones that match the current
// floor. Single-floor dungeons have kg_floor_id null on every extra,
// so they all render on floor 0.
if (state.current?.extras) {
const curFloor = m.kg_floor_id;
for (const ex of state.current.extras) {
// If the extra has no floor assignment, show only on floor 0.
// If it does, show on the matching floor only.
const fid = ex.kg_floor_id;
const matches = fid == null ? state.floorIndex === 0 : fid === curFloor;
if (!matches) continue;
svg.appendChild(makeExtraPin(ex));
}
}
@ -254,14 +258,20 @@ function makeNotePin(note, idx) {
g.dataset.tooltip = note.text || "(empty)";
// drag-to-move + right-click delete + double-click to edit text
// Click on a pin must NOT bubble to the SVG (which would add a waypoint
// / pull / etc. at the same spot, depending on the active tool).
g.addEventListener("click", (e) => e.stopPropagation());
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind: "note", idx };
state.drag = { kind: "note", idx, moved: false, downX: e.clientX, downY: e.clientY };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.kind !== "note" || state.drag.idx !== idx) return;
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
state.drag.moved = true;
}
const pt = svgPointFromEvent(e);
const arr = state.notes[currentKey()];
arr[idx].x = pt.x; arr[idx].y = pt.y;
@ -269,29 +279,40 @@ function makeNotePin(note, idx) {
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
const moved = state.drag.moved;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
updateHash();
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
removeNote(idx);
});
g.addEventListener("dblclick", (e) => {
e.preventDefault();
e.stopPropagation();
const txt = prompt("Edit note text", note.text || "");
if (txt !== null) {
state.notes[currentKey()][idx].text = txt;
if (moved) {
pushHistory();
renderOverlay();
updateHash();
}
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
removeNote(idx);
});
// Double-click → edit. Works regardless of active tool because the
// pin captures click + dblclick before the canvas click handler runs.
g.addEventListener("dblclick", (e) => {
e.preventDefault();
e.stopPropagation();
editNote(idx);
});
return g;
}
function editNote(idx) {
const cur = state.notes[currentKey()]?.[idx];
if (!cur) return;
const txt = prompt("Edit note text:", cur.text || "");
if (txt === null) return;
cur.text = txt;
pushHistory();
renderOverlay();
updateHash();
}
function removeNote(idx) {
const key = currentKey();
const arr = state.notes[key];
@ -338,15 +359,21 @@ function makeTextLabel(item, idx) {
hit.setAttribute("fill", "transparent");
g.appendChild(hit);
// Drag / right-click delete / dbl-click edit
// Stop click propagating so the canvas's tool handler doesn't fire when
// the user is interacting with this label (otherwise editing in Route
// mode would also drop a waypoint underneath).
g.addEventListener("click", (e) => e.stopPropagation());
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind: "text", idx };
state.drag = { kind: "text", idx, moved: false, downX: e.clientX, downY: e.clientY };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.kind !== "text" || state.drag.idx !== idx) return;
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
state.drag.moved = true;
}
const pt = svgPointFromEvent(e);
const arr = state.texts[currentKey()];
arr[idx].x = pt.x; arr[idx].y = pt.y;
@ -354,25 +381,23 @@ function makeTextLabel(item, idx) {
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
const moved = state.drag.moved;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
updateHash();
if (moved) {
pushHistory();
updateHash();
}
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
removeText(idx);
});
g.addEventListener("dblclick", (e) => {
e.preventDefault();
e.stopPropagation();
const txt = prompt("Edit label", item.text || "");
if (txt !== null) {
state.texts[currentKey()][idx].text = txt;
pushHistory();
renderOverlay();
updateHash();
}
editText(idx);
});
return g;
}
@ -387,6 +412,17 @@ function removeText(idx) {
updateHash();
}
function editText(idx) {
const cur = state.texts[currentKey()]?.[idx];
if (!cur) return;
const txt = prompt("Edit label:", cur.text || "");
if (txt === null) return;
cur.text = txt;
pushHistory();
renderOverlay();
updateHash();
}
// kg classification IDs:
// 1 = minor (NPCs, ambient mobs)
// 2 = standard trash
@ -555,14 +591,18 @@ function makeUserPin(x, y, label, cssClass, kind, idx) {
t.textContent = label;
g.appendChild(t);
g.addEventListener("click", (e) => e.stopPropagation());
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind, idx };
state.drag = { kind, idx, moved: false, downX: e.clientX, downY: e.clientY };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return;
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
state.drag.moved = true;
}
const pt = svgPointFromEvent(e);
const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()];
arr[idx] = { x: pt.x, y: pt.y };
@ -579,14 +619,18 @@ function makeUserPin(x, y, label, cssClass, kind, idx) {
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
const moved = state.drag.moved;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
renderInfoPane();
updateHash();
if (moved) {
pushHistory();
renderInfoPane();
updateHash();
}
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
removePoint(kind, idx);
});
return g;
@ -1135,6 +1179,16 @@ function hookEvents() {
$("export").addEventListener("click", exportJson);
const importBtn = $("import");
if (importBtn) importBtn.addEventListener("click", importJson);
// Layer toggles — repaint overlay on change. Persisted in state.show.
for (const layer of ["enemies", "packs", "patrols", "icons"]) {
const cb = $(`layer-${layer}`);
if (!cb) continue;
cb.checked = state.show[layer];
cb.addEventListener("change", () => {
state.show[layer] = cb.checked;
renderOverlay();
});
}
$("tool-route").addEventListener("click", () => setTool("route"));
$("tool-pull").addEventListener("click", () => setTool("pull"));
const noteBtn = $("tool-note");

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View file

@ -33,6 +33,12 @@
<button id="tool-pull" class="tool" title="Click map to drop pull markers">Pull</button>
<button id="tool-note" class="tool" title="Drop an (i) info icon — text shows on hover">Note</button>
<button id="tool-text" class="tool" title="Drop a freetext label — text always visible on the map">Text</button>
<span class="toolbar-sep"></span>
<label class="layer-toggle" title="Show enemy/trash spawns"><input type="checkbox" id="layer-enemies" checked> Enemies</label>
<label class="layer-toggle" title="Show enemy pack polygons"><input type="checkbox" id="layer-packs" checked> Packs</label>
<label class="layer-toggle" title="Show enemy patrol routes"><input type="checkbox" id="layer-patrols" checked> Patrols</label>
<label class="layer-toggle" title="Show map icons (start, doors, comments)"><input type="checkbox" id="layer-icons" checked> Icons</label>
<span class="toolbar-sep"></span>
<button id="undo" title="Undo (⌘Z)">Undo</button>
<button id="clear" title="Clear current floor">Clear</button>
<button id="share">Share</button>

View file

@ -194,6 +194,26 @@ body {
border-color: var(--accent);
font-weight: 600;
}
.toolbar-sep {
width: 1px;
height: 22px;
background: var(--line);
align-self: center;
margin: 0 4px;
}
.layer-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
padding: 4px 6px;
user-select: none;
white-space: nowrap;
}
.layer-toggle:hover { color: var(--text); }
.layer-toggle input { margin: 0; cursor: pointer; }
/* --- canvas / overlay ----------------------------------------------------- */