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]>
D2R UI hitboxes are tight; a 1px offset on the Join Game tab click was
enough to miss the button. Final SetCursorPos pass after the existing
verification check.
GetAsyncKeyState only detects keys when the bot process has focus.
When D2R is focused, F11/F12/End hotkeys were silently ignored.
Replaced polling loop with SetWindowsHookEx WH_KEYBOARD_LL which
intercepts all keystrokes globally before they reach any app.
Keeps polling as fallback if hook installation fails.
Also:
- Removed if-gate on enforce_d2r_window in game_controller.start()
- Added try/except pywintypes.error around all SetWindowPos calls
- Added pywintypes import to misc.py
- Added stop_hotkeys() cleanup in on_exit
Session fixes (2026-06-11), all live-tested over multiple farm games:
- input_layer/hotkey.py: no-arg keyboard.wait() returned on ANY keypress,
silently killing the process right after F11 (all bot threads are
daemons). Now blocks forever like the original keyboard lib. Poll loop
is edge-triggered (no ~20ms refire while a hotkey is held) and callback
exceptions print instead of being swallowed. (Bug 13)
- d2r_image/ocr.py: pytesseract never read PYTESSERACT_TESSERACT_CMD;
tesseract_cmd is now wired from env var / PATH / winget default, fixing
exit 0xC0000135 on every OCR call. (Bug 14)
- bnip/utils.py + config/default.bnip: undefined NipSyntaxError ->
BNipSyntaxError, and Shaefershammer -> Schaefershammer typo; 474 pickit
expressions load (was 473 + parser error). (Bug 15)
- town_manager/a5/bot: A5 WP death-loop containment - per-game WP failure
budget (2 strikes), quick=True direct-path-only retries, sweep trimmed
10->6 steps with 4s select timeouts, A5 select thresholds lowered
(WP 0.62, stash 0.60/0.45; safe - every select is success_func-gated).
Worst case dropped from 10+ min wandering to ~4.5 min contained fail
with fresh-game recovery. (Bug 16)
- bot.py: vendor trip gating - the failure-prone A5->A4 Jamella round
trip now only runs when consumables are needed or 3+ sell items pend.
- town_manager.py: Cain identify skips acts whose Cain timed out this
session (straight to working A5 fallback).
- char/i_char.py: Battle Command buff check waits 0.6-0.8s after the
hotkey (icon fade-in) to reduce double weapon-swaps.
- char/paladin/hammerdin.py: kill_diablo fights with Concentration
instead of Conviction (useless for magic-damage hammers) and drops
mid-fight Redemption downtime - faster kills, merc survives. (Bug 17)
- inventory: stash supports all 6 pages (personal + 5 shared, D2R 2.7+);
gold deposits navigate via OCR-verified select_stash_page instead of
raw 4-tab clicks; rotation %6, shared-first starts at page 5. (Bug 18)
Docs: CLAUDE.md Bugs 13-18; .hermes/plans/dia_run_test_state.md has the
full test log (two complete Diablo kills verified end-to-end today).
Co-Authored-By: Claude Fable 5 <[email protected]>
Reliability
- All run approach() methods now set approach_fail_step before every return False;
bot.py _run_wrapper reads it and includes [step: X] in Discord/log failure reasons
- on_maintenance() sets _maintenance_step before each town step (heal, identify,
buy_consumables, stash_items, repair, resurrect_merc, gamble) for the same coverage
- on_init() logs startup line: char=X | difficulty=Y | routes=[...] each game
Per-run disable (bot runs as long as possible)
- GameStats tracks per-run failure counts across Bot instances (previously reset each game)
- After disable_run_after_failures consecutive failures a run is disabled for the session;
game-level consecutive-fail counter resets so the bot continues on remaining routes
- game_controller no longer quits on max_consecutive_fails if active routes remain;
only exits when all routes are disabled
Maintenance timeout (params.ini: max_maintenance_time_s=120)
- Hard 120 s wall on the entire town maintenance loop; checked between each major step
and before every retry (buy_consumables, stash, repair, resurrect_merc, gamble)
- On timeout: error screenshot + Discord, then trigger end_game → save-and-exit → rejoin
Bug fixes
- Win11 mouse overshoot: mouse_move() uses SetCursorPos + zero-delta MOUSEEVENTF_MOVE
- _curr_loc = True propagation: TownManager.identify() now returns the act Location enum
- DAMAGED KeyError in pickit: added ItemQualityKeyword.Damaged.value to NTIP_ALIAS_QUALITY_MAP
- A4 WP interaction range: force-move character to WP stone before select_by_template
- NPC click blocked by equipped-area guard: open_npc_menu() closes inventory if open
Docs
- Added CLAUDE.md: AI working guide with step tables, bug history, debugging tips
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- win_input.py: detect OS build via platform.win32_ver(), use
MOUSEEVENTF_ABSOLUTE only on Win10 (build < 22000)
- install.bat: detect and display Windows version on install