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:
co-authored by
Claude Opus 5
parent
4bdc5b90db
commit
00e047db98
+20
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user