Files
my-botty/test/test_hud_clicks.py
alexpolo1andClaude Opus 5 ee39aa5dbf test: scope the HUD-click invariant to every movement method, not just move()
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]>
2026-08-28 16:57:01 +02:00

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)
)