validate_build_skill_icons rejected any required check with a blank hotkey before the icon was ever looked at, so moving Blessed Hammer to its real home (left slot, no key) traded one false failure for another. SkillCheck gains a `permanent` flag for skills that sit on a slot with no hotkey selecting them; the guard skips those and the icon check inspects the slot as-is. Preflight now passes clean: blessed_hammer 100% (left), concentration 86.5%, redemption 92.0%, holy_shield 93.0%, teleport 92.4%. Co-Authored-By: Claude Opus 5 <[email protected]>
206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
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
|
|
# True when the skill sits permanently on its slot and no hotkey selects it
|
|
# (Blessed Hammer on left-click). Such a check inspects the slot as-is, so a
|
|
# blank hotkey is correct rather than a misconfiguration.
|
|
permanent: bool = False
|
|
|
|
|
|
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"),
|
|
}
|
|
|
|
PALADIN_TEMPLATE_ALIASES = {
|
|
"blessed_hammer": ("BLESSED_HAMMER", "BLESSEDHAMMER"),
|
|
"concentration": ("CONCENTRATION",),
|
|
"redemption": ("REDEMPTION",),
|
|
"vigor": ("VIGOR",),
|
|
"conviction": ("CONVICTION",),
|
|
"holy_shield": ("HOLY_SHIELD", "HOLYSHIELD"),
|
|
"teleport": ("TELE_ACTIVE", "TELE_INACTIVE"),
|
|
}
|
|
|
|
# merged lookup used by icon checks; per-build dicts kept for clarity
|
|
TEMPLATE_ALIASES = {**SORC_TEMPLATE_ALIASES, **PALADIN_TEMPLATE_ALIASES}
|
|
|
|
|
|
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 in ("hammerdin", "fohdin"):
|
|
build_cfg = getattr(config_instance, char_type, {})
|
|
# Blessed Hammer lives permanently on LEFT-click and no hotkey moves it there:
|
|
# F-keys select onto the RIGHT slot only. Checking it on the right slot (and
|
|
# pressing a key for it) could never pass — it scored 44.8% all session while
|
|
# the hammer sat correctly on the left the whole time. Verify the left slot,
|
|
# and ignore any configured hotkey for it.
|
|
checks.append(SkillCheck(char_type, "blessed_hammer", "",
|
|
"BLESSED_HAMMER", "left", True, permanent=True))
|
|
checks.append(SkillCheck(char_type, "concentration",
|
|
_configured_hotkey(build_cfg, "concentration"),
|
|
"CONCENTRATION", "right", True))
|
|
for skill, template in [("redemption", "REDEMPTION"), ("vigor", "VIGOR"),
|
|
("conviction", "CONVICTION"), ("holy_shield", "HOLY_SHIELD")]:
|
|
hotkey = _configured_hotkey(build_cfg, skill)
|
|
if hotkey:
|
|
checks.append(SkillCheck(char_type, skill, hotkey, template, "right", False))
|
|
# teleport key sits in [char]
|
|
tp_key = _configured_hotkey(config_instance.char, "teleport")
|
|
if tp_key:
|
|
checks.append(SkillCheck(char_type, "teleport", tp_key, "TELE_ACTIVE", "right", False))
|
|
return 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(TEMPLATE_ALIASES.get(check.skill, (check.template,)))
|
|
if template_name is None:
|
|
# Template missing — skip required skills (assume user bound correctly),
|
|
# log warning but don't block the bot.
|
|
if check.required:
|
|
Logger.warning(
|
|
f"Skill visual preflight: {check.skill} template {check.template} missing — "
|
|
f"assuming correct bind and continuing"
|
|
)
|
|
return True, check.template, 1.0
|
|
return None, check.template, -1.0
|
|
|
|
# A skill that lives permanently on a slot (Blessed Hammer on left-click) has no
|
|
# hotkey to press — inspect the slot as-is rather than sending an empty keystroke.
|
|
if check.hotkey:
|
|
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 and not check.permanent:
|
|
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
|