diff --git a/assets/templates/ui/skills/blizzard.png b/assets/templates/ui/skills/blizzard.png new file mode 100644 index 0000000..fd1737e Binary files /dev/null and b/assets/templates/ui/skills/blizzard.png differ diff --git a/assets/templates/ui/skills/energy_shield.png b/assets/templates/ui/skills/energy_shield.png new file mode 100644 index 0000000..fd1737e Binary files /dev/null and b/assets/templates/ui/skills/energy_shield.png differ diff --git a/assets/templates/ui/skills/frozenarmor.png b/assets/templates/ui/skills/frozenarmor.png new file mode 100644 index 0000000..fd1737e Binary files /dev/null and b/assets/templates/ui/skills/frozenarmor.png differ diff --git a/assets/templates/ui/skills/ice_blast.png b/assets/templates/ui/skills/ice_blast.png new file mode 100644 index 0000000..bc6ee24 Binary files /dev/null and b/assets/templates/ui/skills/ice_blast.png differ diff --git a/assets/templates/ui/skills/picker_blizzard.png b/assets/templates/ui/skills/picker_blizzard.png new file mode 100644 index 0000000..caae77c Binary files /dev/null and b/assets/templates/ui/skills/picker_blizzard.png differ diff --git a/assets/templates/ui/skills/picker_frozenarmor.png b/assets/templates/ui/skills/picker_frozenarmor.png new file mode 100644 index 0000000..2cdddc8 Binary files /dev/null and b/assets/templates/ui/skills/picker_frozenarmor.png differ diff --git a/assets/templates/ui/skills/static_field.png b/assets/templates/ui/skills/static_field.png new file mode 100644 index 0000000..fd1737e Binary files /dev/null and b/assets/templates/ui/skills/static_field.png differ diff --git a/assets/templates/ui/skills/telekinesis.png b/assets/templates/ui/skills/telekinesis.png new file mode 100644 index 0000000..fd1737e Binary files /dev/null and b/assets/templates/ui/skills/telekinesis.png differ diff --git a/config/params.ini b/config/params.ini index a96e433..4b105a1 100644 --- a/config/params.ini +++ b/config/params.ini @@ -15,7 +15,7 @@ bnet_pass= ; Character name to auto-select from the character selection screen ; If empty, bot relies on the saved character template from previous sessions -char_name= +char_name=ZapZap ; messaging custom_loot_message_hook= @@ -128,7 +128,7 @@ belt_rows=4 casting_frames=8 cta_casting_frames=8 attack_frames=15 -cta_available=1 +cta_available=0 ;Do we want to cast non-cta buffs (ex energy shield) with cta buff_with_cta=1 diff --git a/src/pather.py b/src/pather.py index f043186..e710cb4 100644 --- a/src/pather.py +++ b/src/pather.py @@ -517,10 +517,12 @@ class Pather: return (rel_loc[0] + pos_abs[0], rel_loc[1] + pos_abs[1]) def traverse_nodes_fixed(self, key: str | list[tuple[float, float]], char: IChar) -> bool: - if not char.capabilities.can_teleport_natively: + if not char.capabilities.can_teleport_natively and not char.capabilities.can_teleport_with_charges: error_msg = "Teleport is required for static pathing" Logger.error(error_msg) raise ValueError(error_msg) + if char.capabilities.can_teleport_with_charges and not char.capabilities.can_teleport_natively: + Logger.debug("traverse_nodes_fixed: using charge-based teleport (charges may deplete mid-path)") char.pre_move(wait_tp = True) if type(key) == str: path = Config().path[key] diff --git a/src/utils/key_detector.py b/src/utils/key_detector.py index ed1276d..0ad0fc6 100644 --- a/src/utils/key_detector.py +++ b/src/utils/key_detector.py @@ -19,11 +19,15 @@ VK_MAP = { 77: "m", 78: "n", 79: "o", 80: "p", 81: "q", 82: "r", 83: "s", 84: "t", 85: "u", 86: "v", 87: "w", 88: "x", 89: "y", 90: "z", - 97: "a", 98: "b", 99: "c", 100: "d", 101: "e", 102: "f", - 103: "g", 104: "h", 105: "i", 106: "j", 107: "k", 108: "l", - 109: "m", 110: "n", 111: "o", 112: "p", 113: "q", 114: "r", - 115: "s", 116: "t", 117: "u", 118: "v", 119: "w", - 120: "x", 121: "y", 122: "z", + 96: "numpad0", 97: "numpad1", 98: "numpad2", 99: "numpad3", + 100: "numpad4", 101: "numpad5", 102: "numpad6", 103: "numpad7", + 104: "numpad8", 105: "numpad9", 106: "numpad*", 107: "numpad+", + 109: "numpad-", 110: "numpad.", 111: "numpad/", + 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", + 117: "f6", 118: "f7", 119: "f8", 120: "f9", 121: "f10", + 122: "f11", 123: "f12", + 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", + 192: "`", 219: "[", 220: "\\", 221: "]", 222: "'", 16: "capslock", 9: "tab", 27: "esc", 28: "enter", 32: "space", 160: "left shift", 161: "right shift", 162: "left ctrl", 163: "right ctrl", @@ -45,20 +49,93 @@ def _normalize_key(k: str) -> str: return k +def _try_fill(cfg: dict, key: str, detected: str) -> None: + current = _normalize_key(str(cfg.get(key, ""))) + detected = _normalize_key(str(detected)) + if not current: + cfg[key] = detected + + +def _auto_fill_char(char_cfg: dict, bindings: dict) -> None: + key_map = { + "inventory": "inventory_screen", + "inventory_screen": "inventory_screen", + "show_items": "show_items", + "stand_still": "stand_still", + "show_belt": "show_belt", + "force_move": "force_move", + "potion1": "potion1", + "potion2": "potion2", + "potion3": "potion3", + "potion4": "potion4", + } + for detected_key, config_key in key_map.items(): + if detected_key in bindings: + _try_fill(char_cfg, config_key, bindings[detected_key]) + + def vk_to_name(vk_code: int) -> str | None: return VK_MAP.get(vk_code, None) +def parse_key_file(filepath: str) -> dict: + """Compatibility parser used by debug tooling and tests.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + if lines and lines[0].isdigit(): + bindings = {} + for line in lines[1:]: + vk_str, action_str, param_str = line.split()[:3] + key_name = vk_to_name(int(vk_str)) + if key_name is None: + continue + action = int(action_str) + param = int(param_str) + if action == 1: + bindings[f"skill{param}"] = key_name + elif action == 2: + bindings["inventory"] = _normalize_key(key_name) + elif action == 3: + bindings["show_items"] = _normalize_key(key_name) + elif action == 4: + bindings["stand_still"] = _normalize_key(key_name) + elif action == 5: + bindings["show_belt"] = _normalize_key(key_name) + elif action == 6 and 1 <= param <= 4: + bindings[f"potion{param}"] = key_name + elif action == 13: + bindings["force_move"] = key_name + return bindings + except UnicodeDecodeError: + pass + + skills, _non_skill_keys, char_bindings = _parse_binary(filepath) + bindings = dict(char_bindings) + if "inventory_screen" in bindings: + bindings["inventory"] = bindings["inventory_screen"] + for slot, key_name in skills.items(): + bindings[f"skill{slot}"] = key_name + return bindings + + def _find_key_file(d2r_path: str, char_name: str) -> str | None: candidates = [] saved_games = os.path.expanduser("~/Saved Games/Diablo II Resurrected") + char_name = (char_name or "").strip() for ext in ("keyo", "key"): candidates.append(os.path.join(saved_games, f"{char_name}.{ext}")) if os.path.isdir(saved_games): try: - for f in sorted(os.listdir(saved_games)): - if f.endswith(".keyo") or f.endswith(".key"): - candidates.append(os.path.join(saved_games, f)) + key_files = [f for f in sorted(os.listdir(saved_games)) if f.lower().endswith((".keyo", ".key"))] + if char_name: + normalized_char = "".join(ch for ch in char_name.lower() if ch.isalnum()) + for f in key_files: + normalized_file = "".join(ch for ch in os.path.splitext(f)[0].lower() if ch.isalnum()) + if normalized_file.startswith(normalized_char): + candidates.append(os.path.join(saved_games, f)) + for f in key_files: + candidates.append(os.path.join(saved_games, f)) except OSError: pass for ext in ("keyo", "key"): @@ -100,7 +177,7 @@ def _parse_binary(filepath: str) -> tuple: with open(filepath, "rb") as f: data = f.read() if len(data) < 14: - return skills, non_skill_keys + return skills, non_skill_keys, char_bindings entry_size = 10 n_entries = (len(data) - 4) // entry_size @@ -148,7 +225,7 @@ def _apply_char_bindings(char_cfg: dict, bindings: dict) -> None: def apply_key_bindings(config_instance) -> None: d2r_path = config_instance.general.get("d2r_path", "") - char_name = config_instance.general.get("name", "") + char_name = config_instance.general.get("char_name") or config_instance.general.get("name", "") if not char_name: return @@ -182,3 +259,77 @@ def apply_key_bindings(config_instance) -> None: ) Logger.info("Key auto-detection complete.") + + +def validate_key_bindings(config_instance, routes=None) -> bool: + d2r_path = config_instance.general.get("d2r_path", "") + char_name = config_instance.general.get("char_name") or config_instance.general.get("name", "") + key_file = _find_key_file(d2r_path, char_name) + if key_file is None: + Logger.error(f"Key preflight failed: no D2R key file found for '{char_name}'. Set [general] char_name.") + return False + + skills, non_skill_keys, char_bindings = _parse_binary(key_file) + detected_skill_keys = {_normalize_key(v) for v in skills.values()} + detected_non_skill_keys = {_normalize_key(v) for v in non_skill_keys} + configured_char_keys = { + key: _normalize_key(str(config_instance.char.get(key, ""))) + for key in ( + "force_move", "show_items", "show_belt", "stand_still", + "potion1", "potion2", "potion3", "potion4", "town_portal", "teleport", + "weapon_switch", "battle_command", "battle_orders", + ) + } + Logger.info(f"Key preflight using: {key_file}") + Logger.info(f"Detected skill hotkeys: {sorted(detected_skill_keys)}") + Logger.info(f"Detected character key bindings: {char_bindings}") + + errors = [] + warnings = [] + for key in ("force_move", "show_items", "show_belt", "stand_still", "potion1", "potion2", "potion3", "potion4", "town_portal"): + if not configured_char_keys[key]: + errors.append(f"[char] {key} is empty") + + char_type = config_instance.char.get("type", "") + required_build_skills = { + "blizz_sorc": ("blizzard",), + "blizzorb_sorc": ("blizzard", "glacial_spike"), + "nova_sorc": ("nova",), + "hydra_sorc": ("hydra", "alt_attack"), + "light_sorc": ("lightning",), + } + section_cfg = getattr(config_instance, char_type, None) + if section_cfg: + for skill in required_build_skills.get(char_type, ()): + hotkey = _normalize_key(str(section_cfg.get(skill, ""))) + if not hotkey: + errors.append(f"[{char_type}] {skill} is empty") + elif hotkey not in detected_skill_keys: + errors.append(f"[{char_type}] {skill}={hotkey!r} is not bound as a skill in {os.path.basename(key_file)}") + for skill, hotkey in section_cfg.items(): + hotkey = _normalize_key(str(hotkey)) + if hotkey and hotkey in detected_non_skill_keys: + errors.append(f"[{char_type}] {skill}={hotkey!r} is detected as a non-skill key") + + if config_instance.char.get("cta_available"): + missing_cta = [ + key for key in ("battle_command", "battle_orders") + if configured_char_keys[key] and configured_char_keys[key] not in detected_skill_keys + ] + if missing_cta: + warnings.append( + "CTA is enabled but these CTA skill hotkeys are not detected as skill keys: " + + ", ".join(missing_cta) + + ". Disabling CTA for this session." + ) + config_instance.char["cta_available"] = False + + for warning in warnings: + Logger.warning(f"Key preflight: {warning}") + for error in errors: + Logger.error(f"Key preflight: {error}") + if errors: + Logger.error("Key preflight failed. Fix D2R key bindings or config before running.") + return False + Logger.info("Key preflight passed.") + return True diff --git a/src/utils/skill_hotkey_setter.py b/src/utils/skill_hotkey_setter.py new file mode 100644 index 0000000..4d44764 --- /dev/null +++ b/src/utils/skill_hotkey_setter.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass + +from config import Config +from input_layer import keyboard, mouse +from logger import Logger +from screen import convert_screen_to_monitor, grab +import template_finder +from utils.misc import wait +from utils.skill_preflight import ( + SORC_TEMPLATE_ALIASES, + SkillCheck, + _check_skill_icon, + get_build_skill_checks, +) + + +MENU_TEMPLATES = ["SAVE_AND_EXIT_NO_HIGHLIGHT", "SAVE_AND_EXIT_HIGHLIGHT"] +PICKER_TEMPLATE_ALIASES = { + "blizzard": ("PICKER_BLIZZARD", "BLIZZARD"), + "frozen_armor": ("PICKER_FROZENARMOR", "PICKER_FROZEN_ARMOR", "FROZENARMOR", "FROZEN_ARMOR"), +} + + +@dataclass(frozen=True) +class SkillBindResult: + skill: str + hotkey: str + side: str + template: str + bound: bool + verified: bool + reason: str = "" + + +def _skill_roi(side: str) -> list[int]: + return Config().ui_roi["skill_left" if side == "left" else "skill_right"] + + +def _expanded_roi(side: str) -> list[int] | None: + key = f"skill_{side}_expanded" + if key in Config().ui_roi: + return Config().ui_roi[key] + return Config().ui_roi.get("skill_right_expanded") + + +def _first_existing_template(check: SkillCheck) -> str | None: + templates = template_finder.stored_templates() + template_names = PICKER_TEMPLATE_ALIASES.get(check.skill, SORC_TEMPLATE_ALIASES.get(check.skill, (check.template,))) + for template_name in template_names: + if template_name in templates: + return template_name + return None + + +def is_ingame_menu_open() -> bool: + return template_finder.search( + MENU_TEMPLATES, + grab(force_new=True), + threshold=0.85, + roi=Config().ui_roi["save_and_exit"], + best_match=True, + ).valid + + +def is_skill_picker_open() -> bool: + return template_finder.search( + "BIND_SKILL", + grab(force_new=True), + threshold=0.8, + roi=Config().ui_roi["bind_skill"], + use_grayscale=True, + ).valid + + +def _open_skill_picker(side: str) -> None: + if is_ingame_menu_open(): + raise RuntimeError("in-game escape menu is open") + already_open = is_skill_picker_open() + Logger.info(f"Skill setter: picker already open={already_open}") + if not already_open: + Logger.info("Skill setter: sending 's' to open skill picker") + keyboard.send("s") + wait(0.25, 0.35) + Logger.info(f"Skill setter: picker open after 's' = {is_skill_picker_open()}") + clear_x = Config().ui_pos["screen_width"] // 2 + clear_y = Config().ui_pos["screen_height"] // 3 + clear_mx, clear_my = convert_screen_to_monitor((clear_x, clear_y)) + mouse.move(clear_mx, clear_my, randomize=0) + wait(0.12, 0.18) + if is_ingame_menu_open(): + raise RuntimeError("in-game escape menu opened while trying to open skill picker") + + +def bind_skill_hotkey(check: SkillCheck, threshold: float = 0.78) -> SkillBindResult: + Logger.info(f"Skill setter: binding {check.skill} -> {check.hotkey} (side={check.side})") + + if not check.hotkey: + Logger.warning(f"Skill setter: {check.skill} skipped — no hotkey configured") + return SkillBindResult(check.skill, check.hotkey, check.side, check.template, False, False, "no hotkey configured") + + template_name = _first_existing_template(check) + if template_name is None: + Logger.error(f"Skill setter: {check.skill} — template missing (checked {PICKER_TEMPLATE_ALIASES.get(check.skill, (check.template,))})") + return SkillBindResult(check.skill, check.hotkey, check.side, check.template, False, False, "template missing") + + expanded_roi = _expanded_roi(check.side) + if expanded_roi is None: + Logger.error(f"Skill setter: {check.skill} — expanded skill ROI missing for side={check.side}") + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, "expanded skill ROI missing") + + Logger.info(f"Skill setter: opening picker (side={check.side})") + try: + _open_skill_picker(check.side) + except RuntimeError as error: + Logger.error(f"Skill setter: {check.skill} — could not open picker: {error}") + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, str(error)) + + if not is_skill_picker_open(): + Logger.error(f"Skill setter: {check.skill} — picker did not open after sending open key") + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, "skill picker did not open") + + Logger.info(f"Skill setter: picker open — searching for {template_name} in ROI {expanded_roi}") + match = template_finder.search(template_name, grab(force_new=True), threshold=threshold, roi=expanded_roi) + if not match.valid: + Logger.error(f"Skill setter: {check.skill} — {template_name} not visible in picker (threshold={threshold})") + if is_skill_picker_open(): + keyboard.send("s") + wait(0.20, 0.30) + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, "skill not visible in picker") + + Logger.info(f"Skill setter: found {template_name} at {match.center_monitor} — moving mouse") + mouse.move(*match.center_monitor) + wait(0.08, 0.12) + Logger.info(f"Skill setter: pressing hotkey {check.hotkey}") + keyboard.send(check.hotkey) + wait(0.15, 0.25) + if is_skill_picker_open(): + keyboard.send("s") + wait(0.20, 0.30) + verified, _selected_template, score = _check_skill_icon(check) + Logger.info(f"Skill setter: post-bind verify {check.skill} -> {'OK' if verified else 'FAILED'} (score={score:.2f})") + return SkillBindResult( + check.skill, + check.hotkey, + check.side, + template_name, + True, + bool(verified), + "" if verified else "selected skill visual verification failed", + ) + + +def bind_build_hotkeys( + config_instance: Config, + char_type: str | None = None, + required_only: bool = False, + only_skill: str | None = None, +) -> list[SkillBindResult]: + char_type = char_type or config_instance.char.get("type", "") + checks = get_build_skill_checks(config_instance, char_type) + if required_only: + checks = [check for check in checks if check.required] + if only_skill: + checks = [check for check in checks if check.skill == only_skill] + + results = [] + Logger.info(f"Skill hotkey setter: build={char_type}, required_only={required_only}, only_skill={only_skill}") + for check in checks: + result = bind_skill_hotkey(check) + results.append(result) + if result.bound and result.verified: + Logger.info(f"Skill hotkey setter: {result.skill} -> {result.hotkey} verified") + elif result.bound: + Logger.error(f"Skill hotkey setter: {result.skill} -> {result.hotkey} bound but not verified: {result.reason}") + else: + Logger.error(f"Skill hotkey setter: {result.skill} -> {result.hotkey} not bound: {result.reason}") + return results diff --git a/src/utils/skill_preflight.py b/src/utils/skill_preflight.py new file mode 100644 index 0000000..ff62fa5 --- /dev/null +++ b/src/utils/skill_preflight.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass + +from config import Config +from input_layer import keyboard +from logger import Logger +from screen import grab +import template_finder +from utils.key_detector import _normalize_key +from utils.misc import wait + + +@dataclass(frozen=True) +class SkillCheck: + build: str + skill: str + hotkey: str + template: str + side: str + required: bool + + +SORC_TEMPLATE_ALIASES = { + "blizzard": ("BLIZZARD",), + "ice_blast": ("ICE_BLAST", "ICEBLAST"), + "static_field": ("STATIC_FIELD", "STATICFIELD"), + "frozen_armor": ("FROZEN_ARMOR", "FROZENARMOR"), + "energy_shield": ("ENERGY_SHIELD", "ENERGYSHIELD"), + "telekinesis": ("TELEKINESIS",), + "thunder_storm": ("THUNDER_STORM", "THUNDERSTORM"), +} + + +def _configured_hotkey(section: dict, skill: str) -> str: + return _normalize_key(str(section.get(skill, ""))) + + +def _first_existing_template(template_names: tuple[str, ...]) -> str | None: + templates = template_finder.stored_templates() + for template_name in template_names: + if template_name in templates: + return template_name + return None + + +def get_build_skill_checks(config_instance: Config, char_type: str | None = None) -> list[SkillCheck]: + char_type = char_type or config_instance.char.get("type", "") + checks = [] + + if char_type != "blizz_sorc": + return checks + + build_cfg = getattr(config_instance, "blizz_sorc", {}) + blizzard_key = _configured_hotkey(build_cfg, "blizzard") + checks.append(SkillCheck(char_type, "blizzard", blizzard_key, "BLIZZARD", "right", True)) + + optional_skills = { + "ice_blast": (build_cfg, "right"), + "static_field": (build_cfg, "right"), + "frozen_armor": (build_cfg, "right"), + "energy_shield": (build_cfg, "right"), + "telekinesis": (build_cfg, "right"), + "thunder_storm": (build_cfg, "right"), + } + for skill, (section, side) in optional_skills.items(): + hotkey = _configured_hotkey(section, skill) + if hotkey: + template = SORC_TEMPLATE_ALIASES[skill][0] + checks.append(SkillCheck(char_type, skill, hotkey, template, side, False)) + return checks + + +def _skill_roi(side: str, pad: int = 6) -> list[int]: + x, y, w, h = Config().ui_roi["skill_left" if side == "left" else "skill_right"] + return [max(0, x - pad), max(0, y - pad), w + pad * 2, h + pad * 2] + + +def _check_skill_icon(check: SkillCheck, threshold: float = 0.84) -> tuple[bool | None, str, float]: + template_name = _first_existing_template(SORC_TEMPLATE_ALIASES.get(check.skill, (check.template,))) + if template_name is None: + return None, check.template, -1.0 + + keyboard.send(check.hotkey) + wait(0.15, 0.25) + roi = _skill_roi(check.side) + match = template_finder.search(template_name, grab(force_new=True), threshold=threshold, roi=roi) + return match.valid, template_name, match.score + + +def _close_skill_picker_if_open() -> None: + if "BIND_SKILL" not in template_finder.stored_templates(): + return + if template_finder.search( + "BIND_SKILL", + grab(force_new=True), + threshold=0.8, + roi=Config().ui_roi["bind_skill"], + use_grayscale=True, + ).valid: + keyboard.send("s") + wait(0.20, 0.30) + + +def validate_build_skill_icons(config_instance: Config, char_type: str | None = None) -> bool: + char_type = char_type or config_instance.char.get("type", "") + checks = get_build_skill_checks(config_instance, char_type) + if not checks: + Logger.debug(f"Skill visual preflight skipped: no build rules for {char_type!r}") + return True + + Logger.info( + "Skill visual preflight: " + f"character={config_instance.general.get('char_name') or config_instance.general.get('name')}, " + f"build={char_type}" + ) + _close_skill_picker_if_open() + + errors = [] + for check in checks: + if not check.hotkey: + if check.required: + errors.append(f"{check.skill} has no configured hotkey") + continue + + matched, template_name, score = _check_skill_icon(check) + if matched is None: + Logger.warning( + "Skill visual preflight: " + f"{check.skill} key={check.hotkey} side={check.side} template={template_name} missing; " + "skipping visual match" + ) + continue + + score_text = f"{score * 100:.1f}%" if score >= 0 else "n/a" + status = "ok" if matched else "failed" + Logger.info( + "Skill visual preflight: " + f"{check.skill} key={check.hotkey} side={check.side} template={template_name} " + f"visual={status} score={score_text}" + ) + if not matched: + severity = "required" if check.required else "configured" + errors.append( + f"{severity} skill {check.skill} did not select {template_name} " + f"on {check.side} skill icon (key={check.hotkey}, score={score_text})" + ) + + for error in errors: + Logger.error(f"Skill visual preflight: {error}") + if errors: + Logger.error("Skill visual preflight failed. Fix D2R skill hotkeys or recapture templates before running.") + return False + + Logger.info("Skill visual preflight passed.") + return True diff --git a/test/test_skill_preflight.py b/test/test_skill_preflight.py new file mode 100644 index 0000000..f482af7 --- /dev/null +++ b/test/test_skill_preflight.py @@ -0,0 +1,48 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from utils.skill_preflight import get_build_skill_checks + + +class DummyConfig: + char = {"type": "blizz_sorc"} + blizz_sorc = { + "blizzard": "f1", + "ice_blast": "", + "energy_shield": "f3", + "frozen_armor": "f4", + "static_field": "f5", + "telekinesis": "f6", + "thunder_storm": "", + } + + +def test_blizz_sorc_skill_checks(): + checks = get_build_skill_checks(DummyConfig()) + by_skill = {check.skill: check for check in checks} + + assert by_skill["blizzard"].required is True + assert by_skill["blizzard"].side == "right" + assert by_skill["blizzard"].hotkey == "f1" + + assert "ice_blast" not in by_skill + assert by_skill["energy_shield"].required is False + assert by_skill["frozen_armor"].side == "right" + assert by_skill["static_field"].hotkey == "f5" + assert by_skill["telekinesis"].hotkey == "f6" + + +def test_blizz_sorc_optional_attack_is_right_skill(): + config = DummyConfig() + config.blizz_sorc = dict(DummyConfig.blizz_sorc) + config.blizz_sorc["ice_blast"] = "f2" + + by_skill = {check.skill: check for check in get_build_skill_checks(config)} + + assert by_skill["ice_blast"].side == "right" + + +def test_unknown_build_has_no_checks(): + assert get_build_skill_checks(DummyConfig(), "hammerdin") == [] diff --git a/tools/capture_sorc_skill_icons.py b/tools/capture_sorc_skill_icons.py new file mode 100644 index 0000000..2285166 --- /dev/null +++ b/tools/capture_sorc_skill_icons.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +import argparse +import os +import sys + +import cv2 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from config import Config +from input_layer import keyboard +from screen import find_and_set_window_position, grab +import template_finder +from utils.misc import cut_roi, wait +from utils.skill_preflight import get_build_skill_checks + + +OUTPUT_DIR = os.path.join("assets", "templates", "ui", "skills") + + +def _target_filename(skill: str) -> str: + if skill == "frozen_armor": + return "frozenarmor.png" + return f"{skill}.png" + + +def capture_sorc_icons(build: str, overwrite: bool, dry_run: bool) -> int: + config = Config() + checks = get_build_skill_checks(config, build) + if not checks: + print(f"No capture rules for build {build!r}") + return 1 + + print(f"Character: {config.general.get('char_name') or config.general.get('name')}") + print(f"Build: {build}") + for check in checks: + target = os.path.join(OUTPUT_DIR, _target_filename(check.skill)) + print(f"{check.skill:14s} key={check.hotkey or '':5s} side={check.side:5s} -> {target}") + + if dry_run: + return 0 + + os.makedirs(OUTPUT_DIR, exist_ok=True) + find_and_set_window_position() + for check in checks: + if not check.hotkey: + print(f"Skipping {check.skill}: no hotkey configured") + continue + + target = os.path.join(OUTPUT_DIR, _target_filename(check.skill)) + if os.path.exists(target) and not overwrite: + print(f"Skipping {check.skill}: {target} exists (use --overwrite)") + continue + + print(f"Selecting {check.skill} with {check.hotkey}...") + keyboard.send(check.hotkey) + wait(0.25, 0.35) + roi = config.ui_roi["skill_left" if check.side == "left" else "skill_right"] + icon = cut_roi(grab(force_new=True), roi) + cv2.imwrite(target, icon) + print(f"Saved {target}") + + template_finder.stored_templates.cache_clear() + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Capture Sorceress skill icon templates from the D2R skill buttons.") + parser.add_argument("--build", default="blizz_sorc", choices=["blizz_sorc"]) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + return capture_sorc_icons(args.build, args.overwrite, args.dry_run) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/inspect_key_bindings.py b/tools/inspect_key_bindings.py index cf8f80b..03b2235 100644 --- a/tools/inspect_key_bindings.py +++ b/tools/inspect_key_bindings.py @@ -29,9 +29,9 @@ test_cases = { 69: "e", # force_move 87: "w", # weapon_switch 16: "capslock", # stand_still - 256: "alt", # show_items - 52: "5", # teleport - 53: "6", # town_portal + 256: "left alt", # show_items + 52: "4", + 53: "5", 116: "f5", # conviction 117: "f6", # foh 118: "f7", # holy_bolt @@ -54,7 +54,7 @@ print(f" Result: {ok} passed, {fail} failed") print("\n2. Finding key file:") config = Config() d2r_path = config.general["d2r_path"] -char_name = config.general["name"] +char_name = config.general.get("char_name") or config.general["name"] print(f" D2R path: {d2r_path}") print(f" Character: {char_name}") diff --git a/tools/set_sorc_skill_hotkeys.py b/tools/set_sorc_skill_hotkeys.py new file mode 100644 index 0000000..f7f2544 --- /dev/null +++ b/tools/set_sorc_skill_hotkeys.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +import argparse +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from config import Config +from screen import find_and_set_window_position +from utils.skill_preflight import get_build_skill_checks +from utils.skill_hotkey_setter import bind_build_hotkeys + + +def main() -> int: + parser = argparse.ArgumentParser(description="Set configured Sorceress skill hotkeys inside the D2R skill picker.") + parser.add_argument("--build", default="blizz_sorc", choices=["blizz_sorc"]) + parser.add_argument("--skill", help="Bind only one configured skill, e.g. blizzard or static_field") + parser.add_argument("--required-only", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + if args.dry_run: + checks = get_build_skill_checks(Config(), args.build) + if args.required_only: + checks = [check for check in checks if check.required] + if args.skill: + checks = [check for check in checks if check.skill == args.skill] + print("Skill hotkey setter dry run:") + for check in checks: + print(f" {check.skill:14s} key={check.hotkey:5s} side={check.side:5s} required={check.required}") + return 0 + + find_and_set_window_position() + results = bind_build_hotkeys(Config(), args.build, required_only=args.required_only, only_skill=args.skill) + failed = [result for result in results if not result.bound or not result.verified] + print("\nSkill hotkey setter report:") + for result in results: + status = "OK" if result.bound and result.verified else "FAIL" + reason = f" ({result.reason})" if result.reason else "" + print(f" {status:4s} {result.skill:14s} {result.hotkey:5s} {result.side:5s} {result.template}{reason}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main())