diff --git a/src/bot.py b/src/bot.py index 74655da..f50c486 100644 --- a/src/bot.py +++ b/src/bot.py @@ -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 diff --git a/src/config.py b/src/config.py index 9450f8f..fd47cb3 100644 --- a/src/config.py +++ b/src/config.py @@ -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"]: diff --git a/src/ui/game_browser.py b/src/ui/game_browser.py index ee96f41..52362af 100644 --- a/src/ui/game_browser.py +++ b/src/ui/game_browser.py @@ -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))