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]>
568 lines
31 KiB
Python
568 lines
31 KiB
Python
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 30–130 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)
|