fix(stealth): make configured stealth behaviour actually run

Most of the stealth surface was configured but inert. Nothing failed and
nothing logged, so the config advertised far more behaviour than executed.

AFK breaks never fired: 0 in 225 games against a configured 5%/game.
maybe_afk_break() sat only in the tp_town() branch of on_end_run, but a
character with no teleport (Pindle red-portal exit) returns from an earlier
branch and never reached it. Now rolled on every way home. baal_xp is
excluded on purpose — it arrives already in town, so there is no
run-just-finished moment for a break to belong to.

Eight [stealth] settings were declared in params.ini and never loaded into
Config(). utils.stealth read them as cfg.get(key, <hardcoded>), so the
hardcoded value always won and editing params.ini did nothing. They only
looked correct because the fallbacks matched the shipped values:
click_delay_{min,max}_ms, key_press_{min,max}_ms,
skill_hesitation_{min,max}_ms, wrong_waypoint_chance, skill_mistake_chance.

Behaviours that move WHERE or WHEN a click lands are now opt-in and default
OFF (click_delay_enabled, click_variance_enabled). The per-call-site
randomize= values in npc_manager/waypoint are tuned against real button
geometry (2-3px for NPCs, +/-9px inside a 47px waypoint button); stacking a
global offset on top is what starts missing NPCs. click_delay's ceiling also
drops 800ms -> 250ms, since it applies to every click.

vary_kill_time is wired into the fohdin boss windows and made LENGTHEN-ONLY
(1.0-1.4x). A shortened attack window leaves the boss alive, which is a
failed run rather than convincing behaviour — do not restore the 0.7 floor.
Verified over 4000 samples on an 8s window: min 8.00, max 11.20, mean 8.95.

wait_jitter_min 0.85 -> 0.95. The floor is clamped to jitter_min*0.8, so
0.85 let waits come out 32% SHORT — the one place jitter stole time from an
action instead of adding it between actions. Waits now run at-or-longer and
cannot expire before the UI they were waiting on has settled.

Also closes the resurrect_merc timeline step on the merc-alive path. It
emitted TL> start with no terminator, leaking an entry in _tl_starts so the
common case never appeared in FAIL> trails or the Discord digest.

test/test_stealth_config.py asserts the invariant (no path to maintenance
skips the AFK roll) rather than naming branches. An earlier draft sliced
source from a branch NAME and was fooled by a comment mentioning tp_town();
the invariant version is what found the two extra unguarded exits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-27 23:52:45 +02:00
parent 3d153e67f5
commit da83f71bef
6 changed files with 162 additions and 14 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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, <hardcoded>) 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 = {}

View File

@@ -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)

View File

@@ -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"
)