afk_break fired 0 times in 9 games at a 50% test rate — odds of roughly 1 in
500, so not variance.
The four call sites added earlier all sit in on_end_run's town-return branches,
and with one route configured the bot never reaches them. After the run it goes
straight to end_game:
Loot from run_pindle: ...
TL> g8 r8 | game | end | ok
Clicking SAVE_AND_EXIT ... End game. Elapsed time: 69.80s
Starting game #9
No return_to_town or tp_town line appears anywhere in the log. Maintenance runs
at game START, not after the run, so those branches are dead code for this
configuration.
The roll now sits in on_end_game beside the scheduled-break check — the same
path that demonstrably works, since scheduled_break has fired and resumed.
Between games is also the right moment semantically: the game is closed, so
idling there is safe.
Worth recording why the guard missed it. The STEALTH> manifest reported
"afk_break 5% wired - 4 call sites" throughout, which was true and useless: a
call site EXISTING is not the same as a call site being REACHED. Static
reachability is not something the manifest can decide. The digest's "NEVER
FIRED" line is the check that actually catches this class, and it is why that
line exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
15 KiB
Python
373 lines
15 KiB
Python
"""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"
|
|
)
|
|
|
|
|
|
# ─── Phase 1: the emergency path must never carry stealth timing ─────────────
|
|
|
|
def test_potion_and_belt_keys_never_receive_stealth():
|
|
"""The bug: the gate was `vk in range(ord('1'), ord('0')+1)` — an EMPTY
|
|
range — plus a digit-string fallback. It therefore fired on potion keys and
|
|
never on skill casts, adding 80-300ms and a 1.5% wrong-key press to every
|
|
emergency heal driven by health_manager.
|
|
"""
|
|
from input_layer import _is_skill_key, _never_stealth_keys
|
|
|
|
for key in _never_stealth_keys():
|
|
assert not _is_skill_key(key), f"potion/belt key {key!r} routes through stealth"
|
|
for key in ("1", "2", "3", "4"):
|
|
assert not _is_skill_key(key), f"digit key {key!r} routes through stealth"
|
|
|
|
|
|
def test_skill_keys_do_receive_stealth():
|
|
from input_layer import _is_skill_key
|
|
|
|
assert _is_skill_key("f1")
|
|
assert _is_skill_key("f12")
|
|
assert not _is_skill_key("esc")
|
|
|
|
|
|
def test_miscast_candidates_are_bound_skills_never_potions():
|
|
"""A 'miscast' that presses a potion is a bug in a costume."""
|
|
from input_layer import _bound_skill_keys, _never_stealth_keys
|
|
|
|
candidates = set(_bound_skill_keys())
|
|
assert candidates, "no bound skill keys discovered"
|
|
assert not (candidates & _never_stealth_keys()), "miscast could press a potion"
|
|
|
|
|
|
# ─── Phase 3: kill-time variance may only lengthen ───────────────────────────
|
|
|
|
def test_kill_time_variance_never_shortens():
|
|
from utils.stealth import randomize_run_duration
|
|
|
|
base = 8.0
|
|
vals = [randomize_run_duration(base) for _ in range(3000)]
|
|
assert min(vals) >= base, (
|
|
f"attack window shortened to {min(vals):.2f}s — a short window leaves the "
|
|
"boss alive, which is a failed run, not stealth"
|
|
)
|
|
assert max(vals) <= base * 1.4 + 1e-9
|
|
|
|
|
|
def test_wait_jitter_floor_holds_under_any_session_bias():
|
|
"""The clamp must be applied AFTER the session bias.
|
|
|
|
Clamping first let a 0.92x session push waits back under the floor, undoing
|
|
the wait_jitter_min fix.
|
|
"""
|
|
import random as _r
|
|
from config import Config
|
|
|
|
cfg = Config().stealth
|
|
lo, hi = cfg["wait_jitter_min"], cfg["wait_jitter_max"]
|
|
floor = lo * 0.8
|
|
for bias in (0.90, 1.00, 1.15):
|
|
worst = min(
|
|
max(floor, min(hi * 1.2, _r.gauss((lo + hi) / 2, (hi - lo) / 4) * bias))
|
|
for _ in range(5000)
|
|
)
|
|
assert worst >= floor - 1e-9, f"floor violated at bias {bias}"
|
|
|
|
|
|
# ─── Phase 2: dead code stays dead ───────────────────────────────────────────
|
|
|
|
def test_no_resurrected_dead_stealth_functions():
|
|
"""These were deleted because they had zero callers and read as implemented.
|
|
|
|
stealth_move additionally carried a latent NameError on its success path,
|
|
which is independent proof it never executed. Re-adding any of them without
|
|
a call site recreates the exact condition this work removed.
|
|
"""
|
|
import utils.stealth as st
|
|
|
|
for name in ("randomize_click_position", "endpoint_wobble",
|
|
"human_key_press", "human_keyboard_send"):
|
|
assert not hasattr(st, name), f"{name} is back without a call site"
|
|
|
|
|
|
# ─── Phase 4: observability ──────────────────────────────────────────────────
|
|
|
|
def test_manifest_reports_every_behaviour_reachably():
|
|
"""The manifest is the guard against silent inertness. Anything it reports
|
|
as UNREACHABLE is a behaviour configured but not wired.
|
|
"""
|
|
from utils.stealth import manifest
|
|
|
|
rows = manifest()
|
|
assert rows, "manifest produced no rows"
|
|
unreachable = [(n, c, s) for n, c, s in rows if "UNREACHABLE" in s]
|
|
assert not unreachable, f"configured but unreachable: {unreachable}"
|
|
|
|
|
|
def test_personality_seed_is_stable_across_processes():
|
|
"""The old implementation used builtin hash(), which Python randomizes per
|
|
process for str — so it produced a different 'stable' seed every session.
|
|
"""
|
|
from utils.stealth import get_personality_seed
|
|
|
|
assert get_personality_seed("testchar") == get_personality_seed("testchar")
|
|
assert get_personality_seed("a") != get_personality_seed("b")
|
|
# Known-good value pins it against a silent hashing change.
|
|
import hashlib
|
|
expected = int.from_bytes(hashlib.sha256(b"testchar").digest()[:4], "big")
|
|
assert get_personality_seed("testchar") == expected
|
|
|
|
|
|
def test_session_bias_is_constant_and_bounded():
|
|
from utils.stealth import get_session_bias
|
|
|
|
first = get_session_bias()
|
|
assert 0.90 <= first <= 1.15
|
|
assert all(get_session_bias() == first for _ in range(20))
|
|
|
|
|
|
# ─── Unbound optional skills must not kill the bot thread ────────────────────
|
|
|
|
def test_unbound_skill_key_is_a_noop_not_an_exception():
|
|
"""A FoHdin has no Holy Shield, Vigor or Cleansing.
|
|
|
|
paladin.cast_buffs does keyboard.send(self._skill_hotkeys["holy_shield"])
|
|
with no check, and there are 90+ such unguarded sends across the paladin
|
|
classes. Raising on an empty key killed the whole bot thread mid-run for a
|
|
skill the character was never meant to cast, so the guard belongs at the
|
|
boundary rather than at every call site.
|
|
"""
|
|
from input_layer import keyboard
|
|
|
|
keyboard.send("")
|
|
keyboard.send(None)
|
|
|
|
|
|
def test_genuinely_unknown_key_still_raises():
|
|
"""The empty-key guard must not swallow real typos."""
|
|
import pytest as _pytest
|
|
from input_layer import keyboard
|
|
|
|
with _pytest.raises(ValueError):
|
|
keyboard.send("notakey")
|
|
|
|
|
|
def test_cast_buffs_skips_when_holy_shield_unbound():
|
|
"""Not just the send — the right-click after it would cast whatever is on
|
|
the right slot instead of the intended buff."""
|
|
import inspect
|
|
from char.paladin.paladin import Paladin
|
|
|
|
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}"
|
|
|
|
|
|
def test_chicken_roll_has_a_call_site_in_bot():
|
|
"""The roll must actually be invoked per game.
|
|
|
|
It was written, committed and reported as "wired" by the manifest while
|
|
having NO call site at all — the patch script that added it exited early on
|
|
an unrelated assertion and never wrote the edit. The manifest missed it
|
|
because that row checked the config value instead of the call site, so the
|
|
guard against silent inertness was itself silently inert.
|
|
"""
|
|
import inspect
|
|
from bot import Bot
|
|
|
|
src = inspect.getsource(Bot)
|
|
assert "set_game_chicken_threshold(" in src, "chicken threshold is never rolled"
|
|
|
|
|
|
def test_health_manager_falls_back_safely_without_a_roll():
|
|
"""If the roll never happens, the configured value must still apply."""
|
|
from config import Config
|
|
import health_manager as hm
|
|
|
|
hm.set_game_chicken_threshold(None)
|
|
assert hm.get_game_chicken_threshold() == Config().char["chicken"]
|
|
|
|
|
|
def test_pickup_skip_advances_the_item_counter():
|
|
"""A bare `continue` here spins on one item until the pickit phase times out.
|
|
|
|
pick_up_items increments item_count at the END of its loop body, so any
|
|
`continue` before that point re-evaluates the SAME item forever. The 2%
|
|
stealth skip was doing exactly that — burning the whole pickup window on a
|
|
single item.
|
|
"""
|
|
import inspect
|
|
from item.pickit import PickIt
|
|
|
|
src = inspect.getsource(PickIt.pick_up_items)
|
|
for i, line in enumerate(src.splitlines()):
|
|
if "_should_walk_past(item)" in line:
|
|
nxt = src.splitlines()[i + 1]
|
|
assert "item_count += 1" in nxt, (
|
|
"stealth skip continues without advancing item_count — "
|
|
"the loop will re-evaluate the same item until timeout"
|
|
)
|
|
|
|
|
|
def test_pickup_skip_is_decided_once_per_item():
|
|
"""The loop re-evaluates items many times; rolling each time compounds the rate."""
|
|
import inspect
|
|
from item.pickit import PickIt
|
|
|
|
src = inspect.getsource(PickIt._should_walk_past)
|
|
assert "_stealth_skipped" in src, "skip decision is not memoised per item"
|
|
|
|
|
|
def test_afk_break_is_rolled_on_the_end_of_game_path():
|
|
"""The roll must sit where a single-route rotation actually goes.
|
|
|
|
It originally lived only in on_end_run's town-return branches. With one
|
|
route configured the bot never reaches them — after the run it goes straight
|
|
to end_game (loot -> game|end -> save+exit -> next game) — so afk_break
|
|
fired 0 times in 9 games at a 50% test rate, odds of roughly 1 in 500.
|
|
|
|
The manifest said "wired - 4 call sites" the whole time. That was true and
|
|
useless: a call site existing is not a call site being reached.
|
|
"""
|
|
import inspect
|
|
from bot import Bot
|
|
|
|
src = inspect.getsource(Bot.on_end_game)
|
|
assert "maybe_afk_break()" in src, (
|
|
"afk_break is not rolled on the end-of-game path, which is the only path "
|
|
"a single-route rotation takes"
|
|
)
|