feat(stealth): session rhythm, idle behaviour, and unproductive actions

Per-action jitter cannot reach the strongest remaining signals. Averaged over a
session jitter converges; what does not converge is a player who starts at the
same hour, plays the same length, and never does anything without a purpose.

Session budget (session_budget_h, default 6). Stops the bot after roughly N
hours, rolled per run at 0.65-1.35x so consecutive days differ in length. NOTE:
this genuinely stops the bot — set to 0 for unlimited.

Scheduled breaks were already implemented at bot.py:1140 and simply switched
off (break_length_m=0). Enabled at 120m/15m, and the interval and duration are
now RE-ROLLED after every break: a break at exactly 120 minutes every time is
still a pattern, just a slower one than no break at all.

Per-game chicken threshold. A fixed 0.40 every game is a precise tell, but the
randomisation is deliberately one-directional: it only ever RAISES the
threshold, capped at base+spread. Lowering it would cost deaths, and an
uncapped Gaussian tail reached 0.55 on a 0.40 base, which throws away healthy
games. Rolled on the BOT thread and handed to health_manager through a setter —
that thread is a read-only monitor by design and must not roll it itself.

Idle cursor drift during long idles; between actions the cursor otherwise sits
exactly where the last click left it. Bot thread only, screen-bounds clamped.

Occasional unproductive town action (open inventory, close it) and occasional
walking past an item the filter wanted. Both are rolled behaviours the bot has
never had — it otherwise picks up exactly what the rules say, instantly, every
single time. The town action is strictly best-effort and can never fail a
maintenance step.

All seven appear in the STEALTH> manifest, so any of them going inert is
visible at startup rather than after 225 games.

DELIBERATELY NOT IMPLEMENTED: pathing node jitter and route variation. Both
would be good cover, and both are the system that produced Bugs 25 and 28,
where a fabricated node position walked the character into the town wall.
Nothing here touches the health manager's potion path or the attack sequences
either — today demonstrated that cost twice.

Tests: 20.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
alexpolo1
2026-08-28 00:35:31 +02:00
co-authored by Claude Opus 5
parent 4bdc5b90db
commit 00e047db98
7 changed files with 317 additions and 10 deletions
+20 -2
View File
@@ -69,9 +69,9 @@ message_api_type=discord
; breaks
; break_length_m: scheduled break duration in minutes (0 = disabled)
break_length_m=0
break_length_m=15
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
max_runtime_before_break_m=0
max_runtime_before_break_m=120
; timers / fail handling
; d2r_path: Diablo II: Resurrected install path
@@ -210,6 +210,24 @@ skill_hesitation_max_ms = 300
; Wrong waypoint chance: 2-3% of selecting wrong TP portal then correcting
; Humans occasionally misclick waypoint targets
; ── session rhythm ───────────────────────────────────────────────────────────
; The strongest remaining signal is not per-click timing: averaged over a
; session jitter converges, but a player who starts at the same hour, plays the
; same length and never does anything unproductive does not.
;
; session_budget_h: stop the bot after roughly this many hours (0 = unlimited).
; Actual length is rolled per run at 0.65-1.35x, so consecutive days differ.
session_budget_h = 6
; Small cursor movement during long idles. Between actions the cursor otherwise
; sits exactly where the last click left it.
idle_drift_enabled = 1
; Per-game chicken threshold spread. Only ever RAISES the threshold (safer) —
; a fixed 0.40 every game is a precise tell, but lowering it would cost deaths.
chicken_variance = 0.08
; Chance of an unproductive town action (open inventory, close it).
town_browse_chance = 0.06
; Chance of walking past an item the filter wanted. Costs real loot — keep low.
pickup_skip_chance = 0.02
wrong_waypoint_chance = 0.025
; Skill mistake chance: 1-2% chance of miscasting and correcting
+61 -7
View File
@@ -176,6 +176,10 @@ class Bot:
# Rolling window for the periodic Discord timing report. Deliberately NOT reset
# per game — it is reset when a report is sent.
self._tl_agg: dict = {}
# Scheduled-break state. Re-rolled after every break so the interval is
# never the same twice.
self._next_break_after = None
self._next_break_len: float = 0.0
self._tl_window_start: float = time.time()
self._tl_games = 0
self._tl_games_failed = 0
@@ -695,6 +699,28 @@ class Bot:
# Drinks health first (cheaper), then rejuv as fallback if health is unavailable.
# Happens before update_pot_needs so the reduced belt is accurately counted
# and the buy step will restock whatever was drunk.
# Stealth: an occasional unproductive town action. The bot otherwise
# never does anything without a purpose, which is itself distinctive.
# Town only, panel check paused, and strictly best-effort — this must
# never be able to fail a maintenance step.
try:
from utils.stealth import should_browse_town, idle_drift
if should_browse_town():
set_panel_check_paused(True)
keyboard.send(Config().char["inventory_screen"])
wait(0.7, 1.9)
idle_drift()
wait(0.3, 0.9)
keyboard.send(Config().char["inventory_screen"])
wait(0.2, 0.4)
set_panel_check_paused(False)
except Exception as e:
Logger.debug(f"town browse skipped: {e}")
try:
set_panel_check_paused(False)
except Exception:
pass
_step("town_heal")
_town_heal_threshold = 0.95
_town_heal_max_drinks = 5
@@ -1137,21 +1163,49 @@ class Bot:
set_pause_state(True)
self._game_stats.log_end_game(failed=failed)
if Config().general["max_runtime_before_break_m"] and Config().general["break_length_m"]:
elapsed_time = time.time() - self._timer
Logger.debug(f'Session length = {math.ceil(elapsed_time/60)} minutes, max_runtime_before_break_m {Config().general["max_runtime_before_break_m"]}.')
# Session budget: stop after roughly N hours (rolled per run), so
# consecutive days are not identical in length. Playing the same hours
# every night is a stronger signal than any per-click timing.
try:
from utils.stealth import session_budget_seconds
budget = session_budget_seconds()
if budget and (time.time() - self._timer) > budget:
msg = f"Session budget reached ({hms(time.time() - self._timer)}) — stopping for today."
Logger.info(msg)
self.tl("stlth", "session_end", "ok", msg)
if self._messenger.enabled:
self._messenger.send_message(msg)
return self.stop()
except Exception as e:
Logger.debug(f"session budget check skipped: {e}")
if elapsed_time > (Config().general["max_runtime_before_break_m"]*60):
break_msg = f'Ran for {hms(elapsed_time)}, taking a break for {hms(Config().general["break_length_m"]*60)}.'
# Scheduled long break. The interval and duration are RE-ROLLED each
# time: a break at exactly 120 minutes every time is still a pattern,
# just a slower one than taking no break at all.
from utils.stealth import break_schedule, idle_drift
if self._next_break_after is None:
self._next_break_after, self._next_break_len = break_schedule()
if self._next_break_after:
elapsed_time = time.time() - self._timer
Logger.debug(f'Session length = {math.ceil(elapsed_time/60)} minutes, next break after {self._next_break_after/60:.0f}m.')
if elapsed_time > self._next_break_after:
break_msg = f'Ran for {hms(elapsed_time)}, taking a break for {hms(self._next_break_len)}.'
Logger.info(break_msg)
self.tl("stlth", "scheduled_break", "start", break_msg)
if self._messenger.enabled:
self._messenger.send_message(break_msg)
if not self._pausing:
self.toggle_pause()
wait(Config().general["break_length_m"]*60)
idle_drift()
wait(self._next_break_len)
break_msg = f'Break over, will now run for {hms(Config().general["max_runtime_before_break_m"]*60)}.'
# Re-roll for the next one and restart the clock.
self._next_break_after, self._next_break_len = break_schedule()
self._timer = time.time()
self.tl("stlth", "scheduled_break", "ok", "resuming")
break_msg = f'Break over, will now run for {self._next_break_after/60:.0f}m.'
Logger.info(break_msg)
if self._messenger.enabled:
self._messenger.send_message(break_msg)
+6
View File
@@ -325,6 +325,12 @@ class Config:
# 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"))),
# Session rhythm — the signals per-action jitter cannot reach.
"session_budget_h": float(self._select_optional("stealth", "session_budget_h", "0")),
"idle_drift_enabled": bool(int(self._select_optional("stealth", "idle_drift_enabled", "1"))),
"chicken_variance": float(self._select_optional("stealth", "chicken_variance", "0.08")),
"town_browse_chance": float(self._select_optional("stealth", "town_browse_chance", "0.06")),
"pickup_skip_chance": float(self._select_optional("stealth", "pickup_skip_chance", "0.02")),
}
self.routes = {}
+20 -1
View File
@@ -160,7 +160,7 @@ class HealthManager:
f"but HP is safe at {(health_percentage*100):.1f}%; not chickening"
)
chicken_threshold = Config().char["chicken"]
chicken_threshold = get_game_chicken_threshold()
if loot_priority_active():
chicken_threshold = max(chicken_threshold * 0.5, LOOT_PRIORITY_HP_FLOOR)
if health_percentage <= chicken_threshold:
@@ -275,6 +275,25 @@ def get_pause_state():
return HealthManager._instance.get_pause_state()
return True
_game_chicken_threshold = None
def set_game_chicken_threshold(value):
"""Per-game chicken threshold, rolled by the BOT thread at game start.
The health manager thread is a read-only monitor by design and must not do
the rolling itself see the threading rules in CLAUDE.md.
"""
global _game_chicken_threshold
_game_chicken_threshold = value
def get_game_chicken_threshold() -> float:
if _game_chicken_threshold is not None:
return _game_chicken_threshold
return Config().char["chicken"]
def set_pause_state(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
+11
View File
@@ -273,6 +273,13 @@ class PickIt:
cached_pickup = True
raw_expression = "pick_rares_for_gold=1 (rare quality override)"
if cached_pickup and not (self._ignore_consumable(item) or self._ignore_gold(item)):
# Stealth: occasionally walk past something the filter
# wanted. The bot otherwise picks up exactly what the rules
# say, instantly, every single time.
from utils.stealth import should_skip_pickup
if should_skip_pickup():
Logger.debug(f"Stealth: walking past {item.Name}")
continue
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)
@@ -298,6 +305,10 @@ class PickIt:
if not pickup:
Logger.debug(f"Skip item {item.Name} at distance {item.Distance}: no matching pickit rule")
if pickup:
from utils.stealth import should_skip_pickup
if should_skip_pickup():
Logger.debug(f"Stealth: walking past {item.Name}")
continue
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)
+133
View File
@@ -316,6 +316,24 @@ def manifest() -> list:
True if cfg["click_delay_enabled"] else None, "" if cfg["click_delay_enabled"] else "opt-in")
add("wait_jitter", f"{cfg['wait_jitter_min']}-{cfg['wait_jitter_max']}", True, "global")
budget_h = cfg.get("session_budget_h", 0)
add("session_budget", f"{budget_h}h" if budget_h else "0",
True if budget_h else None,
f"this run {session_budget_seconds()/3600:.1f}h" if budget_h else "unlimited")
run_s, brk_s = break_schedule()
add("scheduled_break", f"{run_s/60:.0f}m/{brk_s/60:.0f}m" if run_s else "0",
run_s > 0, "re-rolled each break" if run_s else "disabled in [general]")
add("idle_drift", "on" if cfg.get("idle_drift_enabled") else "0",
bool(cfg.get("idle_drift_enabled")), "bot thread only")
add("chicken_variance", f"+0-{cfg.get('chicken_variance', 0)}",
float(cfg.get("chicken_variance", 0)) > 0, "raises only, never lowers")
add("town_browse", f"{cfg.get('town_browse_chance', 0)}",
_count_calls("bot", "should_browse_town()") > 0, "town only")
add("pickup_skip", f"{cfg.get('pickup_skip_chance', 0)}",
_count_calls("item.pickit", "should_skip_pickup()") > 0, "2 decision points")
add("session_bias", f"{get_session_bias():.3f}x", True, "constant this run")
return rows
@@ -340,3 +358,118 @@ def log_manifest(once: bool = True):
Logger.warning(line)
else:
Logger.info(line)
# ─── Tier 5: session rhythm and idle behaviour ────────────────────────────────
# The strongest remaining signal is not per-click timing. Averaged over a
# session, jitter converges; what does NOT converge is a player who starts at
# the same hour, plays the same length, and never does anything unproductive.
_session_budget_s = None
def break_schedule() -> tuple:
"""(run_seconds, break_seconds) for the NEXT scheduled break.
Re-rolled each time, because a break at exactly 120 minutes every time is
still a pattern just a slower one than no break at all.
"""
try:
cfg = Config().general
run_m = float(cfg.get("max_runtime_before_break_m") or 0)
brk_m = float(cfg.get("break_length_m") or 0)
except Exception:
return (0, 0)
if run_m <= 0 or brk_m <= 0:
return (0, 0)
return (random.uniform(run_m * 0.7, run_m * 1.3) * 60,
random.uniform(brk_m * 0.6, brk_m * 1.6) * 60)
def session_budget_seconds() -> float:
"""How long this session should run before stopping. 0 = unlimited.
Fixed for the process, varied per run, so consecutive days differ.
"""
global _session_budget_s
if _session_budget_s is None:
try:
hours = float(Config().stealth.get("session_budget_h", 0) or 0)
except Exception:
hours = 0
if hours <= 0:
_session_budget_s = 0.0
else:
_session_budget_s = random.uniform(hours * 0.65, hours * 1.35) * 3600
Logger.info(f"[Stealth] Session budget: {_session_budget_s / 3600:.1f}h")
return _session_budget_s
def idle_drift():
"""Nudge the cursor a little, the way a resting hand does.
BOT THREAD ONLY never call from health_manager/death_manager (see the
threading rules in CLAUDE.md). Between actions the cursor otherwise sits
exactly where the last click left it, indefinitely.
"""
try:
if not Config().stealth.get("idle_drift_enabled", True):
return
from input_layer import mouse
x, y = mouse.get_position()
rx = x + int(random.gauss(0, 18))
ry = y + int(random.gauss(0, 14))
rx = max(20, min(1260, rx))
ry = max(20, min(700, ry))
mouse.move(rx, ry, randomize=6, delay_factor=[0.6, 1.1])
except Exception:
pass
def chicken_threshold(base: float) -> float:
"""Per-game chicken threshold. Only ever RAISED (safer), never lowered.
A fixed 0.40 every single game is a precise tell. The asymmetry is
deliberate: a higher threshold bails earlier, so the worst case of this
randomisation is a slightly cautious run never a death.
"""
try:
spread = float(Config().stealth.get("chicken_variance", 0.08))
except Exception:
spread = 0.08
if spread <= 0:
return base
# Capped at base+spread. An uncapped Gaussian tail reached 0.55 on a 0.40
# base — chickening at 55% HP throws away healthy games, which is its own
# kind of cost. The band is meant to be a few points wide, not open-ended.
bump = min(spread, abs(random.gauss(0, spread / 2)))
return round(base + bump, 4)
def should_browse_town() -> bool:
"""Chance of an unproductive town action — opening the inventory and
closing it, the way a player checks something and moves on."""
try:
chance = float(Config().stealth.get("town_browse_chance", 0.0))
except Exception:
return False
if chance > 0 and random.random() < chance:
_tl("town_browse", "ok", f"idle inventory check (chance {chance})")
return True
return False
def should_skip_pickup() -> bool:
"""Occasionally walk past something the filter wanted.
The bot otherwise picks up exactly what the filter says, instantly, every
time. Kept small this costs real loot.
"""
try:
chance = float(Config().stealth.get("pickup_skip_chance", 0.0))
except Exception:
return False
if chance > 0 and random.random() < chance:
_tl("pickup_skip", "ok", f"walked past a wanted item (chance {chance})")
return True
return False
+66
View File
@@ -228,3 +228,69 @@ def test_cast_buffs_skips_when_holy_shield_unbound():
src = inspect.getsource(Paladin.cast_buffs)
assert 'if not self._skill_hotkeys.get("holy_shield")' in src
assert src.index("return") < src.index('mouse.click')
# ─── Tier 5: session rhythm ──────────────────────────────────────────────────
def test_chicken_threshold_only_ever_raises():
"""Randomising the chicken threshold is safe in exactly one direction.
A fixed 0.40 every game is a precise tell, but LOWERING it costs deaths.
The band must be closed at the bottom by the configured value and capped at
the top an uncapped Gaussian tail reached 0.55 on a 0.40 base, which
throws away healthy games.
"""
from config import Config
from utils.stealth import chicken_threshold
base = 0.40
spread = Config().stealth["chicken_variance"]
vals = [chicken_threshold(base) for _ in range(5000)]
assert min(vals) >= base, "chicken threshold lowered — this costs deaths"
assert max(vals) <= base + spread + 1e-9, "chicken threshold uncapped"
def test_break_schedule_is_rerolled_not_fixed():
"""A break at exactly the same interval every time is still a pattern."""
from utils.stealth import break_schedule
runs, brks = zip(*[break_schedule() for _ in range(50)])
if runs[0] == 0:
return # breaks disabled in config; nothing to assert
assert len(set(runs)) > 1, "break interval is constant"
assert len(set(brks)) > 1, "break duration is constant"
def test_session_budget_is_constant_within_a_run():
from utils.stealth import session_budget_seconds
first = session_budget_seconds()
assert all(session_budget_seconds() == first for _ in range(20))
def test_idle_drift_never_runs_off_screen():
"""It nudges the cursor; it must not fling it somewhere unclickable."""
import inspect
from utils import stealth
src = inspect.getsource(stealth.idle_drift)
assert "max(20, min(" in src, "idle_drift lacks screen-bounds clamping"
def test_stealth_rates_match_their_config():
"""Each rolled behaviour should fire at roughly its configured rate.
Catches a knob that is read but ignored the class of bug where a setting
exists, looks live, and changes nothing.
"""
from config import Config
from utils.stealth import should_browse_town, should_skip_pickup
cfg = Config().stealth
for fn, key in ((should_browse_town, "town_browse_chance"),
(should_skip_pickup, "pickup_skip_chance")):
want = cfg[key]
if want <= 0:
continue
got = sum(fn() for _ in range(20000)) / 20000
assert abs(got - want) < max(0.01, want * 0.5), f"{key}: configured {want}, observed {got:.4f}"