fix(start_game): leave a game the bot did not create instead of running in it
2026-09-15 16:33: 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 A3 Travincal character spawned in Act 1. start_game returned False, on_create_game ignored the result, and every A1 NPC/WP step failed; two games hit the maintenance timeout and D2R went down. - _wait_for_play_btn returns IN_GAME (not None) for that case; start_game save+exits it and creates a game with the configured difficulty key. Being in town on ENTRY is still accepted (baal_xp re-entry relies on it). - on_create_game no longer continues after a failed start_game. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a2265475d6
commit
f11034869b
+6
-1
@@ -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")
|
||||
|
||||
+30
-9
@@ -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
|
||||
|
||||
@@ -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()")
|
||||
Reference in New Issue
Block a user