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:
2026-09-03 20:39:50 +02:00
parent dcbecd2843
commit 165820c041
40 changed files with 7634 additions and 2699 deletions
+8
View File
@@ -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/"
+184
View File
@@ -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()
+26
View File
@@ -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)"
+194
View File
@@ -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()
+221
View File
@@ -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);
}
}
+24
View File
@@ -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