From 914a107a7274e7921e1d3afb87d394014cef345a Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 18:53:15 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(health):=20detect=20and=20close=20the?= =?UTF-8?q?=20in-game=20ESC=20menu=20=E2=80=94=20one=20bug=20behind=20thre?= =?UTF-8?q?e=20incidents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot was found sitting in OPTIONS -> VIDEO with a "settings have changed, apply or discard?" modal. Discarding revealed the cause: the in-game ESC menu carries these buttons at SCREEN CENTRE. OPTIONS / SAVE AND EXIT / RETURN TO GAME / LOOT FILTER / CHRONICLE A stray esc opens the menu and the bot's next movement click lands on one of them. The HUD mask deliberately leaves screen centre clickable, so nothing stops it. That single mechanism explains three incidents previously treated as separate: - the LOOT FILTER being toggled (blamed on vigor=f4, which was a real but different bug) - CHRONICLE blanking every template match for a whole run, costing a 66s click_red_portal failure - the video OPTIONS being opened and a setting changed, which could have altered resolution and broken every template in the project The menu has NO close button, so the CenterPanel guard (CLOSE_PANEL_2) cannot see it. Bug 31 warned about precisely this: "just send esc is WORSE: with nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not match". SAVE_AND_EXIT_NO_HIGHLIGHT scores 1.000 on the menu frame and does not match a normal town frame, so detection is unambiguous. Escaped without counting toward a chicken, bounded like the waypoint and centred-panel cases. Also adds MANA> threshold-crossing logging for #23. That issue measured "1 mana potion per game, never 2" over 70 games but was undecidable, because mana is only logged when a potion is DRUNK — a second dip that failed to trigger looks identical to mana never dipping twice. Every crossing is now logged with the gate state, edge-triggered so it fires once per crossing rather than per poll. NOT fixed here: whatever sends the stray esc. This is the safety net; the source is still unknown. Co-Authored-By: Claude Opus 5 --- src/health_manager.py | 39 ++++++++++++++++++++++++++++++ src/ui_manager.py | 10 ++++++++ test/test_game_menu_guard.py | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 test/test_game_menu_guard.py diff --git a/src/health_manager.py b/src/health_manager.py index c9dba61..6e590d9 100644 --- a/src/health_manager.py +++ b/src/health_manager.py @@ -38,6 +38,7 @@ class HealthManager: self._count_panel_detects = 0 self._count_wp_panel_detects = 0 self._count_center_panel_detects = 0 + self._mana_below_threshold = False def stop_monitor(self): self._do_monitor = False @@ -178,6 +179,22 @@ class HealthManager: self._last_health = time.time() # check mana last_drink = time.time() - self._last_mana + # Issue #23 instrumentation. "1 mana potion per game, + # never 2" was measured over 70 games, but the cause + # was undecidable: mana is only logged when a potion + # is DRUNK, so a second dip that failed to trigger + # looks identical to mana never dipping twice. Log + # every crossing of the threshold, whether or not it + # results in a drink. + _mana_low = mana_percentage <= Config().char["take_mana_potion"] + if _mana_low and not self._mana_below_threshold: + Logger.debug( + f"MANA> crossed below {Config().char['take_mana_potion']:.2f} " + f"at {mana_percentage*100:.1f}% " + f"(last drink {last_drink:.1f}s ago, gate {lp_mp_potion_delay}s) " + f"-> {'will drink' if last_drink > lp_mp_potion_delay else 'BLOCKED by gate'}" + ) + self._mana_below_threshold = _mana_low if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay: wait(0.05, 0.1) if belt.drink_potion("mana", stats=[health_percentage, mana_percentage]): @@ -209,6 +226,28 @@ class HealthManager: # A5_RED_PORTAL unfindable, 66s approach failure. It is not a # threat, so escape it without counting toward a chicken, # bounded the same way as the waypoint panel. + # The in-game ESC menu. A stray esc opens it, and its LOOT + # FILTER / CHRONICLE / OPTIONS buttons sit at screen centre + # where movement clicks land — which is how the loot filter + # got toggled, how Chronicle blocked every template match, + # and how the bot ended up in the video options with a + # "settings have changed" modal. It has no close button, so + # CenterPanel cannot detect it. esc toggles it shut. + if not self.get_panel_check_paused() and is_visible(ScreenObjects.GameMenu, img): + self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1 + if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES: + Logger.debug( + f"In-game menu open (its buttons sit under movement clicks) — closing it " + f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})" + ) + from input_layer import keyboard as kb + kb.send("esc") + wait(0.1, 0.2) + fn_end = time.perf_counter() + wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2))) + continue + Logger.warning("In-game menu would not close — treating as a blocking panel") + if not self.get_panel_check_paused() and is_visible(ScreenObjects.CenterPanel, img): self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1 if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES: diff --git a/src/ui_manager.py b/src/ui_manager.py index 74b5fb0..c3aeb4f 100644 --- a/src/ui_manager.py +++ b/src/ui_manager.py @@ -225,6 +225,16 @@ class ScreenObjects: threshold=0.8, use_grayscale=True ) + GameMenu=ScreenObject( + # The in-game ESC menu. It has NO close button, so CenterPanel cannot + # see it, and it carries LOOT FILTER / CHRONICLE / OPTIONS buttons at + # screen centre — where the HUD mask deliberately allows clicks. A + # stray esc opens it and the next movement click lands on a button. + ref=["SAVE_AND_EXIT_NO_HIGHLIGHT", "SAVE_AND_EXIT_HIGHLIGHT"], + roi="reduce_to_center", + threshold=0.8, + use_grayscale=True + ) CenterPanel=ScreenObject( ref=["CLOSE_PANEL_2", "CLOSE_PANEL"], roi="center_panel_header", diff --git a/test/test_game_menu_guard.py b/test/test_game_menu_guard.py new file mode 100644 index 0000000..18a153c --- /dev/null +++ b/test/test_game_menu_guard.py @@ -0,0 +1,47 @@ +"""The in-game ESC menu must be detected and closed. + +2026-08-28: the bot was found sitting in OPTIONS -> VIDEO with a "settings have +changed, apply or discard?" modal. Discarding revealed the cause — the in-game +ESC menu carries these buttons at SCREEN CENTRE: + + OPTIONS / SAVE AND EXIT / RETURN TO GAME / LOOT FILTER / CHRONICLE + +A stray esc opens the menu, and the bot's next movement click lands on one of +them. The HUD mask deliberately leaves screen centre clickable, so nothing +stops it. + +That single mechanism explains three separate incidents: the loot filter being +toggled, CHRONICLE blanking every template match for a whole run, and the video +options being opened and changed. + +It has NO close button, so CenterPanel (which matches CLOSE_PANEL_2) cannot see +it. Bug 31 warned about exactly this: "just send esc is WORSE: with nothing +open, esc opens the GAME MENU". +""" +import inspect + + +def test_game_menu_screenobject_exists(): + from ui_manager import ScreenObjects + assert hasattr(ScreenObjects, "GameMenu") + + +def test_guard_closes_the_menu_before_chickening(): + from health_manager import HealthManager + src = inspect.getsource(HealthManager) + assert "ScreenObjects.GameMenu" in src, "the guard never checks for the in-game menu" + menu_at = src.index("ScreenObjects.GameMenu") + chicken_at = src.index("Chickening to be safe") + assert menu_at < chicken_at, "menu escape must come before the chicken path" + + +def test_mana_threshold_crossings_are_logged(): + """Issue #23 cannot be settled without this. + + Mana was only logged when a potion was DRUNK, so a second dip that failed to + trigger was indistinguishable from mana never dipping twice. + """ + from health_manager import HealthManager + src = inspect.getsource(HealthManager) + assert "MANA>" in src, "no threshold-crossing log; #23 stays undecidable" + assert "_mana_below_threshold" in src, "crossings are not edge-triggered" From a139fb32303ef46e207f8e9641b03dc5dd83f9f7 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 19:26:45 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(view):=20pause=20the=20panel=20check=20?= =?UTF-8?q?during=20save=5Fand=5Fexit=20=E2=80=94=20the=20menu=20guard=20r?= =?UTF-8?q?aced=20the=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the GameMenu guard, caught by its own instrumentation: 75 menu escapes across 15 games, ~5 per game, all clustered around game end. save_and_exit deliberately opens the in-game ESC menu, but callers only pause the panel check AFTER it returns: 18:58:48.142 game | end | ok 18:58:48.334 In-game menu open - closing it (1/6) <- the guard 18:58:48.487 Clicking SAVE_AND_EXIT_NO_HIGHLIGHT <- the bot 18:58:48.736 In-game menu open - closing it (2/6) <- the guard again 18:58:49.098 Health Manager is now paused <- too late Games still completed, so this was noise rather than breakage — but it is a race, and the guard was pressing esc while the shutdown clicked the menu. save_and_exit now pauses the panel check for the whole sequence and restores it in a finally, so it cannot leak the paused state if save/exit raises. The guard itself is working: 15 games, 0 failures, 0 portal failures, and the loot-filter/Chronicle/Options incidents have not recurred. MANA> instrumentation has also settled #23 — see the issue. Co-Authored-By: Claude Opus 5 --- src/ui/view.py | 15 +++++++++++++++ test/test_game_menu_guard.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/ui/view.py b/src/ui/view.py index daf5ea1..510f807 100644 --- a/src/ui/view.py +++ b/src/ui/view.py @@ -71,6 +71,21 @@ def save_and_exit() -> bool: Performes save and exit action from within game :return: Bool if action was successful """ + # Pause the panel check for the WHOLE sequence. This function deliberately + # opens the in-game ESC menu, and the health manager now closes that menu on + # sight (its LOOT FILTER / CHRONICLE / OPTIONS buttons sit under movement + # clicks). Callers only pause AFTER save_and_exit returns, so without this + # the guard raced the shutdown — observed as 75 escapes across 15 games, + # interleaved with the save/exit clicks. + from health_manager import set_panel_check_paused + set_panel_check_paused(True) + try: + return _save_and_exit_inner() + finally: + set_panel_check_paused(False) + + +def _save_and_exit_inner() -> bool: # if exit button isn't detected already, press escape attempts = 1 success = False diff --git a/test/test_game_menu_guard.py b/test/test_game_menu_guard.py index 18a153c..a7888d1 100644 --- a/test/test_game_menu_guard.py +++ b/test/test_game_menu_guard.py @@ -45,3 +45,22 @@ def test_mana_threshold_crossings_are_logged(): src = inspect.getsource(HealthManager) assert "MANA>" in src, "no threshold-crossing log; #23 stays undecidable" assert "_mana_below_threshold" in src, "crossings are not edge-triggered" + + +def test_save_and_exit_pauses_the_panel_check(): + """The guard must not fight the shutdown it was built to protect. + + save_and_exit deliberately opens the in-game ESC menu. The health manager + now closes that menu on sight, and callers only pause AFTER save_and_exit + returns — so the guard raced the shutdown: 75 escapes across 15 games, + interleaved with the save/exit clicks. + """ + import inspect + from ui import view + + src = inspect.getsource(view.save_and_exit) + assert "set_panel_check_paused(True)" in src, ( + "save_and_exit opens the ESC menu without pausing the panel check, so " + "the GameMenu guard will close it mid-shutdown" + ) + assert "finally" in src, "the pause must be released even if save/exit raises"