#!/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']*>(.*?)', lines_xml, re.S): seams[m.group(1)] = re.findall(r']*>(.*?)', m.group(2), re.S) pools = {} for m in re.finditer(r']*>(.*?)', pools_xml, re.S): pools[m.group(1)] = re.findall(r']*>(.*?)', 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']*>(.*?)', 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']*>(.*?)', 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()