diff --git a/src/char/i_char.py b/src/char/i_char.py index 67b447f..cb8dfc9 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -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"]) diff --git a/test/test_hud_clicks.py b/test/test_hud_clicks.py new file mode 100644 index 0000000..93c85c2 --- /dev/null +++ b/test/test_hud_clicks.py @@ -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()}" + )