walk() carried the identical unguarded click that move() had and was missed
because the tests were written against move() alone. This asserts the invariant
across the movement methods, so a future one is caught without anyone
remembering to extend the tests.
Deliberately NOT covered, because relocating these breaks what they do:
pick_up_item must click the item itself
_remap_skill_hotkey deliberately clicks the UI
cast_in_arc aims a cast direction, not a destination
Only clicks that choose a DESTINATION may be moved off the HUD. A first draft
of this test flagged all of the above and was wrong to; the distinction is
between "go here" and "hit that".
Verified by falsification: reverting walk() to randomize=5 fails with
"walk: mouse.move(x, y, randomize=5, ...)".
Co-Authored-By: Claude Opus 5 <[email protected]>
149 lines
6.0 KiB
Python
149 lines
6.0 KiB
Python
"""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()}"
|
|
)
|
|
|
|
|
|
def test_every_movement_click_in_ichar_is_guarded():
|
|
"""Scope the invariant to every MOVEMENT method, not one of them.
|
|
|
|
The first version of this guard covered move(), and its tests asserted only
|
|
on move(). walk() carried the identical unguarded click and was missed — it
|
|
is reached from bot.py's walk-back-to-town path and from poison_necro, so it
|
|
was a live second route to the same loot-filter toggle.
|
|
|
|
Only movement clicks may be relocated. Clicks that must land on a specific
|
|
thing are deliberately excluded, because moving them breaks what they do:
|
|
pick_up_item must hit the item itself
|
|
_remap_skill_hotkey deliberately clicks the UI
|
|
cast_in_arc aims a cast direction, not a destination
|
|
"""
|
|
import inspect
|
|
from char.i_char import IChar
|
|
|
|
MOVEMENT = ("move", "walk")
|
|
|
|
offenders = []
|
|
for name in MOVEMENT:
|
|
fn = getattr(IChar, name, None)
|
|
if fn is None:
|
|
continue
|
|
src = inspect.getsource(fn)
|
|
body = chr(10).join(l for l in src.splitlines() if not l.strip().startswith("#"))
|
|
parts = body.split('"""')
|
|
body = parts[0] + "".join(parts[2:]) if len(parts) > 2 else parts[0]
|
|
for line in body.splitlines():
|
|
if "mouse.move(" in line and "randomize=0" not in line:
|
|
offenders.append(f"{name}: {line.strip()}")
|
|
|
|
assert not offenders, (
|
|
"movement clicks that can land on the HUD (must go through "
|
|
"_hud_safe_target then move with randomize=0): " + "; ".join(offenders)
|
|
)
|