Files

185 lines
8.3 KiB
Python

#!/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()