Review catch. The previous commit checked the counter after
find_abs_node_pos, which is still too late.
The anti-stuck block sits BEFORE the scan in the loop body:
790 if _heading_rejects >= MAX: abort <- top-of-loop check
799 if not did_force_move and now - last_move > 3.1:
808 char.move(...) <- the wall-driving guess
826 node_pos_abs = self.find_abs_node_pos(...) <- 3rd rejection recorded
833 if _heading_rejects >= MAX: abort <- too late
With two rejections banked, the moment 3.1s elapses the anti-stuck block
force-moves along last_direction — driving a wall-wedged character further in —
before the third rejection has been recorded. The exact guess this guard exists
to prevent stayed reachable on the threshold iteration.
The scan and the abort decision now both run ahead of the anti-stuck block, so
the counter is current when that decision is made.
The ordering test could not catch this: it searched for "taking a random guess"
only in the source AFTER find_abs_node_pos, while the guess block sits before
that call, so the comparison was against nothing. It now locates the anti-stuck
block explicitly and asserts BOTH the scan and the abort precede it.
Verified by falsification: restoring the scan-after-guess order fails with
"the node scan must run BEFORE the anti-stuck force-move".
That is now four times in this codebase where a check was verified by where it
sat in the source rather than by whether it ran at the deciding moment.
Co-Authored-By: Claude Opus 5 <[email protected]>
Across 79 hell games: 4 random guesses, 0 aborts — including this sequence
inside a SINGLE traverse:
Traverse from a5_town_start to a5_nihlathak_portal
rejecting low-confidence A5_TOWN_1 (66.4%) for node 3
rejecting low-confidence A5_TOWN_1 (65.9%) for node 3
rejecting low-confidence A5_TOWN_1 (64.1%) for node 3
taking a random guess towards (-218, 23)
Wanted to select A5_RED_PORTAL, but could not find it
Three rejections is the threshold, so it should have aborted. The check existed,
was correctly indented inside the while loop, and sat before the anti-stuck
block — the placement I verified with a test when I added it. But the loop does
not reliably come back round to the top of the body after a rejection, so the
check was never evaluated at the moment the counter crossed.
That is why the earlier fix looked right and changed nothing: the ordering test
asserted where the check SAT in the source, not that it ever RAN.
Now checked immediately after find_abs_node_pos, in the same iteration the
counter trips, which removes the dependence on control flow entirely. The
original top-of-loop check is left in place as a second chance.
The character ended up outside the Harrogath battlements again, and the game
was lost to a 82s approach — the exact failure the abort was written to
prevent, still happening because the abort was inert.
Tests now assert the counter TRIPS at the threshold, not merely that the code is
ordered correctly.
Co-Authored-By: Claude Opus 5 <[email protected]>
Follow-up to the GameMenu guard, caught by its own instrumentation: 75 menu
escapes across 15 games, ~5 per game, all clustered around game end.
save_and_exit deliberately opens the in-game ESC menu, but callers only pause
the panel check AFTER it returns:
18:58:48.142 game | end | ok
18:58:48.334 In-game menu open - closing it (1/6) <- the guard
18:58:48.487 Clicking SAVE_AND_EXIT_NO_HIGHLIGHT <- the bot
18:58:48.736 In-game menu open - closing it (2/6) <- the guard again
18:58:49.098 Health Manager is now paused <- too late
Games still completed, so this was noise rather than breakage — but it is a
race, and the guard was pressing esc while the shutdown clicked the menu.
save_and_exit now pauses the panel check for the whole sequence and restores it
in a finally, so it cannot leak the paused state if save/exit raises.
The guard itself is working: 15 games, 0 failures, 0 portal failures, and the
loot-filter/Chronicle/Options incidents have not recurred.
MANA> instrumentation has also settled #23 — see the issue.
Co-Authored-By: Claude Opus 5 <[email protected]>
The bot was found sitting in OPTIONS -> VIDEO with a "settings have changed,
apply or discard?" modal. Discarding revealed the cause: the in-game ESC menu
carries these buttons at SCREEN CENTRE.
OPTIONS / SAVE AND EXIT / RETURN TO GAME / LOOT FILTER / CHRONICLE
A stray esc opens the menu and the bot's next movement click lands on one of
them. The HUD mask deliberately leaves screen centre clickable, so nothing
stops it.
That single mechanism explains three incidents previously treated as separate:
- the LOOT FILTER being toggled (blamed on vigor=f4, which was a real but
different bug)
- CHRONICLE blanking every template match for a whole run, costing a 66s
click_red_portal failure
- the video OPTIONS being opened and a setting changed, which could have
altered resolution and broken every template in the project
The menu has NO close button, so the CenterPanel guard (CLOSE_PANEL_2) cannot
see it. Bug 31 warned about precisely this: "just send esc is WORSE: with
nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not
match".
SAVE_AND_EXIT_NO_HIGHLIGHT scores 1.000 on the menu frame and does not match a
normal town frame, so detection is unambiguous. Escaped without counting toward
a chicken, bounded like the waypoint and centred-panel cases.
Also adds MANA> threshold-crossing logging for #23. That issue measured "1 mana
potion per game, never 2" over 70 games but was undecidable, because mana is
only logged when a potion is DRUNK — a second dip that failed to trigger looks
identical to mana never dipping twice. Every crossing is now logged with the
gate state, edge-triggered so it fires once per crossing rather than per poll.
NOT fixed here: whatever sends the stray esc. This is the safety net; the source
is still unknown.
Co-Authored-By: Claude Opus 5 <[email protected]>
walk() carried the identical unguarded click that move() had and was missed
because the tests were written against move() alone. This asserts the invariant
across the movement methods, so a future one is caught without anyone
remembering to extend the tests.
Deliberately NOT covered, because relocating these breaks what they do:
pick_up_item must click the item itself
_remap_skill_hotkey deliberately clicks the UI
cast_in_arc aims a cast direction, not a destination
Only clicks that choose a DESTINATION may be moved off the HUD. A first draft
of this test flagged all of the above and was wrong to; the distinction is
between "go here" and "hit that".
Verified by falsification: reverting walk() to randomize=5 fails with
"walk: mouse.move(x, y, randomize=5, ...)".
Co-Authored-By: Claude Opus 5 <[email protected]>
Review catch on the previous commit — the guard was real but leaky.
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
construction sits exactly on the mask boundary. mouse.move(randomize=3) then
offsets each axis by randrange(-3, 3) = -3..+2, so the cursor can land back
inside the masked region before the right-click. The loot-filter click was made
intermittent, not fixed.
The order is now: jitter -> guard -> move with randomize=0. The human-like
offset is preserved; the guarantee is no longer given away. Same treatment for
the walk branch (randomize=5).
My test missed this because it only checked the guard's OUTPUT, never the point
finally clicked — it passed while the bug was live. The new test samples 400
jittered targets per filter button and asserts every FINAL point is unmasked,
plus a source check that no mouse.move in move() randomizes after the guard.
Verified by falsification: restoring guard-then-randomize makes the suite fail
with "move() still randomizes after the guard, which can re-enter the HUD".
Note on the test itself: an intermediate version compared string indexes over
the whole function source and reported the ordering backwards, because the
DOCSTRING mentions both names. It now compares code with the docstring and
comments stripped — the third time today a source-order assertion was fooled by
prose rather than code.
Co-Authored-By: Claude Opus 5 <[email protected]>
Reported after Larzuk trips: the bot pressed escape, then flipped the loot
filter. Larzuk stands on the left of Harrogath, so moves to and from him aim at
the bottom-left corner — where D2R puts the seven loot-filter category buttons
(screen x 395-560, y 692-712). A right-click there toggles a filter, which
changes what renders and therefore what every later template match can see.
IChar.move() applied NO HUD avoidance in either branch:
# teleport
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, ...)
mouse.click(button="right")
# walk
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, ...)
The pather's anti-stuck path already called get_closest_non_hud_pixel; move()
never did, and assets/hud_mask.png covers those buttons correctly — the mask
was simply not consulted.
Latent for a long time and surfaced by Enigma. The walk branch shrinks its
target toward centre via adjust_factor, which mostly kept clicks off the HUD by
accident; the teleport branch clicks the raw target, so any low aim point lands
on the interface.
The escape that precedes it is unrelated and correct — common.close() dismissing
the repair panel.
Verified: filter-row targets are moved from y=700 to y=508, a centre target is
returned unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
restart_or_exit spawned a replacement process and exited, with no attempt limit
and no backoff:
subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0])])
os._exit(0)
On 2026-08-28 a 25-minute scheduled break left D2R on a screen the bot could
not re-enter:
=== BOT START ===
select_char: Could not find online/offline tabs
Restarting bot — game kept running
Because the failure was persistent, this span up a new process roughly every 20
seconds. Instances stacked (4 observed) and then refused taskkill.
The counter has to survive the exec — each restart is a NEW PROCESS, so an
in-memory counter cannot bound the chain. It lives in log/.restart_count,
is checked BEFORE spawning a replacement, and after 5 consecutive restarts the
bot stops with a Discord alert instead of looping, telling the user to return
D2R to the main menu.
A 5s-per-attempt backoff (capped at 60s) stops a fast failure spinning CPU or
stacking processes faster than they exit.
The count is cleared on reaching town, not at game end: the loop failed at
select_char, well before town, so reaching town is what proves recovery — and a
healthy bot never accumulates toward the cap.
Verified by falsification: restoring the unbounded restart makes the suite fail
with "restart loop is unbounded".
Co-Authored-By: Claude Opus 5 <[email protected]>
Bug 31 fixed the keypress that opened CHRONICLE but explicitly left the real
gap open: the panel guard matches only LeftPanel / RightPanel, and a centred
panel matches neither. Nothing closed it, and a centred panel blanks EVERY
later template search.
Recurred 2026-08-28 with the merc key never pressed at all
(resurrect_merc | skip | merc alive), so something else opened it and it simply
stayed. The error screenshot shows Chronicle filling the screen while the bot
hunted A5_RED_PORTAL for 66s:
Traverse from a5_larzuk to a5_nihlathak_portal
Wanted to select A5_RED_PORTAL, but could not find it
random guess towards (96, -115)
Wanted to select A5_RED_PORTAL, but could not find it
Why the existing guard missed it, measured on that frame: the Chronicle's close
button scores 1.000 against CLOSE_PANEL_2 at (952, 56). That x IS inside
right_panel_header (830,0,455,56) — but the ROI is 56px tall and the button's
centre sits exactly at y=56, so the match falls outside. Both header ROIs
scored 0.409 and 0.373.
Adds a center_panel_header ROI (400,0,620,92) and a CenterPanel ScreenObject,
and escapes it WITHOUT counting toward a chicken — a centred panel is
self-inflicted UI state like the waypoint panel, and chickening on it throws
away a healthy game. Bounded by the same _MAX_WP_PANEL_ESCAPES so one that
genuinely will not close still falls through.
Verified on the failure frame: CenterPanel True, LeftPanel False, RightPanel
False.
Co-Authored-By: Claude Opus 5 <[email protected]>
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 <[email protected]>
'start' was a plain alias for 'pause' — both branches called the same toggle:
if data == 'start' or data == 'pause':
start_or_pause_bot(controllers)
So a caller retrying a timed-out 'start' PAUSED the bot. On 2026-08-28 a
restart routine sent it three times and the bot sat frozen for 4h50m of an
overnight run, stopping at the next state change because trigger_or_stop blocks
while _pausing is set.
The verification that should have caught it failed too: 'status' returned
controllers.game.is_running, which tracks the game controller and not
Bot._pausing, so a paused bot answered running=True.
Both fixed:
- 'start' is idempotent — starts a stopped bot, resumes a paused one, and is a
no-op on a healthy one. 'pause'/'toggle' remain the toggle.
- 'status' reports "running=X paused=Y".
The handler was extracted from an inline closure into handle_hermes_command()
so this is testable behaviourally rather than by asserting on source text.
Verified by falsification: restoring the original semantics makes the suite
fail with "repeated 'start' paused a healthy bot" and "status hides the pause
state: 'running=True'"; the fix makes all five pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Bug 25 added a heading gate that refuses a low-confidence node match implying a
>90 degree reversal. The gate works, but rejection returned None and the
traverse loop fell through to its anti-stuck path, which force-moves along
last_direction — and when the character is already wedged against the Harrogath
wall, that shoves it further in.
Observed 2026-08-28 01:22:
rejecting low-confidence A5_TOWN_1 (65.3%) for node 3 - implies reversal
rejecting low-confidence A5_TOWN_1 (66.3%) for node 3 - implies reversal
rejecting low-confidence A5_TOWN_1 (67.0%) for node 3 - implies reversal
rejecting low-confidence A5_TOWN_1 (67.7%) for node 3 - implies reversal
Pather: taking a random guess towards (-423, 247)
Wanted to select A5_RED_PORTAL, but could not find it
The error screenshot shows the character outside the battlements in the dark
void with the portal a faint occluded glow. Declaring a position untrustworthy
and then moving on an arbitrary 488px vector are contradictory.
Three consecutive rejections now abort the traverse so the caller re-anchors
from a fresh game (~40s) rather than wedging the character somewhere that
poisons the rest of the run. A confident match clears the counter, and it
resets per traverse so a stale count cannot abort the next one.
The abort MUST precede the anti-stuck block; a test asserts that ordering.
Nothing about the thresholds or the gate itself changed.
Note on the test: its first version searched the source for "random guess" and
matched the explanatory COMMENT above the abort, reporting the ordering
backwards. It now strips comments — the same mistake as slicing source on a
branch name and matching a comment that merely mentioned it. Verified in both
directions: removing the abort makes it fail.
Co-Authored-By: Claude Opus 5 <[email protected]>
Self-inflicted, from the session-rhythm commit. pick_up_items increments
item_count at the END of its loop body, so the `continue` I added after the
skip roll jumped past it and the loop re-evaluated the SAME item until
self.timeout expired. A 2% roll therefore burned the entire pickup window.
Symptom that gave it away: two pickup_skip events in nine games, far above the
configured 2% — each fire was consuming a whole phase rather than skipping one
item.
Second defect in the same code: the roll ran on every EVALUATION, and this loop
re-locates items after each pickup, so a single item is evaluated repeatedly.
That compounded the real skip rate well past the configured value and could
re-roll an item already skipped.
Now decided once per item id and memoised, with the counter advanced before
continuing. Tests pin both: that the counter advances (or the loop hangs) and
that the decision is memoised (or the rate compounds).
Co-Authored-By: Claude Opus 5 <[email protected]>
The same defect has now appeared twice: detect_current_act was fixed in Bug 28,
wait_for_town_spawn kept it, and nothing linked the two. These tests assert the
invariant for both, so a future fix to one cannot silently leave the other
behind:
- both must require _ACT_DETECT_MARGIN over the best rival-act marker
- both must score own-act and rival markers on ONE image (scores from two
different grabs are not comparable — the character can move between them)
- ambiguous detection must return None rather than guess
Audited every other TOWN_MARKERS consumer: bot.py:561 and main_menu.py:40/61
use best_match only as a boolean "are we in town?" check and never derive an
act, so they need no margin.
Co-Authored-By: Claude Opus 5 <[email protected]>
The repo already strips personal profile references (35aa134); a real character
name does not belong in a committed test fixture.
Co-Authored-By: Claude Opus 5 <[email protected]>
The chicken threshold roll was committed but never present in bot.py. The patch
script that was supposed to add it raised SystemExit on an unrelated anchor
assertion and exited before writing the edit; the failure was visible in its
output and went unnoticed.
Caught by its own symptom: 0 "Chicken threshold this game" lines across a
session with games in it.
The manifest reported chicken_variance as "wired" throughout, because that row
was built from the CONFIG VALUE rather than from a call-site check like every
other row. So the guard against configured-but-inert behaviour was itself
configured but inert — the exact defect it exists to catch, one level up.
Both fixed: the roll is wired at game start on the bot thread, and the manifest
row now counts set_game_chicken_threshold( call sites. Verified in both
directions — removing the call site flips the row to
"UNREACHABLE - no call site in bot.py".
Tests: 22.
Co-Authored-By: Claude Opus 5 <[email protected]>
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]>
Regression from the previous commit. Blanking vigor/holy_shield/cleansing
stopped the stray F4 presses, but exposed that paladin.cast_buffs sends the
holy_shield key with no check:
keyboard.send(self._skill_hotkeys["holy_shield"]) # -> ValueError: Unknown key:
which killed the bot thread mid-run:
Uncaught exception in thread Thread-8 (start)
...
File "src/char/paladin/paladin.py", line 33, in cast_buffs
ValueError: Unknown key:
There are 90+ `keyboard.send(self._skill_hotkeys[x])` call sites across the
paladin classes and almost none check first, so the guard belongs at the
boundary: an empty or None key logs at debug and returns. A genuinely unknown
key still raises — the guard is for unbound optional skills, not for typos.
cast_buffs additionally returns early when holy_shield is unbound: send() now
tolerates the empty key, but the right-click after it would still fire and cast
whatever sits on the right slot instead.
Co-Authored-By: Claude Opus 5 <[email protected]>
Phase 1 — stop the harm (safety-critical)
The hesitation/miscast gate was `vk in range(ord('1'), ord('0')+1)` plus a
digit-string fallback. ord('1')=49 and ord('0')=48, so that range is EMPTY and
never matched; only the string test fired, and it listed the digits — the
POTION keys. The gate was aimed at the exact inverse of its intent: every
potion press carried 80-300ms of hesitation and a 1.5% chance of pressing a
different potion first, while skill casts (f1-f12) were never touched. The
"wrong key" fell through the same empty range into an except branch choosing
from ['1'..'5'] — potion keys again.
Potion presses come from health_manager at low HP, so this was a stealth
feature that delayed emergency healing and could drink mana instead of health
mid-death. Replaced with explicit key classification: skill hotkeys may carry
stealth, potion/belt keys are exempt by construction, and a miscast now presses
another BOUND SKILL or does nothing.
Phase 2 — delete the dead code
stealth_move, endpoint_wobble, randomize_click_position, human_key_press,
human_keyboard_send: zero callers. stealth_move also referenced `variance` on
its success path where the name is never assigned, so it would NameError on
first execution — independent proof it never ran, and that the three functions
reachable only through it never ran either. Deleted rather than wired: the
per-call-site randomize= values are tuned to measured button geometry (2-3px
NPC, +/-9px inside a 47px waypoint button) and stacking a global offset on top
is what produces the NPC-detection failures of Bugs 3/4/6/7. click_variance
removed with its last consumer.
Phase 3 — coverage
Added IChar.atk_len(), the single correct way to read an atk_len_* value, and
migrated fohdin's 7 boss windows onto it. 15 other char modules still read
Config().char["atk_len_*"] directly; rather than sweep 173 call sites in
untested classes, the manifest REPORTS the gap (1/16) so it cannot stay silent.
Replaced the personality stub. get_personality_seed used builtin hash(), which
Python randomizes per process for str — it returned a different "stable" seed
every session, the opposite of its docstring. Now sha256-based. Added
get_session_bias(): one timing multiplier held constant for the whole run, so
the session MEAN differs between runs. Per-action jitter alone cannot do this —
averaged over hundreds of actions it converges to the same mean every session,
which is itself a signature.
The bias is applied BEFORE the jitter clamp. Applying it after let a 0.92x
session push waits under the floor, silently undoing the wait_jitter_min fix
from the previous commit.
Phase 4 — make inertness impossible to miss
Every defect here was invisible for one reason: a behaviour that never fires
looks identical to one whose roll has not come up. AFK breaks sat dead for 225
games behind that ambiguity.
- STEALTH> manifest at startup reports each behaviour's configured value AND
whether it has a reachable call site; UNREACHABLE logs at WARNING. Logged
once per process, since === BOT START === fires per game.
- The 2-hourly digest now compares observed against configured rates and
prints "NEVER FIRED" for anything absent from a statistically meaningful
window, instead of omitting the row.
Tests: 12, covering each phase's invariant — potion keys can never route
through stealth, miscast candidates are never potions, kill windows never
shorten, the wait floor holds under any session bias, deleted functions stay
deleted, and the manifest reports nothing unreachable.
Co-Authored-By: Claude Opus 5 <[email protected]>
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 <[email protected]>
Pindle (src/run/pindle.py):
- Pre-check if already in Pindle area before portal click
- Extended verify timeout to max(5s) for temple marker detection
A5 waypoint (src/town/a5.py):
- Two-tier WP scan: 0.45 threshold first, then 0.55
- Progressive stash thresholds (0.60 -> 0.50 -> 0.40) with direct click fallback
NPC detection (src/npc_manager.py):
- Close waypoint panel before NPC search (prevents WP UI blocking template match)
Diablo battle (src/char/paladin/hammerdin.py):
- Extended spawn wait from 15s to 20s
- Mid-fight target re-verification and repositioning
- Extra redemption burst to ensure kill
Auto-fixer (test/auto/auto_fixer.py):
- Updated to detect and verify all applied fixes
CI (.github/workflows/ci.yml):
- Added log analyzer tests to CI pipeline
- Excluded self-healing orchestrator (requires live D2R) and broken tests
New modules:
- test/auto/log_analyzer.py: parses bot logs and event JSONL files,
categorizes failures (approach, maintenance, battle, chicken, timeout,
crash, OCR) into structured BotFailure objects
- test/auto/auto_fixer.py: maps failure patterns to targeted code fixes,
checks if fixes are already applied, reports what needs work
- test/auto/test_self_healing.py: orchestrator that launches the bot,
monitors for failures in real-time, analyzes logs, applies fixes,
and retries up to 3 rounds
Supporting changes:
- test/auto/test_log_analyzer.py: 8 tests against historical run data
- pytest.ini: added repo root to pythonpath for test package imports
- test/__init__.py: new, enables test/ as importable package
All 95 existing tests pass. 8 new auto-test tests pass.
test_setup_bat_files.py asserted that run_asset_extractor.bat and
run_quest_debug.bat exist in the repo root. Those are developer tools that
the end-user `stable` branch deliberately strips, so a fresh clone of
stable shipped 4 failing tests even though the bot was fine.
Split the list into CORE_BATS (install/find_python/run_botty -- required on
every branch) and OPTIONAL_BATS (dev tooling -- validated only when
present). The username, absolute-path and find_python checks now iterate
over the files that actually exist rather than a hardcoded list.
Found by cloning stable from GitHub onto a clean machine and running the
suite as a new user would.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
time.time() on Windows has ~15 ms resolution; a 0.0-second budget produced a
deadline equal to the current tick, so the anchor-loop check never fired and
traverse_calls reached 5 instead of 1. Using -1.0 puts the deadline one second
in the past — guaranteed expired on any hardware.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The A5 waypoint stone matches reliably (65-91% when on screen). The real
failure mode is a stale curr_loc that lands the char off the stone, so
select_by_template("A5_WP") never matches. The old escalation (NPC anchors +
a 6-step directed sweep) then looped for 5+ minutes — the "stuck in town"
behavior seen in log/log.txt 2026-06-24 (08:28:48 -> 08:30 force-exit).
- Add a 45s hard wall-clock budget to open_wp; bail between anchors once past.
- Drop the directed sweep entirely: it never recovered in practice and was the
main multi-minute time sink. A failure now returns fast so the caller falls
back (buy at Malah / skip to stash) instead of stranding the bot.
- Also folds in the in-progress A5 repair-menu timing fix (wait_until_visible
instead of a too-short 0.2-0.3s peek).
- Add test/town/a5_open_wp_test.py covering fast-fail, quick-mode, and budget.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Make the standalone exe work with OCR out of the box — no separate
Tesseract install, no tesserocr DLL hell. Verified end-to-end: built the
exe, ran it frozen with the system Tesseract blinded, confirmed it
resolves the bundled binary and reads text ("CHAM RUNE").
- ocr.py: resolve an _APP_BASE (exe dir when frozen, else cwd) and prefer
a bundled <exe_dir>/tesseract/tesseract.exe over PATH / Program Files.
Resolve assets/tessdata to an absolute path so OCR no longer depends on
the current working dir. Applies to both the tesserocr and pytesseract
paths.
- build.py: copy a portable Tesseract (exe + DLLs) from TESSERACT_DIR
(default C:\Program Files\Tesseract-OCR) into <release>/tesseract/. Our
trained models in assets/tessdata are used via --tessdata-dir, so their
tessdata is skipped. Warns (non-fatal) if Tesseract isn't present.
- ci.yml: choco install tesseract before the build so the bundle is
reproducible on the runner; verify it landed in the release dir.
- test/conftest.py: apply the SSL cert-store workaround so pytest can be
collected on Windows boxes with a corrupted cert store (no-op on CI).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Restore test/assets/ from commit 5127417^ (was moved to bottytools/botty-test-assets which is now 404)
- Remove 'Download test assets' step from CI workflow (assets are now in-repo)
- Make download_test_assets.py a no-op for backward compatibility
- Fix import: from pipes import Template -> from template_finder import Template
- Remove test/assets/ from .gitignore
install.bat:
- Add conda self-test (conda --version) before env create
- Verify botty python.exe exists after env creation
- Smoke-test key imports (cv2, tesserocr, discord, etc.)
CI (.github/workflows/ci.yml):
- Add test_setup_bat_files.py to test matrix
test/test_setup_bat_files.py (7 tests):
- All expected .bat files exist
- No hardcoded usernames (alex, alexpolo, ultimate) in run scripts
- No absolute home paths in run scripts (must use %~dp0 / %USERNAME%)
- All run_*.bat source find_python.bat (no duplicated conda detection)
- find_python.bat checks >= 6 conda locations
- install.bat has conda self-test and python verification
- Read character .key file from Saved Games / D2R install dir / APPDATA
- Auto-fill empty hotkeys in [char] section (inventory, belt, potions, etc.)
- Match skill slots to configured skills for validation
- Normalize key variants (left alt ~ alt, left shift ~ shift)
- params.ini values always take priority over detected bindings
- Warn on genuine mismatches between config and .key file