#!/usr/bin/env python3 """Re-checks the bot farming zones (FakePlayerProgression.xml) against the real monster spawns of the datapack and keeps only spots where the monsters match the zone level band. Bots are real players now: a level 15 fighter dropped next to a level 34 Turek Orc Elder simply dies. For every zone point the monsters spawned within RADIUS are collected (point spawns and territory spawns), the point is kept when the median monster level fits the band and no dangerous pack (p75) sits above band+2. Zones that end up with too few points get their band re-labelled to the real monster levels. Usage: python tools/fix_zones.py [--apply] [--datapack server/game/data] Without --apply only the report is printed. """ import argparse import math import re import statistics import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] XML = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude' / 'dist' / 'game' / 'data' / 'FakePlayerProgression.xml' RADIUS = 1400 MIN_POINTS = 10 BAND = 4 # Width of a level band a spot is sorted into. # Names for auto generated zones (grid cell of 4000 units -> area name), used to fill level holes. CELL_NAMES = { (-2, 27): 'RuinsOfAgony', (-5, 35): 'RuinsOfDespair', (-4, 37): 'RuinsOfDespair', (-12, 11): 'Swampland', (-13, 11): 'Swampland', (-3, 12): 'DarkForest', (-4, 12): 'DarkForest', (3, 19): 'SpiderNest', (4, 19): 'SpiderNest', (-17, 26): 'WindmillHill', (-15, 27): 'AbandonedCamp', (-14, 27): 'AbandonedCamp', (36, -44): 'AbandonedCoalMines', (37, -43): 'AbandonedCoalMines', } def load_npc_levels(datapack): levels = {} for f in (datapack / 'stats' / 'npcs').rglob('*.xml'): text = f.read_text(encoding='utf-8', errors='replace') for m in re.finditer(r']*>(.*?)', text, re.S): body = g.group(1) nodes = [(int(a), int(b)) for a, b in re.findall(r']*\sx=)[^>]*?count="(\d+)"', body): level = levels.get(int(npc_id)) if level: for _ in range(min(int(count), 6)): blobs.append((cx, cy, level, radius)) return blobs def near(blobs, x, y): out = [] for bx, by, level, radius in blobs: if math.hypot(bx - x, by - y) <= RADIUS + radius: out.append(level) return out def percentile(values, p): values = sorted(values) k = (len(values) - 1) * p lo = math.floor(k) hi = math.ceil(k) return values[lo] + (values[hi] - values[lo]) * (k - lo) def main(): parser = argparse.ArgumentParser() parser.add_argument('--apply', action='store_true') parser.add_argument('--datapack', default=str(ROOT / 'server' / 'game' / 'data')) args = parser.parse_args() datapack = Path(args.datapack) levels = load_npc_levels(datapack) blobs = load_spawns(datapack, levels) print(f'monster templates: {len(levels)}, spawn blobs: {len(blobs)}') text = XML.read_text(encoding='utf-8') new_text = text for zm in re.finditer(r'(]*)minLevel="(\d+)" maxLevel="(\d+)">)(.*?)()', text, re.S): header, name, extra, lo, hi, body, tail = zm.groups() lo = int(lo) hi = int(hi) points = re.findall(r'', body) # Local danger of every point: the strongest packs around it (p90 of monster levels within RADIUS). groups = {} dropped = 0 for x, y, z in points: around = near(blobs, int(x), int(y)) if len(around) < 3: dropped += 1 continue danger = int(percentile(around, 0.9)) groups.setdefault((danger - 1) // BAND, []).append((x, y, z, danger)) pieces = '' summary = [] for band_index in sorted(groups): pts = groups[band_index] if len(pts) < MIN_POINTS: dropped += len(pts) continue dangers = [p[3] for p in pts] band_lo = max(1, min(dangers) - 3) band_hi = max(band_lo + 2, max(dangers)) summary.append(f'{band_lo}-{band_hi}:{len(pts)}') pieces += f'\t\n' + ''.join(f'\t\t\n' for p in pts) + '\t\n' if not pieces: # Nothing safe here at all: keep the ten least dangerous points under the old band. ranked = sorted(((x, y, z, int(percentile(near(blobs, int(x), int(y)) or [hi], 0.9))) for x, y, z in points), key=lambda p: p[3])[:MIN_POINTS] pieces = f'\t\n' + ''.join(f'\t\t\n' for p in ranked) + '\t\n' summary.append(f'{lo}-{hi}:{len(ranked)} (least dangerous)') print(f'{name:22s} was {lo}-{hi}: {len(points)} points -> bands {", ".join(summary)}; dropped {dropped}') new_text = new_text.replace('\t' + header + body + tail + '\n', pieces) # Every level 1..76 must have a zone: fill holes with zones built from the spawns themselves. # Racial starting areas only serve their race, so the holes are computed over the shared zones. bands = [(int(a), int(b)) for a, b in re.findall(r'', new_text)] holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)] if holes: print(f'levels without a shared zone: {holes} -> building zones from spawns') existing = set(re.findall(r'\n' + ''.join(f'\t\t\n' for x, y, z in zpoints) + '\t\n' print(f'{name:22s} new zone {lo}-{hi} with {len(zpoints)} points') if added: new_text = new_text.replace('\t', new_text)] holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)] if holes: print(f'still without a shared zone (nearest zone is used): {holes}') if args.apply: XML.write_text(new_text, encoding='utf-8') print(f'written {XML}') else: print('dry run, add --apply to write') if __name__ == '__main__': main()