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,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()
|
||||
Reference in New Issue
Block a user