Files
my-botty/src/utils/skill_preflight.py
T
alexpolo1andClaude Opus 5 0faa31f3d8 fix(skills): let a permanently-slotted skill pass preflight without a hotkey
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]>
2026-09-04 12:26:03 +02:00

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