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,8 @@
|
||||
#!/bin/bash
|
||||
# Builds GameServer.jar / LoginServer.jar from src_mobius with ant (JDK 25).
|
||||
# Usage: tools/build.sh -> build/dist/libs/GameServer.jar
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude"
|
||||
ant -q jar
|
||||
ls -la "$ROOT/src_mobius/mobius/build/dist/libs/"
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static checks of the bot data files (run from anywhere, no server needed).
|
||||
|
||||
- FakePlayerChatLines.xml / FakePlayerPools.xml: every #pool# exists, no cycles,
|
||||
every {slot} is one the code can fill, morph brackets are balanced;
|
||||
- every seam the Java code or the intent lexicon refers to exists in the XML;
|
||||
- FakePlayerCombat.xml: every skill id is learnable by that class (or one of its
|
||||
parent classes) according to the datapack skill trees, and every class id
|
||||
used by the seeded characters has a profile.
|
||||
|
||||
Usage: python tools/check_data.py [--skilltrees PATH] [--seed PATH]
|
||||
Exit code 1 on errors.
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude'
|
||||
DATA = SRC / 'dist' / 'game' / 'data'
|
||||
JAVA = SRC / 'java' / 'org' / 'l2jmobius' / 'gameserver'
|
||||
|
||||
KNOWN_SLOTS = {'player', 'target', 'level', 'boss', 'zone', 'town', 'msg', 'prev', 'enemyclan', 'mob', 'castle',
|
||||
'clan', 'class', 'drop', 'item', 'price', 'crowd', 'daytime'}
|
||||
# Seams the code calls by a literal name (kept in sync with the harness list).
|
||||
CODE_SEAMS = {
|
||||
'greeting', 'whisper_default', 'party_accept', 'reply_insult_soft', 'reply_insult_aggro', 'reply_caps',
|
||||
'reply_again', 'revenge_meet', 'friendly_meet', 'gank_start', 'pkk_start', 'war_start', 'assist',
|
||||
'companion_done', 'invite_player', 'ambient', 'lfp', 'levelup', 'grats', 'overheard', 'overheard_q',
|
||||
'killed_by', 'died_mob', 'migrate', 'profession', 'store_title', 'trade_shout', 'call_help', 'raid_start',
|
||||
'bot_talk', 'siege_start', 'siege_defend', 'siege_gate', 'siege_engrave', 'siege_win', 'siege_lose',
|
||||
'reply_help_yes', 'reply_help_no', 'loot_brag',
|
||||
}
|
||||
# Class transfer chains (Interlude): child -> parent.
|
||||
PARENT = {1: 0, 2: 1, 3: 1, 4: 0, 5: 4, 6: 4, 7: 0, 8: 7, 9: 7, 11: 10, 12: 11, 13: 11, 14: 11, 15: 10, 16: 15,
|
||||
17: 15, 19: 18, 20: 19, 21: 19, 22: 18, 23: 22, 24: 22, 26: 25, 27: 26, 28: 26, 29: 25, 30: 29, 32: 31,
|
||||
33: 32, 34: 32, 35: 31, 36: 35, 37: 35, 39: 38, 40: 39, 41: 39, 42: 38, 43: 42, 45: 44, 46: 45, 47: 44,
|
||||
48: 47, 50: 49, 51: 50, 52: 50, 54: 53, 55: 54, 56: 53, 57: 56,
|
||||
88: 2, 89: 3, 90: 5, 91: 6, 92: 9, 93: 8, 94: 12, 95: 13, 96: 14, 97: 16, 98: 17, 99: 20, 100: 21,
|
||||
101: 23, 102: 24, 103: 27, 104: 28, 105: 30, 106: 33, 107: 34, 108: 36, 109: 37, 110: 40, 111: 41,
|
||||
112: 43, 113: 46, 114: 48, 115: 51, 116: 52, 117: 55, 118: 57}
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
|
||||
def err(msg):
|
||||
errors.append(msg)
|
||||
|
||||
|
||||
def warn(msg):
|
||||
warnings.append(msg)
|
||||
|
||||
|
||||
def check_chat():
|
||||
lines_xml = (DATA / 'FakePlayerChatLines.xml').read_text(encoding='utf-8')
|
||||
pools_xml = (DATA / 'FakePlayerPools.xml').read_text(encoding='utf-8')
|
||||
seams = {}
|
||||
for m in re.finditer(r'<seam key="([^"]+)"[^>]*>(.*?)</seam>', lines_xml, re.S):
|
||||
seams[m.group(1)] = re.findall(r'<line[^>]*>(.*?)</line>', m.group(2), re.S)
|
||||
pools = {}
|
||||
for m in re.finditer(r'<pool key="([^"]+)"[^>]*>(.*?)</pool>', pools_xml, re.S):
|
||||
pools[m.group(1)] = re.findall(r'<line[^>]*>(.*?)</line>', m.group(2), re.S)
|
||||
total_lines = sum(len(v) for v in seams.values())
|
||||
total_pool = sum(len(v) for v in pools.values())
|
||||
print(f'seams: {len(seams)} ({total_lines} lines), pools: {len(pools)} ({total_pool} lines)')
|
||||
|
||||
def scan(text, where):
|
||||
for ref in re.findall(r'#([^#\s]+)#', text):
|
||||
if ref not in pools:
|
||||
err(f'{where}: unknown pool #{ref}#')
|
||||
for slot in re.findall(r'\{([a-z_]+)\}', text):
|
||||
if slot not in KNOWN_SLOTS:
|
||||
err(f'{where}: unknown slot {{{slot}}}')
|
||||
for open_, close in (('[', ']'), ('(', ')')):
|
||||
if text.count(open_) != text.count(close):
|
||||
err(f'{where}: unbalanced {open_}{close} in "{text}"')
|
||||
if '{msg}' in text and where.split(':')[0] not in ('overheard', 'overheard_q', 'reply_again'):
|
||||
pass
|
||||
|
||||
for key, lines in seams.items():
|
||||
if not lines:
|
||||
err(f'seam {key} is empty')
|
||||
for line in lines:
|
||||
scan(line, f'{key}:line')
|
||||
for key, lines in pools.items():
|
||||
if not lines:
|
||||
err(f'pool {key} is empty')
|
||||
for line in lines:
|
||||
scan(line, f'pool {key}')
|
||||
|
||||
# Pool cycles.
|
||||
graph = {k: set(re.findall(r'#([^#\s]+)#', ' '.join(v))) for k, v in pools.items()}
|
||||
|
||||
def cyclic(node, stack):
|
||||
if node in stack:
|
||||
return True
|
||||
for nxt in graph.get(node, ()):
|
||||
if cyclic(nxt, stack | {node}):
|
||||
return True
|
||||
return False
|
||||
|
||||
for key in pools:
|
||||
if cyclic(key, set()):
|
||||
err(f'pool cycle through {key}')
|
||||
|
||||
# Seams referenced by code and intents.
|
||||
intents_xml = (DATA / 'FakePlayerIntents.xml').read_text(encoding='utf-8')
|
||||
intent_seams = set(re.findall(r'seam="([^"]+)"', intents_xml)) - {''}
|
||||
for seam in sorted(CODE_SEAMS | intent_seams):
|
||||
if seam not in seams:
|
||||
err(f'seam referenced by code/intents but missing in XML: {seam}')
|
||||
# Seams referenced as literals in the Java sources (best effort).
|
||||
literal = set()
|
||||
for java in JAVA.rglob('FakePlayer*.java'):
|
||||
text = java.read_text(encoding='utf-8', errors='replace')
|
||||
for m in re.finditer(r'(?:eventLine|optionalLine|compose|speak|speakPrivate|pick|pickFor)\([^;]*?"([a-z_]+)"', text):
|
||||
literal.add(m.group(1))
|
||||
for seam in sorted(literal):
|
||||
if seam not in seams and seam not in ('personality', 'player', 'target', 'level', 'msg', 'boss', 'zone', 'neutral', 'ganker', 'item', 'price'):
|
||||
warn(f'literal "{seam}" used with the chat engine but not a seam (check the call)')
|
||||
unused = sorted(set(seams) - CODE_SEAMS - intent_seams)
|
||||
if unused:
|
||||
warn(f'seams not referenced by code or intents: {", ".join(unused)}')
|
||||
|
||||
|
||||
def check_combat(skilltrees, seed):
|
||||
combat_xml = (DATA / 'FakePlayerCombat.xml').read_text(encoding='utf-8')
|
||||
profiles = {}
|
||||
for m in re.finditer(r'<class id="(\d+)"[^>]*>(.*?)</class>', combat_xml, re.S):
|
||||
profiles[int(m.group(1))] = [int(x) for x in re.findall(r'id="(\d+)"', m.group(2))]
|
||||
print(f'combat profiles: {len(profiles)} classes')
|
||||
if skilltrees and Path(skilltrees).is_dir():
|
||||
learnable = {}
|
||||
for f in Path(skilltrees).rglob('*.xml'):
|
||||
text = f.read_text(encoding='utf-8', errors='replace')
|
||||
for tree in re.finditer(r'<skillTree type="classSkillTree" classId="(\d+)"[^>]*>(.*?)</skillTree>', text, re.S):
|
||||
cid = int(tree.group(1))
|
||||
learnable.setdefault(cid, set()).update(int(x) for x in re.findall(r'skillId="(\d+)"', tree.group(2)))
|
||||
|
||||
def chain(cid):
|
||||
out = set()
|
||||
while cid is not None:
|
||||
out |= learnable.get(cid, set())
|
||||
cid = PARENT.get(cid)
|
||||
return out
|
||||
|
||||
for cid, skills in profiles.items():
|
||||
available = chain(cid)
|
||||
for sid in skills:
|
||||
if sid not in available:
|
||||
err(f'combat profile class {cid}: skill {sid} is not learnable by this class chain')
|
||||
else:
|
||||
warn('skill trees not found, combat skill ids not verified (pass --skilltrees)')
|
||||
if seed and Path(seed).is_file():
|
||||
text = Path(seed).read_text(encoding='utf-8', errors='replace')
|
||||
classes = set()
|
||||
for m in re.finditer(r"'sppbots', '[^']*', (?:-?\d+, ){12}(\d+), \d+, \d+", text):
|
||||
classes.add(int(m.group(1)))
|
||||
missing = sorted(c for c in classes if c not in profiles)
|
||||
if missing:
|
||||
warn(f'seed classes without a combat profile (default profile is used): {missing}')
|
||||
else:
|
||||
print(f'all {len(classes)} seed classes have a combat profile')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--skilltrees', default=str(ROOT / 'server' / 'game' / 'data' / 'stats' / 'players' / 'skillTrees'))
|
||||
parser.add_argument('--seed', default=str(SRC / 'dist' / 'db_installer' / 'sql' / 'spp_clan_seed.sql'))
|
||||
args = parser.parse_args()
|
||||
check_chat()
|
||||
check_combat(args.skilltrees, args.seed)
|
||||
for w in warnings:
|
||||
print('WARN', w)
|
||||
for e in errors:
|
||||
print('ERROR', e)
|
||||
print(f'{len(errors)} errors, {len(warnings)} warnings')
|
||||
sys.exit(1 if errors else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Deploys the SPP overlay (bot data, configs, chat handlers, seeds) and the built jars
|
||||
# into a server directory (default: ../server). The datapack itself is vanilla Mobius,
|
||||
# only the files under src_mobius/.../dist are ours.
|
||||
# Usage: tools/deploy.sh [server_dir] [GameServer.jar]
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SRC="$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude"
|
||||
TARGET="${1:-$ROOT/server}"
|
||||
JAR="${2:-$ROOT/src_mobius/mobius/build/dist/libs/GameServer.jar}"
|
||||
if [ ! -d "$TARGET/game" ]; then
|
||||
echo "server dir not found: $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Overlay: everything under dist except libs (vendored build deps) and the ini files that
|
||||
# the docker entrypoint patches in place (Database.ini / Server.ini are not in the overlay anyway).
|
||||
rsync -a --no-perms --no-owner --no-group --exclude 'libs/' "$SRC/dist/" "$TARGET/"
|
||||
if [ -f "$JAR" ]; then
|
||||
cp -f "$JAR" "$TARGET/libs/GameServer.jar"
|
||||
LOGIN="$(dirname "$JAR")/LoginServer.jar"
|
||||
[ -f "$LOGIN" ] && cp -f "$LOGIN" "$TARGET/libs/LoginServer.jar"
|
||||
echo "jars -> $TARGET/libs"
|
||||
else
|
||||
echo "no jar at $JAR (build first: tools/build.sh); overlay deployed only"
|
||||
fi
|
||||
echo "overlay -> $TARGET (data, config, scripts, sql seeds)"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,221 @@
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerBotContext;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerChatLines;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerHeardManager;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerIdiolect;
|
||||
import org.l2jmobius.gameserver.managers.FakePlayerIntentParser;
|
||||
|
||||
/**
|
||||
* Offline harness for the bot chat engine: runs the real server classes
|
||||
* (FakePlayerChatLines, FakePlayerIntentParser, FakePlayerIdiolect,
|
||||
* FakePlayerBotContext, FakePlayerHeardManager) against the real XML data
|
||||
* without a game server. Run from a directory that contains data/ (see
|
||||
* tools/run_harness.sh). Exit code 1 on any failed check.
|
||||
*/
|
||||
public class ChatHarness
|
||||
{
|
||||
private static int failures = 0;
|
||||
private static int checks = 0;
|
||||
|
||||
private static void check(boolean condition, String what)
|
||||
{
|
||||
checks++;
|
||||
if (!condition)
|
||||
{
|
||||
failures++;
|
||||
System.out.println(" FAIL: " + what);
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args)
|
||||
{
|
||||
final FakePlayerChatLines lines = FakePlayerChatLines.getInstance();
|
||||
final FakePlayerIntentParser intents = FakePlayerIntentParser.getInstance();
|
||||
|
||||
// 1. Every seam the code uses must exist and produce a line with full slots.
|
||||
final String[] codeSeams =
|
||||
{
|
||||
"greeting", "whisper_default", "reply_where", "reply_price", "reply_help_yes", "reply_help_no", "reply_party_no", "party_accept", "reply_insult_soft", "reply_insult_aggro", "reply_thanks", "reply_botcheck", "reply_duel", "reply_rumor_none", "reply_bye", "reply_yes_ok", "reply_how", "reply_caps", "reply_again", "overheard_q", "reply_level", "gank_start", "pkk_start", "war_start", "killed_by", "revenge_meet", "friendly_meet", "ambient", "levelup", "grats", "migrate", "raid_start", "trade_shout", "invite_player", "call_help", "lfp", "overheard", "assist", "companion_done", "store_title", "bot_talk", "reply_drop", "siege_start", "siege_defend", "siege_gate", "siege_engrave", "siege_win", "siege_lose", "profession", "died_mob", "loot_brag", "reply_clan", "reply_solo", "reply_long", "reply_pk"
|
||||
};
|
||||
final Map<String, String> slots = new HashMap<>();
|
||||
slots.put("player", "Vanger");
|
||||
slots.put("target", "Vanger");
|
||||
slots.put("level", "42");
|
||||
slots.put("boss", "Core");
|
||||
slots.put("zone", "Cruma");
|
||||
slots.put("town", "Giran");
|
||||
slots.put("msg", "продам дроп");
|
||||
slots.put("prev", "не пойду");
|
||||
slots.put("enemyclan", "RedCrew");
|
||||
slots.put("mob", "Cruma Marshlands Traitor");
|
||||
slots.put("castle", "Giran");
|
||||
slots.put("clan", "IronPact");
|
||||
slots.put("class", "Gladiator");
|
||||
slots.put("drop", "Oriharukon Ore");
|
||||
slots.put("item", "соски D");
|
||||
slots.put("price", "50к");
|
||||
slots.put("crowd", "6");
|
||||
System.out.println("== seams used by code (" + codeSeams.length + ") ==");
|
||||
final Map<String, String> samples = new TreeMap<>();
|
||||
for (String seam : codeSeams)
|
||||
{
|
||||
String ok = null;
|
||||
for (int botId = 1; (botId <= 12) && (ok == null); botId++)
|
||||
{
|
||||
final Map<String, String> facts = new HashMap<>();
|
||||
facts.put("personality", (botId % 4 == 0) ? "ganker" : (botId % 4 == 1) ? "helper" : (botId % 4 == 2) ? "pkk" : "neutral");
|
||||
facts.put("asker", (botId % 3 == 0) ? "stranger" : (botId % 3 == 1) ? "clanmate" : "killed_me");
|
||||
ok = lines.speak(botId, seam, facts, slots, false, null);
|
||||
}
|
||||
check(ok != null, "seam produces nothing: " + seam);
|
||||
if (ok != null)
|
||||
{
|
||||
samples.put(seam, ok);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, String> entry : samples.entrySet())
|
||||
{
|
||||
System.out.println(" " + entry.getKey() + ": " + entry.getValue());
|
||||
}
|
||||
|
||||
// 2. Context facts change what a bot says (plan / event / grounding slots).
|
||||
System.out.println("== context ==");
|
||||
FakePlayerBotContext.setPlan(77, "siege", 60000);
|
||||
FakePlayerBotContext.setSlot(77, "castle", "Aden");
|
||||
int siegeMentions = 0;
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
final String line = lines.speak(77, "ambient", Map.of("personality", "neutral"), null, false, null);
|
||||
if ((line != null) && (line.contains("осад") || line.contains("Aden")))
|
||||
{
|
||||
siegeMentions++;
|
||||
}
|
||||
}
|
||||
System.out.println(" plan=siege -> siege mentions in 40 ambient lines: " + siegeMentions);
|
||||
check(siegeMentions >= 8, "plan=siege rule should fire regularly (got " + siegeMentions + ")");
|
||||
FakePlayerBotContext.setPlan(78, "farm", 0);
|
||||
FakePlayerBotContext.setEvent(78, "profession");
|
||||
int professionMentions = 0;
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
final String line = lines.speak(78, "ambient", Map.of("personality", "neutral"), null, false, null);
|
||||
if ((line != null) && line.contains("проф"))
|
||||
{
|
||||
professionMentions++;
|
||||
}
|
||||
}
|
||||
System.out.println(" event=profession -> mentions in 40 ambient lines: " + professionMentions);
|
||||
check(professionMentions >= 2, "event=profession rule should fire (got " + professionMentions + ")");
|
||||
FakePlayerBotContext.setSlot(79, "drop", "Adamantite Nugget");
|
||||
FakePlayerBotContext.setSlot(79, "mob", "Dion Grizzly");
|
||||
final String dropLine = lines.speak(79, "reply_drop", Map.of("personality", "neutral"), Map.of("player", "Vanger"), false, null);
|
||||
System.out.println(" reply_drop with grounding slots: " + dropLine);
|
||||
check(dropLine != null, "reply_drop with slots from context");
|
||||
final String noDrop = lines.speak(80, "reply_drop", Map.of("personality", "neutral"), Map.of("player", "Vanger"), false, null);
|
||||
System.out.println(" reply_drop without drop slot: " + noDrop);
|
||||
check(noDrop != null, "reply_drop must have slot-free lines");
|
||||
|
||||
// 3. Bot to bot openers are understood by the listener (intent) or fall back to overheard.
|
||||
System.out.println("== bot_talk -> intents ==");
|
||||
final Map<String, Integer> distribution = new LinkedHashMap<>();
|
||||
int understood = 0;
|
||||
final int openers = 200;
|
||||
for (int i = 0; i < openers; i++)
|
||||
{
|
||||
final int speaker = 100 + (i % 20);
|
||||
final String opener = lines.speak(speaker, "bot_talk", Map.of("personality", "neutral"), Map.of("player", "Kolyan", "level", "35"), false, null);
|
||||
if (opener == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
final FakePlayerIntentParser.Intent intent = intents.parse(opener);
|
||||
final String key = (intent != null) ? intent.key : (FakePlayerHeardManager.isQuestionLine(opener) ? "(overheard_q)" : "(overheard)");
|
||||
distribution.merge(key, 1, Integer::sum);
|
||||
if (intent != null)
|
||||
{
|
||||
understood++;
|
||||
}
|
||||
if (i < 12)
|
||||
{
|
||||
System.out.println(" \"" + opener + "\" -> " + key);
|
||||
}
|
||||
}
|
||||
System.out.println(" intents: " + distribution);
|
||||
check(understood >= (openers / 2), "at least half of bot_talk openers should map to an intent (got " + understood + "/" + openers + ")");
|
||||
|
||||
// 4. Sample dialogue as the social layer would run it (opener -> reply seam of the intent).
|
||||
System.out.println("== sample dialogues ==");
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
final int a = 300 + i;
|
||||
final int b = 400 + i;
|
||||
final String opener = lines.speak(a, "bot_talk", Map.of("personality", "neutral"), Map.of("player", "Zheka"), false, null);
|
||||
final FakePlayerIntentParser.Intent intent = (opener != null) ? intents.parse(opener) : null;
|
||||
String reply;
|
||||
if ((intent != null) && !intent.seam.isEmpty())
|
||||
{
|
||||
final Map<String, String> replySlots = new HashMap<>(slots);
|
||||
replySlots.put("player", "Vovan");
|
||||
reply = lines.speak(b, intent.seam, Map.of("personality", "helper", "asker", "clanmate"), replySlots, false, FakePlayerIdiolect.Mirror.of(opener));
|
||||
}
|
||||
else
|
||||
{
|
||||
reply = lines.speak(b, (opener != null) && FakePlayerHeardManager.isQuestionLine(opener) ? "overheard_q" : "overheard", Map.of("personality", "helper"), Map.of("msg", FakePlayerHeardManager.echoOf(opener != null ? opener : ""), "player", "Vovan"), true, null);
|
||||
}
|
||||
System.out.println(" Vovan: " + opener);
|
||||
System.out.println(" Zheka: " + reply + " [" + ((intent != null) ? intent.key : "overheard") + "]");
|
||||
}
|
||||
|
||||
// 5. Idiolect: two bots never sound the same.
|
||||
System.out.println("== idiolect ==");
|
||||
final FakePlayerIdiolect one = FakePlayerIdiolect.of(1001);
|
||||
final FakePlayerIdiolect two = FakePlayerIdiolect.of(1002);
|
||||
check(!(one.laugh.equals(two.laugh) && one.filler.equals(two.filler) && (one.chattiness == two.chattiness) && (one.slang == two.slang)), "idiolects should differ");
|
||||
System.out.println(" 1001: laugh=" + one.laugh + " filler=" + one.filler + " slang=" + one.slang + " mat=" + one.traitMat);
|
||||
System.out.println(" 1002: laugh=" + two.laugh + " filler=" + two.filler + " slang=" + two.slang + " mat=" + two.traitMat);
|
||||
|
||||
// 6. Heard classification helpers.
|
||||
check(FakePlayerHeardManager.isQuestionLine("кто знает где рб"), "cyrillic question detection");
|
||||
check(!FakePlayerHeardManager.isQuestionLine("фарм идет норм"), "statement is not a question");
|
||||
check(FakePlayerHeardManager.isCommercialLine("продам соски дешево"), "commercial detection");
|
||||
check("продам соски дешево".startsWith(FakePlayerHeardManager.echoOf("продам соски дешево налетай")), "echo takes first words");
|
||||
|
||||
// 7. Intents that sieges / economy rely on.
|
||||
final FakePlayerIntentParser.Intent drop = intents.parse("дроп есть?");
|
||||
check((drop != null) && "ask_drop".equals(drop.key), "ask_drop intent");
|
||||
final FakePlayerIntentParser.Intent level = intents.parse("какой лвл");
|
||||
check((level != null) && "ask_level".equals(level.key), "ask_level intent");
|
||||
final FakePlayerIntentParser.Intent party = intents.parse("го в пати");
|
||||
check((party != null) && "ask_party".equals(party.key), "ask_party intent");
|
||||
final FakePlayerIntentParser.Intent how = intents.parse("как фарм?");
|
||||
check((how != null) && "how_are_you".equals(how.key), "how_are_you intent");
|
||||
final String[][] regression =
|
||||
{
|
||||
{ "го пвп", "duel" }, { "помгите", "ask_help" }, { "дуель?", "duel" }, { "ты бот?", "botcheck" }, { "спс бро", "thanks" },
|
||||
{ "лох", "insult" }, { "где ты", "ask_where" }, { "что нового", "ask_rumors" }, { "пока", "bye" }, { "прив", "greeting" },
|
||||
{ "ок", "yes_ok" }, { "сколько стоит", "ask_price" }, { "клан есть?", "ask_clan" }, { "ты соло?", "ask_solo" },
|
||||
{ "давно тут стоишь?", "ask_long" }, { "пк ходят?", "ask_pk" }, { "здарова, как жизнь молодая?", "how_are_you" },
|
||||
{ "где качаться думаешь", "ask_where" }
|
||||
};
|
||||
for (String[] pair : regression)
|
||||
{
|
||||
final FakePlayerIntentParser.Intent parsed = intents.parse(pair[0]);
|
||||
final String got = (parsed != null) ? parsed.key : "null";
|
||||
check(got.equals(pair[1]), "intent of \"" + pair[0] + "\": expected " + pair[1] + ", got " + got);
|
||||
}
|
||||
final String[] mustBeNull = { "думаешь тут фармить?", "молодая", "стоишь тут" };
|
||||
for (String text : mustBeNull)
|
||||
{
|
||||
final FakePlayerIntentParser.Intent parsed = intents.parse(text);
|
||||
check((parsed == null) || "ask_long".equals(parsed.key) || "ask_where".equals(parsed.key), "no false positive for \"" + text + "\" (got " + ((parsed != null) ? parsed.key : "null") + ")");
|
||||
}
|
||||
|
||||
System.out.println("== " + (checks - failures) + "/" + checks + " checks passed ==");
|
||||
System.exit(failures == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Offline chat harness: real engine classes + real XML, no game server.
|
||||
# Usage: tools/run_harness.sh [path/to/GameServer.jar]
|
||||
# Needs JAVA_HOME with JDK 25 (or java/javac on PATH).
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SRC="$ROOT/src_mobius/mobius/L2J_Mobius_CT_0_Interlude"
|
||||
JAR="${1:-$ROOT/build/dist/libs/GameServer.jar}"
|
||||
if [ ! -f "$JAR" ]; then
|
||||
echo "GameServer.jar not found: $JAR (build first: tools/build.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
WORK="$(mktemp -d)"
|
||||
mkdir -p "$WORK/data/xsd"
|
||||
cp "$SRC"/dist/game/data/FakePlayer*.xml "$WORK/data/"
|
||||
cp "$SRC"/dist/game/data/xsd/FakePlayer*.xsd "$WORK/data/xsd/"
|
||||
JAVAC="${JAVA_HOME:+$JAVA_HOME/bin/}javac"
|
||||
JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||
"$JAVAC" -nowarn -d "$WORK" -cp "$JAR:$SRC/dist/libs/*" "$ROOT/tools/harness/ChatHarness.java"
|
||||
cd "$WORK"
|
||||
"$JAVA" -Dfile.encoding=UTF-8 -cp "$WORK:$JAR:$SRC/dist/libs/*" ChatHarness
|
||||
STATUS=$?
|
||||
rm -rf "$WORK"
|
||||
exit $STATUS
|
||||
Reference in New Issue
Block a user