#!/usr/bin/env python3 """ Import missing travel-node graph data from cmangos-playerbots into AC mod-playerbots. Strategy: 1. Match cmangos nodes to ours by (name, mapId) — closest by position wins when there are duplicates. 2. Identify missing nodes (cmangos has, we don't). 3. Assign new IDs starting at max(existing) + 1. 4. Build cmangosId -> ourId remap covering matched + new. 5. For links: insert only those involving at least one NEW node — preserves all our existing existing-to-existing relationships. 6. For paths: same rule. Output: SQL update files in data/sql/playerbots/updates/. """ import os import re import sys from pathlib import Path # Inputs CMANGOS_SQL = Path(r"C:/Users/Admin/git/scratch/cmangos-playerbots/sql/world/wotlk/ai_playerbot_travel_nodes.sql") MOD_DIR = Path(r"C:/Users/Admin/git/main/azerothcore-wotlk/modules/mod-playerbots") OUR_NODE_SQL = MOD_DIR / "data/sql/playerbots/base/playerbots_travelnode.sql" OUR_LINK_SQL = MOD_DIR / "data/sql/playerbots/base/playerbots_travelnode_link.sql" # Output UPDATES_DIR = MOD_DIR / "data/sql/playerbots/updates" NODE_OUT = UPDATES_DIR / "2026_05_09_01_travelnode_cmangos_import.sql" LINK_OUT = UPDATES_DIR / "2026_05_09_02_travelnode_link_cmangos_import.sql" PATH_OUT = UPDATES_DIR / "2026_05_09_03_travelnode_path_cmangos_import.sql" NODE_RE = re.compile( r"\((\d+),\s*'([^']*)',\s*(\d+),\s*(-?[\d.eE+-]+),\s*(-?[\d.eE+-]+),\s*(-?[\d.eE+-]+),\s*(\d+)\)" ) LINK_RE = re.compile( r"\((\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(-?[\d.eE+-]+),\s*(-?[\d.eE+-]+),\s*(-?[\d.eE+-]+),\s*(\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\)" ) PATH_RE = re.compile( r"\((\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(-?[\d.eE+-]+),\s*(-?[\d.eE+-]+),\s*(-?[\d.eE+-]+)\)" ) def parse_nodes(path: Path): """Parse all rows from a `*_travelnode` table-style SQL file. Returns dict id -> (name, mapId, x, y, z, linked).""" out = {} text = path.read_text(encoding="utf-8", errors="replace") # Skip CREATE TABLE blocks: only count rows after the INSERT line in the # node table. The link/path table rows have a different shape so they # won't match NODE_RE anyway, but constraining via `INSERT INTO ... travelnode` # boundary makes intent explicit. in_node_block = False for line in text.splitlines(): if "INSERT INTO" in line and "travelnode" in line.lower() and "_link" not in line.lower() and "_path" not in line.lower(): in_node_block = True continue if "INSERT INTO" in line and ("_link" in line.lower() or "_path" in line.lower()): in_node_block = False continue if not in_node_block: continue for m in NODE_RE.finditer(line): nid, name, mapId, x, y, z, linked = m.groups() out[int(nid)] = (name, int(mapId), float(x), float(y), float(z), int(linked)) return out def parse_links(path: Path): """Parse links into list[(node_id, to_node_id, type, object, distance, swim, extra, calc, mc0, mc1, mc2)].""" out = [] text = path.read_text(encoding="utf-8", errors="replace") in_link_block = False for line in text.splitlines(): if "INSERT INTO" in line and "travelnode_link" in line.lower(): in_link_block = True continue if "INSERT INTO" in line and "travelnode_link" not in line.lower(): in_link_block = False continue if "CREATE TABLE" in line: in_link_block = False continue if not in_link_block: continue for m in LINK_RE.finditer(line): g = m.groups() out.append((int(g[0]), int(g[1]), int(g[2]), int(g[3]), float(g[4]), float(g[5]), float(g[6]), int(g[7]), int(g[8]), int(g[9]), int(g[10]))) return out def parse_paths(path: Path): """Parse paths into list[(node_id, to_node_id, nr, map_id, x, y, z)].""" out = [] text = path.read_text(encoding="utf-8", errors="replace") in_path_block = False for line in text.splitlines(): if "INSERT INTO" in line and "travelnode_path" in line.lower(): in_path_block = True continue if "INSERT INTO" in line and "travelnode_path" not in line.lower(): in_path_block = False continue if "CREATE TABLE" in line: in_path_block = False continue if not in_path_block: continue for m in PATH_RE.finditer(line): g = m.groups() out.append((int(g[0]), int(g[1]), int(g[2]), int(g[3]), float(g[4]), float(g[5]), float(g[6]))) return out def sq2(a, b): return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2 def build_remap(cmangos_nodes, our_nodes): """Build cmangosId -> ourId map. Returns (remap, missing_cmangos_ids, new_id_assignments). new_id_assignments is dict cmangosId -> assigned ourId for the missing ones. """ # Bucket our nodes by (name, mapId) for fast lookup; multiple ours may # share the same name (different positions), so we keep a list. our_by_key = {} for oid, (name, mid, x, y, z, lk) in our_nodes.items(): our_by_key.setdefault((name, mid), []).append((oid, x, y, z)) remap = {} missing = [] for cid, (name, mid, x, y, z, lk) in cmangos_nodes.items(): candidates = our_by_key.get((name, mid)) if candidates: # Closest by position best = min(candidates, key=lambda c: sq2((c[1], c[2], c[3]), (x, y, z))) remap[cid] = best[0] else: missing.append(cid) # Assign new IDs to missing max_our = max(our_nodes.keys()) if our_nodes else 0 new_assign = {} next_id = max_our + 1 for cid in missing: new_assign[cid] = next_id remap[cid] = next_id next_id += 1 return remap, missing, new_assign def write_node_sql(out_path, cmangos_nodes, missing_ids, new_assign): rows = [] for cid in missing_ids: ourId = new_assign[cid] name, mid, x, y, z, lk = cmangos_nodes[cid] # Escape single quotes in name ename = name.replace("'", "''") rows.append(f"({ourId}, '{ename}', {mid}, {x:.4f}, {y:.4f}, {z:.4f}, {lk})") with open(out_path, "w", encoding="utf-8") as f: f.write("-- Imported from cmangos-playerbots ai_playerbot_travelnode (wotlk)\n") f.write(f"-- {len(rows)} new nodes (cmangos has them, we didn't)\n") f.write("-- Matched on (name, mapId) + closest position; remaining are unique to cmangos.\n\n") if not rows: f.write("-- (no new nodes to insert)\n") return f.write("INSERT INTO `playerbots_travelnode` (`id`, `name`, `map_id`, `x`, `y`, `z`, `linked`) VALUES\n") # Batch in chunks of 500 rows per INSERT for readability BATCH = 500 i = 0 while i < len(rows): chunk = rows[i:i + BATCH] f.write(",\n".join(chunk)) if i + BATCH < len(rows): f.write(";\nINSERT INTO `playerbots_travelnode` (`id`, `name`, `map_id`, `x`, `y`, `z`, `linked`) VALUES\n") else: f.write(";\n") i += BATCH def write_link_sql(out_path, cmangos_links, remap, new_ids_set, our_link_pairs): """Insert cmangos links involving at least one NEW node, mapped to our IDs. Skip links where both endpoints are existing-to-existing (we keep our own). """ rows = [] skipped_existing = 0 skipped_unmapped = 0 skipped_dup_with_ours = 0 for c_from, c_to, t, obj, dist, swim, extra, calc, mc0, mc1, mc2 in cmangos_links: if c_from not in remap or c_to not in remap: skipped_unmapped += 1 continue o_from = remap[c_from] o_to = remap[c_to] # Skip if both endpoints are existing — preserve our own link data. if o_from not in new_ids_set and o_to not in new_ids_set: skipped_existing += 1 continue # If we already have this exact link (shouldn't happen since both new # IDs are fresh, but defensive), skip. if (o_from, o_to) in our_link_pairs: skipped_dup_with_ours += 1 continue rows.append( f"({o_from}, {o_to}, {t}, {obj}, {dist:.4f}, {swim:.4f}, {extra:.4f}, {calc}, {mc0}, {mc1}, {mc2})" ) with open(out_path, "w", encoding="utf-8") as f: f.write("-- Imported from cmangos-playerbots ai_playerbot_travelnode_link (wotlk)\n") f.write(f"-- {len(rows)} new links involving new nodes (mapped cmangos IDs to our IDs)\n") f.write(f"-- skipped: {skipped_existing} existing-to-existing, " f"{skipped_unmapped} unmapped, {skipped_dup_with_ours} dup-with-ours\n\n") if not rows: f.write("-- (no new links to insert)\n") return f.write( "INSERT INTO `playerbots_travelnode_link` " "(`node_id`, `to_node_id`, `type`, `object`, `distance`, `swim_distance`, " "`extra_cost`, `calculated`, `max_creature_0`, `max_creature_1`, `max_creature_2`) VALUES\n" ) BATCH = 500 i = 0 while i < len(rows): chunk = rows[i:i + BATCH] f.write(",\n".join(chunk)) if i + BATCH < len(rows): f.write( ";\nINSERT INTO `playerbots_travelnode_link` " "(`node_id`, `to_node_id`, `type`, `object`, `distance`, `swim_distance`, " "`extra_cost`, `calculated`, `max_creature_0`, `max_creature_1`, `max_creature_2`) VALUES\n" ) else: f.write(";\n") i += BATCH def write_path_sql(out_path, cmangos_paths, remap, new_ids_set, our_link_pairs): """Insert cmangos paths whose link involves at least one NEW node.""" rows = [] skipped_existing = 0 skipped_unmapped = 0 skipped_dup_with_ours = 0 # Stream rows; the path list is millions of entries for c_from, c_to, nr, mid, x, y, z in cmangos_paths: if c_from not in remap or c_to not in remap: skipped_unmapped += 1 continue o_from = remap[c_from] o_to = remap[c_to] if o_from not in new_ids_set and o_to not in new_ids_set: skipped_existing += 1 continue if (o_from, o_to) in our_link_pairs: skipped_dup_with_ours += 1 continue rows.append((o_from, o_to, nr, mid, x, y, z)) with open(out_path, "w", encoding="utf-8") as f: f.write("-- Imported from cmangos-playerbots ai_playerbot_travelnode_path (wotlk)\n") f.write(f"-- {len(rows)} new path waypoints belonging to links involving new nodes\n") f.write(f"-- skipped: {skipped_existing} existing-to-existing, " f"{skipped_unmapped} unmapped, {skipped_dup_with_ours} dup-with-ours\n\n") if not rows: f.write("-- (no new path rows to insert)\n") return f.write( "INSERT INTO `playerbots_travelnode_path` " "(`node_id`, `to_node_id`, `nr`, `map_id`, `x`, `y`, `z`) VALUES\n" ) BATCH = 1000 i = 0 while i < len(rows): chunk_strs = [] for o_from, o_to, nr, mid, x, y, z in rows[i:i + BATCH]: chunk_strs.append(f"({o_from}, {o_to}, {nr}, {mid}, {x:.4f}, {y:.4f}, {z:.4f})") f.write(",\n".join(chunk_strs)) if i + BATCH < len(rows): f.write( ";\nINSERT INTO `playerbots_travelnode_path` " "(`node_id`, `to_node_id`, `nr`, `map_id`, `x`, `y`, `z`) VALUES\n" ) else: f.write(";\n") i += BATCH def parse_our_link_pairs(path: Path): """Return set of (node_id, to_node_id) pairs we already have.""" pairs = set() text = path.read_text(encoding="utf-8", errors="replace") for m in LINK_RE.finditer(text): pairs.add((int(m.group(1)), int(m.group(2)))) return pairs def main(): print("Parsing cmangos nodes...", flush=True) cmangos_nodes = parse_nodes(CMANGOS_SQL) print(f" {len(cmangos_nodes)} nodes") print("Parsing our nodes...", flush=True) our_nodes = parse_nodes(OUR_NODE_SQL) print(f" {len(our_nodes)} nodes") print("Building remap...", flush=True) remap, missing, new_assign = build_remap(cmangos_nodes, our_nodes) print(f" matched: {len(remap) - len(missing)}, missing (new): {len(missing)}") new_ids_set = set(new_assign.values()) print("Parsing cmangos links...", flush=True) cm_links = parse_links(CMANGOS_SQL) print(f" {len(cm_links)} links") print("Parsing our existing link pairs...", flush=True) our_link_pairs = parse_our_link_pairs(OUR_LINK_SQL) print(f" {len(our_link_pairs)} existing pairs") print("Parsing cmangos paths...", flush=True) cm_paths = parse_paths(CMANGOS_SQL) print(f" {len(cm_paths)} path rows") UPDATES_DIR.mkdir(parents=True, exist_ok=True) print(f"Writing {NODE_OUT.name}...", flush=True) write_node_sql(NODE_OUT, cmangos_nodes, missing, new_assign) print(f"Writing {LINK_OUT.name}...", flush=True) write_link_sql(LINK_OUT, cm_links, remap, new_ids_set, our_link_pairs) print(f"Writing {PATH_OUT.name}...", flush=True) write_path_sql(PATH_OUT, cm_paths, remap, new_ids_set, our_link_pairs) print("Done.") if __name__ == "__main__": main()