diff --git a/README.md b/README.md index 9dd14d8..a77b1d3 100644 --- a/README.md +++ b/README.md @@ -64,4 +64,3 @@ Top-level scripts in `scripts/` are compatibility wrappers. The implementation i - `scripts/d2lib/`: shared repository, service, exporter, and wiki generator code - `scripts/cli/`: primary CLI implementations - `scripts/devtools/`: development inspection utilities -- `scripts/legacy/`: older skill extraction reference tooling still used by dev utilities diff --git a/scripts/README.md b/scripts/README.md index 7498513..f8ba039 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,6 @@ This folder keeps the existing top-level script names for compatibility, but the - `d2lib/`: shared repository, model, service, and exporter code - `cli/`: primary user-facing entrypoints - `devtools/`: developer utilities for inspection and validation -- `legacy/`: older skill extraction prototypes and reference tooling Top-level wrappers are intentionally thin so existing commands like `python scripts/extract_class_skills.py` and imports like `from d2_services import ...` continue to work. diff --git a/scripts/devtools/validate_ui.py b/scripts/devtools/validate_ui.py deleted file mode 100644 index 57de530..0000000 --- a/scripts/devtools/validate_ui.py +++ /dev/null @@ -1,135 +0,0 @@ -import json -import os -import sys -import re -from legacy.extract_class_skills_paginated import D2Data, Evaluator, SYNERGY_MAP - -def normalize_val(val): - if not val or val == "" or val == "NOT_SHOWN": return None - val = val.replace('%', '').strip() - # Handle ranges - if '-' in val: - # Check if it's a negative number vs a range - # A range has a '-' that is not at the start, or multiple '-' - parts = val.split('-') - if val.startswith('-'): - # Potentially -15 or -40--10 - if len(parts) == 2: # Just a negative number like -15 - return float(val) - elif len(parts) == 3: # Negative range like -40--10 -> ['', '40', '', '10']? No, split('-') on '-40--10' is ['', '40', '', '10'] - # Better use regex to find all numbers including negative ones - nums = re.findall(r'-?\d+\.?\d*', val) - if len(nums) == 2: return [float(nums[0]), float(nums[1])] - return float(nums[0]) if nums else None - else: - if len(parts) == 2: - return [float(parts[0]), float(parts[1])] - - # Fallback to regex for any complex strings - nums = re.findall(r'-?\d+\.?\d*', val) - if len(nums) == 2: return [float(nums[0]), float(nums[1])] - return float(nums[0]) if nums else None - -def main(): - script_dir = os.path.dirname(os.path.abspath(__file__)) - # Assuming script is in BT-BKDiff/scripts - # root should be BT-BKDiff - root = os.path.dirname(script_dir) - - mod_path = os.path.join(root, "mods/BKDiablo/bkdiablo.mpq") - retail_path = os.path.join(os.path.dirname(root), "data/retail") # E:\Games\GeminiDiff\data\retail - if not os.path.exists(retail_path): - retail_path = os.path.join(root, "data/retail") - - data = D2Data(mod_path, retail_path) - ev = Evaluator(data) - - test_path = os.path.join(root, "tests/amazon_test_cases.json") - with open(test_path, 'r') as f: - tc = json.load(f) - - results = [] - - for group_name, skills in tc['groups'].items(): - print(f"\n>>> Testing Group: {group_name}") - for sk_name, sk_data in skills.items(): - print(f" Skill: {sk_name}") - - # Setup Synergies - SYNERGY_MAP.clear() - for syn_n, syn_v in sk_data.get('synergies', {}).items(): - SYNERGY_MAP[syn_n.lower().replace(' ', '')] = syn_v - - skill_obj = data.skill_map.get(sk_name.lower()) - if not skill_obj: - print(f" ERROR: Skill '{sk_name}' not found in data.") - continue - - desc_row = data.desc_map.get(skill_obj['skilldesc'].lower()) - if not desc_row: - print(f" ERROR: SkillDesc '{skill_obj['skilldesc']}' not found.") - continue - - for scen in sk_data['scenarios']: - lvl = scen['lvl'] - blvl = scen['blvl'] - ui = scen['ui'] - - print(f" Scenario: L{lvl} (Base {blvl})") - - for i in range(1, 10): - line_id = desc_row.get(f"descline{i}") - if not line_id or line_id == '0': continue - - text_raw = data.strings.get(desc_row.get(f"desctexta{i}", "").lower(), "") - label = text_raw.replace('%d', '').replace('%+d', '').replace('%%', '%').strip(': ') - - ui_key = None - # Prioritize exact match - for k in ui.keys(): - if k.lower() == label.lower(): - ui_key = k - break - - # Then partial match - if not ui_key: - for k in ui.keys(): - if k.lower() in label.lower() or label.lower() in k.lower(): - ui_key = k - break - - if ui_key: - expected_raw = str(ui[ui_key]) - if expected_raw == "" or expected_raw == "NOT_SHOWN": continue - - expected = None - calculated_str = "N/A" - try: - expected = normalize_val(expected_raw) - calculated_str = ev.calculate(skill_obj, desc_row, i, lvl, blvl) - calculated = normalize_val(calculated_str) - - match = False - if expected is None or calculated is None: - match = (expected == calculated) - elif isinstance(expected, list) and isinstance(calculated, list): - match = abs(expected[0] - calculated[0]) < 1.1 and abs(expected[1] - calculated[1]) < 1.1 - elif not isinstance(expected, list) and not isinstance(calculated, list): - match = abs(expected - calculated) < 1.1 - - if not match: - print(f" [FAIL] {ui_key}: UI={expected_raw}, Calc={calculated_str} (Label: {label})") - results.append({"skill": sk_name, "scenario": f"L{lvl}", "field": ui_key, "ui": expected_raw, "calc": calculated_str}) - except Exception as e: - print(f" [ERROR] {ui_key}: {e} (Expected: {expected_raw}, Calc: {calculated_str})") - - print("\n" + "="*40) - if not results: - print("ALL TESTS PASSED!") - else: - print(f"FOUND {len(results)} DISCREPANCIES:") - for r in results: - print(f" {r['skill']} ({r['scenario']}) - {r['field']}: UI={r['ui']} vs Calc={r['calc']}") - -if __name__ == "__main__": - main() diff --git a/scripts/extract_class_skills_paginated.py b/scripts/extract_class_skills_paginated.py deleted file mode 100644 index eb45947..0000000 --- a/scripts/extract_class_skills_paginated.py +++ /dev/null @@ -1,2 +0,0 @@ -from legacy.extract_class_skills_paginated import * # noqa: F401,F403 - diff --git a/scripts/legacy/__init__.py b/scripts/legacy/__init__.py deleted file mode 100644 index e1a97b2..0000000 --- a/scripts/legacy/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Legacy skill extraction experiments kept for reference.""" - diff --git a/scripts/legacy/extract_class_skills_paginated.py b/scripts/legacy/extract_class_skills_paginated.py deleted file mode 100644 index dc0c950..0000000 --- a/scripts/legacy/extract_class_skills_paginated.py +++ /dev/null @@ -1,286 +0,0 @@ -import csv -import os -import json -import re -import sys - -# Global map for synergy levels provided via CLI -SYNERGY_MAP = {} - -class D2Data: - def __init__(self, mod_path, retail_path): - self.mod_path = mod_path - self.retail_path = retail_path - self.skills = self.load_data("skills.txt") - self.missiles = self.load_data("missiles.txt") - self.skilldescs = self.load_data("skilldesc.txt") - self.charstats = self.load_data("charstats.txt") - self.monstats = self.load_data("monstats.txt") - self.strings = self.load_strings() - - self.skill_map = {s['skill'].lower(): s for s in self.skills} - self.missile_map = {m['Missile'].lower(): m for m in self.missiles} - self.desc_map = {d['skilldesc'].lower(): d for d in self.skilldescs} - self.mon_map = {m['Id'].lower(): m for m in self.monstats} - - for s in self.skills: - dr = self.desc_map.get(s['skilldesc'].lower()) - if dr: - n = self.strings.get(dr.get('skillname', '').lower()) - if n: self.skill_map[n.lower()] = s - - def load_tsv(self, filepath): - if not os.path.exists(filepath): return [] - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - if not lines: return [] - header = [h.strip() for h in lines[0].split('\t')] - data = [] - for line in lines[1:]: - row = [v.strip() for v in line.split('\t')] - if len(row) < len(header): continue - data.append(dict(zip(header, row))) - return data - - def load_data(self, filename): - path = os.path.join(self.mod_path, "data/global/excel", filename) - data = self.load_tsv(path) - if not data and self.retail_path: - data = self.load_tsv(os.path.join(self.retail_path, "excel", filename)) - return data - - def load_strings(self): - strings = {} - paths = [ - os.path.join(os.path.dirname(self.mod_path), "../../data/base/strings"), - os.path.join(self.retail_path, "../local/lng/strings"), - os.path.join(self.mod_path, "data/local/lng/strings") - ] - for p in paths: - if os.path.exists(p): - for f in os.listdir(p): - if f.endswith(".json"): - with open(os.path.join(p, f), 'r', encoding='utf-8-sig') as file: - try: - for entry in json.load(file): - k, v = entry.get('Key'), entry.get('enUS') - if k and v: strings[k.lower()] = v - except: continue - return strings - -class Evaluator: - def __init__(self, data): - self.data = data - - def get_blvl(self, skill_name, use_synergy=True): - if not use_synergy: return 0.0 - name_clean = skill_name.lower().strip().replace(' ', '').replace("'", "") - if name_clean in SYNERGY_MAP: return float(SYNERGY_MAP[name_clean]) - sk = self.data.skill_map.get(name_clean) - if sk: return float(SYNERGY_MAP.get(sk['skill'].lower().replace(' ',''), 0.0)) - return 0.0 - - def get_synergy_bonus(self, skill): - syn_calc = skill.get('EDmgSymPerCalc') or skill.get('DmgSymPerCalc') - if not syn_calc or syn_calc == '0': return 1.0 - try: - res = float(self.evaluate(syn_calc, skill, 1, 1, use_synergy=True)) - return 1.0 + (res / 100.0) - except: return 1.0 - - def get_elen_synergy_bonus(self, skill): - syn_calc = skill.get('ELenSymPerCalc') - if not syn_calc or syn_calc == '0': return 1.0 - try: - res = float(self.evaluate(syn_calc, skill, 1, 1, use_synergy=True)) - return 1.0 + (res / 100.0) - except: return 1.0 - - def get_raw_value(self, row, lvl, col_prefix): - try: - val_raw = row.get(col_prefix, '0') - if not val_raw or val_raw == '': val_raw = '0' - base = float(val_raw) - add = 0.0 - for i in range(1, int(lvl)): - idx = 1 - if i < 8: idx = 1 - elif i < 16: idx = 2 - elif i < 22: idx = 3 - elif i < 28: idx = 4 - else: idx = 5 - - l_val = 0.0 - found_lev = False - for search_idx in range(idx, 0, -1): - # Possible leveling column names - possible_cols = [ - f"Lev{col_prefix}{search_idx}", - f"{col_prefix}Lev{search_idx}", - f"Lev{col_prefix}", - f"{col_prefix}Lev" - ] - if col_prefix == 'MinDam' or col_prefix == 'MinDamage': - possible_cols = [f'MinLevDam{search_idx}', f'LevMinDam{search_idx}'] - elif col_prefix == 'MaxDam' or col_prefix == 'MaxDamage': - possible_cols = [f'MaxLevDam{search_idx}', f'LevMaxDam{search_idx}'] - elif col_prefix == 'EMin': - possible_cols = [f'EMinLev{search_idx}', f'MinELev{search_idx}', f'LevEMin{search_idx}'] - elif col_prefix == 'EMax': - possible_cols = [f'EMaxLev{search_idx}', f'MaxELev{search_idx}', f'LevEMax{search_idx}'] - elif col_prefix == 'ELen': - possible_cols = [f'ELevLen{search_idx}', f'ELenLev{search_idx}', f'LevELen{search_idx}'] - - for c in possible_cols: - if c in row and row.get(c, '') != '': - l_val = float(row.get(c, '0') or '0') - found_lev = True - break - if found_lev: break - add += l_val - return base + add - except: return 0.0 - - def resolve_symbol(self, var, skill, lvl, blvl, desc_row=None, apply_syn=False): - try: - s = skill - if var == 'lvl': return float(lvl) - if var == 'blvl': return float(blvl) - if var.startswith('par'): return float(s.get(f"Param{var[3:]}", "0") or '0') - if var.startswith('pa'): return float(s.get(f"Param{var[2:]}", "0") or '0') - ln_match = re.match(r'ln(\d+)', var) - if ln_match: - v = ln_match.group(1); p1_idx, p2_idx = int(v[0]), int(v[1:]) - p1, p2 = float(s.get(f"Param{p1_idx}", 0)), float(s.get(f"Param{p2_idx}", 0)) - return p1 + (lvl - 1) * p2 - dm_match = re.match(r'dm(\d+)', var) - if dm_match: - v = dm_match.group(1); p1_idx, p2_idx = int(v[0]), int(v[1:]) - p1, p2 = float(s.get(f"Param{p1_idx}", 0)), float(s.get(f"Param{p2_idx}", 0)) - return ((110.0 * lvl) * (p2 - p1)) / (100.0 * (lvl + 6)) + p1 - if var in ['mps', 'usmc', 'manc', 'manv']: - m, lm, shift = float(s.get('mana', '0')), float(s.get('lvlmana', '0')), int(s.get('manashift', '8')) - return max(float(s.get('minmana', '0')), m + lm * (lvl - 1)) / (2.0**(8 - shift)) - if var == 'mael': - if 'Immolation Arrow' in s.get('skill', ''): return (self.get_blvl('fire arrow') + self.get_blvl('exploding arrow')) * 5.0 - syn_calc = s.get('EDmgSymPerCalc') or s.get('DmgSymPerCalc') - if syn_calc: return float(self.evaluate(syn_calc, skill, 1, 1, use_synergy=True)) - return 0.0 - if var == 'toht': - th, lth = float(s.get('ToHit', '0') or '0'), float(s.get('LevToHit', '0') or '0') - if th == 0 and lth == 0 and s.get('ToHitCalc'): return float(self.evaluate(s.get('ToHitCalc'), s, lvl, blvl)) - return th + lth * (lvl - 1) - if var == 'thtc': return float(self.evaluate(s.get('ToHitCalc', '0'), s, lvl, blvl)) - if var in ['enma', 'edmn', 'pnma', 'exma', 'edmx', 'pxma', 'enms', 'exms']: - prefix = 'EMin' if 'e' in var else ('MinDam' if 'pnma' == var else 'MaxDam') - if 'exma' in var or 'edmx' in var or 'pxma' == var or 'exms' == var: prefix = 'EMax' if 'e' in var else 'MaxDam' - val, shift = self.get_raw_value(s, lvl, prefix), int(s.get('HitShift', '8')) - res = val * (2.0**shift) - if apply_syn: res *= self.get_synergy_bonus(s) - return res - if var in ['edln', 'len']: - res = self.get_raw_value(s, lvl, 'ELen') - if apply_syn: res *= self.get_elen_synergy_bonus(s) - return res - if desc_row and var.startswith('m') and len(var) == 4: - m_idx, m_type, m_name = var[1], var[2:], desc_row.get(f'descmissile{var[1]}') - if m_name: - m = self.data.missile_map.get(m_name.lower()) - if m: - cm = {'nm': 'MinDamage', 'xm': 'MaxDamage', 'eo': 'EMin', 'ey': 'EMax', 'rn': 'Range'} - val = self.get_raw_value(m, lvl, cm.get(m_type)) - if m_type == 'rn': return val - shift = int(m.get('HitShift', '8')) - res = val * (2.0**shift) - if apply_syn and m_type in ['eo', 'ey', 'nm', 'xm']: res *= self.get_synergy_bonus(s) - return res - return 0.0 - except: return 0.0 - - def evaluate(self, formula, skill, lvl, blvl, desc_row=None, use_synergy=True): - if not formula or formula == '' or formula == '0': return 0.0 - calc = str(formula).replace('"', '').strip() - calc = re.sub(r"sklvl\('(.*?)'\.(.*?)\.(.*?)\)", lambda m: str(self.resolve_symbol(m.group(2), self.data.skill_map.get(m.group(1).lower(), skill), self.get_blvl(m.group(1), use_synergy) if m.group(3) == 'blvl' else lvl, self.get_blvl(m.group(1), use_synergy))), calc) - calc = re.sub(r"skill\('(.*?)'\.(.*?)\)", lambda m: str(self.resolve_symbol(m.group(2), self.data.skill_map.get(m.group(1).lower(), skill), lvl, self.get_blvl(m.group(1), use_synergy))), calc) - calc = re.sub(r"miss\('(.*?)'\.(.*?)\)", lambda m: str(self.resolve_symbol('m1' + ('rn' if m.group(2)=='rang' else m.group(2)), skill, lvl, blvl, {'descmissile1': m.group(1)})), calc) - if '?' in calc: calc = re.sub(r"(.*?)\?(.*?):(.*?)$", r"(\2 if \1 else \3)", calc) - B = r'\b'; symbols = [r'ln\d+', r'dm\d+', 'mps', 'usmc', 'mael', 'enma', 'exma', 'edmn', 'edmx', r'm\d[a-z]{2}', r'par\d+', r'pa\d+', 'manc', 'manv', 'edln', 'pnma', 'pxma', 'len', 'toht', 'thtc', 'enms', 'exms'] - # Determine if automatic synergy should be applied to symbols in this formula - # Skip automatic synergy if explicit synergy (mael) or skill reference is present in the formula - apply_to_this_formula = use_synergy and not any(x in calc.lower() for x in ['mael', 'skill(']) - for sym_pat in symbols: - matches = re.findall(sym_pat, calc) - for m in set(matches): calc = re.sub(B + m + B, str(self.resolve_symbol(m, skill, lvl, blvl, desc_row, apply_syn=apply_to_this_formula)), calc) - calc = re.sub(B + 'lvl' + B, str(lvl), calc); calc = re.sub(B + 'blvl' + B, str(blvl), calc) - if 'mael' in formula.lower(): - print(f" EVAL_DEBUG: '{formula}' -> '{calc}'") - try: - res = eval(calc, {"__builtins__": None}, {'min': min, 'max': max, 'float': float}) - return float(res) - except: return 0.0 - - def calculate(self, skill, dr, line_idx, lvl, blvl, block=""): - text_prefix = f"{block}texta" if block else "desctexta" - line_prefix = f"{block}line" if block else "descline" - calc_a_prefix = f"{block}calca" if block else "desccalca" - calc_b_prefix = f"{block}calcb" if block else "desccalcb" - - text_raw = self.data.strings.get(dr.get(text_prefix + str(line_idx), "").lower(), "").replace('%d', '').replace('%+d', '').replace('%%', '%').strip(': ') - syn = self.get_synergy_bonus(skill) - c1, c2 = dr.get(calc_a_prefix + str(line_idx)), dr.get(calc_b_prefix + str(line_idx)) - is_range = ('-' in text_raw or '%d-%d' in text_raw) - - def get_val(calc): - if not calc or calc == '': return 0.0 - val = self.evaluate(calc, skill, lvl, blvl, dr, use_synergy=True) - if '/256' not in str(calc).replace(' ','') and abs(val) > 100: - if any(x in str(calc) for x in ['enma','exma','edmn','edmx','m1eo','m1ey','pnma','pxma','enms','exms']): val /= 256.0 - if 'poison damage' in text_raw.lower(): - elen = self.get_raw_value(skill, lvl, 'ELen') - if 'm1' in str(calc): - m_name = dr.get('descmissile1'); m = self.data.missile_map.get(m_name.lower()) - if m: elen = self.get_raw_value(m, lvl, 'ELen') - # If formula doesn't have duration already, multiply by it - if 'edln' not in str(calc).lower() and 'len' not in str(calc).lower(): - val *= (elen / 256.0) - if dr.get(line_prefix + str(line_idx)) in ['12', '31'] or ('second' in text_raw.lower() and 'per second' not in text_raw.lower()): - if val > 15: val /= 25.0 - if dr.get(line_prefix + str(line_idx)) == '36' and 'radius' in text_raw.lower(): - if c2 == '3': val /= 3.0 - # Synergy is now applied inside evaluate() via resolve_symbol(apply_syn=True) - if 'life' in text_raw.lower() and dr.get(line_prefix + str(line_idx)) == '13': val = 440.0 * (1.0 + (val+400.0) / 100.0) - return val - v_min = get_val(c1) - if is_range and c2: return f"{v_min:.1f}-{get_val(c2):.1f}" - return f"{v_min:.1f}" - -def main(): - if len(sys.argv) < 2: return - class_code = sys.argv[1].lower() - if len(sys.argv) > 2: - for pair in sys.argv[2].split(','): - if '=' in pair: - n, v = pair.split('='); SYNERGY_MAP[n.strip().lower().replace(' ', '')] = int(v.strip()) - script_dir = os.path.dirname(os.path.abspath(__file__)); root = os.path.dirname(script_dir) - data = D2Data(os.path.join(root, "mods/BKDiablo/bkdiablo.mpq"), os.path.join(root, "data/retail")); ev = Evaluator(data) - class_skills = [s for s in data.skills if s.get('charclass') == class_code] - output = f"# {class_code.upper()} Skill Trees (BKDiablo)\n\n" - for page in ['1', '2', '3']: - page_skills = [(s, data.desc_map[s['skilldesc'].lower()]) for s in class_skills if s['skilldesc'].lower() in data.desc_map and data.desc_map[s['skilldesc'].lower()].get('SkillPage') == page] - if not page_skills: continue - output += f"# Tree {page}\n\n" - for s, dr in page_skills: - name = data.strings.get(dr.get('skillname', '').lower(), s['skill']) - output += f"## {name}\n\n| Effect | L1 | L10 | L20 | L20+10 |\n| :--- | :---: | :---: | :---: | :---: |\n" - for i in range(1, 10): - if not dr.get(f"descline{i}") or dr.get(f"descline{i}") == '0': continue - text = data.strings.get(dr.get(f"desctexta{i}", "").lower(), "").replace('%d', '').replace('%+d', '').replace('%%', '%').strip(': ') - def get_row(idx): return [ev.calculate(s, dr, idx, l, bl) for l, bl in [(1,1),(10,10),(20,20),(30,20)]] - r = get_row(i); output += f"| {text} | {r[0]} | {r[1]} | {r[2]} | {r[3]} |\n" - output += "\n" - out_path = os.path.join(root, f"output/skill_trees/{class_code}_skills.md") - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, 'w', encoding='utf-8') as f: - f.write(output) - print(f"Exported to {out_path}") -if __name__ == "__main__": main() diff --git a/scripts/legacy/extract_class_skills_v3.py b/scripts/legacy/extract_class_skills_v3.py deleted file mode 100644 index 47b20c0..0000000 --- a/scripts/legacy/extract_class_skills_v3.py +++ /dev/null @@ -1,287 +0,0 @@ -import csv -import os -import json -import re -import sys - -def load_tsv(filepath): - if not os.path.exists(filepath): return [] - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - if not lines: return [] - header = [h.strip() for h in lines[0].split('\t')] - data = [] - for line in lines[1:]: - row = [v.strip() for v in line.split('\t')] - if len(row) < len(header): continue - data.append(dict(zip(header, row))) - return data - -def load_all_strings(mod_path, base_strings_path): - strings = {} - if os.path.exists(base_strings_path): - for filename in os.listdir(base_strings_path): - if filename.endswith(".json"): - with open(os.path.join(base_strings_path, filename), 'r', encoding='utf-8-sig') as f: - try: - data = json.load(f) - for entry in data: - key, val = entry.get('Key'), entry.get('enUS') - if key and val: strings[key.lower()] = val - except: continue - mod_strings_dir = os.path.join(mod_path, "data", "local", "lng", "strings") - if os.path.exists(mod_strings_dir): - for filename in os.listdir(mod_strings_dir): - if filename.endswith(".json"): - with open(os.path.join(mod_strings_dir, filename), 'r', encoding='utf-8-sig') as f: - try: - data = json.load(f) - for entry in data: - key, val = entry.get('Key'), entry.get('enUS') - if key and val: strings[key.lower()] = val - except: continue - return strings - -def get_dam_generic(s, lvl, prefix): - try: - base = int(s.get(f'{prefix}', '0') or '0') - add = 0 - for i in range(1, lvl): - if i < 8: add += int(s.get(f'{prefix}Lev1', '0') or '0') - elif i < 16: add += int(s.get(f'{prefix}Lev2', '0') or '0') - elif i < 22: add += int(s.get(f'{prefix}Lev3', '0') or '0') - elif i < 28: add += int(s.get(f'{prefix}Lev4', '0') or '0') - else: add += int(s.get(f'{prefix}Lev5', '0') or '0') - return base + add - except: return 0 - -def get_missile_dam(m, lvl, prefix): - try: - base = int(m.get(prefix, '0') or '0') - p_root = prefix - if prefix.startswith('EMin'): p_root = 'MinE' - elif prefix.startswith('EMax'): p_root = 'MaxE' - elif prefix.startswith('MinDam'): p_root = 'Min' - elif prefix.startswith('MaxDam'): p_root = 'Max' - add = 0 - for i in range(1, lvl): - if i < 8: add += int(m.get(f'{p_root}Lev1', '0') or '0') - elif i < 16: add += int(m.get(f'{p_root}Lev2', '0') or '0') - elif i < 22: add += int(m.get(f'{p_root}Lev3', '0') or '0') - elif i < 28: add += int(m.get(f'{p_root}Lev4', '0') or '0') - else: add += int(m.get(f'{p_root}Lev5', '0') or '0') - return base + add - except: return 0 - -def get_skill_val(s, lvl, blvl, var, skill_map, missile_map, desc_row=None, depth=0): - if depth > 15: return 0 - try: - def p(n): return int(s.get(f"Param{n}", "0") or "0") - ln_match = re.match(r'ln(\d+)', var) - if ln_match: - v = ln_match.group(1) - if len(v) >= 2: - p1, p2 = int(v[0]), int(v[1:]) - return p(p1) + (lvl - 1) * p(p2) - dm_match = re.match(r'dm(\d+)', var) - if dm_match: - v = dm_match.group(1) - if len(v) >= 2: - p1, p2 = int(v[0]), int(v[1:]) - return ((110 * lvl) * (p(p2) - p(1))) // (100 * (lvl + 6)) + p(1) - if var == 'macr': return p(1) + (lvl - 1) * p(2) - if var == 'madm': return p(3) + (lvl - 1) * p(4) - if var == 'math': return p(5) + (lvl - 1) * p(6) - if var == 'edln': - elen = int(s.get('ELen', '0') or '0') - elevlen = sum([int(s.get(f'ELevLen{i}', '0') or '0') for i in range(1, 4)]) - return elen + elevlen * (lvl - 1) - if var in ['mps', 'usmc']: - m, lm = int(s.get('mana', '0') or '0'), int(s.get('lvlmana', '0') or '0') - min_m = int(s.get('minmana', '0') or '0') - val = max(min_m, m + lm * (lvl - 1)) - shift = int(s.get('manashift', '8') or '8') - return val / (2**(8 - shift)) - if var == 'toht': return int(s.get('ToHit', '0') or '0') + int(s.get('LevToHit', '0') or '0') * (lvl - 1) - if var == 'pnma' or var == 'edmn': return get_dam_generic(s, lvl, 'MinDam') - if var == 'pxma' or var == 'edmx': return get_dam_generic(s, lvl, 'MaxDam') - if var == 'enma': return get_dam_generic(s, lvl, 'EMin') - if var == 'exma': return get_dam_generic(s, lvl, 'EMax') - if var == 'mael': - m_name = f'passive_{s.get("EType", "")}_mastery' - mastery = skill_map.get(m_name) - if mastery: return get_skill_val(mastery, lvl, blvl, 'accr', skill_map, missile_map, None, depth+1) - return 0 - if desc_row and var.startswith('m') and len(var) == 4: - m_idx, m_type = var[1], var[2:] - m_name = desc_row.get(f'descmissile{m_idx}') - if m_name: - m = missile_map.get(m_name.lower()) - if m: - if m_type == 'nm': return get_missile_dam(m, lvl, 'MinDamage') - elif m_type == 'xm': return get_missile_dam(m, lvl, 'MaxDamage') - elif m_type == 'eo': return get_missile_dam(m, lvl, 'EMin') - elif m_type == 'ey': return get_missile_dam(m, lvl, 'EMax') - if var in ['enms', 'exms', 'edns', 'edxs']: - base_col = 'EMin' if (var == 'enms' or var == 'edns') else 'EMax' - val = get_dam_generic(s, lvl, base_col) - elen = int(s.get('ELen', '0') or '0') + int(s.get('ELevLen1', '0') or '0') * (lvl - 1) - return (val * elen) / 256 - if var.startswith('clc'): - expr = s.get(f'calc{var[3:]}', s.get(f'cltcalc{var[3:]}', '0')) - return resolve_calc(expr, s, lvl, blvl, skill_map, missile_map, desc_row, depth + 1) - if var.startswith('pst'): - expr = s.get(f'passivecalc{var[3:]}', '0') - return resolve_calc(expr, s, lvl, blvl, skill_map, missile_map, desc_row, depth + 1) - if var.startswith('ps'): - expr = s.get(f'passivecalc{var[2:]}', '0') - return resolve_calc(expr, s, lvl, blvl, skill_map, missile_map, desc_row, depth + 1) - if var.startswith('ast'): - expr = s.get(f'aurastatcalc{var[3:]}', '0') - return resolve_calc(expr, s, lvl, blvl, skill_map, missile_map, desc_row, depth + 1) - if var.startswith('pa'): - return s.get(f'Param{var[2:]}', '0') - if var.startswith('par'): return p(var[3:]) - if var == 'accr': return p(1) + (lvl - 1) * p(2) - return var - except: return var - -def resolve_calc(calc, s, lvl, blvl, skill_map, missile_map, desc_row=None, depth=0): - if not calc or depth > 15: return "0" - calc = str(calc) - - def resolve_ternary(match): - cond, v1, v2 = match.group(1), match.group(2), match.group(3) - return f"({v1} if ({cond}) else {v2})" - - if '?' in calc and ':' in calc: - calc = re.sub(r"\((.*?)\)\s*\?\s*(.*?)\s*:\s*(.*?)$", resolve_ternary, calc) - if '?' in calc and ':' in calc: # Check again for non-parenthesized - calc = re.sub(r"(.*?)\s*\?\s*(.*?)\s*:\s*(.*?)$", resolve_ternary, calc) - - calc = re.sub(r"sklvl\('(.*?)'\.(.*?)\.(.*?)\)", lambda m: str(get_skill_val(skill_map.get(m.group(1).lower(), s), (lvl if m.group(3) in ['lvl','0'] else (blvl if m.group(3)=='blvl' else int(float(resolve_calc(m.group(3), s, lvl, blvl, skill_map, missile_map, desc_row, depth+1))))), (blvl if m.group(3)=='blvl' else lvl), m.group(2), skill_map, missile_map, None, depth+1)), calc) - calc = re.sub(r"skill\('(.*?)'\.(.*?)\)", lambda m: str(get_skill_val(skill_map.get(m.group(1).lower(), s), lvl, blvl, m.group(2), skill_map, missile_map, None, depth+1)), calc) - calc = re.sub(r"miss\('(.*?)'\.(.*?)\)", lambda m: (str(float(resolve_calc(missile_map.get(m.group(1).lower(),{}).get('Range' if m.group(2)=='rang' else m.group(2), '0'), s, lvl, blvl, skill_map, missile_map, desc_row, depth+1))/25) if m.group(2)=='rang' else str(resolve_calc(missile_map.get(m.group(1).lower(),{}).get(m.group(2), '0'), s, lvl, blvl, skill_map, missile_map, desc_row, depth+1))), calc) - calc = re.sub(r"stat\('(.*?)'\.(.*?)\)", "0", calc) - - B = r'\b' - vars_to_res = [r'ln\d+', r'dm\d+', 'edln', 'mps', 'usmc', 'toht', 'macr', 'madm', 'math', 'pnma', 'pxma', 'edmn', 'edmx', 'enma', 'exma', 'enms', 'exms', 'edns', 'edxs', 'mael', 'accr'] - for i in range(1, 21): vars_to_res.extend([f'clc{i}', f'pst{i}', f'ps{i}', f'ast{i}', f'pa{i}']) - for i in range(1, 4): - for t in ['nm', 'xm', 'eo', 'ey']: vars_to_res.append(f'm{i}{t}') - vars_to_res.sort(key=lambda x: len(x.replace(r'\d+', '0')), reverse=True) - for v in vars_to_res: - if r'\d' in v: - matches = re.findall(v, calc) - for m in set(matches): - val = get_skill_val(s, lvl, blvl, m, skill_map, missile_map, desc_row, depth + 1) - calc = re.sub(B + m + B, str(val), calc) - elif v in calc: - val = get_skill_val(s, lvl, blvl, v, skill_map, missile_map, desc_row, depth + 1) - calc = re.sub(B + v + B, str(val), calc) - for i in range(20, 0, -1): - p_var = 'par' + str(i) - if p_var in calc: calc = re.sub(B + p_var + B, s.get(f"Param{i}", "0") or "0", calc) - - if 'if' in calc and 'else' in calc: # Python ternary - pass - elif '?' in calc and ':' in calc: # Should have been resolved - calc = re.sub(r'(.*?)\?(.*?):(.*)', r'(\2 if \1 else \3)', calc) - - calc = calc.replace('min(', 'min(').replace('max(', 'max(') - try: - context = {'min': min, 'max': max, 'lvl': lvl, 'blvl': blvl} - check_str = re.sub(r'[\d\+\-\*\/\(\)\.\s\?:\,\<\>\=\!]', '', calc.replace('min', '').replace('max', '').replace('lvl', '').replace('blvl', '').replace('if', '').replace('else', '')) - if check_str: return calc - res = eval(calc, {"__builtins__": None}, context) - return f"{res:.1f}" if isinstance(res, float) else str(res) - except: return calc - -def format_generic_label(s): - if not s: return "" - label = s - for code in ['%+d%%', '%d%%', '%+d', '%d', '%s']: label = label.replace(code, "") - label = label.replace("-%d", "").replace("-%+d", "") - label = label.replace("per second", "").replace("second", "").replace("yard", "") - return re.sub(r'\s+', ' ', label).strip(': -').strip() - -def analyze_scaling(calc, s, unit, skill_map, missile_map, desc_row): - try: - v1 = float(resolve_calc(calc, s, 1, 1, skill_map, missile_map, desc_row)) - v2 = float(resolve_calc(calc, s, 2, 2, skill_map, missile_map, desc_row)) - v10 = float(resolve_calc(calc, s, 10, 10, skill_map, missile_map, desc_row)) - v11 = float(resolve_calc(calc, s, 11, 11, skill_map, missile_map, desc_row)) - v20 = float(resolve_calc(calc, s, 20, 20, skill_map, missile_map, desc_row)) - v21 = float(resolve_calc(calc, s, 21, 20, skill_map, missile_map, desc_row)) - v99 = float(resolve_calc(calc, s, 99, 99, skill_map, missile_map, desc_row)) - d1, d10, ds = v2 - v1, v11 - v10, v21 - v20 - def fs(v): return f"{v:+.1f}".rstrip('0').rstrip('.') - if abs(d1 - d10) < 0.01: scaling = f"Linear ({fs(d1)}{unit})" - elif d1 > d10: scaling = f"Diminishing ({fs(d1)} -> {fs(d10)}{unit})" - else: scaling = f"Accelerating ({fs(d1)} -> {fs(d10)}{unit})" - if abs(ds - d10) > 0.01: scaling += f" [Soft: {fs(ds)}{unit}]" - cap = f"Max: {v20}{unit}" if abs(v99 - v20) < 0.01 and abs(v20 - v10) > 0.01 else (f"Static: {v1}{unit}" if v99 == v1 and v1 == v20 and v1 != 0 else "--") - return scaling, cap - except: return "Complex", "--" - -def analyze_range_scaling(c1, c2, s, unit, skill_map, missile_map, desc_row): - try: - s1, cap1 = analyze_scaling(c1, s, "", skill_map, missile_map, desc_row) - s2, cap2 = analyze_scaling(c2, s, "", skill_map, missile_map, desc_row) - if s1 == s2 and s1 != "Complex": return s1 + unit, (cap1 if cap1 == cap2 else f"{cap1}/{cap2}") - return "Variable" + unit, "--" - except: return "--", "--" - -def main(): - if len(sys.argv) < 2: return - class_code = sys.argv[1].lower() - mod_path, base_strings_path = "mods/BKDiablo/bkdiablo.mpq", "data/base/strings" - strings = load_all_strings(mod_path, base_strings_path) - skills = load_tsv(os.path.join(mod_path, "data/global/excel/skills.txt")) - skilldescs = load_tsv(os.path.join(mod_path, "data/global/excel/skilldesc.txt")) - missiles = load_tsv(os.path.join(mod_path, "data/global/excel/missiles.txt")) - skill_map, missile_map, desc_map = {s['skill'].lower(): s for s in skills}, {m['Missile'].lower(): m for m in missiles}, {d['skilldesc']: d for d in skilldescs} - class_mapping = {'nec':"Necromancer", 'bar':"Barbarian", 'ama':"Amazon", 'sor':"Sorceress", 'pal':"Paladin", 'dru':"Druid", 'asn':"Assassin", 'war':"Warlock"} - class_skills = [s for s in skills if s.get('charclass') == class_code] - output = f"# {class_mapping.get(class_code, class_code.upper())} Skill Tree (BKDiablo)\n\n" - for s in class_skills: - desc_row = desc_map.get(s['skilldesc']) - if not desc_row: continue - real_name = strings.get(desc_row.get('skillname', '').lower(), s['skill']) - output += f"## {real_name}\n\n| Effect | Scaling | L1 | L10 | L20 | L20+10 | Limit |\n| :--- | :--- | :---: | :---: | :---: | :---: | :---: |\n" - for i in range(1, 7): - if not desc_row.get(f"descline{i}") or desc_row.get(f"descline{i}") == '0': continue - text_label = strings.get(desc_row.get(f"desctexta{i}", "").lower(), desc_row.get(f"desctexta{i}", "")) - unit = "%" if "%%" in text_label or "percent" in text_label.lower() else ("s" if "second" in text_label.lower() else ("y" if "yard" in text_label.lower() else "")) - if "per second" in text_label.lower(): unit = " dmg/s" - c1, c2 = desc_row.get(f"desccalca{i}", ""), desc_row.get(f"desccalcb{i}", "") - label = format_generic_label(text_label) - if "%d-%d" in text_label: - scaling, cap = analyze_range_scaling(c1, c2, s, unit, skill_map, missile_map, desc_row) - v1, v10, v20, v30 = resolve_calc(c1, s, 1, 1, skill_map, missile_map, desc_row), resolve_calc(c1, s, 10, 10, skill_map, missile_map, desc_row), resolve_calc(c1, s, 20, 20, skill_map, missile_map, desc_row), resolve_calc(c1, s, 30, 20, skill_map, missile_map, desc_row) - v1b, v10b, v20b, v30b = resolve_calc(c2, s, 1, 1, skill_map, missile_map, desc_row), resolve_calc(c2, s, 10, 10, skill_map, missile_map, desc_row), resolve_calc(c2, s, 20, 20, skill_map, missile_map, desc_row), resolve_calc(c2, s, 30, 20, skill_map, missile_map, desc_row) - output += f"| {label} | {scaling} | {v1}-{v1b}{unit} | {v10}-{v10b}{unit} | {v20}-{v20b}{unit} | {v30}-{v30b}{unit} | {cap} |\n" - else: - scaling, cap = analyze_scaling(c1, s, unit, skill_map, missile_map, desc_row) - v1, v10, v20, v30 = resolve_calc(c1, s, 1, 1, skill_map, missile_map, desc_row), resolve_calc(c1, s, 10, 10, skill_map, missile_map, desc_row), resolve_calc(c1, s, 20, 20, skill_map, missile_map, desc_row), resolve_calc(c1, s, 30, 20, skill_map, missile_map, desc_row) - prefix = "+" if "%+" in text_label and not str(v1).startswith('-') else "" - output += f"| {label} | {scaling} | {prefix}{v1}{unit} | {prefix}{v10}{unit} | {prefix}{v20}{unit} | {prefix}{v30}{unit} | {cap} |\n" - synergies = [] - for dsc in ['dsc2', 'dsc3']: - for i in range(1, 8): - if desc_row.get(f"{dsc}line{i}") in ['76', '77']: - text_id = desc_row.get(f"{dsc}texta{i}", "").lower() - label_t, syn_id = strings.get(text_id, ""), desc_row.get(f"{dsc}textb{i}", "").lower() - syn_n = strings.get(syn_id, syn_id) - val = resolve_calc(desc_row.get(f"{dsc}calca{i}", ""), s, 1, 1, skill_map, missile_map, desc_row) - output_syn = f"+{val}% Damage per Level" - if "Magic" in label_t: output_syn = f"+{val}% Magic Damage per Level" - elif "Poison" in label_t: output_syn = f"+{val}% Poison Damage per Level" - elif "HP" in label_t: output_syn = f"+{val}% HP per Level" - synergies.append(f"* **{syn_n}**: {output_syn}") - if synergies: output += "\n### Synergies\n" + "\n".join(synergies) + "\n" - output += "\n---\n\n" - with open(f"{class_code}_skills.md", "w", encoding='utf-8') as f: f.write(output) - print(f"Exported to {class_code}_skills.md") - -if __name__ == "__main__": main() diff --git a/scripts/legacy/extract_class_skills_v4.py b/scripts/legacy/extract_class_skills_v4.py deleted file mode 100644 index 3318f3d..0000000 --- a/scripts/legacy/extract_class_skills_v4.py +++ /dev/null @@ -1,72 +0,0 @@ -import csv, os, json, re, sys - -def load_tsv(filepath): - if not os.path.exists(filepath): return [] - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines() - if not lines: return [] - header = [h.strip() for h in lines[0].split('\t')] - return [dict(zip(header, [v.strip() for v in l.split('\t')])) for l in lines[1:] if len(l.split('\t')) >= len(header)] - -def load_all_strings(mod_path, base_strings_path): - strings = {} - paths = [base_strings_path, os.path.join(mod_path, "data/local/lng/strings")] - for p in paths: - if os.path.exists(p): - for f in os.listdir(p): - if f.endswith(".json"): - with open(os.path.join(p, f), 'r', encoding='utf-8-sig') as f: - try: - for entry in json.load(f): - k, v = entry.get('Key'), entry.get('enUS') - if k and v: strings[k.lower()] = v - except: continue - return strings - -def get_dam_generic(s, lvl, prefix): - try: - base = int(s.get(prefix, '0') or '0') - p_root = prefix - for r in ['EMin','EMax','MinDam','MaxDam']: - if prefix.startswith(r): - p_root = r.replace('EMin','MinE').replace('EMax','MaxE').replace('MinDam','Min').replace('MaxDam','Max') - break - add = 0 - for i in range(1, lvl): - if i < 8: add += int(s.get(f'{p_root}Lev1', '0') or '0') - elif i < 16: add += int(s.get(f'{p_root}Lev2', '0') or '0') - elif i < 22: add += int(s.get(f'{p_root}Lev3', '0') or '0') - elif i < 28: add += int(s.get(f'{p_root}Lev4', '0') or '0') - else: add += int(s.get(f'{p_root}Lev5', '0') or '0') - return base + add - except: return 0 - -def get_skill_val(s, lvl, blvl, var, skill_map, missile_map, desc_row=None, depth=0): - if depth > 15: return 0 - try: - def p(n): return int(s.get(f"Param{n}", "0") or "0") - if re.match(r'ln\d+', var): - v = re.match(r'ln(\d+)', var).group(1) - return p(int(v[0])) + (lvl - 1) * p(int(v[1:])) - if re.match(r'dm\d+', var): - v = re.match(r'dm(\d+)', var).group(1) - p1, p2 = int(v[0]), int(v[1:]) - return ((110 * lvl) * (p(p2) - p(1))) // (100 * (lvl + 6)) + p(1) - if var in ['macr','madm','math']: - off = 1 if var=='macr' else (3 if var=='madm' else 5) - return p(off) + (lvl - 1) * p(off+1) - if var == 'edln': - elen = int(s.get('ELen', '0') or '0') - elevlen = sum([int(s.get(f'ELevLen{i}', '0') or '0') for i in range(1, 4)]) - return elen + elevlen * (lvl - 1) - if var in ['mps', 'usmc']: - val = max(int(s.get('minmana', '0') or '0'), int(s.get('mana', '0') or '0') + int(s.get('lvlmana', '0') or '0') * (lvl - 1)) - shift = int(s.get('manashift', '8') or '8') - return val / (2**shift) if shift != 8 else val - if var == 'toht': return int(s.get('ToHit', '0') or '0') + int(s.get('LevToHit', '0') or '0') * (lvl - 1) - if var in ['pnma','edmn']: return get_dam_generic(s, lvl, 'MinDam') - if var in ['pxma','edmx']: return get_dam_generic(s, lvl, 'MaxDam') - if var == 'enma': return get_dam_generic(s, lvl, 'EMin') - if var == 'exma': return get_dam_generic(s, lvl, 'EMax') - if var == 'mael': - m = skill_map.get(f'passive_{s.get( \ No newline at end of file diff --git a/scripts/legacy/test_calculations.py b/scripts/legacy/test_calculations.py deleted file mode 100644 index 370055f..0000000 --- a/scripts/legacy/test_calculations.py +++ /dev/null @@ -1,143 +0,0 @@ -import unittest -import json -import os -import re -import sys -from legacy.extract_class_skills_paginated import D2Data, Evaluator, SYNERGY_MAP - -def normalize_val(val): - if not val or val == "" or val == "NOT_SHOWN": return None - val = str(val).replace('%', '').strip() - # Handle ranges - if '-' in val: - # Check if it's a negative number vs a range - if val.startswith('-'): - parts = val.split('-') - if len(parts) == 2: # Just a negative number like -15 - return float(val) - else: - # Negative range like -40--10 or similar - nums = re.findall(r'-?\d+\.?\d*', val) - if len(nums) == 2: return [float(nums[0]), float(nums[1])] - return float(nums[0]) if nums else None - else: - parts = val.split('-') - if len(parts) == 2: - return [float(parts[0]), float(parts[1])] - - # Fallback to regex for any complex strings - nums = re.findall(r'-?\d+\.?\d*', val) - if len(nums) == 2: return [float(nums[0]), float(nums[1])] - return float(nums[0]) if nums else None - -class TestD2Calculations(unittest.TestCase): - @classmethod - def setUpClass(cls): - script_dir = os.path.dirname(os.path.abspath(__file__)) - root = os.path.dirname(script_dir) - - mod_path = os.path.join(root, "mods/BKDiablo/bkdiablo.mpq") - retail_path = os.path.join(os.path.dirname(root), "data/retail") - if not os.path.exists(retail_path): - retail_path = os.path.join(root, "data/retail") - - cls.data = D2Data(mod_path, retail_path) - cls.ev = Evaluator(cls.data) - - test_path = os.path.join(root, "tests/amazon_test_cases.json") - with open(test_path, 'r') as f: - cls.test_cases = json.load(f) - - def test_all_skills(self): - """Data-driven test for all skills in the JSON test cases.""" - for group_name, skills in self.test_cases['groups'].items(): - for sk_name, sk_data in skills.items(): - # Setup Synergies for this skill - SYNERGY_MAP.clear() - for syn_n, syn_v in sk_data.get('synergies', {}).items(): - SYNERGY_MAP[syn_n.lower().replace(' ', '')] = syn_v - - skill_obj = self.data.skill_map.get(sk_name.lower()) - self.assertIsNotNone(skill_obj, f"Skill '{sk_name}' not found in data.") - - desc_row = self.data.desc_map.get(skill_obj['skilldesc'].lower()) - self.assertIsNotNone(desc_row, f"SkillDesc '{skill_obj['skilldesc']}' not found.") - - for scen in sk_data['scenarios']: - lvl = scen['lvl'] - blvl = scen['blvl'] - ui = scen['ui'] - - # For each UI field, find matching description line - for ui_key, expected_raw in ui.items(): - print(f" TESTING: {sk_name} {ui_key}") - if expected_raw == "" or expected_raw == "NOT_SHOWN": - continue - - expected = normalize_val(expected_raw) - if expected is None: continue - - # Find which line in skilldesc matches this UI key - best_match = None # (block, i, label, calc_str) - found_line = False - - for block in ["", "dsc2", "dsc3"]: - for i in range(1, 10): - prefix = f"{block}line" if block else "descline" - line_id = desc_row.get(f"{prefix}{i}") - if not line_id or line_id == '0': continue - - text_prefix = f"{block}texta" if block else "desctexta" - text_raw = self.data.strings.get(desc_row.get(f"{text_prefix}{i}", "").lower(), "") - label = text_raw.replace('%d', '').replace('%+d', '').replace('%%', '%').strip(': ') - if label.startswith('%s:'): continue - - def clean(s): - s = s.lower().replace('x', '').replace(':', '').replace('%', '').replace('+', '').replace('-', ' ') - s = s.replace('duration', '').replace('length', '').replace('of ', '').replace('freezes', 'freeze').replace('attacks', 'attack') - s = re.sub(r'\s+', ' ', s).strip() - return s - - clean_ui = clean(ui_key) - clean_label = clean(label) - - match = False - if clean_ui == clean_label: - match = True - elif clean_ui in clean_label or clean_label in clean_ui: - if len(clean_ui) >= 4 or clean_ui in ["life"]: - if ui_key.lower() == "damage" and "converts" in label.lower(): - match = False - else: - match = True - - if match: - # Check type compatibility before committing to this match - calc_str = self.ev.calculate(skill_obj, desc_row, i, lvl, blvl, block=block) - calc_val = normalize_val(calc_str) - if calc_val is not None: - # compatible if both are lists or both are not lists - if isinstance(expected, list) == isinstance(calc_val, list): - if best_match is None or len(clean_label) > len(clean(best_match[2])): - best_match = (block, i, label, calc_str, calc_val) - - if best_match: - block, i, label, calculated_str, calculated = best_match - found_line = True - - if isinstance(expected, list) and isinstance(calculated, list): - val_delta = 5.0 if expected[0] > 1000 else 1.1 - self.assertAlmostEqual(expected[0], calculated[0], delta=val_delta, - msg=f"{sk_name} L{lvl} {ui_key} MIN: UI={expected[0]} Calc={calculated[0]} (Label: {label})") - self.assertAlmostEqual(expected[1], calculated[1], delta=val_delta, - msg=f"{sk_name} L{lvl} {ui_key} MAX: UI={expected[1]} Calc={calculated[1]} (Label: {label})") - elif not isinstance(expected, list) and not isinstance(calculated, list): - val_delta = 5.0 if expected > 1000 else 1.1 - self.assertAlmostEqual(expected, calculated, delta=val_delta, - msg=f"{sk_name} L{lvl} {ui_key}: UI={expected} Calc={calculated} (Label: {label})") - - if not found_line: - self.fail(f"Could not find display line for UI field '{ui_key}' in skill '{sk_name}'") - -if __name__ == '__main__': - unittest.main()