diff --git a/assets/templates/a5_wp_2.png b/assets/templates/a5_wp_2.png new file mode 100644 index 0000000..6c2276a Binary files /dev/null and b/assets/templates/a5_wp_2.png differ diff --git a/config/params.ini b/config/params.ini index 18ffde7..e3651c7 100644 --- a/config/params.ini +++ b/config/params.ini @@ -382,6 +382,15 @@ runs_per_repair=5 ; 2026-06-10: switched to a5_larzuk — session logs showed Halbu detection failing ; 100% (body score ~0.39) and each failed A4 trip desynced the bot's act state. repair_npc=a5_larzuk +; resurrect_npc: which NPC revives the mercenary. Blank = whichever act the character is +; already in. Set to a4_tyrael to always use Act 4 Tyrael. +; Measured 2026-08-27 on this setup: +; A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever +; A5 Qual-Kehk fail 113.6s / 52.0s / 163.1s / 72.6s - 43 errors +; Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC in +; the route. A failed hunt costs more than the whole A5->A4 trip, and one 163s hunt pushed +; the town visit past max_maintenance_time_s and killed the game. +resurrect_npc=a4_tyrael ; runs_per_stash: stash/sell every X runs (blank/0 disables) runs_per_stash=4 ; sell_junk: 1 = vendor non-keep items automatically diff --git a/src/bot.py b/src/bot.py index ab7a74d..f196858 100644 --- a/src/bot.py +++ b/src/bot.py @@ -969,23 +969,18 @@ class Bot: keyboard.send("o") wait(0.3, 0.4) merc_panel_open = is_visible(ScreenObjects.MercPanelText) + # ALWAYS toggle 'o' back, whatever it opened. Symmetry is the only safe + # dismiss here: + # - closing only when MercPanelText matched left the CHRONICLE panel + # (what 'o' actually opens on this client) up for the whole game + # - "just send esc" is WORSE: with nothing open, esc opens the GAME MENU, + # which LeftPanel/RightPanel do not match, so nothing closes that either + # and every later template search sees a menu instead of the town + # 'o' opened it, so 'o' closes it, and it cannot open something new. + keyboard.send("o") + wait(0.25, 0.35) if merc_panel_open: - # Merc is alive - panel opened, close it - keyboard.send("o") - wait(0.2, 0.3) merc_visible = True - else: - # 'o' opened SOMETHING that is not the merc panel and nothing closed it. - # On this client it opens the CHRONICLE collection panel, which covers - # the screen and blanks every later template match — the run then dies - # on click_red_portal after ~66s of flailing at a covered screen - # (g90 2026-08-27). Always dismiss whatever appeared, then confirm. - keyboard.send("esc") - wait(0.25, 0.35) - if is_visible(ScreenObjects.LeftPanel) or is_visible(ScreenObjects.RightPanel): - Logger.warning("Merc check: a panel is still open after esc — sending esc again") - keyboard.send("esc") - wait(0.25, 0.35) gs = self._game_stats skip_until = getattr(gs, "_merc_resurrect_skip_until", 0) if not merc_visible and gs._game_counter < skip_until: diff --git a/src/char/i_char.py b/src/char/i_char.py index f67cd6e..0d98d0d 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -130,7 +130,8 @@ class IChar: success_func: Callable = None, timeout: float = 8, threshold: float = 0.68, - telekinesis: bool = False + telekinesis: bool = False, + roi: list = None ) -> bool: """ Finds any template from the template finder and interacts with it @@ -138,6 +139,10 @@ class IChar: :param success_func: Function that will return True if the interaction is successful e.g. return True when loading screen is reached, defaults to None :param timeout: Timeout for the whole template selection, defaults to None :param threshold: Threshold which determines if a template is found or not. None will use default form .ini files + :param roi: Optional [left, top, width, height] to restrict the search. Pass + ui_roi["cut_skill_bar"] for WORLD objects: the HUD is static, so a template + that happens to resemble part of it wins every frame and the real object is + never clicked. Cost a whole evening of A5_WP failures on 2026-08-27. :return: True if success. False otherwise """ templates = template_type if isinstance(template_type, list) else [template_type] @@ -148,7 +153,7 @@ class IChar: wait(0.2, 0.3) start = time.time() while timeout is None or (time.time() - start) < timeout: - template_match = template_finder.search(template_type, grab(), threshold=threshold) + template_match = template_finder.search(template_type, grab(), threshold=threshold, roi=roi) if template_match.valid: Logger.debug(f"Select {template_match.name} ({template_match.score*100:.1f}% confidence)") mouse.move(*template_match.center_monitor) diff --git a/src/config.py b/src/config.py index a9caf2b..bc3ed50 100644 --- a/src/config.py +++ b/src/config.py @@ -376,6 +376,7 @@ class Config: "runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")), "runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")), "repair_npc": self._select_optional("char", "repair_npc", "a4_halbu").strip().lower(), + "resurrect_npc": self._select_optional("char", "resurrect_npc", "").strip().lower(), "gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","), "sell_junk": bool(int(self._select_val("char", "sell_junk"))), "protect_shields_from_sell": bool(int(self._select_optional("char", "protect_shields_from_sell", "1"))), diff --git a/src/town/a5.py b/src/town/a5.py index 392a44d..55fbc00 100644 --- a/src/town/a5.py +++ b/src/town/a5.py @@ -7,6 +7,7 @@ import template_finder from utils.misc import wait from ui_manager import ScreenObjects, is_visible from logger import Logger +from config import Config from input_layer import mouse @@ -146,11 +147,23 @@ class A5(IAct): deadline = time.time() + self._OPEN_WP_BUDGET_S found_wp_func = lambda: is_visible(ScreenObjects.WaypointLabel) + # The waypoint is a WORLD object, so never let the HUD compete. main's recaptured + # a5_wp.png matches the belt/mana-orb area at 0.966 — above threshold and higher + # than the real stone — so every search locked onto (980, 656), clicked the HUD, + # and open_wp burned ~83s escalating through anchors before giving up. That took + # buy_consumables to 222-253s and tripped the 240s maintenance timeout. + _wp_roi = Config().ui_roi["cut_skill_bar"] + # Search BOTH waypoint captures and take the best. main recaptured a5_wp.png + # (125x112); on this client it only reaches ~0.50-0.58 on the real stone while the + # older capture (a5_wp_2.png, 201x120) historically hit 0.73-0.98. Rather than pick + # a winner and break the other setup, let best_match decide per frame. + _wp_tpl = ["A5_WP", "A5_WP_2"] + def _try_click_wp(threshold: float = 0.55, require_visible: bool = False) -> bool: wait(0.4, 0.6) # Direct full-screen scan first — if the WP stone is on screen, walk onto # it and click, regardless of how we got here. - wp_match = template_finder.search("A5_WP", grab(), threshold=threshold) + wp_match = template_finder.search(_wp_tpl, grab(), threshold=threshold, roi=_wp_roi, best_match=True) if not wp_match.valid: # Nothing on screen. For the speculative pre-scan below, stop here: # calling select_by_template anyway just spins out its 4s timeout and @@ -163,7 +176,7 @@ class A5(IAct): else: self._char.move(wp_match.center_monitor, force_move=True) wait(0.4, 0.6) - return self._char.select_by_template("A5_WP", found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True) + return self._char.select_by_template(_wp_tpl, found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True, roi=_wp_roi) # 0) Immediate direct WP scan — after TP-back from Pindle the char often spawns # near the WP and a quick scan avoids all pathing issues from stale curr_loc. diff --git a/src/town/town_manager.py b/src/town/town_manager.py index 8472b0f..d57ebd8 100644 --- a/src/town/town_manager.py +++ b/src/town/town_manager.py @@ -297,6 +297,30 @@ class TownManager: if curr_act is None: Logger.error(f"resurrect: could not determine act from location {curr_loc}") return False + # Prefer A4 Tyrael over A5 Qual-Kehk when configured. Tyrael stands on a fixed spot + # next to the A4 waypoint and detects reliably; Qual-Kehk is the worst NPC in the + # route. Measured over 2026-08-27: + # A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever + # A5 Qual-Kehk fail 113.6s, 52.0s, 163.1s, 72.6s - 43 errors + # A failed Qual-Kehk hunt costs more than the whole A5->A4 waypoint trip, and one + # 163s hunt took the town visit past the 240s maintenance timeout and killed the + # game outright. + resurrect_pref = (Config().char.get("resurrect_npc") or "").lower() + if curr_act == Location.A5_TOWN_START and resurrect_pref in ("a4_tyrael", "tyrael", "act4"): + Logger.info("Resurrect preference is Act 4 Tyrael — traveling from A5") + a4_loc = self.go_to_act(4, curr_loc) + if a4_loc: + set_panel_check_paused(True) + res = self._acts[Location.A4_TOWN_START].resurrect(a4_loc) + if res: + return res + set_panel_check_paused(False) + # Travelled but failed — leave the breadcrumb so the caller re-anchors to + # A4 rather than assuming A5 (Bug 9). + self.last_known_loc = a4_loc + Logger.warning("Resurrect: Tyrael failed after travelling to A4") + return False + Logger.warning("Resurrect: could not travel to A4 — falling back to the current act") # check if we can resurrect in current act if self._acts[curr_act].can_resurrect(): set_panel_check_paused(True)