#!/usr/bin/env python3 """ Compare NPC drops between pk_npcdrops.sql and NpcDrops.java. Usage: python compare_drops.py list python compare_drops.py npc python compare_drops.py summary """ import re import sys import os from collections import defaultdict # ─── ANSI colors ──────────────────────────────────────────────────────────── GREEN = "\033[92m" YELLOW = "\033[93m" RED = "\033[91m" CYAN = "\033[96m" BOLD = "\033[1m" DIM = "\033[2m" RESET = "\033[0m" # ─── Bone item IDs (excluded from comparison) ─────────────────────────────── BONE_IDS = {20, 466, 814, 274, 23, 413} # From ItemId.java: BONES(20), BIG_BONES(413), BAT_BONES(604), DRAGON_BONES(814), ASHES(181), UNICORN_HORN(466) BONE_ITEM_IDS = {20, 413, 604, 814, 181, 466} BONE_ITEM_NAMES = {20: "Bones", 413: "Big Bones", 604: "Bat Bones", 814: "Dragon Bones", 181: "Ashes", 466: "Unicorn Horn"} # ─── File paths (relative to script location) ─────────────────────────────── SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SQL_PATH = os.environ.get( "PK_NPCDROPS_FILE", os.path.join(SCRIPT_DIR, "pk_npcdrops.sql"), ) JAVA_PATH = os.path.join(SCRIPT_DIR, "server/src/com/openrsc/server/constants/NpcDrops.java") NPC_PATH = os.path.join(SCRIPT_DIR, "server/src/com/openrsc/server/constants/NpcId.java") ITEM_PATH = os.path.join(SCRIPT_DIR, "server/src/com/openrsc/server/constants/ItemId.java") # ═══════════════════════════════════════════════════════════════════════════ # Enum parsing # ═══════════════════════════════════════════════════════════════════════════ def parse_enum_file(path): """Parse a Java enum file and return {name: id} and {id: name}.""" name_to_id = {} id_to_name = {} with open(path, "r") as f: content = f.read() # Match lines like: UNICORN(0), or NOBODY(-1), for m in re.finditer(r'^\s+([A-Z_0-9]+)\((-?\d+)\)', content, re.MULTILINE): name = m.group(1) val = int(m.group(2)) name_to_id[name] = val id_to_name[val] = name return name_to_id, id_to_name def load_enums(): npc_name_to_id, npc_id_to_name = parse_enum_file(NPC_PATH) item_name_to_id, item_id_to_name = parse_enum_file(ITEM_PATH) return npc_name_to_id, npc_id_to_name, item_name_to_id, item_id_to_name # ═══════════════════════════════════════════════════════════════════════════ # SQL parsing # ═══════════════════════════════════════════════════════════════════════════ def parse_sql(): """Parse pk_npcdrops.sql and return {npc_id: [(item_id, amount, weight), ...]}.""" drops = defaultdict(list) with open(SQL_PATH, "r") as f: content = f.read() # Match all INSERT value tuples: (npcdef_id, 'amount', id, weight, db_index) for m in re.finditer(r"\((\d+),\s*'(-?\d+)',\s*(-?\d+),\s*(\d+),\s*\d+\)", content): npc_id = int(m.group(1)) amount = int(m.group(2)) item_id = int(m.group(3)) weight = int(m.group(4)) drops[npc_id].append((item_id, amount, weight)) return dict(drops) # ═══════════════════════════════════════════════════════════════════════════ # Sub-table definitions # ═══════════════════════════════════════════════════════════════════════════ class SubTable: """Represents a parsed Java sub-table (herbDropTable, rareDropTable, etc.).""" def __init__(self, name, display_name): self.name = name self.display_name = display_name self.items = [] # list of (item_id, amount, weight) self.table_refs = [] # list of (subtable_name, weight) self.empty_weight = 0 self._total_weight = None @property def total_weight(self): if self._total_weight is not None: return self._total_weight tw = sum(w for _, _, w in self.items) + sum(w for _, w in self.table_refs) + self.empty_weight return tw def set_total_weight(self, tw): self._total_weight = tw def expand(self, all_tables, visited=None): """ Recursively expand this sub-table into a flat list of (item_id, amount, effective_weight) entries, where effective_weight is relative to this table's total weight. Returns: list of (item_id, amount, effective_probability) where effective_probability is a float in [0, 1] representing the chance of getting that item when this table is rolled. """ if visited is None: visited = set() if self.name in visited: return [] # prevent infinite recursion visited = visited | {self.name} tw = self.total_weight if tw <= 0: return [] result = [] # Direct item drops for item_id, amount, weight in self.items: if item_id == -1: # "Nothing" entry continue prob = weight / tw result.append((item_id, amount, prob)) # Nested table references for table_name, ref_weight in self.table_refs: if table_name not in all_tables: continue sub = all_tables[table_name] sub_items = sub.expand(all_tables, visited) ref_prob = ref_weight / tw for item_id, amount, sub_prob in sub_items: result.append((item_id, amount, ref_prob * sub_prob)) return result def parse_sub_tables(content, item_name_to_id): """ Parse the shared sub-table definitions from NpcDrops.java. Returns {table_var_name: SubTable}. """ def resolve_item(token): m = re.match(r'ItemId\.([A-Z_0-9]+)\.id\(\)', token.strip()) if m: return item_name_to_id.get(m.group(1)) try: return int(token.strip()) except ValueError: return None tables = {} # Define which methods create which tables table_methods = { 'createHerbDropTable': 'herbDropTable', 'createRareDropTable': 'rareDropTable', 'createMegaRareDropTable': 'megaRareDropTable', 'createUltraRareDropTable': 'ultraRareDropTable', 'initializeCustomRareDropTables': 'kbdTableCustom', } # Also the OPENPK tables (we parse but flag them) openpk_tables = {'runeDropTable', 'dragonDropTable', 'arrowsRunesDropTable'} # Table display names display_names = { 'herbDropTable': '[Herb Table]', 'rareDropTable': '[Rare Table]', 'megaRareDropTable': '[Mega Rare Table]', 'ultraRareDropTable': '[Ultra Rare Table]', 'kbdTableCustom': '[KBD Custom Table]', 'runeDropTable': '[Rune Table]', 'dragonDropTable': '[Dragon Table]', 'arrowsRunesDropTable': '[Arrows & Runes Table]', } # Parse each table variable's definition from the Java source # We look for patterns like: # herbDropTable = new DropTable(...) # herbDropTable.addItemDrop(...) # herbDropTable.addTableDrop(...) # herbDropTable.addEmptyDrop(...) # First, find all table variable names used all_table_vars = set() for m in re.finditer(r'(\w+)\s*=\s*new\s+DropTable\(', content): var = m.group(1) if var != 'currentNpcDrops' and var not in ('balrog', 'chaosDruidDouble', 'jogreOneBoneTable', 'jogreTwoBoneTable', 'paladinSteelBarDrop', 'paladinIronBarDrop'): all_table_vars.add(var) for var in all_table_vars: display = display_names.get(var, f'[{var}]') st = SubTable(var, display) # Find addItemDrop calls for this variable for m in re.finditer( rf'{re.escape(var)}\.addItemDrop\((.+?),\s*(\d+),\s*(\d+)', content ): item_id = resolve_item(m.group(1)) amount = int(m.group(2)) weight = int(m.group(3)) if item_id is not None: st.items.append((item_id, amount, weight)) # Find addTableDrop calls for this variable for m in re.finditer( rf'{re.escape(var)}\.addTableDrop\((\w+),\s*(\d+)\)', content ): ref_table = m.group(1) weight = int(m.group(2)) st.table_refs.append((ref_table, weight)) # Find addEmptyDrop calls for m in re.finditer( rf'{re.escape(var)}\.addEmptyDrop\((.+?)\)', content ): expr = m.group(1).strip() # Could be "128 - varname.getTotalWeight()" or a number try: st.empty_weight = int(expr) except ValueError: # Calculate: it's typically 128 - total of items+table_refs item_weight = sum(w for _, _, w in st.items) ref_weight = sum(w for _, w in st.table_refs) st.empty_weight = 128 - item_weight - ref_weight if st.empty_weight < 0: st.empty_weight = 0 tables[var] = st return tables # ═══════════════════════════════════════════════════════════════════════════ # Java parsing # ═══════════════════════════════════════════════════════════════════════════ def parse_java(item_name_to_id, npc_name_to_id): """ Parse NpcDrops.java and return: npc_drops: {npc_id: [(item_id, amount, weight), ...]} bone_sets: {npc_id: bone_type} (boneless, bat, big, dragon, ashes) table_refs: {npc_id: [(table_name, weight), ...]} for sub-table references sub_tables: {table_var_name: SubTable} parsed sub-table definitions """ with open(JAVA_PATH, "r") as f: content = f.read() # ── Parse sub-tables first ─────────────────────────────────────── sub_tables = parse_sub_tables(content, item_name_to_id) # ── Resolve item/npc references ────────────────────────────────── def resolve_item(token): """Resolve 'ItemId.RAW_CHICKEN.id()' to numeric id.""" m = re.match(r'ItemId\.([A-Z_0-9]+)\.id\(\)', token.strip()) if m: name = m.group(1) return item_name_to_id.get(name) try: return int(token.strip()) except ValueError: return None def resolve_npc(token): m = re.match(r'NpcId\.([A-Z_0-9]+)\.id\(\)', token.strip()) if m: name = m.group(1) return npc_name_to_id.get(name) try: return int(token.strip()) except ValueError: return None # ── Parse bone sets ────────────────────────────────────────────── bone_sets = {} # npc_id -> bone_type string for m in re.finditer(r'this\.bonelessNpcs\.add\(NpcId\.([A-Z_0-9]+)\.id\(\)\)', content): nid = npc_name_to_id.get(m.group(1)) if nid is not None: bone_sets[nid] = "boneless" for m in re.finditer(r'this\.batBonedNpcs\.add\(NpcId\.([A-Z_0-9]+)\.id\(\)\)', content): nid = npc_name_to_id.get(m.group(1)) if nid is not None: bone_sets[nid] = "bat_bones" for m in re.finditer(r'this\.bigBoneNpcs\.add\(NpcId\.([A-Z_0-9]+)\.id\(\)\)', content): nid = npc_name_to_id.get(m.group(1)) if nid is not None: bone_sets[nid] = "big_bones" for m in re.finditer(r'this\.dragonNpcs\.add\(NpcId\.([A-Z_0-9]+)\.id\(\)\)', content): nid = npc_name_to_id.get(m.group(1)) if nid is not None: bone_sets[nid] = "dragon_bones" for m in re.finditer(r'this\.ashesNpcs\.add\(NpcId\.([A-Z_0-9]+)\.id\(\)\)', content): nid = npc_name_to_id.get(m.group(1)) if nid is not None: bone_sets[nid] = "ashes" # ── Parse generateNpcDrops() ───────────────────────────────────── method_match = re.search(r'private void generateNpcDrops\(\)\s*\{', content) if not method_match: print("ERROR: Could not find generateNpcDrops() method") sys.exit(1) start = method_match.end() depth = 1 pos = start while pos < len(content) and depth > 0: if content[pos] == '{': depth += 1 elif content[pos] == '}': depth -= 1 pos += 1 method_body = content[start:pos - 1] # ── Remove WANT_OPENPK_POINTS blocks ───────────────────────────── cleaned = _remove_openpk_blocks(method_body) # ── Parse the cleaned method body line by line ─────────────────── npc_drops = {} # npc_id -> list of (item_id, amount, weight) table_refs = {} # npc_id -> list of (table_var_name, weight) current_drops = [] current_tables = [] current_is_clone = False assigned_npc_ids = [] # Known table name mapping (var name -> display name) TABLE_NAMES = { "herbDropTable": "[Herb Table]", "rareDropTable": "[Rare Table]", "megaRareDropTable": "[Mega Rare Table]", "ultraRareDropTable": "[Ultra Rare Table]", "runeDropTable": "[Rune Table]", "dragonDropTable": "[Dragon Table]", "arrowsRunesDropTable": "[Arrows & Runes Table]", "kbdTableCustom": "[KBD Custom Table]", } # Split into statements for line in cleaned.split('\n'): line = line.strip() if not line or line.startswith('//'): continue # New DropTable m = re.match(r'currentNpcDrops\s*=\s*new\s+DropTable\(', line) if m: _finalize_puts(npc_drops, table_refs, assigned_npc_ids, current_drops, current_tables) current_drops = [] current_tables = [] assigned_npc_ids = [] current_is_clone = False continue # Clone m = re.match(r'currentNpcDrops\s*=\s*currentNpcDrops\.clone\(', line) if m: _finalize_puts(npc_drops, table_refs, assigned_npc_ids, current_drops, current_tables) current_drops = list(current_drops) # copy current_tables = list(current_tables) assigned_npc_ids = [] current_is_clone = True continue # addItemDrop m = re.match( r'currentNpcDrops\.addItemDrop\((.+?),\s*(.+?),\s*(\d+)', line ) if m: item_id = resolve_item(m.group(1)) amount_str = m.group(2).strip() ternary = re.match(r'config\.WANT_OPENPK_POINTS\s*\?\s*\d+\s*:\s*(\d+)', amount_str) if ternary: amount = int(ternary.group(1)) else: try: amount = int(amount_str) except ValueError: amount = 1 weight = int(m.group(3)) if item_id is not None: current_drops.append((item_id, amount, weight)) continue # addEmptyDrop m = re.match(r'currentNpcDrops\.addEmptyDrop\(', line) if m: total = sum(w for _, _, w in current_drops) + sum(w for _, w in current_tables) empty_weight = 128 - total if empty_weight > 0: current_drops.append((-1, -1, empty_weight)) continue # addTableDrop (sub-table reference) — store the variable name, not display name m = re.match(r'currentNpcDrops\.addTableDrop\((\w+),\s*(\d+)\)', line) if m: table_var = m.group(1) weight = int(m.group(2)) current_tables.append((table_var, weight)) continue # npcDrops.put m = re.match(r'this\.npcDrops\.put\((.+?),\s*currentNpcDrops\)', line) if m: npc_id = resolve_npc(m.group(1)) if npc_id is not None: assigned_npc_ids.append(npc_id) continue # Finalize last block _finalize_puts(npc_drops, table_refs, assigned_npc_ids, current_drops, current_tables) return npc_drops, bone_sets, table_refs, sub_tables def _finalize_puts(npc_drops, table_refs, assigned_npc_ids, drops, tables): """Assign current drops/tables to all pending NPC IDs.""" for nid in assigned_npc_ids: npc_drops[nid] = list(drops) if tables: table_refs[nid] = list(tables) def _remove_openpk_blocks(text): """ Remove if(config.WANT_OPENPK_POINTS) { ... } blocks. If there's an else { ... } block, keep its contents. """ result = [] i = 0 while i < len(text): m = re.search(r'if\s*\(\s*config\.WANT_OPENPK_POINTS\s*\)', text[i:]) if not m: result.append(text[i:]) break result.append(text[i:i + m.start()]) pos = i + m.end() while pos < len(text) and text[pos] in ' \t\n\r': pos += 1 if pos < len(text) and text[pos] == '{': depth = 1 pos += 1 while pos < len(text) and depth > 0: if text[pos] == '{': depth += 1 elif text[pos] == '}': depth -= 1 pos += 1 rest = text[pos:] else_m = re.match(r'\s*else\s*\{', rest) if else_m: pos += else_m.end() depth = 1 start = pos while pos < len(text) and depth > 0: if text[pos] == '{': depth += 1 elif text[pos] == '}': depth -= 1 pos += 1 else_content = text[start:pos - 1] result.append(else_content) i = pos return "".join(result) # ═══════════════════════════════════════════════════════════════════════════ # Sub-table expansion # ═══════════════════════════════════════════════════════════════════════════ def expand_npc_tables(npc_id, java_drops_list, table_refs_list, sub_tables): """ Given an NPC's direct drops and table references, expand all sub-table references into individual items with effective weights. Returns: expanded_drops: list of (item_id, amount, effective_weight) These are the NPC's direct items PLUS expanded sub-table items, all with weights relative to the NPC's total weight (128). table_expanded: dict of {table_display_name: [(item_id, amount, npc_prob%)]} For display: which items came from which sub-table. """ # Calculate NPC's total weight (direct items + table ref weights) direct_weight = sum(w for _, _, w in java_drops_list) table_weight = sum(w for _, w in table_refs_list) if table_refs_list else 0 npc_total_weight = direct_weight + table_weight if npc_total_weight <= 0: return java_drops_list, {} expanded_drops = list(java_drops_list) # start with direct drops table_expanded = {} # display_name -> [(item_id, amount, npc_prob%)] TABLE_DISPLAY = { "herbDropTable": "[Herb Table]", "rareDropTable": "[Rare Table]", "megaRareDropTable": "[Mega Rare Table]", "ultraRareDropTable": "[Ultra Rare Table]", "kbdTableCustom": "[KBD Custom Table]", "runeDropTable": "[Rune Table]", "dragonDropTable": "[Dragon Table]", "arrowsRunesDropTable": "[Arrows & Runes Table]", } if table_refs_list: for table_var, ref_weight in table_refs_list: if table_var not in sub_tables: continue st = sub_tables[table_var] display = TABLE_DISPLAY.get(table_var, f'[{table_var}]') sub_items = st.expand(sub_tables) table_items = [] for item_id, amount, sub_prob in sub_items: # sub_prob is probability within the sub-table [0,1] # ref_weight / npc_total_weight is chance of rolling this table # effective weight in NPC terms: effective_weight = ref_weight * sub_prob expanded_drops.append((item_id, amount, effective_weight)) npc_prob = (ref_weight / npc_total_weight) * sub_prob * 100.0 table_items.append((item_id, amount, npc_prob)) if table_items: table_expanded[display] = table_items return expanded_drops, table_expanded # ═══════════════════════════════════════════════════════════════════════════ # Comparison logic # ═══════════════════════════════════════════════════════════════════════════ def normalize_drops(drop_list, exclude_bones=True): """ Normalize a drop list into {(item_id, amount): weight} with bone filtering. Returns (guaranteed, weighted, total_weight, bone_items). guaranteed: list of (item_id, amount) that always drop (weight=0) weighted: {(item_id, amount): probability%} bone_items: set of bone item IDs found """ guaranteed = [] weighted = {} total_weight = 0 bone_items = set() for item_id, amount, weight in drop_list: if item_id in BONE_ITEM_IDS and exclude_bones: bone_items.add(item_id) continue if weight == 0: guaranteed.append((item_id, amount)) else: total_weight += weight key = (item_id, amount) weighted[key] = weighted.get(key, 0) + weight # Convert weights to probabilities probs = {} if total_weight > 0: for key, w in weighted.items(): probs[key] = (w / total_weight) * 100.0 return guaranteed, probs, total_weight, bone_items def normalize_drops_expanded(drop_list, exclude_bones=True): """ Like normalize_drops but handles fractional weights from sub-table expansion. """ guaranteed = [] weighted = {} total_weight = 0.0 bone_items = set() for item_id, amount, weight in drop_list: if item_id in BONE_ITEM_IDS and exclude_bones: bone_items.add(item_id) continue if weight == 0: guaranteed.append((item_id, amount)) else: total_weight += weight key = (item_id, amount) weighted[key] = weighted.get(key, 0) + weight probs = {} if total_weight > 0: for key, w in weighted.items(): probs[key] = (w / total_weight) * 100.0 return guaranteed, probs, total_weight, bone_items def compare_npc(sql_drops, java_drops, java_tables=None, sub_tables=None): """ Compare drops for a single NPC. Returns (status, max_diff). When sub_tables is provided, expands Java sub-table references for comparison. """ sql_guar, sql_probs, sql_tw, sql_bones = normalize_drops(sql_drops) # If we have sub-tables, expand Java drops if sub_tables and java_tables: expanded, _ = expand_npc_tables(None, java_drops, java_tables, sub_tables) java_guar, java_probs, java_tw, java_bones = normalize_drops_expanded(expanded) else: java_guar, java_probs, java_tw, java_bones = normalize_drops(java_drops) sql_non_empty = {k: v for k, v in sql_probs.items() if k != (-1, -1)} java_non_empty = {k: v for k, v in java_probs.items() if k != (-1, -1)} only_empty_sql = (len(sql_probs) > 0 and len(sql_non_empty) == 0) only_empty_java = (len(java_probs) > 0 and len(java_non_empty) == 0) no_weighted_sql = len(sql_probs) == 0 no_weighted_java = len(java_probs) == 0 if (only_empty_sql and no_weighted_java) or (no_weighted_sql and only_empty_java): all_keys = set(sql_non_empty.keys()) | set(java_non_empty.keys()) else: all_keys = set(sql_probs.keys()) | set(java_probs.keys()) sql_guar_set = set(sql_guar) java_guar_set = set(java_guar) max_diff = 0.0 for key in all_keys: sp = sql_probs.get(key, 0.0) jp = java_probs.get(key, 0.0) diff = abs(sp - jp) max_diff = max(max_diff, diff) guar_match = (sql_guar_set == java_guar_set) if not guar_match: max_diff = max(max_diff, 100.0) if max_diff <= 1.0 and guar_match: status = "MATCH" elif max_diff <= 5.0: status = "SIMILAR" else: status = "DIFFERENT" return status, max_diff def get_status_color(status): colors = { "MATCH": GREEN, "SIMILAR": YELLOW, "DIFFERENT": RED, "SQL_ONLY": CYAN, "JAVA_ONLY": CYAN, } return colors.get(status, "") def get_diff_color(diff): if diff <= 1.0: return GREEN elif diff <= 5.0: return YELLOW else: return RED # ═══════════════════════════════════════════════════════════════════════════ # Commands # ═══════════════════════════════════════════════════════════════════════════ def cmd_list(sql_data, java_drops, java_tables, sub_tables, npc_id_to_name, item_id_to_name): """List all NPCs with comparison status.""" all_npc_ids = set(sql_data.keys()) | set(java_drops.keys()) results = [] for npc_id in sorted(all_npc_ids): npc_name = npc_id_to_name.get(npc_id, f"Unknown") has_sql = npc_id in sql_data has_java = npc_id in java_drops # Skip NPCs that only have bone drops in SQL (just guaranteed bones, no weighted) if has_sql and not has_java: sql_d = sql_data[npc_id] non_bone = [d for d in sql_d if d[0] not in BONE_ITEM_IDS] if len(non_bone) == 0: continue # Only bone drops in SQL, skip if has_sql and has_java: status, max_diff = compare_npc(sql_data[npc_id], java_drops[npc_id], java_tables.get(npc_id), sub_tables) elif has_sql: sql_d = sql_data[npc_id] non_bone = [d for d in sql_d if d[0] not in BONE_ITEM_IDS and not (d[0] == -1 and d[1] == -1)] if len(non_bone) == 0: continue status = "SQL_ONLY" max_diff = 0.0 else: status = "JAVA_ONLY" max_diff = 0.0 results.append((status, max_diff, npc_id, npc_name)) # Sort: DIFFERENT first, then SIMILAR, then MATCH, then ONLY order = {"DIFFERENT": 0, "SIMILAR": 1, "MATCH": 2, "SQL_ONLY": 3, "JAVA_ONLY": 4} results.sort(key=lambda x: (order.get(x[0], 5), -x[1], x[2])) # Count stats counts = defaultdict(int) for status, _, _, _ in results: counts[status] += 1 print(f"\n{BOLD}NPC Drop Comparison: SQL vs Java{RESET}") print(f"{'=' * 70}") print(f" {GREEN}MATCH{RESET}: {counts.get('MATCH', 0):>4} " f"{YELLOW}SIMILAR{RESET}: {counts.get('SIMILAR', 0):>4} " f"{RED}DIFFERENT{RESET}: {counts.get('DIFFERENT', 0):>4} " f"{CYAN}SQL_ONLY{RESET}: {counts.get('SQL_ONLY', 0):>4} " f"{CYAN}JAVA_ONLY{RESET}: {counts.get('JAVA_ONLY', 0):>4}") print(f"{'=' * 70}") print(f" {'ID':>5} {'Status':<12} {'Max Diff':>9} {'NPC Name'}") print(f" {'---':>5} {'---':<12} {'---':>9} {'---'}") for status, max_diff, npc_id, npc_name in results: color = get_status_color(status) diff_str = f"{max_diff:>7.1f}%" if status not in ("SQL_ONLY", "JAVA_ONLY") else " N/A" print(f" {npc_id:>5} {color}{status:<12}{RESET} {diff_str} {npc_name}") print(f"\n Total NPCs compared: {len(results)}\n") def cmd_npc(npc_query, sql_data, java_drops, java_bone_sets, java_tables, sub_tables, npc_name_to_id, npc_id_to_name, item_id_to_name): """Show detailed comparison for a specific NPC.""" # Resolve NPC ID npc_id = None try: npc_id = int(npc_query) except ValueError: query_lower = npc_query.lower().replace(" ", "_") matches = [] for name, nid in npc_name_to_id.items(): if query_lower in name.lower(): matches.append((name, nid)) if len(matches) == 1: npc_id = matches[0][1] elif len(matches) > 1: print(f"\n{YELLOW}Multiple NPCs match '{npc_query}':{RESET}") for name, nid in sorted(matches, key=lambda x: x[1]): print(f" {nid:>5} {name}") print(f"\nPlease be more specific or use the numeric ID.") return else: print(f"\n{RED}No NPC found matching '{npc_query}'{RESET}") return npc_name = npc_id_to_name.get(npc_id, "Unknown") has_sql = npc_id in sql_data has_java = npc_id in java_drops print(f"\n{BOLD}NPC #{npc_id}: {npc_name}{RESET}") print(f"{'=' * 70}") if not has_sql and not has_java: print(f" {DIM}No drop data found in either source.{RESET}") return # ── Bone information ──────────────────────────────────────────── java_bone = java_bone_sets.get(npc_id, "normal_bones") bone_name_map = { "boneless": "No bones", "bat_bones": "Bat Bones", "big_bones": "Big Bones", "dragon_bones": "Dragon Bones", "ashes": "Ashes", "normal_bones": "Bones", } sql_bone_items = set() if has_sql: for item_id, amount, weight in sql_data[npc_id]: if item_id in BONE_ITEM_IDS: sql_bone_items.add(item_id) sql_bone_str = ", ".join(BONE_ITEM_NAMES.get(b, str(b)) for b in sorted(sql_bone_items)) if sql_bone_items else "None listed" java_bone_str = bone_name_map.get(java_bone, java_bone) print(f"\n {BOLD}Bone Type:{RESET}") print(f" SQL: {sql_bone_str}") print(f" Java: {java_bone_str}") # ── Sub-table references (Java) ───────────────────────────────── npc_table_list = java_tables.get(npc_id, []) TABLE_DISPLAY = { "herbDropTable": "[Herb Table]", "rareDropTable": "[Rare Table]", "megaRareDropTable": "[Mega Rare Table]", "ultraRareDropTable": "[Ultra Rare Table]", "kbdTableCustom": "[KBD Custom Table]", "runeDropTable": "[Rune Table]", "dragonDropTable": "[Dragon Table]", "arrowsRunesDropTable": "[Arrows & Runes Table]", } if npc_table_list: print(f"\n {BOLD}Java Sub-Table References:{RESET}") for tvar, tw in npc_table_list: display = TABLE_DISPLAY.get(tvar, f'[{tvar}]') print(f" {display}: weight {tw}") # ── Expand sub-tables for Java ───────────────────────────────── java_drop_list = java_drops.get(npc_id, []) expanded_drops, table_expanded = expand_npc_tables( npc_id, java_drop_list, npc_table_list, sub_tables ) def item_str(item_id, amount=None): name = item_id_to_name.get(item_id, f"Item#{item_id}") if item_id == -1: return "Nothing (empty)" if amount is not None and amount != 1: return f"{name} x{amount}" return name # ── Show expanded sub-table items ────────────────────────────── if table_expanded: print(f"\n {BOLD}Items from Java Sub-Tables (expanded):{RESET}") for tbl_name, items in table_expanded.items(): print(f"\n {CYAN}{tbl_name}:{RESET}") # Sort by probability descending sorted_items = sorted(items, key=lambda x: -x[2]) for iid, amt, prob in sorted_items: name = item_str(iid, amt) if len(name) > 35: name = name[:32] + "..." print(f" {name:<35} {prob:>7.4f}%") # ── Get normalized data (expanded) ───────────────────────────── sql_guar, sql_probs, sql_tw, _ = normalize_drops(sql_data.get(npc_id, [])) java_guar, java_probs, java_tw, _ = normalize_drops_expanded(expanded_drops) # ── Guaranteed drops ──────────────────────────────────────────── sql_guar_set = set(sql_guar) java_guar_set = set(java_guar) all_guar = sorted(sql_guar_set | java_guar_set, key=lambda x: (x[0], x[1])) if all_guar: print(f"\n {BOLD}Guaranteed Drops (weight=0):{RESET}") print(f" {'Item':<35} {'SQL':>6} {'Java':>6}") print(f" {'---':<35} {'---':>6} {'---':>6}") for key in all_guar: in_sql = key in sql_guar_set in_java = key in java_guar_set iid, amt = key name = item_str(iid, amt) sql_mark = f"{GREEN} YES{RESET}" if in_sql else f"{RED} NO{RESET}" java_mark = f"{GREEN} YES{RESET}" if in_java else f"{RED} NO{RESET}" print(f" {name:<35} {sql_mark} {java_mark}") # ── Weighted drops ────────────────────────────────────────────── all_keys = set(sql_probs.keys()) | set(java_probs.keys()) sorted_keys = sorted(all_keys, key=lambda k: max(sql_probs.get(k, 0), java_probs.get(k, 0)), reverse=True) both = [(k, sql_probs.get(k, 0), java_probs.get(k, 0)) for k in sorted_keys if k in sql_probs and k in java_probs] sql_only = [(k, sql_probs[k]) for k in sorted_keys if k in sql_probs and k not in java_probs] java_only = [(k, java_probs[k]) for k in sorted_keys if k not in sql_probs and k in java_probs] if both: print(f"\n {BOLD}Weighted Drops (both sources):{RESET}") print(f" SQL total weight: {sql_tw} Java total weight: {java_tw:.1f}") print(f" {'Item':<35} {'SQL %':>8} {'Java %':>8} {'Diff':>8}") print(f" {'---':<35} {'---':>8} {'---':>8} {'---':>8}") for key, sp, jp in both: iid, amt = key diff = abs(sp - jp) color = get_diff_color(diff) name = item_str(iid, amt) if len(name) > 35: name = name[:32] + "..." print(f" {name:<35} {sp:>7.2f}% {jp:>7.2f}% {color}{diff:>+7.2f}%{RESET}") if sql_only: print(f"\n {BOLD}{RED}Items only in SQL:{RESET}") print(f" {'Item':<35} {'SQL %':>8}") print(f" {'---':<35} {'---':>8}") for key, sp in sql_only: iid, amt = key name = item_str(iid, amt) if len(name) > 35: name = name[:32] + "..." print(f" {RED}{name:<35} {sp:>7.2f}%{RESET}") if java_only: print(f"\n {BOLD}{CYAN}Items only in Java:{RESET}") print(f" {'Item':<35} {'Java %':>8}") print(f" {'---':<35} {'---':>8}") for key, jp in java_only: iid, amt = key name = item_str(iid, amt) if len(name) > 35: name = name[:32] + "..." print(f" {CYAN}{name:<35} {jp:>7.2f}%{RESET}") print() def cmd_summary(sql_data, java_drops, java_tables, sub_tables, npc_id_to_name, item_id_to_name): """Show overall statistics.""" all_sql = set(sql_data.keys()) all_java = set(java_drops.keys()) # Filter SQL NPCs to those with non-bone drops meaningful_sql = set() for npc_id in all_sql: non_bone = [d for d in sql_data[npc_id] if d[0] not in BONE_ITEM_IDS and not (d[0] == -1 and d[1] == -1)] if non_bone: meaningful_sql.add(npc_id) shared = meaningful_sql & all_java sql_only = meaningful_sql - all_java java_only = all_java - meaningful_sql counts = {"MATCH": 0, "SIMILAR": 0, "DIFFERENT": 0} diffs = [] for npc_id in sorted(shared): status, max_diff = compare_npc(sql_data[npc_id], java_drops[npc_id], java_tables.get(npc_id), sub_tables) counts[status] += 1 diffs.append((npc_id, status, max_diff)) total_shared = len(shared) print(f"\n{BOLD}Drop Comparison Summary{RESET}") print(f"{'=' * 70}") print(f"\n {BOLD}Coverage:{RESET}") print(f" NPCs with drops in SQL (non-bone): {len(meaningful_sql)}") print(f" NPCs with drops in Java: {len(all_java)}") print(f" Shared NPCs: {total_shared}") print(f" SQL only: {len(sql_only)}") print(f" Java only: {len(java_only)}") print(f"\n {BOLD}Match Quality (shared NPCs):{RESET}") if total_shared > 0: for status in ("MATCH", "SIMILAR", "DIFFERENT"): color = get_status_color(status) count = counts[status] pct = (count / total_shared) * 100.0 bar_len = int(pct / 2) bar = "#" * bar_len print(f" {color}{status:<12}{RESET} {count:>4} ({pct:>5.1f}%) {color}{bar}{RESET}") # Top differences diffs_sorted = sorted(diffs, key=lambda x: -x[2]) big_diffs = [d for d in diffs_sorted if d[1] == "DIFFERENT"] if big_diffs: print(f"\n {BOLD}NPCs with Biggest Differences:{RESET}") print(f" {'ID':>5} {'Max Diff':>9} {'NPC Name'}") print(f" {'---':>5} {'---':>9} {'---'}") for npc_id, status, max_diff in big_diffs[:20]: npc_name = npc_id_to_name.get(npc_id, "Unknown") print(f" {npc_id:>5} {RED}{max_diff:>7.1f}%{RESET} {npc_name}") # Average difference for shared NPCs if diffs: avg_diff = sum(d[2] for d in diffs) / len(diffs) print(f"\n Average max probability difference: {avg_diff:.2f}%") print() # ═══════════════════════════════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════════════════════════════ def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) cmd = sys.argv[1].lower() # Load enums npc_name_to_id, npc_id_to_name, item_name_to_id, item_id_to_name = load_enums() # Parse data sql_data = parse_sql() java_drops, java_bone_sets, java_tables, sub_tables = parse_java(item_name_to_id, npc_name_to_id) if cmd == "list": cmd_list(sql_data, java_drops, java_tables, sub_tables, npc_id_to_name, item_id_to_name) elif cmd == "npc": if len(sys.argv) < 3: print("Usage: python compare_drops.py npc ") sys.exit(1) query = " ".join(sys.argv[2:]) cmd_npc(query, sql_data, java_drops, java_bone_sets, java_tables, sub_tables, npc_name_to_id, npc_id_to_name, item_id_to_name) elif cmd == "summary": cmd_summary(sql_data, java_drops, java_tables, sub_tables, npc_id_to_name, item_id_to_name) else: print(f"Unknown command: {cmd}") print(__doc__) sys.exit(1) if __name__ == "__main__": main()