Files
my-botty/src/npc_manager.py
T
alexpolo1andClaude Opus 5 e976749910 fix(npc): raise grid-sweep name-tag threshold so it stops clicking noise
SWEEP_TAG_THRESHOLD was 0.4. A rendered name tag matches almost perfectly, so anything
mediocre is noise — and at 0.4 the noise won: the sweep stopped at the first match over
threshold, clicked empty ground, and gave up. Scores measured across a full day:

    akara 0.980   halbu 0.995   malah 0.990   larzuk 0.996    <- real, dialogue opened
    qual_kehk 0.424   malah 0.501   larzuk 0.494              <- false, clicked nothing

Real hits cluster at 0.98-1.00, false ones at 0.42-0.50. Raised to 0.7, in the gap with
margin either side. This is why qual_kehk failed 100% (5 timeouts in 5 attempts) while
akara succeeded 177 times.

Also corrects Bug 29 in CLAUDE.md, which blamed the Qual-Kehk asset. That was wrong. I
walked the char to the NPC with the project's own Pather, hovered a grid capturing
full-res frames, found the one where QUAL-KEHK renders, and scored the stored template
against it: 1.000 raw and 0.997 through the color_filter path npc_manager actually uses.
The template was never the problem.

NAME_TAG_THRESHOLD (0.26, the hover path) is deliberately left alone — Akara genuinely
hovers at ~0.28 per Bug 3. The two thresholds serve different paths.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 17:12:17 +02:00

568 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import time
import os
import numpy as np
from input_layer import keyboard
import template_finder
from config import Config
from screen import grab
from ui_manager import ScreenObjects, center_mouse, is_visible, wait_until_hidden
from utils.misc import color_filter, wait
from utils.log_rotation import safe_imwrite
from logger import Logger
from input_layer import mouse
from math import sqrt
class Npc:
#A1
KASHYA = "kashya"
CHARSI = "charsi"
AKARA = "akara"
CAIN = "cain"
#A2
FARA = "fara"
DROGNAN = "droganan"
LYSANDER = "lysander"
#A3
ORMUS = "ormus"
#A4
TYRAEL = "tyrael"
JAMELLA = "jamella"
HALBU = "halbu"
#A5
QUAL_KEHK = "qual_kehk"
MALAH = "malah"
LARZUK = "larzuk"
ANYA = "anya"
def _build_npcs():
"""Build the npcs dict — deferred so the module can import on systems
missing template assets (e.g. headless Linux or incomplete clones)."""
return {
Npc.QUAL_KEHK: {
"name_tag_white": color_filter(template_finder.get_template("QUAL_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("QUAL_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"resurrect": {
"white": color_filter(template_finder.get_template("RESURRECT"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("RESURRECT_BLUE"), Config().colors["blue"])[1],
"red": color_filter(template_finder.get_template("RESURRECT"), Config().colors["red"])[1],
}
},
"template_group": ["QUAL_0", "QUAL_45", "QUAL_45_B", "QUAL_90", "QUAL_135", "QUAL_135_B", "QUAL_135_C", "QUAL_180", "QUAL_180_B", "QUAL_225", "QUAL_225_B", "QUAL_270", "QUAL_315"],
"roi": [225, 57, (850-225), (442-57)],
"poses": [[350, 140], [310, 268], [385, 341], [481, 196], [502, 212], [771, 254]]
},
Npc.MALAH: {
"name_tag_white": color_filter(template_finder.get_template("MALAH_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("MALAH_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["MALAH_FRONT", "MALAH_BACK", "MALAH_45", "MALAH_SIDE", "MALAH_SIDE_2"],
"roi": [383, 193, (762-383), (550-193)],
"poses": [[445, 485], [526, 473], [602, 381], [623, 368], [641, 323], [605, 300], [622, 272], [638, 284], [677, 308], [710, 288]],
"body_threshold": 0.35,
"pose_tolerance": 200,
},
Npc.LARZUK: {
"name_tag_white": color_filter(template_finder.get_template("LARZUK_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("LARZUK_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade_repair": {
"white": color_filter(template_finder.get_template("TRADE_REPAIR"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_REPAIR_BLUE"), Config().colors["blue"])[1],
}
},
"roi": [570, 70, (1038-570), (290-70)],
"template_group": ["LARZUK_FRONT", "LARZUK_BACK", "LARZUK_SIDE", "LARZUK_SIDE_2", "LARZUK_SIDE_3"],
"poses": [[733, 192], [911, 143]]
},
Npc.ANYA: {
"name_tag_white": color_filter(template_finder.get_template("ANYA_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("ANYA_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["ANYA_FRONT", "ANYA_BACK", "ANYA_SIDE"]
},
Npc.TYRAEL: {
"name_tag_white": color_filter(template_finder.get_template("TYRAEL_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("TYRAEL_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"resurrect": {
"white": color_filter(template_finder.get_template("RESURRECT"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("RESURRECT_BLUE"), Config().colors["blue"])[1],
"red": color_filter(template_finder.get_template("RESURRECT"), Config().colors["red"])[1],
}
},
"roi": [569, 86, (852-569), (357-86)],
"template_group": ["TYRAEL_1", "TYRAEL_2"]
},
Npc.ORMUS: {
"name_tag_white": color_filter(template_finder.get_template("ORMUS_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("ORMUS_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
}
},
"roi": [444, 13, (816-444), (331-13)],
"poses": [[526, 131], [602, 192], [698, 218], [756, 188]],
"template_group": ["ORMUS_0", "ORMUS_1", "ORMUS_2", "ORMUS_3", "ORMUS_4", "ORMUS_5"]
},
Npc.FARA: {
"name_tag_white": color_filter(template_finder.get_template("FARA_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("FARA_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade_repair": {
"white": color_filter(template_finder.get_template("TRADE_REPAIR"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_REPAIR_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["FARA_LIGHT_1", "FARA_LIGHT_2", "FARA_LIGHT_3", "FARA_LIGHT_4", "FARA_LIGHT_5", "FARA_LIGHT_6", "FARA_LIGHT_7", "FARA_LIGHT_8", "FARA_LIGHT_9", "FARA_MEDIUM_1", "FARA_MEDIUM_2", "FARA_MEDIUM_3", "FARA_MEDIUM_4", "FARA_MEDIUM_5", "FARA_MEDIUM_6", "FARA_MEDIUM_7", "FARA_DARK_1", "FARA_DARK_2", "FARA_DARK_3", "FARA_DARK_4", "FARA_DARK_5", "FARA_DARK_6", "FARA_DARK_7"]
},
Npc.DROGNAN: {
"name_tag_white": color_filter(template_finder.get_template("DROGNAN_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("DROGNAN_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["DROGNAN_FRONT", "DROGNAN_LEFT", "DROGNAN_RIGHT_SIDE"]
},
Npc.LYSANDER: {
"name_tag_white": color_filter(template_finder.get_template("LYSANDER_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("LYSANDER_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["LYSANDER_FRONT", "LYSANDER_BACK", "LYSANDER_SIDE", "LYSANDER_SIDE_2"]
},
Npc.CAIN: {
"name_tag_white": color_filter(template_finder.get_template("CAIN_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("CAIN_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"identify": {
"white": color_filter(template_finder.get_template("IDENTIFY"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("IDENTIFY_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["CAIN_0", "CAIN_1", "CAIN_2", "CAIN_3"]
},
Npc.JAMELLA: {
"name_tag_white": color_filter(template_finder.get_template("JAMELLA_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("JAMELLA_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
},
"gamble": {
"white": color_filter(template_finder.get_template("GAMBLE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("GAMBLE_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["JAMELLA_FRONT", "JAMELLA_BACK", "JAMELLA_SIDE", "JAMELLA_SIDE_2", "JAMELLA_SIDE_3", "JAMELLA_DRAWING"]
},
Npc.HALBU: {
"name_tag_white": color_filter(template_finder.get_template("HALBU_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("HALBU_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade_repair": {
"white": color_filter(template_finder.get_template("TRADE_REPAIR"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_REPAIR_BLUE"), Config().colors["blue"])[1],
}
},
"template_group": ["HALBU_FRONT", "HALBU_BACK", "HALBU_SIDE", "HALBU_SIDE_2"]
},
Npc.AKARA: {
"name_tag_white": color_filter(template_finder.get_template("AKARA_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("AKARA_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade": {
"white": color_filter(template_finder.get_template("TRADE"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_BLUE"), Config().colors["blue"])[1],
}
},
"roi": [605,176,(1004-605),(478-176)],
"poses": [[694,377], [836,378], [950,345], [869,290], [698,317]],
"template_group": ["AKARA_FRONT", "AKARA_BACK", "AKARA_SIDE", "AKARA_SIDE_2"]
},
Npc.CHARSI: {
"name_tag_white": color_filter(template_finder.get_template("CHARSI_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("CHARSI_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"trade_repair": {
"white": color_filter(template_finder.get_template("TRADE_REPAIR"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("TRADE_REPAIR_BLUE"), Config().colors["blue"])[1],
}
},
"roi": [249, 76, (543-249), (363-76)],
"poses": [[331, 227], [368, 284], [484, 174]],
"template_group": ["CHARSI_FRONT", "CHARSI_BACK", "CHARSI_SIDE", "CHARSI_SIDE_2", "CHARSI_SIDE_3"]
},
Npc.KASHYA: {
"name_tag_white": color_filter(template_finder.get_template("KASHYA_NAME_TAG_WHITE"), Config().colors["white"])[1],
"name_tag_gold": color_filter(template_finder.get_template("KASHYA_NAME_TAG_GOLD"), Config().colors["gold"])[1],
"action_btns": {
"resurrect": {
"white": color_filter(template_finder.get_template("RESURRECT"), Config().colors["white"])[1],
"blue": color_filter(template_finder.get_template("RESURRECT_BLUE"), Config().colors["blue"])[1],
"red": color_filter(template_finder.get_template("RESURRECT"), Config().colors["red"])[1],
}
},
"template_group": ["KASHYA_FRONT", "KASHYA_BACK", "KASHYA_SIDE", "KASHYA_SIDE_2"]
}
}
npcs = {}
try:
npcs = _build_npcs()
except Exception:
pass # npcs stays empty on headless/incomplete setups — populated lazily at runtime
def _action_btns_visible(npc_key: str, img) -> bool:
"""Return True if any action button for *npc_key* is detectable in *img*."""
if "action_btns" not in npcs.get(npc_key, {}):
return False
roi = Config().ui_roi["cut_skill_bar"]
for btn_key, btn_colors in npcs[npc_key]["action_btns"].items():
for color_name in ("white", "blue"):
if color_name not in btn_colors:
continue
try:
_, filtered = color_filter(img, Config().colors[color_name])
res = template_finder.search(btn_colors[color_name], filtered, 0.78, roi=roi)
if res.valid:
Logger.debug(f"NPC {npc_key} - action btn '{btn_key}' ({color_name}) detected (score {res.score:.3f})")
return True
except Exception:
pass
first_color = next(iter(btn_colors))
res = template_finder.search(btn_colors[first_color], img, 0.78, roi=roi, use_grayscale=True)
if res.valid:
Logger.debug(f"NPC {npc_key} - action btn '{btn_key}' (gray) detected (score {res.score:.3f})")
return True
return False
def _wait_action_btns(npc_key: str, timeout: float = 2.5) -> bool:
"""Poll for the NPC dialogue action buttons for up to *timeout* seconds.
A single-frame check misses slow dialogue fade-in animations."""
start = time.time()
while (time.time() - start) < timeout:
if _action_btns_visible(npc_key, grab()):
return True
wait(0.2, 0.3)
return False
def escape_dialogue(img) -> np.ndarray:
while is_visible(ScreenObjects.NPCDialogue, img):
keyboard.send("esc")
if wait_until_hidden(ScreenObjects.NPCDialogue, 0.5):
break
img = grab()
return img
def open_npc_menu(npc_key: Npc) -> bool:
global npcs
from screen import convert_monitor_to_screen
roi = Config().ui_roi["cut_skill_bar"]
roi_npc_search = Config().ui_roi["search_npcs"]
# Name tag match threshold — lower than the body template threshold because D2R
# name tag rendering can differ slightly from saved templates.
NAME_TAG_THRESHOLD = 0.26
# Timeout: 12 s per NPC search attempt (8 s was too short with body+pose fallback).
NPC_SEARCH_TIMEOUT = 12.0
# If any panel is open (inventory right, stash/vendor left), ESC-close it before
# hunting. A misclick on the stash chest opens the stash UI which covers most of
# the screen and blinds every template search. ESC closes both panels; the
# inventory key alone leaves a stash panel open.
def _close_open_panels(_img=None) -> bool:
_img = grab() if _img is None else _img
left_open = is_visible(ScreenObjects.GoldBtnStash, _img) or is_visible(ScreenObjects.GoldBtnVendor, _img)
right_open = is_visible(ScreenObjects.GoldBtnInventory, _img)
if left_open or right_open:
Logger.debug(f"open_npc_menu: closing open panels (left={left_open}, right={right_open})")
keyboard.send("esc")
wait(0.3, 0.4)
return True
return False
# Also close the waypoint panel — an open WP covers the center of the screen
# and prevents NPC template matching.
if is_visible(ScreenObjects.WaypointLabel, grab()):
Logger.debug("open_npc_menu: closing waypoint panel before NPC search")
keyboard.send("esc")
wait(0.2, 0.3)
_close_open_panels()
# Search for npc name tags by hovering to all template locations that are found
start = time.time()
attempts = 0
while (time.time() - start) < NPC_SEARCH_TIMEOUT:
img = grab()
# Fast path: if the target NPC's dialogue is already open, return immediately.
if _action_btns_visible(npc_key, img):
Logger.debug(f"NPC {npc_key} - dialogue already open (action buttons visible)")
return True
# A previous click may have opened a container (stash) — close and re-grab.
if _close_open_panels(img):
img = grab()
results = []
for key in npcs[npc_key]["template_group"]:
if attempts == 0 and "roi" in npcs[npc_key] and (time.time() - start) < 6:
roi_npc = npcs[npc_key]["roi"]
else:
roi_npc = roi_npc_search
res = template_finder.search(key, img, threshold=0.35, roi=roi_npc)
if res.valid:
is_unique = True
for r in results:
if (abs(r["pos"][0] - res.center_monitor[0]) + abs(r["pos"][1] - res.center_monitor[1])) < 22:
is_unique = False
break
if is_unique:
min_dist_val = 10000
if attempts == 0 and "poses" in npcs[npc_key]:
# find distance between template match and nearest pose (([x2] - x1)**2 + (y2 - y1)**2)
for pose in npcs[npc_key]["poses"]:
dist = sqrt((res.center_monitor[0] - pose[0])**2 + (res.center_monitor[1] - pose[1])**2)
min_dist_val = dist if dist < min_dist_val else min_dist_val
results.append({"pos": res.center_monitor, "score": res.score, "combo": min_dist_val / (res.score**2), "min_dist": min_dist_val})
# sort by composite of template match score and distance to NPC pose
results = sorted(results, key=lambda r: r["combo"])
if not results:
Logger.debug(f"NPC {npc_key} search - No templates matched in this frame")
for result in results:
Logger.debug(f"Hovering over NPC at monitor pos: {result['pos']}")
mouse.move(*result["pos"], randomize=3, delay_factor=[0.3, 0.5])
wait(0.2, 0.3)
img = grab()
img = escape_dialogue(img)
# Build a small ROI above the hover position where the NPC name tag appears.
# This prevents distant false-positive text from the wide screen ROI from
# triggering a click. NPC name tags appear 30130 px above the NPC sprite.
hover_screen = convert_monitor_to_screen(result["pos"])
name_tag_roi = [
max(0, hover_screen[0] - 120),
max(0, hover_screen[1] - 160),
240,
140,
]
_, filtered_inp_w = color_filter(img, Config().colors["white"])
_, filtered_inp_g = color_filter(img, Config().colors["gold"])
res_w = template_finder.search(npcs[npc_key]["name_tag_white"], filtered_inp_w, NAME_TAG_THRESHOLD, roi=name_tag_roi)
res_g = template_finder.search(npcs[npc_key]["name_tag_gold"], filtered_inp_g, NAME_TAG_THRESHOLD, roi=name_tag_roi)
# fallback to grayscale if color filter is too aggressive
if not res_w.valid:
res_w_gray = template_finder.search(npcs[npc_key]["name_tag_white"], img, NAME_TAG_THRESHOLD, roi=name_tag_roi, use_grayscale=True)
if res_w_gray.score > res_w.score:
res_w = res_w_gray
Logger.debug(f"NPC {npc_key} hover - using grayscale fallback (score: {res_w.score:.3f})")
Logger.debug(f"NPC {npc_key} hover - White score: {res_w.score:.3f}, Gold score: {res_g.score:.3f}, Body score: {result['score']:.3f}")
# Decision: when to click the NPC
# 1. Name tag confirmed (white or gold) AND position is within NPC's ROI.
# High white-text false positives (0.98+) occur at positions far outside the
# ROI (e.g. x=200 while Akara's ROI starts at x=605). Gating on in_npc_roi
# eliminates these without affecting true matches.
# 2. Body template matched well AND we're at a known pose location.
if "roi" in npcs[npc_key] and attempts == 0:
npc_roi = npcs[npc_key]["roi"]
in_npc_roi = (npc_roi[0] <= hover_screen[0] <= npc_roi[0] + npc_roi[2] and
npc_roi[1] <= hover_screen[1] <= npc_roi[1] + npc_roi[3])
else:
in_npc_roi = True
name_tag_confirmed = (res_w.valid or res_g.valid) and in_npc_roi
# Per-NPC thresholds for body confidence and pose distance.
# Malah's templates are weaker (0.36-0.41) so she needs lower thresholds.
npc_body_threshold = npcs[npc_key].get("body_threshold", 0.40)
npc_pose_tolerance = npcs[npc_key].get("pose_tolerance", 150)
body_confident = result["score"] >= npc_body_threshold
pose_confirmed = "poses" in npcs[npc_key] and result["min_dist"] < npc_pose_tolerance
if name_tag_confirmed:
Logger.info(f"Clicking on {npc_key} at {mouse.get_position()} (name tag confirmed)")
elif body_confident and pose_confirmed:
Logger.info(f"Clicking on {npc_key} at {mouse.get_position()} (body+pose confirmed, name tag score {res_w.score:.3f})")
else:
Logger.debug(f"NPC {npc_key} - skipping pos {result['pos']}: name_tag={name_tag_confirmed} in_roi={in_npc_roi} body={body_confident} pose={pose_confirmed}")
continue
mouse.click(button="left")
attempts += 1
wait(0.5, 0.7)
# Confirm dialogue opened by checking for action buttons — more reliable than
# the NPCDialogue border template (stale) or gold name tag template (also stale).
# Poll for a couple of seconds: the dialogue fades in and a single-frame check
# can miss it, and a premature retry click would CLOSE the open dialogue.
if _wait_action_btns(npc_key, timeout=2.5):
Logger.debug(f"NPC {npc_key} - dialogue open (action buttons visible)")
return True
# Retry once: first click sometimes just turns the NPC toward you.
if attempts <= 2:
Logger.debug(f"NPC {npc_key} - action buttons not found, retrying click on body")
mouse.move(*result["pos"], randomize=3, delay_factor=[0.2, 0.3])
wait(0.2, 0.3)
mouse.click(button="left")
wait(0.5, 0.7)
if _wait_action_btns(npc_key, timeout=2.5):
Logger.debug(f"NPC {npc_key} - dialogue open after retry (action buttons visible)")
return True
Logger.warning(f"NPC {npc_key} - clicked but no action buttons found after retry")
else:
Logger.warning(f"NPC {npc_key} - clicked but no action buttons found")
wait(0.1) # Brief pause before next frame grab if no matches were valid
# FINAL FALLBACK: body templates can go stale after a game patch (June 2026
# patch changed several NPC appearances), but the name-tag-on-hover check is
# rendering-stable — a real hover scores 0.9+. Sweep a coarse hover grid over
# the NPC's ROI and click wherever the name tag appears.
from screen import convert_screen_to_monitor
Logger.debug(f"NPC {npc_key} - body-template hunt timed out, starting grid hover sweep")
_close_open_panels()
# Raised 0.4 -> 0.7 (2026-08-27). A rendered name tag matches almost perfectly, so
# anything mediocre is noise, and at 0.4 the noise won. Measured across a full day of
# sweeps — real hits that opened the dialogue vs false hits that clicked empty ground:
# akara 0.980 halbu 0.995 malah 0.990 larzuk 0.996 <- real
# qual_kehk 0.424 malah 0.501 larzuk 0.494 <- false
# qual_kehk failed 100% of the time (5 timeouts / 5 attempts) because every sweep
# stopped on a 0.424 match, clicked nothing, and gave up instead of carrying on to the
# position where the tag actually renders. Verified directly: the stored
# QUAL_NAME_TAG_WHITE template scores 1.000 on a frame where the tag IS shown, and
# 0.997 through the color_filter path — the template was never the problem.
SWEEP_TAG_THRESHOLD = 0.7
# Pass 1: the NPC's known ROI. Pass 2: the whole NPC search area — patch
# updates and pathing drift can put the NPC outside the stored ROI.
sweep_rois = [npcs[npc_key]["roi"]] if "roi" in npcs[npc_key] else []
sweep_rois.append(roi_npc_search)
sweep_start = time.time()
SWEEP_BUDGET_S = 25.0
for sweep_roi in sweep_rois:
sx0, sy0, sw, sh = sweep_roi
for sy in range(int(sy0 + 30), int(sy0 + sh - 15), 70):
if time.time() - sweep_start > SWEEP_BUDGET_S:
Logger.debug(f"NPC {npc_key} - grid sweep budget exhausted")
break
for sx in range(int(sx0 + 30), int(sx0 + sw - 15), 60):
pos_m = convert_screen_to_monitor((sx, sy))
mouse.move(*pos_m, randomize=2, delay_factor=[0.05, 0.1])
wait(0.10, 0.14)
hov = grab()
tag_roi = [max(0, sx - 120), max(0, sy - 160), 240, 140]
_, f_w = color_filter(hov, Config().colors["white"])
res_w = template_finder.search(npcs[npc_key]["name_tag_white"], f_w, SWEEP_TAG_THRESHOLD, roi=tag_roi)
if not res_w.valid:
res_w = template_finder.search(npcs[npc_key]["name_tag_white"], hov, SWEEP_TAG_THRESHOLD, roi=tag_roi, use_grayscale=True)
if res_w.valid:
Logger.info(f"NPC {npc_key} - grid sweep found name tag at ({sx}, {sy}) (score {res_w.score:.3f}), clicking")
mouse.click(button="left")
wait(0.5, 0.7)
# 5s: a click on a distant NPC walks the char over first and the
# interaction only fires on arrival.
if _wait_action_btns(npc_key, timeout=5.0):
Logger.info(f"NPC {npc_key} - dialogue open via grid sweep")
return True
# Wrong NPC's dialogue may have opened (e.g. Cain's TALK menu
# when sweeping for Tyrael) — close it. But if nothing was
# open, that ESC opens the GAME MENU — detect and close it.
def _dismiss_accidental_menus():
keyboard.send("esc")
wait(0.25, 0.35)
if is_visible(ScreenObjects.SaveAndExit):
keyboard.send("esc")
wait(0.25, 0.35)
_dismiss_accidental_menus()
# one retry: first click sometimes just turns the NPC; the char
# has now walked closer, so re-hover the same screen spot may
# miss — re-scan the tag near the cursor first.
mouse.move(*pos_m, randomize=2, delay_factor=[0.1, 0.15])
wait(0.15, 0.2)
mouse.click(button="left")
wait(0.5, 0.7)
if _wait_action_btns(npc_key, timeout=5.0):
Logger.info(f"NPC {npc_key} - dialogue open via grid sweep (retry)")
return True
_dismiss_accidental_menus()
Logger.error(f"open_npc_menu: timed out finding {npc_key} — saving screenshot")
if Config().general["info_screenshots"]:
safe_imwrite("./log/screenshots/info/info_npc_menu_timeout_" + time.strftime("%Y%m%d_%H%M%S") + ".png", grab())
return False
def press_npc_btn(npc_key: Npc, action_btn_key: str) -> bool:
global npcs
Logger.info(f"press_npc_btn: looking for '{action_btn_key}' button for NPC '{npc_key}'")
for threshold in (0.85, 0.78):
img = grab()
_, filtered_inp_w = color_filter(img, Config().colors['white'])
res = template_finder.search(
npcs[npc_key]['action_btns'][action_btn_key]['white'],
filtered_inp_w, threshold, roi=Config().ui_roi['cut_skill_bar']
)
Logger.debug(f"press_npc_btn: white search at threshold {threshold} score={res.score:.3f}")
if not res.valid and 'blue' in npcs[npc_key]['action_btns'][action_btn_key]:
_, filtered_inp_b = color_filter(img, Config().colors['blue'])
res = template_finder.search(
npcs[npc_key]['action_btns'][action_btn_key]['blue'],
filtered_inp_b, threshold, roi=Config().ui_roi['cut_skill_bar']
)
Logger.debug(f"press_npc_btn: blue search at threshold {threshold} score={res.score:.3f}")
if not res.valid:
res = template_finder.search(
npcs[npc_key]['action_btns'][action_btn_key]['white'],
img, threshold, roi=Config().ui_roi['cut_skill_bar'],
use_grayscale=True,
)
Logger.debug(f"press_npc_btn: grayscale search at threshold {threshold} score={res.score:.3f}")
if res.valid:
Logger.info(f"press_npc_btn: found '{action_btn_key}' button, clicking")
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button='left')
wait(0.2, 0.3)
center_mouse()
return True
if 'red' in npcs[npc_key]['action_btns'][action_btn_key]:
img = grab()
_, filtered_inp_r = color_filter(img, Config().colors['red'])
res = template_finder.search(
npcs[npc_key]['action_btns'][action_btn_key]['red'],
filtered_inp_r, 0.78, roi=Config().ui_roi['cut_skill_bar']
)
if res.valid:
Logger.warning(f'Cannot afford {action_btn_key} (red button detected). Skipping...')
keyboard.send('esc')
wait(0.3)
return False
Logger.error(f'press_npc_btn: Could not find {action_btn_key} btn for NPC {npc_key}. Should not happen! Continue...')
keyboard.send('esc')
return False
# Testing: Stand close to Qual-Kehk or Malah and run
if __name__ == "__main__":
from screen import grab
from config import Config
import os
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
open_npc_menu(Npc.MALAH)