Resolves the 11 conflicting files blocking PR #41. Where both branches fixed the same problem, the fixes are combined rather than one side dropped: - a5.py: main's dual WP templates + require_visible pre-scan, bounded by baalxp's town_world ROI (excludes the HUD band and party column) - pindle.py: main's removal of the town-false-positive "already in temple" shortcut (also dropped baalxp's post-nudge copy of it); kept baalxp's nudge, click tuning and PORTAL_APPROACH node; main's detect-act retry - town_manager.py: main's act-margin check in wait_for_town_spawn, then baalxp's marker->location resolution; baalxp's in_act repair skip before main's repair_npc destination table - bot.py: baalxp's restructured leech cycle, with main's death recovery and InGame miss tolerance ported into _baal_xp_hide_and_wait - game_browser.py, skill_preflight.py, [hammerdin] binds: baalxp (lobby join flow; binds verified against the live client 2026-09-04) - params.ini: baalxp's "leech stays out of order=" rule with order=run_trav - CLAUDE.md: both bug writeups kept; baalxp's 23-25 renumbered to 32-34 since tests/docs reference main's numbering - test_stealth_config.py: AFK-break invariant exempts the whole baal_xp block instead of a 6-line window (the branch grew several handoffs) Tests: 272 passed. Remaining failures are pre-existing on origin/main (gems-tab transmute test, smoke_test/test_version_consistency collection). Co-Authored-By: Claude Opus 5 <[email protected]>
386 lines
15 KiB
Python
386 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.
|
|
# The exemption covers the WHOLE `if self.state == "baal_xp":` block (found by
|
|
# indentation) — the leech branch grew several handoffs, far from its `if`.
|
|
def _indent(ln):
|
|
return len(ln) - len(ln.lstrip())
|
|
|
|
baal_xp_block = set()
|
|
for start, ln in enumerate(lines):
|
|
if 'if self.state == "baal_xp"' in ln:
|
|
end = start + 1
|
|
while end < len(lines) and (not lines[end].strip() or _indent(lines[end]) > _indent(ln)):
|
|
end += 1
|
|
baal_xp_block.update(range(start, end))
|
|
|
|
unguarded = [
|
|
i for i in handoffs
|
|
if i not in baal_xp_block
|
|
and not any("maybe_afk_break()" 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"
|
|
)
|