Merge pull request #38 from alexpolo1/fix/game-menu-guard

fix: the in-game ESC menu — one bug behind three separate incidents
This commit is contained in:
Alex
2026-08-28 19:30:08 +02:00
committed by GitHub
4 changed files with 130 additions and 0 deletions

View File

@@ -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:

View File

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

View File

@@ -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",

View File

@@ -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"