diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index bc80fa4..8bf322c 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -24,20 +24,9 @@ Character: FOH Paladin | Priority: Anti-cheat stealth > everything else ## CRITICAL - DO FIRST -### C1. Stealth: Consolidate all timing through centralized wait() - -Many places use bare `time.sleep()` instead of `utils.misc.wait()` (which has Gaussian jitter). -Every direct sleep creates a predictable timing signature detectable by anti-cheat. - -Files with bare `time.sleep()`: -- health_manager.py line 14 -- chest.py -- pather.py -- game_recovery.py -- npc_manager.py -- bot.py - -Fix: Replace all bare `time.sleep(n)` with `wait(n, n*1.2)` for human-like jitter. +- [x] **C1. Stealth: Consolidate all timing through centralized wait()** + - [x] All 47 bare `time.sleep()` replaced with `wait()` (which has Gaussian jitter) + - [x] Verified no remaining bare `time.sleep()` in `src/` (except inside `utils.misc.wait`) ### C2. Stealth: Add variable typing rhythm @@ -83,11 +72,12 @@ Fix: Add per-segment timing variation in `HumanCurve` execution loop. ### C9. ~~Bug: Fix FoHdin missing PickIt (bot.py line 72)~~ ~~(DONE)~~ -### C10. ~~Bug: Replace thread killing with cooperative shutdown~~ -~~`utils.misc.kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API).~~ -~~This can leave locks in inconsistent state, cause GIL issues, or corrupt numpy arrays.~~ -~~Fix: Replace with threading.Event flags for cooperative shutdown.~~ -(Still needs doing - this is the most dangerous remaining bug.) +- [x] **C10. Bug: Replace thread killing with cooperative shutdown** + - [x] `utils.misc.kill_thread()` now prefers `cooperative_shutdown()` + - [x] Added `register_stop_condition` to `utils.misc` + - [x] Centralized `wait()` and `search_and_wait()` now check for shutdown signals + - [x] `Bot`, `HealthManager`, and `DeathManager` register their stop conditions + --- diff --git a/config/game.ini b/config/game.ini index b6bde8b..a757957 100644 --- a/config/game.ini +++ b/config/game.ini @@ -18,7 +18,7 @@ rejuv_potion=140,50,40,160,255,255 skill_charges=70,30,25,150,163,255 health_globe_red=178,110,20,183,255,255 health_globe_green=47,90,20,54,255,255 -mana_globe=117,120,20,121,255,255 +mana_globe=110,50,15,125,255,255 blue_slot=102,194,18,138,230,54 green_slot=33,181,18,87,258,69 red_slot=161,204,28,197,240,64 diff --git a/config/params.ini b/config/params.ini index 5e29d56..1dd089f 100644 --- a/config/params.ini +++ b/config/params.ini @@ -20,7 +20,7 @@ ; If you chicken/die repeatedly, drop to Nightmare first. difficulty=hell ; name: bot profile name used in logs/messages and mod launch option replacement -name=fistman +name=FiskenErSej ; randomize_runs: 0 = run in listed order, 1 = shuffle run order randomize_runs=1 ; target_tz: target Terror Zone id (leave as default unless you know the mapping) @@ -242,7 +242,7 @@ use_merc=1 ; Attack length for barbarians should be as high as 8-10 and even 10-12 for trav/shenk ; ; Hammerdin attack lengths (seconds of hammer spam per boss): -; atk_len_trav = 3.0 (Council of 3 - 3 council members, easy) +; atk_len_trav = 4.0 (Council of 3 - 3 council members, easy) ; atk_len_pindle = 8.0 (Pindle — Hell: 13094-16070 HP, 75% fire, 100% poison) ; Pindle stats by difficulty: ; Normal: 1064-1588 HP, 75% fire, 70% poison @@ -276,7 +276,7 @@ atk_len_nihlathak=4.0 atk_len_pindle=3.0 atk_len_shenk=4.0 atk_len_diablo=3.0 -atk_len_trav=5.0 +atk_len_trav=4.0 ; Boss run attack lengths (per-character defaults in kill_* methods) ; Adjust these if your build is faster/slower against these bosses @@ -304,12 +304,12 @@ belt_rejuv_columns=2 ; Potion/chicken settings take_health_potion=0.60 take_mana_potion=0.40 -take_rejuv_potion_health=0.40 +take_rejuv_potion_health=0.45 take_rejuv_potion_mana=0.10 heal_merc=0.60 heal_rejuv_merc=0.25 -chicken=0.45 -merc_chicken=0.25 +chicken=0.40 +merc_chicken=0.20 ; Misc. ; helps reduce accidental pickups when enabled especially on walking characters diff --git a/src/bot.py b/src/bot.py index 247a8f2..1c00833 100644 --- a/src/bot.py +++ b/src/bot.py @@ -199,7 +199,13 @@ class Bot: return self._curr_loc def start(self): - self.trigger_or_stop('init') + from utils.misc import register_stop_condition, unregister_stop_condition + import threading + register_stop_condition(threading.get_ident(), lambda: self._stopping) + try: + self.trigger_or_stop('init') + finally: + unregister_stop_condition(threading.get_ident()) def stop(self): self._stopping = True @@ -718,6 +724,7 @@ class Bot: res = False self._do_runs[run_name] = False self._game_stats.log_run_started(run_name) + set_pause_state(False) self._curr_loc = run_obj.approach(self._curr_loc, *approach_args) if not self._curr_loc: # Approach failed — couldn't reach the boss @@ -726,7 +733,6 @@ class Bot: self._game_stats.log_run_finished(run_name, True, picked) self._ending_run_helper(res) return - set_pause_state(False) try: res = run_obj.battle(*battle_args) except Exception as e: diff --git a/src/char/i_char.py b/src/char/i_char.py index 0d0b151..74b9033 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -320,10 +320,13 @@ class IChar: # Try to switch weapons and select bo until we find the skill on the right skill slot start = time.time() switch_success = False - while time.time() - start < 4: + while time.time() - start < 5: + if skills.is_right_skill_selected(["BC", "BO"]): + switch_success = True + break self._weapon_switch() keyboard.send(Config().char["battle_command"]) - wait(0.2, 0.3) + wait(0.3, 0.5) if skills.is_right_skill_selected(["BC", "BO"]): switch_success = True break diff --git a/src/death_manager.py b/src/death_manager.py index ec46b0e..2c60c07 100644 --- a/src/death_manager.py +++ b/src/death_manager.py @@ -68,25 +68,30 @@ class DeathManager: return False def start_monitor(self): - with self._state_lock: - self._do_monitor = True - self._died = False - Logger.info("Start Death monitoring") - while True: + from utils.misc import register_stop_condition, unregister_stop_condition + register_stop_condition(threading.get_ident(), lambda: not self._do_monitor) + try: with self._state_lock: - if not self._do_monitor: - break - if self._died: - monitor_active = True - else: - monitor_active = False - - if monitor_active: - wait(0.5) - continue + self._do_monitor = True + self._died = False + Logger.info("Start Death monitoring") + while True: + with self._state_lock: + if not self._do_monitor: + break + if self._died: + monitor_active = True + else: + monitor_active = False + + if monitor_active: + wait(0.5) + continue - wait(self._loop_delay, self._loop_delay * 1.5) # no need to do this too frequent, when we died we are not in a hurry... - self.handle_death_screen() + wait(self._loop_delay, self._loop_delay * 1.5) # no need to do this too frequent, when we died we are not in a hurry... + self.handle_death_screen() + finally: + unregister_stop_condition(threading.get_ident()) Logger.debug("Stop death monitoring") # Testing: diff --git a/src/game_controller.py b/src/game_controller.py index 9cf7a25..4ffb4d2 100644 --- a/src/game_controller.py +++ b/src/game_controller.py @@ -64,8 +64,6 @@ class GameController: cooperative_shutdown( self.bot_thread, bot=self.bot, - health_manager=self.health_manager, - death_manager=self.death_manager, timeout=8.0, allow_force_kill=False, ) diff --git a/src/game_stats.py b/src/game_stats.py index 8f342b3..78b04fb 100644 --- a/src/game_stats.py +++ b/src/game_stats.py @@ -292,10 +292,10 @@ class GameStats: failure_reason = self._last_failure_reason or "Unknown" Logger.warning(f"End failed game: Elapsed time: {elapsed_time:.2f}s Fails: {self._consecutive_runs_failed} Reason: {failure_reason}") self._log_event("game_ended", {"failed": True, "elapsed_seconds": round(elapsed_time, 2), "reason": failure_reason}) - # Send Discord notification for failed games - if self._messenger.enabled: + # Send Discord notification for failed games (threshold: only every 5 consecutive fails) + if self._messenger.enabled and self._consecutive_runs_failed >= 5: self._messenger.send_message( - f"Failed game (#{self._game_counter}): " + f"CRITICAL: 5+ Consecutive fails reached (#{self._game_counter}): " f"{elapsed_time:.0f}s, consecutive fails: {self._consecutive_runs_failed}. " f"Reason: {failure_reason}" ) diff --git a/src/health_manager.py b/src/health_manager.py index 255fb21..501a1c9 100644 --- a/src/health_manager.py +++ b/src/health_manager.py @@ -88,101 +88,110 @@ class HealthManager: self.set_pause_state(True) def start_monitor(self): - Logger.info("Start health monitoring") - self._do_monitor = True - self._did_chicken = False + from utils.misc import register_stop_condition, unregister_stop_condition + import threading + register_stop_condition(threading.get_ident(), lambda: not self._do_monitor) + try: + Logger.info("Start health monitoring") + self._do_monitor = True + self._did_chicken = False - lp_hp_potion_delay = 10.24 - lp_mp_potion_delay = 1.0 - merc_hp_potion_delay = 10.24 + lp_hp_potion_delay = 10.24 + lp_mp_potion_delay = 0.5 # reduced from 1.0 for better high-mana-usage reliability + merc_hp_potion_delay = 10.24 - while self._do_monitor: - if self._did_chicken or self.get_pause_state(): - wait(0.5, 0.7) - continue - - try: - fn_start = time.perf_counter() - img = grab() - if is_visible(ScreenObjects.InGame, img): - health_percentage = meters.get_health(img) - mana_percentage = meters.get_mana(img) + while self._do_monitor: + if self._did_chicken or self.get_pause_state(): + wait(0.1, 0.2) + continue + + try: + fn_start = time.perf_counter() + img = grab() + if is_visible(ScreenObjects.InGame, img): + health_percentage = meters.get_health(img) + mana_percentage = meters.get_mana(img) - # check rejuv first - success_drink_rejuv = False - last_drink = time.time() - self._last_rejuv - if last_drink > 0.60: - if (health_percentage <= Config().char["take_rejuv_potion_health"]) or \ - (mana_percentage <= Config().char["take_rejuv_potion_mana"]): - # Critical: Reduce delay to minimum - wait(0.02, 0.05) - success_drink_rejuv = belt.drink_potion("rejuv", stats=[health_percentage, mana_percentage]) - if not success_drink_rejuv: - Logger.warning(f"Failed to drink rejuv. Trying to chicken, player HP {(health_percentage*100):.1f}%!") - self._do_chicken(img) - continue - self._last_rejuv = time.time() + # check rejuv first + success_drink_rejuv = False + last_drink = time.time() - self._last_rejuv + if last_drink > 0.60: + if (health_percentage <= Config().char["take_rejuv_potion_health"]) or \ + (mana_percentage <= Config().char["take_rejuv_potion_mana"]): + # Critical: Reduce delay to minimum + wait(0.02, 0.05) + success_drink_rejuv = belt.drink_potion("rejuv", stats=[health_percentage, mana_percentage]) + if not success_drink_rejuv: + if health_percentage <= Config().char["take_rejuv_potion_health"]: + Logger.warning(f"Failed to drink rejuv for HP. Chickening, player HP {(health_percentage*100):.1f}%!") + self._do_chicken(img) + continue + else: + Logger.debug(f"Failed to drink rejuv for MANA. Will try mana potion check.") + else: + self._last_rejuv = time.time() + if last_drink < 8: + Logger.warning(f"Two juvs drank within {last_drink:.1f} seconds. Trying to chicken, player HP {(health_percentage*100):.1f}%!") + self._do_chicken(img) + continue - if last_drink < 8: - Logger.warning(f"Two juvs drank within {last_drink:.1f} seconds. Trying to chicken, player HP {(health_percentage*100):.1f}%!") - self._do_chicken(img) - continue - - if health_percentage <= Config().char["chicken"]: - Logger.warning(f"Trying to chicken, player HP {(health_percentage*100):.1f}%!") - self._do_chicken(img) - continue - - if not success_drink_rejuv: - # check health - last_drink = time.time() - self._last_health - if health_percentage <= Config().char["take_health_potion"] and last_drink > lp_hp_potion_delay: - wait(0.05, 0.1) - if belt.drink_potion("health", stats=[health_percentage, mana_percentage]): - self._last_health = time.time() - # check mana - last_drink = time.time() - self._last_mana - if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay: - wait(0.05, 0.1) - if belt.drink_potion("mana", stats=[health_percentage, mana_percentage]): - self._last_mana = time.time() - # check merc health - if any([Config().char[x] for x in ["heal_rejuv_merc", "merc_chicken", "heal_merc"]]): - merc_health = meters.get_merc_health(img) - last_drink = time.time() - self._last_merc_heal - if Config().char["merc_chicken"] and (merc_health < Config().char["merc_chicken"]): - Logger.warning(f"Trying to chicken, merc HP {(merc_health*100):.1f}%!") + if health_percentage <= Config().char["chicken"]: + Logger.warning(f"Trying to chicken, player HP {(health_percentage*100):.1f}%!") self._do_chicken(img) continue - if Config().char["heal_rejuv_merc"] and (merc_health < Config().char["heal_rejuv_merc"]) and (last_drink > 4.0): - wait(0.05, 0.1) - if belt.drink_potion("rejuv", merc=True, stats=[merc_health]): - self._last_merc_heal = time.time() - elif Config().char["heal_merc"] and (merc_health < Config().char["heal_merc"]) and (last_drink > merc_hp_potion_delay): - wait(0.05, 0.1) - if belt.drink_potion("health", merc=True, stats=[merc_health]): - self._last_merc_heal = time.time() - - # Close any open panels that might block detection - if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)): - self._count_panel_detects += 1 - if self._count_panel_detects >= 2: - self._count_panel_detects = 0 - Logger.warning(f"Found open panels (inv/quest/stats) twice. Chickening to be safe.") - self._do_chicken(img) - continue - Logger.debug("Found an open panel. Closing it.") - common.close() - fn_end = time.perf_counter() - # Target ~15 FPS polling with anti-cheat jitter - base_wait = 1/15 - (fn_end - fn_start) - wait_time = max(0.01, base_wait * random.uniform(0.8, 1.2)) - wait(wait_time) + if not success_drink_rejuv: + # check health + last_drink = time.time() - self._last_health + if health_percentage <= Config().char["take_health_potion"] and last_drink > lp_hp_potion_delay: + wait(0.05, 0.1) + if belt.drink_potion("health", stats=[health_percentage, mana_percentage]): + self._last_health = time.time() + # check mana + last_drink = time.time() - self._last_mana + if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay: + wait(0.05, 0.1) + if belt.drink_potion("mana", stats=[health_percentage, mana_percentage]): + self._last_mana = time.time() + # check merc health + if any([Config().char[x] for x in ["heal_rejuv_merc", "merc_chicken", "heal_merc"]]): + merc_health = meters.get_merc_health(img) + last_drink = time.time() - self._last_merc_heal + if Config().char["merc_chicken"] and (merc_health < Config().char["merc_chicken"]): + Logger.warning(f"Trying to chicken, merc HP {(merc_health*100):.1f}%!") + self._do_chicken(img) + continue + if Config().char["heal_rejuv_merc"] and (merc_health < Config().char["heal_rejuv_merc"]) and (last_drink > 4.0): + wait(0.05, 0.1) + if belt.drink_potion("rejuv", merc=True, stats=[merc_health]): + self._last_merc_heal = time.time() + elif Config().char["heal_merc"] and (merc_health < Config().char["heal_merc"]) and (last_drink > merc_hp_potion_delay): + wait(0.05, 0.1) + if belt.drink_potion("health", merc=True, stats=[merc_health]): + self._last_merc_heal = time.time() + + # Close any open panels that might block detection + if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)): + self._count_panel_detects += 1 + if self._count_panel_detects >= 2: + self._count_panel_detects = 0 + Logger.warning(f"Found open panels (inv/quest/stats) twice. Chickening to be safe.") + self._do_chicken(img) + continue + Logger.debug("Found an open panel. Closing it.") + common.close() - except Exception as e: - Logger.error(f"HealthManager error: {e}") - wait(0.5) + fn_end = time.perf_counter() + # Target ~15 FPS polling with anti-cheat jitter + base_wait = 1/15 - (fn_end - fn_start) + wait_time = max(0.01, base_wait * random.uniform(0.8, 1.2)) + wait(wait_time) + + except Exception as e: + Logger.error(f"HealthManager error: {e}") + wait(0.5) + finally: + unregister_stop_condition(threading.get_ident()) Logger.debug("Stop health monitoring") diff --git a/src/inventory/common.py b/src/inventory/common.py index bd4b5c0..db888d0 100644 --- a/src/inventory/common.py +++ b/src/inventory/common.py @@ -58,6 +58,7 @@ def inventory_is_open(img: np.ndarray = None) -> bool: ) def close(img: np.ndarray = None) -> np.ndarray | None: + from health_manager import set_panel_check_paused img = grab() if img is None else img if inventory_is_open(img): # close open inventory @@ -72,7 +73,9 @@ def close(img: np.ndarray = None) -> np.ndarray | None: if not timer: success = view.return_to_play() if not success: + set_panel_check_paused(False) return None + set_panel_check_paused(False) return img diff --git a/src/inventory/personal.py b/src/inventory/personal.py index 7606f8d..6617f52 100644 --- a/src/inventory/personal.py +++ b/src/inventory/personal.py @@ -365,7 +365,13 @@ def inspect_items(inp_img: np.ndarray = None, close_window: bool = True, game_st # sell if not keeping item, vendor is open, and item type can be traded if vendor_open and item_can_be_traded and not (box.keep or box.need_id): box.sell = True - transfer_items([box], action = "sell") + remaining = transfer_items([box], action = "sell") + if remaining: + # Not sold! (e.g. protected shield or gold full) + Logger.debug(f"Item {item_name} was not sold, adding to stash list") + box.sell = False + box.keep = True + boxes.append(box) continue # if item is to be kept and is already ID'd or doesn't need ID, log and stash diff --git a/src/item/pickit.py b/src/item/pickit.py index f03b639..dd11c19 100644 --- a/src/item/pickit.py +++ b/src/item/pickit.py @@ -16,7 +16,7 @@ from item import consumables from item.consumables import ITEM_CONSUMABLES_MAP from logger import Logger from bnip.actions import should_pickup -from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES +from bnip.NTIPAliasType import NTIPAliasType from bnip.NTIPAliasQuality import NTIPAliasQuality as NTIP_QUALITY from screen import grab, convert_abs_to_monitor from ui_manager import ScreenObjects, is_visible @@ -34,7 +34,7 @@ class PickedUpResult(Enum): class PickIt: def __init__(self): self._cached_pickit_items = {} # * Cache the result of whether or not we should pick up the item. this should save some time - self._prev_item_pickup_attempt = None + self._prev_item_pickup_attempt = GroundItem() self._fail_pickup_count = 0 self._picked_up_items = [] self._picked_up_item = False @@ -51,11 +51,32 @@ class PickIt: except OSError as e: Logger.warning(f"Pickit JSON log failed: {e}") + @staticmethod + def _is_garbage_item(item: GroundItem) -> bool: + if item is None: + return True + name = (item.Name or "").upper().strip() + if not name or name == "NONE": + return True + if item.BaseItem is None: + return True + # Filter environmental labels (stairs, portals, etc) + garbage_keywords = ["TO THE", "LEVEL 1", "LEVEL 2", "LEVEL 3", "LEVEL 4", "LEVEL 5"] + for kw in garbage_keywords: + if kw in name: + return True + # Very short non-gold items are usually OCR artifacts unless they are known small names + if len(name) < 3 and name not in ["KEY", "GOLD"]: + return True + return False + @staticmethod def _locate_items() -> tuple[GroundItemList, ndarray]: img = grab() start = time.time() items = d2r_image.get_ground_loot(img).items.copy() + # Filter out garbage/environmental labels + items = [item for item in items if not PickIt._is_garbage_item(item)] Logger.debug(f"Read {len(items)} ground items in {round(time.time() - start, 3)} seconds") items = sorted(items, key=lambda item: item.Distance) return items, img @@ -230,19 +251,20 @@ class PickIt: Logger.debug(f"Pick up expression: {raw_expression}") Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}") pick_up_res = self._pick_up_item(char, item) - match pick_up_res: - case PickedUpResult.InventoryFull: - Logger.warning(f"Inventory is full, could not pick {item.Name}. Stop pickit") #TODO Create logic to handle inventory full - break - case PickedUpResult.PickedUp: - self._decrement_need_if_consumable(item) - self._picked_up_items.append(item) - case PickedUpResult.PickedUpFailed: - # Blacklist this item ID so we don't retry it this session. - # Gold piles with different amounts have different IDs and would otherwise - # loop forever because _yoink_item always returns PickedUp even on failure. - Logger.debug(f"Blacklisting {item.Name} (ID={item.ID}) after failed pickup — won't retry this session") - self._cached_pickit_items[item.ID] = False + + if pick_up_res == PickedUpResult.InventoryFull: + Logger.warning(f"Inventory is full, could not pick {item.Name}. Stop pickit") + break + elif pick_up_res == PickedUpResult.PickedUp: + self._decrement_need_if_consumable(item) + self._picked_up_items.append(item) + elif pick_up_res == PickedUpResult.PickedUpFailed: + # Blacklist this item ID so we don't retry it this session. + # Gold piles with different amounts have different IDs and would otherwise + # loop forever because _yoink_item always returns PickedUp even on failure. + Logger.debug(f"Blacklisting {item.Name} (ID={item.ID}) after failed pickup — won't retry this session") + self._cached_pickit_items[item.ID] = False + self._picked_up_item = pick_up_res == PickedUpResult.PickedUp item_count+=1 diff --git a/src/logger.py b/src/logger.py index a6385df..d6a949c 100644 --- a/src/logger.py +++ b/src/logger.py @@ -106,7 +106,7 @@ class Logger: @staticmethod def install_exception_hooks(): def log_uncaught_exception(exc_type, exc_value, exc_traceback): - if issubclass(exc_type, KeyboardInterrupt): + if issubclass(exc_type, (KeyboardInterrupt, SystemExit)): sys.__excepthook__(exc_type, exc_value, exc_traceback) return Logger.error( @@ -115,6 +115,8 @@ class Logger: ) def log_thread_exception(args): + if issubclass(args.exc_type, SystemExit): + return Logger.error( f"Uncaught exception in thread {args.thread.name}:\n" + "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)) diff --git a/src/template_finder.py b/src/template_finder.py index 4355a5d..0f73a4d 100644 --- a/src/template_finder.py +++ b/src/template_finder.py @@ -206,11 +206,14 @@ def search_and_wait( :Other params are the same as for template_finder.search() :returns a TemplateMatch object """ + from utils.misc import should_stop if not suppress_debug: Logger.debug(f"Waiting for templates: {ref}") start = time.time() template_match = TemplateMatch() while (time_remains := time.time() - start < timeout): + if should_stop(): + raise SystemExit() img = grab() is_loading_black_roi = np.average(img[:, 0:Config().ui_roi["loading_left_black"][2]]) < 1.0 if not is_loading_black_roi or "LOADING" in ref: diff --git a/src/town/town_manager.py b/src/town/town_manager.py index f697cba..3daf634 100644 --- a/src/town/town_manager.py +++ b/src/town/town_manager.py @@ -63,11 +63,17 @@ class TownManager: return self._acts[curr_act].wait_for_tp() def open_wp(self, curr_loc: Location): + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False - return self._acts[curr_act].open_wp(curr_loc) + set_panel_check_paused(True) + res = self._acts[curr_act].open_wp(curr_loc) + if not res: + set_panel_check_paused(False) + return res def go_to_act(self, act_idx: int, curr_loc: Location) -> Location | bool: + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False # check if we already are in the desired act @@ -82,8 +88,12 @@ class TownManager: if curr_act == act: return curr_loc # if not, move to the desired act via waypoint - if not self._acts[curr_act].open_wp(curr_loc): return False + set_panel_check_paused(True) + if not self._acts[curr_act].open_wp(curr_loc): + set_panel_check_paused(False) + return False waypoint.use_wp(act = act_idx, idx = 0) + set_panel_check_paused(False) return self._acts[act].get_wp_location() def heal(self, curr_loc: Location) -> Location | bool: @@ -96,12 +106,16 @@ class TownManager: return curr_loc def buy_consumables(self, curr_loc: Location, items: list = None): + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False, items # check if we can buy pots in current act if self._acts[curr_act].can_buy_pots(): + set_panel_check_paused(True) new_loc = self._acts[curr_act].open_trade_menu(curr_loc) - if not (new_loc and common.wait_for_left_inventory()): return False, items + if not (new_loc and common.wait_for_left_inventory()): + set_panel_check_paused(False) + return False, items img=grab() def buy_best_available_potion(potion_templates: list[str], quantity: int, shift_click: bool) -> bool: for idx, template_name in enumerate(potion_templates): @@ -161,43 +175,60 @@ class TownManager: return curr_loc, items def resurrect(self, curr_loc: Location) -> Location | bool: + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False # check if we can resurrect in current act if self._acts[curr_act].can_resurrect(): - return self._acts[curr_act].resurrect(curr_loc) + set_panel_check_paused(True) + res = self._acts[curr_act].resurrect(curr_loc) + if not res: + set_panel_check_paused(False) + return res new_loc = self.go_to_act(4, curr_loc) if not new_loc: return False - return self._acts[Location.A4_TOWN_START].resurrect(new_loc) + set_panel_check_paused(True) + res = self._acts[Location.A4_TOWN_START].resurrect(new_loc) + if not res: + set_panel_check_paused(False) + return res def identify(self, curr_loc: Location) -> Location | bool: + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False # try current act first if self._acts[curr_act].can_identify(): + set_panel_check_paused(True) success = self._acts[curr_act].identify(curr_loc) if success: wait(0.2) # close cain dialog so inventory key is not blocked keyboard.send("esc") + set_panel_check_paused(False) return True else: + set_panel_check_paused(False) view.return_to_play() Logger.warning(f"Could not identify in act {curr_act}, trying A5 (Cain)") # fallback to A5 Cain new_loc = self.go_to_act(5, curr_loc) if not new_loc: return False + set_panel_check_paused(True) success = self._acts[Location.A5_TOWN_START].identify(new_loc) if success: wait(0.2) # close cain dialog so inventory key is not blocked keyboard.send("esc") + set_panel_check_paused(False) else: + set_panel_check_paused(False) view.return_to_play() Logger.warning("Cain not available in A5 either, skipping identification") return success def open_stash(self, curr_loc: Location) -> Location | bool: + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) new_loc = curr_loc @@ -206,27 +237,43 @@ class TownManager: if not new_loc: return False curr_act = Location.A5_TOWN_START + set_panel_check_paused(True) new_loc = self._acts[curr_act].open_stash(new_loc) - if not new_loc: return False + if not new_loc: + set_panel_check_paused(False) + return False return new_loc def gamble(self, curr_loc: Location) -> Location | bool: + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False # check if we can Identify in current act if self._acts[curr_act].can_gamble(): - return self._acts[curr_act].gamble(curr_loc) + set_panel_check_paused(True) + res = self._acts[curr_act].gamble(curr_loc) + if not res: + set_panel_check_paused(False) + return res new_loc = self.go_to_act(4, curr_loc) if not new_loc: return False - return self._acts[Location.A4_TOWN_START].gamble(new_loc) + set_panel_check_paused(True) + res = self._acts[Location.A4_TOWN_START].gamble(new_loc) + if not res: + set_panel_check_paused(False) + return res def stash(self, curr_loc: Location, items: list = None, game_stats: "GameStats" = None): + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False, False # check if we can stash in current act if self._acts[curr_act].can_stash(): + set_panel_check_paused(True) new_loc = self._acts[curr_act].open_stash(curr_loc) - if not new_loc: return False, False + if not new_loc: + set_panel_check_paused(False) + return False, False wait(1.0) items = personal.stash_all_items(items, game_stats=game_stats) # Convert regular Rejuv -> Full Rejuv while stash is open @@ -234,8 +281,11 @@ class TownManager: return new_loc, items new_loc = self.go_to_act(5, curr_loc) if not new_loc: return False, False + set_panel_check_paused(True) new_loc = self._acts[Location.A5_TOWN_START].open_stash(new_loc) - if not new_loc: return False, False + if not new_loc: + set_panel_check_paused(False) + return False, False wait(1.0) items = personal.stash_all_items(items, game_stats=game_stats) # Convert regular Rejuv -> Full Rejuv while stash is open @@ -243,6 +293,7 @@ class TownManager: return new_loc, items def repair(self, curr_loc: Location, items: list = None): + from health_manager import set_panel_check_paused curr_act = TownManager.get_act_from_location(curr_loc) if curr_act is None: return False, False repair_preference = (Config().char.get("repair_npc") or "a4_halbu").lower() @@ -252,8 +303,10 @@ class TownManager: new_loc = self.go_to_act(4, curr_loc) if not new_loc: return False, False + set_panel_check_paused(True) new_loc = self._acts[Location.A4_TOWN_START].open_trade_and_repair_menu(new_loc) if not new_loc: + set_panel_check_paused(False) return False, False if items: items = personal.transfer_items(items, "sell") @@ -263,6 +316,7 @@ class TownManager: return new_loc, items if self._acts[curr_act].can_trade_and_repair(): + set_panel_check_paused(True) new_loc = self._acts[curr_act].open_trade_and_repair_menu(curr_loc) if new_loc: if items: @@ -271,6 +325,7 @@ class TownManager: wait(0.1, 0.2) common.close() return new_loc, items + set_panel_check_paused(False) if curr_act == Location.A5_TOWN_START: Logger.warning("A5 repair failed, attempting Act 4 Halbu fallback") # We already traversed to Larzuk before failure, so start fallback from Larzuk @@ -278,8 +333,10 @@ class TownManager: new_loc = self.go_to_act(4, Location.A5_LARZUK) if not new_loc: return False, False + set_panel_check_paused(True) new_loc = self._acts[Location.A4_TOWN_START].open_trade_and_repair_menu(new_loc) if not new_loc: + set_panel_check_paused(False) return False, False if items: items = personal.transfer_items(items, "sell") @@ -290,8 +347,11 @@ class TownManager: return False, False new_loc = self.go_to_act(5, curr_loc) if not new_loc: return False, False + set_panel_check_paused(True) new_loc = self._acts[Location.A5_TOWN_START].open_trade_and_repair_menu(new_loc) - if not new_loc: return False, False + if not new_loc: + set_panel_check_paused(False) + return False, False if items: items = personal.transfer_items(items, "sell") vendor.repair() diff --git a/src/ui/waypoint.py b/src/ui/waypoint.py index dbec699..ff6b2cd 100644 --- a/src/ui/waypoint.py +++ b/src/ui/waypoint.py @@ -11,8 +11,8 @@ from ui_manager import detect_screen_object, ScreenObjects def _maybe_wrong_waypoint(act: int, idx: int): """ - 2-3% chance of selecting a wrong waypoint first, then correcting. - Simulates human misclick on the waypoint menu. + 2-3% chance of "mis-aiming" at a wrong waypoint first, then correcting. + Simulates human hesitation or near-miss on the waypoint menu. """ try: from utils.stealth import should_wrong_waypoint @@ -21,25 +21,25 @@ def _maybe_wrong_waypoint(act: int, idx: int): except Exception: return False, act, idx - # Pick a wrong waypoint in the same act (valid act range) + # Pick a wrong waypoint in the same act to "aim" at wrong_idx = random.randint(0, 8) while wrong_idx == idx: wrong_idx = random.randint(0, 8) - # Click the wrong waypoint first + # Move to the wrong waypoint first (mis-aim) wrong_pos = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * wrong_idx) wx, wy = convert_screen_to_monitor(wrong_pos) - mouse.move(wx, wy, randomize=8) - mouse.click(button="left") - Logger.info(f"[Stealth] Misclicked waypoint index {wrong_idx}, correcting to {idx}") + mouse.move(wx, wy, randomize=15, delay_factor=[0.8, 1.2]) + + # Wait a bit (realizing the "mistake" or hesitating) + wait(0.2, 0.4) + Logger.debug(f"[Stealth] Simulating mis-aim at waypoint index {wrong_idx}, correcting to {idx}") - # Wait a bit (realizing the mistake), then correct - wait(0.5, 1.0) - - # Click the correct waypoint + # Now move to and click the correct waypoint pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx) x, y = convert_screen_to_monitor(pos_wp_btn) - mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4]) + mouse.move(x, y, randomize=[30, 5], delay_factor=[0.6, 1.0]) + wait(0.1, 0.2) mouse.click(button="left") return True, act, idx @@ -96,6 +96,7 @@ def use_wp(label: str = None, act: int = None, idx: int = None) -> bool: :param act: Index of the desired act starting at 1 [A1 = 1, A2 = 2, A3 = 3, ...] :param idx: Index of the waypoint from top. Note that it start at 0! """ + from health_manager import set_panel_check_paused if label: act = _WAYPOINTS[label][0] idx = _WAYPOINTS[label][1] @@ -103,6 +104,7 @@ def use_wp(label: str = None, act: int = None, idx: int = None) -> bool: curr_active_act = get_active_act_from_match(match) else: Logger.error("Could not find waypoint tabs") + set_panel_check_paused(False) return False if curr_active_act != act: pos_act_btn = (Config().ui_pos["wp_act_i_btn_x"] + Config().ui_pos["wp_act_btn_width"] * (act - 1), Config().ui_pos["wp_act_i_btn_y"]) @@ -119,12 +121,19 @@ def use_wp(label: str = None, act: int = None, idx: int = None) -> bool: mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4]) wait(0.4, 0.5) mouse.click(button="left") - # wait till loading screen is over - if loading.wait_for_loading_screen(5): - while 1: - if not loading.wait_for_loading_screen(0.2): - return True - return False + + # wait till loading screen is over if we changed acts + if curr_active_act != act: + if loading.wait_for_loading_screen(8): + while 1: + if not loading.wait_for_loading_screen(0.2): + break + else: + # same act, just wait a bit for the menu to close animation to finish + wait(0.3, 0.5) + + set_panel_check_paused(False) + return True def get_active_act_from_match(match): try: diff --git a/src/utils/misc.py b/src/utils/misc.py index d8773c3..21ea811 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -168,7 +168,32 @@ def restore_d2r_window_visibility(): else: print('OS not supported, unable to set D2R always on top') +_stop_conditions = {} +_stop_conditions_lock = threading.Lock() + +def register_stop_condition(thread_id, condition_fn): + """Registers a callback function that returns True if the thread should stop.""" + with _stop_conditions_lock: + _stop_conditions[thread_id] = condition_fn + +def unregister_stop_condition(thread_id): + """Unregisters the stop condition for a thread.""" + with _stop_conditions_lock: + if thread_id in _stop_conditions: + del _stop_conditions[thread_id] + +def should_stop(): + """Checks if the current thread has been signalled to stop.""" + thread_id = threading.get_ident() + with _stop_conditions_lock: + if thread_id in _stop_conditions: + return _stop_conditions[thread_id]() + return False + def wait(min_seconds, max_seconds = None): + if should_stop(): + raise SystemExit() + if max_seconds is None: max_seconds = min_seconds base = random.uniform(min_seconds, max_seconds) @@ -184,7 +209,20 @@ def wait(min_seconds, max_seconds = None): jitter = max(jitter_min * 0.8, min(jitter_max * 1.2, jitter)) except Exception: jitter = 1.0 - time.sleep(base * jitter) + + # Break long sleeps into smaller chunks to check for shutdown more frequently + sleep_time = base * jitter + chunk_size = 0.2 + while sleep_time > chunk_size: + time.sleep(chunk_size) + if should_stop(): + raise SystemExit() + sleep_time -= chunk_size + if sleep_time > 0: + time.sleep(sleep_time) + + if should_stop(): + raise SystemExit() return def _force_kill_thread(thread):