fix(baal_xp): join at 1+ players and survive a stale LOBBY template

Two failures observed 2026-09-02 that stopped the leech cycle completing.

min_players default 3 -> 1. A public Baal game spawns at 1-2 players and fills
within seconds, so a 3-player floor skipped every real game: all listed games
read 1-2p and were passed over, the fallback candidate's join never completed,
and the cycle failed until the controller's circuit breaker restarted D2R. The
band was always meant to be a preference rather than a gate — the in-game checks
(portal wait, party size, XP idle) are what decide whether a game is worth
staying in, so the floor was doing nothing except rejecting valid games.

LOBBY button detection hardened. Its template is the one that goes stale: after
the mod font was removed it scored 0.70 against a 0.8 threshold, so every
save-and-exit failed with "not at character select" while the screen was plainly
showing it. Now:

- retry the detect three times before giving up
- fall back to a coordinate click on the known LOBBY position when the template
  never resolves, gated on at_character_select() so we only click blind when we
  have independent confirmation of the screen
- retry open_lobby once on a transient miss

The "no character selected" path is preserved: LOBBY and PLAY are inert until a
character is highlighted, so that still reports the real cause instead of
burning the timeout on a misleading "lobby did not open".

Also refreshes config/fg_daily_estimates.json (generated data, updated by the
bot's own pricing pass).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
alexpolo1
2026-09-02 10:12:00 +02:00
co-authored by Claude Opus 5
parent d1ee54f372
commit 21c62f2cc9
4 changed files with 53 additions and 24 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
{
"generated_at": "2026-08-26T06:13:10.206599+00:00",
"generated_at": "2026-09-02T06:11:52.162227+00:00",
"mode": "offline-improved",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-20",
@@ -8,7 +8,7 @@
"skipped": {
"blocked": 2,
"no_topic": 0,
"no_date": 868,
"no_date": 872,
"no_item": 0
},
"filters": {
+8 -5
View File
@@ -665,11 +665,14 @@ join_timeout_s=180
; Difficulty of the public games to join: normal | nightmare | hell.
; Hiding in a game above the character's level is a death sentence.
difficulty=normal
; Join games in this player band. Baal runs fill within seconds, so 1-2 players
; usually means nobody is running; 8 is full and the join bounces back to the
; lobby. This is a PREFERENCE, not a hard gate: when no game in the band is
; listed, the best remaining game is joined anyway and the in-game checks
; (portal wait, party size, XP idle) decide whether it is worth staying.
; Join games in this player band. This is a PREFERENCE, not a hard gate: when
; no game in the band is listed, the best remaining game is joined anyway and
; the in-game checks (portal wait, party size, XP idle) decide whether it is
; worth staying. Keep min_players at 1: a public Baal game spawns at 1-2 players
; and fills within seconds, so a 3-player floor skips every real game and the
; join fails every cycle (observed 2026-09-02: all listed games read 1-2p and
; were skipped, the fallback candidate's join never completed, and the cycle
; failed until the controller's circuit breaker restarted D2R).
min_players=1
max_players=7
; Take the host's town portal to the Throne rather than hiding in town.
+5 -3
View File
@@ -404,9 +404,11 @@ class Config:
# character can actually survive while hiding.
"difficulty": self._normalize_difficulty(
self._select_optional("baal_xp", "difficulty", "normal")),
# A public Baal run fills within seconds; a game still showing one
# or two players is someone idling, not a run.
"min_players": int(self._select_optional("baal_xp", "min_players", "3")),
# A public Baal game spawns at 1-2 players and fills within seconds,
# so a floor above 1 skips every real game and the join fails every
# cycle. Keep the default at 1; the in-game checks (portal wait,
# party size, XP idle) are what decide whether a game is worth staying.
"min_players": int(self._select_optional("baal_xp", "min_players", "1")),
# Take the host's town portal to the Throne. XP is shared by area,
# so hiding in town earns nothing while the run is in the Worldstone.
"take_portal": self._select_optional("baal_xp", "take_portal", "1") not in (None, "", "0", "false", "False"),
+38 -14
View File
@@ -168,19 +168,37 @@ def open_lobby(timeout: float = 20.0) -> bool:
"""From the character-select screen, click LOBBY and select the JOIN GAME
tab. No-op (returns True) if that is already the state."""
if not _in_lobby():
if not (lb := detect_screen_object(ScreenObjects.LobbyBtn)).valid:
Logger.error("game_browser: not at character select - cannot open the lobby")
return False
# LOBBY and PLAY are inert until a character is selected, so a click on a
# perfectly-detected button silently does nothing. Say so instead of
# burning the full timeout on a misleading "lobby did not open".
if not is_visible(ScreenObjects.SelectedCharacter):
Logger.error("game_browser: no character selected - LOBBY will not respond. "
"The character list is empty or none is highlighted "
"(a dropped Battle.net session does this); log in again.")
return False
select_screen_object_match(lb)
# The LOBBY button is the "am I at character select" signal, but its
# template is the one that goes stale (after the mod font was removed
# it scored 0.70 against the 0.8 threshold and every save-and-exit
# failed with "not at character select" while the screen was plainly
# showing). Retry the detect a few times before giving up, and fall back
# to a coordinate click on the known LOBBY position when the template
# never resolves - a covered or stale template must not stall the whole
# join, since the button is at a fixed spot on this screen.
lb = None
for _ in range(3):
lb = detect_screen_object(ScreenObjects.LobbyBtn)
if lb.valid:
break
wait(0.4, 0.7)
if lb.valid:
# LOBBY and PLAY are inert until a character is selected, so a click on
# a perfectly-detected button silently does nothing. Say so instead of
# burning the full timeout on a misleading "lobby did not open".
if not is_visible(ScreenObjects.SelectedCharacter):
Logger.error("game_browser: no character selected - LOBBY will not respond. "
"The character list is empty or none is highlighted "
"(a dropped Battle.net session does this); log in again.")
return False
select_screen_object_match(lb)
else:
if not at_character_select():
Logger.error("game_browser: not at character select - cannot open the lobby")
return False
Logger.warning("game_browser: LOBBY button template not matching - "
"falling back to a coordinate click")
_click(LOBBY_BTN, "LOBBY (coordinate fallback)")
start = time.time()
while not _in_lobby():
if time.time() - start > timeout:
@@ -449,8 +467,14 @@ def join_game(name_filter: str = "", max_wait_s: float = 60.0,
Logger.warning("game_browser: already in a game")
return True
# open_lobby can fail on a transient template miss or a covered window.
# Retry once before giving up - the join is the whole point of the cycle.
if not open_lobby():
return False
Logger.warning("game_browser: open_lobby failed - retrying once")
wait(1.0, 1.5)
if not open_lobby():
Logger.error("game_browser: could not open the lobby")
return False
# Difficulty first: it changes which games the list shows.
if not set_difficulty(difficulty):