Fix CTA spam, traverse_nodes_fixed crash, and add skill preflight/setter
- config: cta_available=0 (user has no CTA weapon; was causing 5 failed weapon-swap attempts per run) - pather: traverse_nodes_fixed now allows can_teleport_with_charges chars through instead of raising ValueError; when charges deplete mid-path char.move() falls back to walking gracefully - utils: add skill_preflight.py (visual hotkey verification, all blizz_sorc skills use side=right) and skill_hotkey_setter.py (automated picker binding with step-by-step logging) - test: add test_skill_preflight.py (3 passing tests) - assets: add sorc skill icon templates for preflight matching - tools: add capture_sorc_skill_icons.py and set_sorc_skill_hotkeys.py - key_detector: extend VK map with numpad/F-key/symbol codes; add validate_key_bindings() and parse_key_file(); fix char_name lookup Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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 '<empty>':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())
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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())
|
||||