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/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/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..a7888d1 --- /dev/null +++ b/test/test_game_menu_guard.py @@ -0,0 +1,66 @@ +"""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" + + +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"