Files
L2-SPP/tools/fix_zones.py
T

195 lines
9.1 KiB
Python

#!/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'<npc id="(\d+)" level="(\d+)" type="([A-Za-z]+)"', text):
if m.group(3) in ('Monster', 'FeedableBeast', 'Chest', 'FriendlyMob'):
levels[int(m.group(1))] = int(m.group(2))
return levels
point_spawns = []
def load_spawns(datapack, levels):
"""Returns list of (x, y, level, radius) monster spawn blobs."""
blobs = []
for f in (datapack / 'spawns').rglob('*.xml'):
text = f.read_text(encoding='utf-8', errors='replace')
if 'enabled="false"' in text[:300]:
continue
for m in re.finditer(r'<npc id="(\d+)" x="(-?\d+)" y="(-?\d+)" z="(-?\d+)"', text):
level = levels.get(int(m.group(1)))
if level:
blobs.append((int(m.group(2)), int(m.group(3)), level, 0))
point_spawns.append((int(m.group(2)), int(m.group(3)), int(m.group(4)), level))
for g in re.finditer(r'<spawn[^>]*>(.*?)</spawn>', text, re.S):
body = g.group(1)
nodes = [(int(a), int(b)) for a, b in re.findall(r'<node x="(-?\d+)" y="(-?\d+)"', body)]
if not nodes:
continue
cx = sum(n[0] for n in nodes) / len(nodes)
cy = sum(n[1] for n in nodes) / len(nodes)
radius = max(math.hypot(n[0] - cx, n[1] - cy) for n in nodes)
for npc_id, count in re.findall(r'<npc id="(\d+)"(?![^>]*\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'(<zone name="([^"]+)"([^>]*)minLevel="(\d+)" maxLevel="(\d+)">)(.*?)(</zone>)', text, re.S):
header, name, extra, lo, hi, body, tail = zm.groups()
lo = int(lo)
hi = int(hi)
points = re.findall(r'<point x="(-?\d+)" y="(-?\d+)" z="(-?\d+)" />', 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<zone name="{name}"{extra}minLevel="{band_lo}" maxLevel="{band_hi}">\n' + ''.join(f'\t\t<point x="{p[0]}" y="{p[1]}" z="{p[2]}" />\n' for p in pts) + '\t</zone>\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<zone name="{name}"{extra}minLevel="{lo}" maxLevel="{hi}">\n' + ''.join(f'\t\t<point x="{p[0]}" y="{p[1]}" z="{p[2]}" />\n' for p in ranked) + '\t</zone>\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'<zone name="[^"]+" minLevel="(\d+)" maxLevel="(\d+)">', 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'<zone name="([^"]+)"', new_text))
grid = {}
for bx, by, level, radius in blobs:
if (min(holes) - 1) <= level <= (max(holes) + 2) and radius < 2500:
grid.setdefault((int(bx // 4000), int(by // 4000)), []).append((bx, by, level))
zones = {}
for cell, entries in grid.items():
if len(entries) < 12 or cell not in CELL_NAMES:
continue
zones.setdefault(CELL_NAMES[cell], []).extend(entries)
added = ''
for name, entries in zones.items():
if name in existing:
continue
medians = sorted(e[2] for e in entries)
lo = max(1, int(percentile(medians, 0.2)) - 2)
hi = max(lo + 3, int(percentile(medians, 0.9)))
# Points: spawn positions (spread), z from the nearest point spawn with a real z.
points = []
seen = set()
for bx, by, level in entries:
key = (int(bx // 300), int(by // 300))
if key in seen:
continue
seen.add(key)
points.append((int(bx), int(by)))
zpoints = []
for px, py in points[:60]:
best = None
for x, y, z, lv in point_spawns:
d = math.hypot(x - px, y - py)
if best is None or d < best[0]:
best = (d, z)
zpoints.append((px, py, best[1] if best and best[0] < 1500 else -3400))
added += f'\t<zone name="{name}" minLevel="{lo}" maxLevel="{hi}">\n' + ''.join(f'\t\t<point x="{x}" y="{y}" z="{z}" />\n' for x, y, z in zpoints) + '\t</zone>\n'
print(f'{name:22s} new zone {lo}-{hi} with {len(zpoints)} points')
if added:
new_text = new_text.replace('\t<town name=', added + '\t<town name=', 1)
bands = [(int(a), int(b)) for a, b in re.findall(r'<zone name="[^"]+" minLevel="(\d+)" maxLevel="(\d+)">', 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()