diff --git a/src/bot.py b/src/bot.py index 1d7747a..c7c6085 100644 --- a/src/bot.py +++ b/src/bot.py @@ -699,7 +699,12 @@ class Bot: if (m := wait_until_visible(ScreenObjects.MainMenu)).valid: if "DARK" in m.name: keyboard.send("esc") - main_menu.start_game() + if not main_menu.start_game(): + # Carrying on would run town routines in no game, or in one we did + # not create (wrong difficulty/act). + self._game_stats.set_failure_reason("Could not create a game from hero selection") + self.restart_or_exit() + return view.move_to_corpse() else: self._game_stats.set_failure_reason("Could not detect main menu to start game") diff --git a/src/ui/main_menu.py b/src/ui/main_menu.py index 61bc855..a0b1be4 100644 --- a/src/ui/main_menu.py +++ b/src/ui/main_menu.py @@ -14,19 +14,22 @@ TOWN_MARKERS = ["A5_TOWN_0", "A5_TOWN_1", "A4_TOWN_4", "A4_TOWN_5", "A3_TOWN_0", def _play_active(match) -> bool: return match.name == "PLAY_BTN" +# Returned by _wait_for_play_btn when a game appears while we are still waiting to +# press PLAY, i.e. a game the bot did not create. +IN_GAME = object() + def _wait_for_play_btn(timeout: float = 45.0): """Wait for the active PLAY button on the character-select screen. - Returns the TemplateMatch, or None on timeout. Also returns None when the - character is already in a game (town markers visible) - callers treat that - as "nothing to do" rather than an error.""" + Returns the TemplateMatch, None on timeout, or IN_GAME when the character + ends up in a game (town markers visible) before PLAY was pressed.""" start = time.time() while time.time() - start < timeout: if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid and _play_active(m): return m if template_finder.search(TOWN_MARKERS, grab(), best_match=True).valid: Logger.warning("_wait_for_play_btn: already in a game (town markers visible)") - return None + return IN_GAME wait(1, 2) Logger.error(f"_wait_for_play_btn: active play button never appeared within {timeout}s") return None @@ -45,16 +48,34 @@ def start_game() -> bool: difficulty = Config().general["difficulty"].lower() difficulty_key = "r" if difficulty == "normal" else "n" if difficulty == "nightmare" else "h" + left_unrequested_game = False try: for _ in range(3): - # Already in a game (e.g. previous game wasn't fully exited) - nothing to do. - if _in_town(): + # Already in a game on entry (e.g. previous game wasn't fully exited) - + # nothing to do. Not after we left a game we did not create: that one + # must be replaced, not resumed. + if not left_unrequested_game and _in_town(): Logger.warning("start_game: Already in town, skipping game creation") return True - # _wait_for_play_btn returns None on timeout (and when already in a - # game), so it must not be dereferenced directly - doing so crashed - # the run with "NoneType has no attribute 'valid'". m = _wait_for_play_btn() + if m is IN_GAME: + # A game started without our PLAY + difficulty key, so it runs on + # D2R's remembered difficulty - which the baal_xp leech leaves on + # its own setting. On 2026-09-15 that dropped the Hell A3 Trav char + # into a NORMAL game in Act 1: every A1 NPC/WP step failed, two + # games hit the maintenance timeout, and D2R went down. + Logger.warning(f"start_game: in a game the bot did not create (difficulty unknown) " + f"— leaving it to create a {difficulty} game") + from ui import view + if not (view.save_and_exit() or view.fast_save_and_exit()): + Logger.error("start_game: could not leave the unrequested game") + return False + left_unrequested_game = True + wait(2.0, 3.0) + continue + # None = timeout; _wait_for_play_btn already logged it. Never + # dereference it directly - that crashed the run with + # "NoneType has no attribute 'valid'". if m is None or not m.valid: # _wait_for_play_btn already logged the timeout return False diff --git a/test/test_main_menu_unrequested_game.py b/test/test_main_menu_unrequested_game.py new file mode 100644 index 0000000..dd5cd2e --- /dev/null +++ b/test/test_main_menu_unrequested_game.py @@ -0,0 +1,63 @@ +"""start_game must never keep a game it did not create. + +2026-09-15: a game appeared while start_game was still waiting for PLAY. It ran on +D2R's remembered difficulty (NORMAL, left there by the baal_xp leech), so the Hell +Act 3 Travincal character spawned in Act 1. start_game returned False, on_create_game +ignored that, and every A1 town step failed until D2R went down. +""" +from ui import main_menu + + +class _Match: + valid = True + name = "PLAY_BTN" + + +def _patch_common(monkeypatch, wait_results, in_town_results): + calls = {"exits": 0, "keys": []} + waits = iter(wait_results) + towns = iter(in_town_results) + monkeypatch.setattr(main_menu, "_wait_for_play_btn", lambda *a, **k: next(waits)) + monkeypatch.setattr(main_menu, "_in_town", lambda: next(towns)) + monkeypatch.setattr(main_menu, "stop_detecting_window", lambda: None) + monkeypatch.setattr(main_menu, "start_detecting_window", lambda: None) + monkeypatch.setattr(main_menu, "find_and_set_window_position", lambda **k: None) + monkeypatch.setattr(main_menu, "select_screen_object_match", lambda m: None) + monkeypatch.setattr(main_menu, "wait", lambda *a, **k: None) + monkeypatch.setattr(main_menu.keyboard, "press", lambda k: calls["keys"].append(k)) + monkeypatch.setattr(main_menu.keyboard, "release", lambda k: None) + monkeypatch.setattr(main_menu, "is_visible", lambda obj, *a: obj == main_menu.ScreenObjects.Loading) + + from ui import view + + def _exit(): + calls["exits"] += 1 + return True + monkeypatch.setattr(view, "save_and_exit", _exit) + monkeypatch.setattr(view, "fast_save_and_exit", _exit) + return calls + + +def test_unrequested_game_is_left_and_replaced(monkeypatch): + # entry: not in town -> game appears while waiting -> after leaving, PLAY shows + calls = _patch_common(monkeypatch, [main_menu.IN_GAME, _Match()], [False]) + assert main_menu.start_game() is True + assert calls["exits"] == 1, "the unrequested game must be exited" + assert len(calls["keys"]) == 1, "a game must then be created with the difficulty key" + + +def test_already_in_town_on_entry_is_still_kept(monkeypatch): + # baal_xp re-entry relies on this: in town before start_game did anything. + calls = _patch_common(monkeypatch, [], [True]) + assert main_menu.start_game() is True + assert calls["exits"] == 0 + assert calls["keys"] == [] + + +def test_on_create_game_does_not_continue_after_failed_start(): + import inspect + from bot import Bot + + src = inspect.getsource(Bot.on_create_game) + assert "if not main_menu.start_game()" in src + assert src.index("if not main_menu.start_game()") < src.index("view.move_to_corpse()")