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:
alexpolo1
2026-09-15 17:49:15 +02:00
co-authored by Claude Opus 5
parent a2265475d6
commit f11034869b
3 changed files with 99 additions and 10 deletions
+6 -1
View File
@@ -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
View File
@@ -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
+63
View File
@@ -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()")