fix(baal_xp): harden handler — real DeathManager, stealth hide-click,

exception safety, on_end_run town shortcut, config enabled parse

- bot.py: GameRecovery(None) -> GameRecovery(DeathManager()) (None crashed
  on death-screen handling); hide spot now clicks once (char actually walks
  there); wait loop breaks on any non-InGame screen (death/kick) instead of
  only MainMenu; whole handler wrapped in try/except with best-effort
  recovery so the bot never stops; on_end_run() short-circuits for baal_xp
  (char is already in town — skip TP logic, go straight to maintenance)
- config.py: enabled parsed as plain bool (was bool(int(str)) crash on
  'true'); empty game_name_filter no longer crashes float()/int() overrides
- game_browser.py: drop unused imports (keyboard, Config, focus_d2r_window,
  select_screen_object_match)
This commit is contained in:
alexpolo1
2026-08-24 23:13:00 +02:00
parent 09bfd330e6
commit a014fc538e
3 changed files with 167 additions and 129 deletions
+147 -112
View File
@@ -42,6 +42,7 @@ from ui import meters, skills, view, character_select, main_menu, game_browser
from ui import player_bar
from inventory import personal, vendor, belt, common
from game_recovery import GameRecovery
from death_manager import DeathManager
from run import Pindle, ShenkEld, Trav, Nihlathak, Arcane, Diablo, Vizier, Level
from run import ColdPlains
@@ -878,6 +879,16 @@ class Bot:
self.trigger_or_stop("init")
def on_end_run(self):
# baal_xp is a self-contained cycle: it leaves the public game and re-enters
# its own game inside the handler, so the character is already standing in
# town when we get here. Skip the TP-to-town logic and go straight to
# maintenance (which re-detects the act and does the town routine).
if self.state == "baal_xp":
if not self._curr_loc:
self._curr_loc = self._verify_town_location()
set_pause_state(True)
self.trigger_or_stop("maintenance")
return
if not Config().char["pre_buff_every_run"]:
self._pre_buffed = True
# No-TP levelling chars (e.g. a pre-clvl-18 sorc): the run object
@@ -1130,8 +1141,14 @@ class Bot:
def on_run_baal_xp(self):
"""Baal XP farm: leave own game, join a public game, hide, collect XP, leave.
This is a self-contained cycle — it does NOT use the run_wrapper/approach/battle
pattern because it exits and re-enters the game entirely."""
Self-contained cycle — it exits and re-enters the game entirely, so it does
NOT use the run_wrapper/approach/battle pattern. The character is back in
its own game (town) when this returns; on_end_run() then triggers maintenance.
The hide-and-wait phase is a pure wait: no maintenance, no runs, no pathing.
The health manager thread keeps running (auto-potions). Death or low HP
leaves the game immediately; the game_controller's death handling takes
over from there (corpse pickup on respawn)."""
self._game_stats.update_location("BaalXP")
self._do_runs["run_baal_xp"] = False
self._game_stats.log_run_started("run_baal_xp")
@@ -1140,142 +1157,160 @@ class Bot:
cfg = Config().baal_xp
Logger.info(f"=== BAAL XP FARM === filter='{cfg['game_name_filter']}' max_wait={cfg['max_wait_s']}s xp_target={cfg['xp_threshold']}")
# --- Phase 1: Leave current game, get to hero selection ---
if is_visible(ScreenObjects.InGame):
if not view.fast_save_and_exit():
Logger.error("baal_xp: fast_save_and_exit failed")
self._game_stats.set_failure_reason("baal_xp: save_and_exit failed")
self._save_error_screenshot("baal_xp", "save_exit_failed")
try:
# --- Phase 1: Leave current game, get to hero selection ---
if is_visible(ScreenObjects.InGame):
if not view.fast_save_and_exit():
Logger.error("baal_xp: fast_save_and_exit failed")
self._game_stats.set_failure_reason("baal_xp: save_and_exit failed")
self._save_error_screenshot("baal_xp", "save_exit_failed")
self.trigger_or_stop("end_run")
return
# Wait for hero selection (main menu)
if not GameRecovery(DeathManager()).go_to_hero_selection():
Logger.error("baal_xp: could not reach hero selection")
self._game_stats.set_failure_reason("baal_xp: hero selection unreachable")
self.trigger_or_stop("end_run")
return
# Wait for hero selection (main menu)
_recovery = GameRecovery(None)
if not _recovery.go_to_hero_selection():
Logger.error("baal_xp: could not reach hero selection")
self._game_stats.set_failure_reason("baal_xp: hero selection unreachable")
self.trigger_or_stop("end_run")
return
# --- Phase 2: Select character and join a public game ---
if not character_select.select_char():
Logger.error("baal_xp: character selection failed")
self._game_stats.set_failure_reason("baal_xp: char select failed")
self.trigger_or_stop("end_run")
return
# --- Phase 2: Select character and join a public game ---
if not character_select.select_char():
Logger.error("baal_xp: character selection failed")
self._game_stats.set_failure_reason("baal_xp: char select failed")
self.trigger_or_stop("end_run")
return
if not game_browser.join_game(
name_filter=cfg["game_name_filter"],
max_wait_s=cfg["join_timeout_s"],
):
Logger.error("baal_xp: failed to join a game")
self._game_stats.set_failure_reason("baal_xp: game join failed")
self._save_error_screenshot("baal_xp", "join_failed")
# Go back to our own game
self._recover_to_own_game()
self.trigger_or_stop("end_run")
return
if not game_browser.join_game(
name_filter=cfg["game_name_filter"],
max_wait_s=cfg["join_timeout_s"],
):
Logger.error("baal_xp: failed to join a game")
self._game_stats.set_failure_reason("baal_xp: game join failed")
self._save_error_screenshot("baal_xp", "join_failed")
# Go back to our own game
self._recover_to_own_game()
self.trigger_or_stop("end_run")
return
# --- Phase 3: Wait for in-game ---
if not game_browser.wait_for_in_game(timeout=60):
Logger.error("baal_xp: never entered the joined game")
self._game_stats.set_failure_reason("baal_xp: in-game timeout")
self._recover_to_own_game()
self.trigger_or_stop("end_run")
return
# --- Phase 3: Wait for in-game ---
if not game_browser.wait_for_in_game(timeout=60):
Logger.error("baal_xp: never entered the joined game")
self._game_stats.set_failure_reason("baal_xp: in-game timeout")
self._recover_to_own_game()
self.trigger_or_stop("end_run")
return
# --- Phase 4: Enter game setup ---
# Handle corpse if we died in this game before
if is_visible(ScreenObjects.Corpse):
view.pickup_corpse()
wait_until_hidden(ScreenObjects.Corpse)
belt.fill_up_belt_from_inventory(Config().char["num_loot_columns"])
self._char.discover_capabilities()
# /nopickup so we don't grab loot from other players
if Config().char["enable_no_pickup"] and not self._game_stats._nopickup_active:
if view.enable_no_pickup():
self._game_stats._nopickup_active = True
Logger.info("baal_xp: /nopickup active")
# Pre-buff
self._char.pre_buff()
# --- Phase 4: Enter game setup ---
# Handle corpse if we died in this game before
if is_visible(ScreenObjects.Corpse):
view.pickup_corpse()
wait_until_hidden(ScreenObjects.Corpse)
belt.fill_up_belt_from_inventory(Config().char["num_loot_columns"])
self._char.discover_capabilities()
# /nopickup so we don't grab loot from other players
if Config().char["enable_no_pickup"] and not self._game_stats._nopickup_active:
if view.enable_no_pickup():
self._game_stats._nopickup_active = True
Logger.info("baal_xp: /nopickup active")
# Pre-buff
self._char.pre_buff()
# --- Phase 5: Walk to hide spot and stand still ---
hide_x, hide_y = cfg["hide_x"], cfg["hide_y"]
hide_pos = convert_screen_to_monitor((hide_x, hide_y))
Logger.info(f"baal_xp: walking to hide spot ({hide_x}, {hide_y})")
from input_layer import mouse
mouse.move(*hide_pos)
wait(1.5, 2.5) # give the character time to walk there
# --- Phase 5: Walk to hide spot and stand still ---
# One click to the configured screen position, then zero input while
# hiding (anti-cheat stealth: minimal input while AFK).
hide_x, hide_y = cfg["hide_x"], cfg["hide_y"]
hide_pos = convert_screen_to_monitor((hide_x, hide_y))
Logger.info(f"baal_xp: walking to hide spot ({hide_x}, {hide_y})")
mouse.move(*hide_pos)
mouse.click("left")
wait(2.0, 3.0) # give the character time to walk there
# Record starting XP
try:
start_exp, _ = player_bar.get_experience()
except Exception:
start_exp = 0
Logger.info(f"baal_xp: starting XP = {start_exp}")
# Record starting XP
try:
start_exp, _ = player_bar.get_experience()
except Exception:
start_exp = 0
Logger.info(f"baal_xp: starting XP = {start_exp}")
# --- Phase 6: Hide and wait ---
max_wait = cfg["max_wait_s"]
xp_target = cfg["xp_threshold"]
min_hp = cfg["min_hp_pct"]
start_time = time.time()
last_log = 0.0
# --- Phase 6: Hide and wait ---
# Pure wait loop: no maintenance, no runs. The health manager thread
# keeps running (auto-potions). Leave on: max timer, XP target,
# low HP, death, or being kicked back to the main menu.
max_wait = cfg["max_wait_s"]
xp_target = cfg["xp_threshold"]
min_hp = cfg["min_hp_pct"]
start_time = time.time()
last_log = 0.0
last_xp_check = 0.0
while True:
elapsed = time.time() - start_time
if elapsed > max_wait:
Logger.info(f"baal_xp: max wait {max_wait}s reached — leaving")
break
while True:
elapsed = time.time() - start_time
if elapsed > max_wait:
Logger.info(f"baal_xp: max wait {max_wait}s reached — leaving")
break
img = grab()
if not is_visible(ScreenObjects.InGame, img):
# Death screen, kicked to menu, or loading — bail out.
# The death manager thread handles the death screen itself.
Logger.warning("baal_xp: left in-game state (kicked/died) — leaving")
break
# Check HP
img = grab()
hp_pct = 100.0
if is_visible(ScreenObjects.InGame, img):
hp_pct = meters.get_health(img) * 100
if hp_pct < min_hp:
Logger.warning(f"baal_xp: HP {hp_pct:.0f}% < {min_hp}% — chickening out")
break
# Check XP (every 30s to avoid OCR spam)
if time.time() - last_log > 30:
last_log = time.time()
try:
cur_exp, req_exp = player_bar.get_experience()
gained = cur_exp - start_exp if cur_exp > start_exp else 0
Logger.info(f"baal_xp: {elapsed:.0f}s in | XP {cur_exp} (+{gained}) | HP {hp_pct:.0f}%")
if gained >= xp_target:
Logger.info(f"baal_xp: XP target {xp_target} reached (+{gained}) — leaving")
break
except Exception as e:
Logger.debug(f"baal_xp: XP check failed: {e}")
# Check XP every 30s to avoid OCR spam
if time.time() - last_xp_check > 30:
last_xp_check = time.time()
try:
cur_exp, _ = player_bar.get_experience()
gained = cur_exp - start_exp if cur_exp > start_exp else 0
Logger.info(f"baal_xp: {elapsed:.0f}s in | XP {cur_exp} (+{gained}) | HP {hp_pct:.0f}%")
if gained >= xp_target:
Logger.info(f"baal_xp: XP target {xp_target} reached (+{gained}) — leaving")
break
except Exception as e:
Logger.debug(f"baal_xp: XP check failed: {e}")
# Check if we got kicked (back at main menu)
if is_visible(ScreenObjects.MainMenu):
Logger.warning("baal_xp: kicked from game (main menu visible) — leaving")
break
if time.time() - last_log > 60:
last_log = time.time()
Logger.debug(f"baal_xp: hiding... {elapsed:.0f}s in, HP {hp_pct:.0f}%")
wait(3, 5)
wait(3, 5)
# --- Phase 7: Leave the game ---
Logger.info("baal_xp: leaving game")
if not view.fast_save_and_exit():
Logger.error("baal_xp: save_and_exit on leave failed")
self._game_stats.set_failure_reason("baal_xp: leave failed")
self._save_error_screenshot("baal_xp", "leave_failed")
# --- Phase 7: Leave the game ---
Logger.info("baal_xp: leaving game")
if is_visible(ScreenObjects.InGame) and not view.fast_save_and_exit():
Logger.error("baal_xp: save_and_exit on leave failed")
self._game_stats.set_failure_reason("baal_xp: leave failed")
self._save_error_screenshot("baal_xp", "leave_failed")
# --- Phase 8: Recover to own game ---
self._recover_to_own_game()
# --- Phase 8: Recover to own game ---
self._recover_to_own_game()
# Log run result
self._game_stats.log_run_finished("run_baal_xp", False, None, loot=[])
self._game_stats.log_exp()
self.trigger_or_stop("end_run")
# Log run result
self._game_stats.log_run_finished("run_baal_xp", False, None, loot=[])
self._game_stats.log_exp()
self.trigger_or_stop("end_run")
except Exception as e:
import traceback
tb = traceback.format_exc()
error_msg = f"baal_xp exception: {type(e).__name__}: {e}"
Logger.error(error_msg)
Logger.error(tb)
self._game_stats.set_failure_reason(error_msg)
self._save_error_screenshot("baal_xp", f"exception_{type(e).__name__}")
# Best effort: get back to our own game so the bot keeps running
self._recover_to_own_game()
self._game_stats.log_run_finished("run_baal_xp", True, None, loot=[])
self.trigger_or_stop("end_run")
def _recover_to_own_game(self):
"""After leaving a public game, get back to hero selection and re-enter our own game."""
# Wait for hero selection
if not is_visible(ScreenObjects.MainMenu):
_recovery = GameRecovery(None)
if not _recovery.go_to_hero_selection():
if not GameRecovery(DeathManager()).go_to_hero_selection():
Logger.error("baal_xp: could not reach hero selection after leaving public game")
return
# Select character
+15 -12
View File
@@ -392,8 +392,8 @@ class Config:
self.cold_plains.update(dict(self.configs["custom"]["parser"]["cold_plains"]))
# Baal XP farm (join public games, hide, collect XP, leave)
self.baal_xp = {
"enabled": bool(int(self._select_optional("baal_xp", "enabled", "0"))),
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", ""),
"enabled": self._select_optional("baal_xp", "enabled", "0") not in (None, "", "0", "false", "False"),
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", "") or "",
"max_wait_s": float(self._select_optional("baal_xp", "max_wait_s", "900")),
"xp_threshold": int(self._select_optional("baal_xp", "xp_threshold", "50000000")),
"min_hp_pct": float(self._select_optional("baal_xp", "min_hp_pct", "40")),
@@ -401,16 +401,19 @@ class Config:
"hide_y": int(self._select_optional("baal_xp", "hide_y", "360")),
"join_timeout_s": float(self._select_optional("baal_xp", "join_timeout_s", "60")),
}
if "baal_xp" in self.configs["profile"]["parser"]:
for k in list(self.baal_xp.keys()):
if k in self.configs["profile"]["parser"]["baal_xp"]:
raw = self.configs["profile"]["parser"]["baal_xp"][k]
self.baal_xp[k] = type(self.baal_xp[k])(raw) if not isinstance(self.baal_xp[k], str) else raw
if "baal_xp" in self.configs["custom"]["parser"]:
for k in list(self.baal_xp.keys()):
if k in self.configs["custom"]["parser"]["baal_xp"]:
raw = self.configs["custom"]["parser"]["baal_xp"][k]
self.baal_xp[k] = type(self.baal_xp[k])(raw) if not isinstance(self.baal_xp[k], str) else raw
for _baal_xp_src in ("profile", "custom"):
if "baal_xp" in self.configs[_baal_xp_src]["parser"]:
_baal_xp_over = self.configs[_baal_xp_src]["parser"]["baal_xp"]
for k in list(self.baal_xp.keys()):
if k not in _baal_xp_over:
continue
raw = _baal_xp_over[k]
if k == "enabled":
self.baal_xp[k] = raw not in (None, "", "0", "false", "False")
elif isinstance(self.baal_xp[k], str):
self.baal_xp[k] = raw if raw is not None else ""
else:
self.baal_xp[k] = type(self.baal_xp[k])(raw)
# Sorc base config
sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"])
if "sorceress" in self.configs["profile"]["parser"]:
+5 -5
View File
@@ -10,11 +10,10 @@ No dedicated templates exist for the browser UI, so this module relies on:
- ScreenObjects.Loading / InGame for confirming the join succeeded
"""
import time
from input_layer import keyboard, mouse
from config import Config
from utils.misc import wait, cut_roi, focus_d2r_window
from input_layer import mouse
from utils.misc import wait, cut_roi
from logger import Logger
from ui_manager import detect_screen_object, is_visible, select_screen_object_match, ScreenObjects
from ui_manager import detect_screen_object, is_visible, ScreenObjects
from screen import grab, convert_screen_to_monitor, find_and_set_window_position, stop_detecting_window, start_detecting_window
from d2r_image import ocr
@@ -70,7 +69,8 @@ def _scan_game_list(img) -> list[dict]:
def _click_game_row(y_center_screen: int) -> None:
"""Click a game row at the given screen y-coordinate (center of list horizontally)."""
"""Click a game row at the given screen y-coordinate (center of list horizontally).
Single click D2R's game browser joins on one click (no confirm dialog)."""
x, y, w, h = _GAME_LIST_ROI
click_x = x + w // 2
pos = convert_screen_to_monitor((click_x, y_center_screen))