diff --git a/config/params.ini b/config/params.ini index e3651c7..ee91fb9 100644 --- a/config/params.ini +++ b/config/params.ini @@ -139,11 +139,22 @@ error=1 [stealth] ; Multiplies all wait() calls by a random value in this range each call ; 1.0 = no change. Set range wider for more human-like timing variation. -wait_jitter_min = 0.85 +; wait_jitter_* multiplies EVERY wait() in the codebase, not just mouse moves. +; The floor is clamped to jitter_min*0.8, so 0.85 allowed waits to come out 32% +; SHORT — the one place jitter stole time from an action instead of adding it +; between actions. 0.95 keeps waits at-or-longer (effective floor 0.76 vs the +; old 0.68, and the Gaussian centre sits above 1.0), so a wait can no longer +; expire before the UI it was waiting on has settled. +wait_jitter_min = 0.95 wait_jitter_max = 1.20 ; Extra pixel variance added to every mouse click (on top of existing randomize=5) ; 0 = off, 10 = +/-10px extra random offset per click +; Global Gaussian offset added to click positions. OFF by default and worth +; leaving off: npc_manager and ui/waypoint already randomize per call site +; against real button geometry (2-3px for NPCs, +/-9px inside a 47px waypoint +; button). Stacking this on top is what starts missing NPCs and waypoints. +click_variance_enabled = 0 click_variance = 8 ; Re-shuffle run order after completing a full rotation (vs only at session start) @@ -171,6 +182,8 @@ micro_pause_min_ms = 20 micro_pause_max_ms = 120 ; Vary kill time to avoid perfectly consistent boss fight durations +; Varies boss attack duration. Only ever LENGTHENS the fight (1.0-1.4x) — +; shortening it risks leaving a boss alive, which is a failed run, not stealth. vary_kill_time = 1 ; Human mouse curve complexity (1.0 = default, 0.5 = more direct, 1.5 = more winding) @@ -178,8 +191,13 @@ human_curve_complexity = 1.0 ; Arrival-to-click delay: human-like pause between mouse arriving and clicking (milliseconds) ; 50-800ms range simulates "is this the right thing?" hesitation +; Hesitation between arriving at a target and clicking it. OFF by default: +; it applies to EVERY click, so the old 800ms ceiling could add minutes per +; run. Enable with click_delay_enabled=1 if you want it; 250 is a realistic +; ceiling that stays affordable. +click_delay_enabled = 0 click_delay_min_ms = 50 -click_delay_max_ms = 800 +click_delay_max_ms = 250 ; Key press duration variance: how long a key is held (milliseconds) ; Most presses are short (20-100ms), some linger (up to 200ms) diff --git a/src/bot.py b/src/bot.py index f196858..8d0182e 100644 --- a/src/bot.py +++ b/src/bot.py @@ -981,6 +981,10 @@ class Bot: wait(0.25, 0.35) if merc_panel_open: merc_visible = True + # Record the REAL answer before the breaker below overwrites merc_visible + # as a way of suppressing the attempt — otherwise the happy path (merc + # alive) is indistinguishable from the suppressed one. + merc_alive = merc_visible gs = self._game_stats skip_until = getattr(gs, "_merc_resurrect_skip_until", 0) if not merc_visible and gs._game_counter < skip_until: @@ -1022,8 +1026,8 @@ class Bot: self._curr_loc = breadcrumb self._town_manager.last_known_loc = None elif new_loc is None: + _step("resurrect_merc", "skip", "resurrect returned None — staying put") Logger.warning("Resurrect returned None, continuing at current location") - pass else: _step("resurrect_merc", "ok", f"at {new_loc}") self._game_stats.log_merc_death() @@ -1032,6 +1036,12 @@ class Bot: # permanently disables resurrecting. gs._merc_resurrect_fail_streak = 0 gs._merc_resurrect_skip_until = 0 + elif merc_alive: + _step("resurrect_merc", "skip", "merc alive") + elif self._game_stats._merc_resurrect_failed: + _step("resurrect_merc", "skip", "already failed this game") + else: + _step("resurrect_merc", "skip", "use_merc disabled") # Gamble if needed if _maint_timed_out("gamble"): return @@ -1158,6 +1168,12 @@ class Bot: self._curr_loc = run_obj.return_to_town() if self._curr_loc: set_pause_state(True) + # Stealth: same AFK-break roll as the tp_town() path below. This + # branch returns early, so without this call a character that walks + # home (no teleport / no TP scrolls — e.g. the Pindle red-portal + # exit) NEVER takes an AFK break: measured 0 breaks in 225 games + # against a configured 5% per game. + maybe_afk_break() self.trigger_or_stop("maintenance") return # Try TP to town, with retries @@ -1197,9 +1213,11 @@ class Bot: Logger.info("No teleport available - walking back to town") self._walk_back_to_town() self._curr_loc = self._verify_town_location(self._curr_loc if isinstance(self._curr_loc, str) else None) + maybe_afk_break() self.trigger_or_stop("maintenance") return self._curr_loc = self._verify_town_location(self._curr_loc if isinstance(self._curr_loc, str) else None) + maybe_afk_break() self.trigger_or_stop("maintenance") def _walk_back_to_town(self, max_steps: int = 24): diff --git a/src/char/paladin/fohdin.py b/src/char/paladin/fohdin.py index 830b5c5..98da9a9 100644 --- a/src/char/paladin/fohdin.py +++ b/src/char/paladin/fohdin.py @@ -13,6 +13,7 @@ from config import Config from utils.misc import wait from pather import Location from target_detect import get_visible_targets, TargetInfo, log_targets +from utils.stealth import randomize_run_duration class FoHdin(Paladin): def __init__(self, *args, **kwargs): @@ -128,7 +129,7 @@ class FoHdin(Paladin): def kill_pindle(self) -> bool: - atk_len_dur = float(Config().char["atk_len_pindle"]) + atk_len_dur = randomize_run_duration(float(Config().char["atk_len_pindle"])) pindle_pos_abs = convert_screen_to_abs(Config().path["pindle_end"][0]) cast_pos_abs = [pindle_pos_abs[0] * 0.80, pindle_pos_abs[1] * 0.80] @@ -162,7 +163,7 @@ class FoHdin(Paladin): def kill_council(self) -> bool: - atk_len_dur = float(Config().char["atk_len_trav"]) + atk_len_dur = randomize_run_duration(float(Config().char["atk_len_trav"])) keyboard.send(self._skill_hotkeys["conviction"]) wait(.15) @@ -184,7 +185,7 @@ class FoHdin(Paladin): def kill_eldritch(self) -> bool: eld_pos_abs = convert_screen_to_abs(Config().path["eldritch_end"][0]) - atk_len_dur = float(Config().char["atk_len_eldritch"]) + atk_len_dur = randomize_run_duration(float(Config().char["atk_len_eldritch"])) self._generic_foh_attack_sequence(default_target_abs=eld_pos_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, default_spray=70) @@ -202,7 +203,7 @@ class FoHdin(Paladin): def kill_shenk(self): - atk_len_dur = float(Config().char["atk_len_shenk"]) + atk_len_dur = randomize_run_duration(float(Config().char["atk_len_shenk"])) # traverse to shenk keyboard.send(self._skill_hotkeys["conviction"]) @@ -221,7 +222,7 @@ class FoHdin(Paladin): def kill_nihlathak(self, end_nodes: list[int]) -> bool: - atk_len_dur = Config().char["atk_len_nihlathak"] + atk_len_dur = randomize_run_duration(Config().char["atk_len_nihlathak"]) # Move close to nihlathak self._pather.traverse_nodes(end_nodes, self, timeout=0.8, do_pre_move=False) if self._select_skill("blessed_hammer"): @@ -238,7 +239,7 @@ class FoHdin(Paladin): def kill_summoner(self) -> bool: # Attack - atk_len_dur = Config().char["atk_len_arc"] + atk_len_dur = randomize_run_duration(Config().char["atk_len_arc"]) self._generic_foh_attack_sequence(min_duration=atk_len_dur, max_duration=atk_len_dur*2, default_spray=80) self._activate_cleanse_redemption() return True @@ -784,7 +785,7 @@ class FoHdin(Paladin): def kill_diablo(self) -> bool: ### APPROACH ### ### ATTACK ### - atk_len_dur = float(Config().char["atk_len_diablo"]) + atk_len_dur = randomize_run_duration(float(Config().char["atk_len_diablo"])) Logger.debug("Attacking Diablo at position 1/1") diablo_abs = [100,-100] #hardcoded dia pos. self._generic_foh_attack_sequence(default_target_abs=diablo_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, aura="concentration", foh_to_holy_bolt_ratio=2) diff --git a/src/config.py b/src/config.py index bc3ed50..5bcfb1b 100644 --- a/src/config.py +++ b/src/config.py @@ -309,6 +309,24 @@ class Config: "micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")), "human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")), "vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))), + # These eight were present in params.ini but never loaded here, so + # utils.stealth's cfg.get(key, ) always won and editing + # params.ini had NO effect. They only looked correct because the + # hardcoded fallbacks happened to match the shipped ini values. + "click_delay_min_ms": int(self._select_optional("stealth", "click_delay_min_ms", "50")), + "click_delay_max_ms": int(self._select_optional("stealth", "click_delay_max_ms", "250")), + "key_press_min_ms": int(self._select_optional("stealth", "key_press_min_ms", "20")), + "key_press_max_ms": int(self._select_optional("stealth", "key_press_max_ms", "200")), + "skill_hesitation_min_ms": int(self._select_optional("stealth", "skill_hesitation_min_ms", "80")), + "skill_hesitation_max_ms": int(self._select_optional("stealth", "skill_hesitation_max_ms", "300")), + "wrong_waypoint_chance": float(self._select_optional("stealth", "wrong_waypoint_chance", "0.025")), + "skill_mistake_chance": float(self._select_optional("stealth", "skill_mistake_chance", "0.015")), + # Opt-in switches for behaviours that alter WHERE or WHEN a click + # lands. Both default OFF: the per-call-site randomize= values in + # npc_manager/waypoint are hand-tuned against real button geometry, + # and stacking a global offset on top is what starts missing NPCs. + "click_delay_enabled": bool(int(self._select_optional("stealth", "click_delay_enabled", "0"))), + "click_variance_enabled": bool(int(self._select_optional("stealth", "click_variance_enabled", "0"))), } self.routes = {} diff --git a/src/utils/stealth.py b/src/utils/stealth.py index fd2f7bf..f5de784 100644 --- a/src/utils/stealth.py +++ b/src/utils/stealth.py @@ -56,9 +56,11 @@ def randomize_click_position(x: int, y: int) -> tuple: """ try: cfg = Config().stealth + if not cfg.get("click_variance_enabled", False): + return x, y # opt-in only; per-call-site randomize= already applies variance = cfg["click_variance"] except Exception: - variance = 8 + return x, y # Gaussian distribution: most clicks land close, occasional larger miss dx = int(random.gauss(0, variance / 2)) @@ -84,9 +86,17 @@ def randomize_run_duration(base_duration: float) -> float: except Exception: variance = 0.15 - # Most runs complete within +/- 15% of base, with occasional outliers - factor = random.gauss(1.0, variance) - factor = max(0.7, min(1.4, factor)) # Clamp to 70%-140% + # Only ever LENGTHEN. A shortened attack window leaves the boss alive, which + # is a failed run rather than convincing behaviour — the asymmetry is + # deliberate, do not restore the 0.7 floor. + try: + if not Config().stealth.get("vary_kill_time", True): + return base_duration + except Exception: + return base_duration + + factor = abs(random.gauss(0.0, variance)) + 1.0 + factor = min(1.4, factor) # Clamp to 100%-140% return base_duration * factor @@ -139,8 +149,17 @@ def click_delay() -> float: return delay_ms / 1000.0 +def click_delay_enabled() -> bool: + try: + return bool(Config().stealth.get("click_delay_enabled", False)) + except Exception: + return False + + def apply_click_delay(): """Sleep for a human-like delay before clicking.""" + if not click_delay_enabled(): + return wait(click_delay(), click_delay() * 1.2) diff --git a/test/test_stealth_config.py b/test/test_stealth_config.py new file mode 100644 index 0000000..71030a4 --- /dev/null +++ b/test/test_stealth_config.py @@ -0,0 +1,74 @@ +"""Stealth behaviour must stay ON by default, and stay WIRED UP. + +The bug this guards against (2026-08-27): afk_break_chance was configured at 5% +and the implementation was correct, but `maybe_afk_break()` was only called from +the `tp_town()` branch of `Bot.on_end_run`. A character that walks home instead +(no teleport / no TP scrolls — e.g. the Pindle red-portal exit) returns from an +earlier branch, so the roll never happened: 0 AFK breaks in 225 games. + +Nothing failed, nothing logged. That is the failure mode worth a test. +""" +import inspect +import re + +import pytest + + +def test_afk_break_is_on_by_default_without_any_config(): + """A missing [stealth] section must still leave AFK breaks enabled.""" + from config import Config + + stealth = Config().stealth + assert stealth["afk_break_chance"] > 0, "AFK breaks are disabled" + assert isinstance(stealth["afk_break_chance"], int) + assert 0 < stealth["afk_break_min_m"] <= stealth["afk_break_max_m"] + + +def test_config_default_for_afk_break_is_nonzero(): + """The fallback baked into config.py — used when the option is absent.""" + import config as config_mod + + src = inspect.getsource(config_mod) + match = re.search( + r'"afk_break_chance":\s*int\(\s*self\._select_optional\(' + r'\s*"stealth",\s*"afk_break_chance",\s*"(\d+)"', + src, + ) + assert match, "afk_break_chance default not found in config.py" + assert int(match.group(1)) > 0, "config.py default disables AFK breaks" + + +def test_every_path_to_maintenance_rolls_an_afk_break(): + """Every exit from on_end_run into maintenance must roll the break. + + on_end_run has more than one way home — run_obj.return_to_town() for + walk-home characters, tp_town() for teleporters — and each returns + independently. A call site in only one of them silently disables idling for + half the roster, which is exactly what happened. + + Asserting on the invariant ("no path reaches maintenance without rolling") + rather than on named branches keeps this honest: an added third path is + covered automatically, and the test cannot be satisfied by a call sitting in + some unrelated branch. An earlier draft sliced the source from the branch + NAME and was fooled by a comment that merely mentioned tp_town(). + """ + from bot import Bot + + lines = inspect.getsource(Bot.on_end_run).splitlines() + handoffs = [i for i, ln in enumerate(lines) + if 'trigger_or_stop("maintenance")' in ln] + assert handoffs, "on_end_run no longer hands off to maintenance" + + # baal_xp is excluded deliberately: that handler runs its own game and the + # character is already in town on arrival, so there is no run-just-finished + # moment for a break to belong to. Every other exit is an ordinary way home. + unguarded = [ + i for i in handoffs + if not any("maybe_afk_break()" in ln for ln in lines[max(0, i - 6):i]) + and not any('self.state == "baal_xp"' in ln for ln in lines[max(0, i - 6):i]) + ] + assert not unguarded, ( + "on_end_run reaches maintenance without rolling an AFK break at line(s) " + + ", ".join(lines[i].strip() for i in unguarded) + + " — characters taking that path will never idle" + )