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:
+147
-112
@@ -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
@@ -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"]:
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user