Bots really play: shared understanding for headless bots, social layer (bot-to-bot talk, squads, invites), castle sieges, economy (sell/buy/professions/jewelry/buffs), telemetry, brain (loot, fight back, leash, spoil/sweep), 43 combat profiles, zones rebuilt from real spawns, tools (harness, data checks, zone fixer, build/deploy)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user