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:
@@ -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)
|
||||
|
||||
20
src/bot.py
20
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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
74
test/test_stealth_config.py
Normal file
74
test/test_stealth_config.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user