Merge pull request #35 from alexpolo1/fix/hud-click-guard
fix: move() clicked into the HUD and toggled the loot filter
This commit is contained in:
@@ -231,7 +231,41 @@ class IChar:
|
||||
if skills.wait_until_right_skill_selected("TELE_ACTIVE") == False:
|
||||
Logger.error("timeout waiting for tele skill to activate")
|
||||
|
||||
@staticmethod
|
||||
def _hud_safe_target(pos_monitor: tuple[float, float], jitter: int) -> tuple[int, int]:
|
||||
"""Jitter FIRST, then snap out of the HUD. Returns a point to click with
|
||||
randomize=0.
|
||||
|
||||
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
|
||||
construction lies exactly on the mask boundary. Applying mouse.move's
|
||||
randomize AFTER it therefore pushes the cursor straight back inside:
|
||||
randomize=3 offsets each axis by randrange(-3, 3), i.e. -3..+2. That
|
||||
made the loot-filter clicks intermittent rather than fixed — the first
|
||||
version of this guard had exactly that hole, and its test only checked
|
||||
the guard's output, never the cursor that was finally clicked.
|
||||
|
||||
Order matters: jitter, then guard, then move with no further
|
||||
randomisation. The human-like offset is preserved; the guarantee is not
|
||||
given away.
|
||||
"""
|
||||
x, y = int(pos_monitor[0]), int(pos_monitor[1])
|
||||
if jitter and jitter > 0:
|
||||
x += random.randrange(-jitter, jitter)
|
||||
y += random.randrange(-jitter, jitter)
|
||||
return get_closest_non_hud_pixel((x, y), "monitor")
|
||||
|
||||
def move(self, pos_monitor: tuple[float, float], force_tp: bool = False, force_move: bool = False):
|
||||
# Never click into the HUD. Neither branch below did this, so a move
|
||||
# target low on the screen landed on the interface — most visibly the
|
||||
# loot-filter toggles at the bottom-left (screen x 395-560, y 692-712),
|
||||
# which a right-click flips. Reported after Larzuk trips: he stands on
|
||||
# the left of Harrogath, so moves to and from him aim at that corner.
|
||||
#
|
||||
# Latent for a long time and surfaced by Enigma: the walk branch shrinks
|
||||
# its target toward centre via adjust_factor, which mostly kept clicks
|
||||
# off the HUD by accident, while the teleport branch clicks the raw
|
||||
# target. The pather's anti-stuck path already guarded this; move() did
|
||||
# not.
|
||||
factor = Config().advanced_options["pathing_delay_factor"]
|
||||
if "teleport" in Config().char and Config().char["teleport"] and (
|
||||
force_tp
|
||||
@@ -241,7 +275,8 @@ class IChar:
|
||||
)
|
||||
):
|
||||
self._set_active_skill("right", "teleport")
|
||||
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, delay_factor=[factor*0.1, factor*0.14])
|
||||
tx, ty = self._hud_safe_target(pos_monitor, 3)
|
||||
mouse.move(tx, ty, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
mouse.click(button="right")
|
||||
wait(self._cast_duration, self._cast_duration + 0.02)
|
||||
@@ -255,7 +290,8 @@ class IChar:
|
||||
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
|
||||
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
|
||||
x, y = convert_abs_to_monitor(pos_abs)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
|
||||
x, y = self._hud_safe_target((x, y), 5)
|
||||
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
if force_move:
|
||||
keyboard.send(Config().char["force_move"])
|
||||
|
||||
110
test/test_hud_clicks.py
Normal file
110
test/test_hud_clicks.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""move() must never click into the HUD.
|
||||
|
||||
Reported 2026-08-28: after a Larzuk trip the bot pressed escape and then
|
||||
toggled the LOOT FILTER. Larzuk stands on the left of Harrogath, so moves to
|
||||
and from him aim at the bottom-left corner — where D2R puts the seven
|
||||
loot-filter category buttons (screen x 395-560, y 692-712). A right-click there
|
||||
flips a filter, which changes what renders and therefore what every later
|
||||
template match can see.
|
||||
|
||||
IChar.move() applied no HUD avoidance in either branch. The pather's anti-stuck
|
||||
path already called get_closest_non_hud_pixel; move() did not.
|
||||
|
||||
Latent for a long time and surfaced by Enigma: the walk branch shrinks its
|
||||
target toward centre via adjust_factor, which mostly kept clicks off the HUD by
|
||||
accident, while the teleport branch clicks the raw target.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
|
||||
FILTER_ROW = [(400, 700), (470, 700), (550, 700), (470, 710)]
|
||||
|
||||
|
||||
def test_hud_mask_covers_the_loot_filter_buttons():
|
||||
import cv2
|
||||
mask = cv2.imread("assets/hud_mask.png", cv2.IMREAD_GRAYSCALE)
|
||||
mask = cv2.threshold(mask, 1, 255, cv2.THRESH_BINARY)[1]
|
||||
for x, y in FILTER_ROW:
|
||||
assert mask[y, x] == 0, f"loot-filter button ({x},{y}) is not masked as HUD"
|
||||
|
||||
|
||||
def test_targets_on_the_filter_row_are_moved_off_it():
|
||||
from ui_manager import get_closest_non_hud_pixel
|
||||
for x, y in FILTER_ROW:
|
||||
ox, oy = get_closest_non_hud_pixel((x + 5, y + 98), "monitor") # monitor offset
|
||||
assert (oy - 98) < 690, f"({x},{y}) still lands on the HUD at y={oy - 98}"
|
||||
|
||||
|
||||
def test_a_centre_target_is_left_alone():
|
||||
"""The guard must not perturb ordinary targets."""
|
||||
from ui_manager import get_closest_non_hud_pixel
|
||||
assert get_closest_non_hud_pixel((645, 458), "monitor") == (645, 458)
|
||||
|
||||
|
||||
def test_move_applies_the_guard():
|
||||
"""Every click target must pass through the HUD-safe helper first."""
|
||||
from char.i_char import IChar
|
||||
|
||||
src = inspect.getsource(IChar.move)
|
||||
assert "_hud_safe_target" in src, "move() can still click into the HUD"
|
||||
guard_at = src.index("_hud_safe_target")
|
||||
first_click = min([i for i in (src.find("mouse.move"), src.find("mouse.click")) if i != -1])
|
||||
assert guard_at < first_click, "the guard must run before any click"
|
||||
|
||||
|
||||
def test_the_guard_itself_masks_out():
|
||||
from char.i_char import IChar
|
||||
# Compare CODE only. The docstring mentions both names, and matching it
|
||||
# reports the order backwards — the same trap as slicing source on a branch
|
||||
# name and hitting a comment that merely mentions it.
|
||||
src = inspect.getsource(IChar._hud_safe_target)
|
||||
body = src.split('"""')[-1]
|
||||
code = chr(10).join(l for l in body.splitlines() if not l.strip().startswith("#"))
|
||||
|
||||
assert "get_closest_non_hud_pixel" in code
|
||||
jitter_at = code.index("randrange")
|
||||
guard_at = code.index("get_closest_non_hud_pixel")
|
||||
assert jitter_at < guard_at, (
|
||||
"jitter must be applied BEFORE the guard — applying it after re-enters "
|
||||
"the mask, because the guard returns a boundary pixel"
|
||||
)
|
||||
|
||||
|
||||
def test_jitter_cannot_push_the_click_back_into_the_hud():
|
||||
"""The guard returns a BOUNDARY pixel, so jitter applied after it re-enters.
|
||||
|
||||
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
|
||||
construction sits exactly on the mask edge. mouse.move(randomize=3) then
|
||||
offsets each axis by randrange(-3, 3) = -3..+2, pushing the cursor straight
|
||||
back inside. That made the loot-filter click intermittent rather than fixed.
|
||||
|
||||
This samples the FINAL point, which is what the earlier test failed to do:
|
||||
it only checked the guard's output and passed while the bug was live.
|
||||
"""
|
||||
import cv2
|
||||
from char.i_char import IChar
|
||||
|
||||
mask = cv2.imread("assets/hud_mask.png", cv2.IMREAD_GRAYSCALE)
|
||||
mask = cv2.threshold(mask, 1, 255, cv2.THRESH_BINARY)[1]
|
||||
|
||||
for x, y in FILTER_ROW:
|
||||
for _ in range(400):
|
||||
mx, my = IChar._hud_safe_target((x + 5, y + 98), 3)
|
||||
sx, sy = mx - 5, my - 98
|
||||
assert mask[sy, sx] != 0, (
|
||||
f"final click ({sx},{sy}) landed on the HUD from target ({x},{y})"
|
||||
)
|
||||
|
||||
|
||||
def test_move_clicks_with_randomization_disabled():
|
||||
"""Jitter belongs before the guard, never after it."""
|
||||
import inspect
|
||||
from char.i_char import IChar
|
||||
|
||||
src = inspect.getsource(IChar.move)
|
||||
for line in src.splitlines():
|
||||
if "mouse.move(" in line:
|
||||
assert "randomize=0" in line, (
|
||||
f"move() still randomizes after the guard, which can re-enter "
|
||||
f"the HUD: {line.strip()}"
|
||||
)
|
||||
Reference in New Issue
Block a user