Commit Graph
1350 Commits
Author SHA1 Message Date
Alex 0ce92b4e7c Merge pull request #32 from alexpolo1/tools/spellbook-mapper
tools: spellbook mapper — read skill binds from the game and check them against config
2026-08-28 14:46:40 +02:00
Alex a7e9f4e50f Merge pull request #33 from alexpolo1/feat/stealth-wiring
fix: 8 commits PR #29 left behind, plus the AFK break that never fired
2026-08-28 14:15:50 +02:00
alexpolo1andClaude Opus 5 1f440cfed2 fix(health): detect and close CENTRED panels — Chronicle blocked every template match
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]>
2026-08-28 13:28:57 +02:00
alexpolo1andClaude Opus 5 c8e63bc9fa fix(stealth): roll the AFK break on the path a single-route rotation actually takes
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]>
2026-08-28 12:46:41 +02:00
alexpolo1andClaude Opus 5 fd889a4df7 tools(spellbook): detect grid state, template-match the hotkey labels
Three fixes from running it against the live game:

1. It blind-pressed the open key. The grid was ALREADY open, so the press
   CLOSED it and the scan read grass. Now it detects the grid via its bottom
   hint and toggles only when needed, restoring the state it found. The hint
   match is fuzzy because Tesseract renders it "PRESS FI-F8 TO BIRD A SKIN"
   (BIND->BIRD, SKILL->SKIN).

2. The tooltip ROI was fixed, but the tooltip renders ABOVE the hovered cell
   and moves with the row, so it was reading the game world. Now taken relative
   to the cell. Skill identification is a fuzzy match against a known list
   rather than a demand for clean OCR: real reads included "XI BLESSED HAMMER"
   and one frame OCR'd Fist of the Heavens as "LNMEPULE".

3. Hotkey labels are read by TEMPLATE MATCH, not OCR. They are ~20x12px of
   white glyph over whatever icon is behind them; brightness thresholding
   cannot separate the two when the icon is also bright (F4's swirl, the row-4
   weapons) and OCR managed 4/8. A top-hat isolates small bright features
   regardless of background: 8/8 with zero false positives.

   Matching a same-SIZED crop is 1px-brittle — int() rounding in the grid
   geometry lands a pixel off the measured centre and the score collapses from
   ~1.0 to ~0.4. Exactly the three cells where truncation differed failed. A
   4px search slack fixes it; 8/8 held at every threshold 0.60-0.78.

Adds assets/templates/skill_binds/f1..f8.png, cut from a frame with all eight
labels visible.

Verified live: correctly reported conviction=f5 as casting TELEPORT and
concentration=f8 as casting CONVICTION — both confirmed by hand beforehand —
and found Concentration sitting unbound.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 10:42:36 +02:00
alexpolo1andClaude Opus 5 2feb13f44a tools: add spellbook mapper — read skill binds from the game, compare to config
Answers "which key is each skill actually on?" with screenshots and clicks, no
external service. For each cell of the bind grid it hovers, OCRs the tooltip
for the skill name, OCRs the icon's upper-right corner for the bound F-key, and
optionally saves the icon as a template with that corner blanked.

The corner is excluded from the saved template on purpose. D2R draws the hotkey
label there, so an icon captured with it only matches while the skill stays on
that key — rebind it and the template silently stops matching, looking like
template rot rather than a bind change. Same pixels, read separately as data.

Why it exists: on 2026-08-28 an Enigma put Teleport on F5, displacing
Conviction, while config still said conviction=f5 — every attack-aura cast
would have teleported the character mid-fight. F7 was Vengeance, not Holy Bolt;
F8 was Conviction, not Concentration. The startup preflight reported this
correctly and it was dismissed as a marginal template.

Two things learned building it, both encoded here:

- The tooltip renders ABOVE the hovered cell and moves with the row, so a fixed
  ROI reads the game world. The first version returned "YEW Y" and "PET". The
  band is now taken relative to the cell.
- Identification is a fuzzy match against a known-skill list rather than a
  demand for clean OCR. Real reads included "XI BLESSED HAMMER" and "HOLY
  SHIELD L", and one frame OCR'd Fist of the Heavens as "LNMEPULE" while the
  full text still contained the name. Validated 7/7 offline against saved
  frames.

It refuses to compare when it clearly could not read the grid (<3 skills or 0
bound keys) and exits 2. The first version scanned a closed grid, identified
nothing, and then reported all seven configured keys as unbound — presenting
its own blindness as findings.

Exits 1 on a real mismatch so it can gate a run. Menu entry:
    python tools/testbed.py spellbook --assets

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 10:27:07 +02:00
alexpolo1andClaude Opus 5 963b19dbe5 fix(control): accepted hermes connections were non-blocking — the root of every socket timeout
select() reports only that a connection is PENDING; the client's bytes may not
have arrived when accept() returns. The accepted socket inherits the listener's
non-blocking mode, so conn.recv() raised

    BlockingIOError: [WinError 10035] A non-blocking socket operation could not
    be completed immediately

which `except Exception: pass` swallowed. The connection was never closed
(observable as CLOSE_WAIT in netstat) and the caller saw "no response from bot
(socket timeout)".

This is the root cause of the control-socket flakiness throughout 2026-08-27/28
— roughly half of all status/start/stop calls — and therefore of the retry
loops written to work around it. One of those retry loops sent 'start' three
times to what was then a toggle and paused the bot for 4h50m of an overnight
run.

Fixes:
- accepted connections are set blocking with a 2s timeout
- handler exceptions are LOGGED instead of silently swallowed, and the
  connection is closed on the error path

The logging is what found this in one restart, after the silent swallow had
made the same failure undiagnosable all night. Verified: 6 consecutive status
calls now succeed where they previously timed out intermittently, and the bot
reaches "=== BOT START ===" on a single idempotent start.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 06:50:59 +02:00
alexpolo1andClaude Opus 5 93185cb93c fix(control): make hermes 'start' idempotent and report pause state in status
'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]>
2026-08-28 06:40:19 +02:00
alexpolo1andClaude Opus 5 2162046dac fix(pather): abort after repeated heading rejections instead of guessing
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]>
2026-08-28 01:25:55 +02:00
alexpolo1andClaude Opus 5 55075d2c23 fix(town): never fall back to Qual-Kehk when Tyrael is unreachable
Measured across the whole log:

    A5 Qual-Kehk   0 successful resurrects, 49 hunt timeouts (111 clicks)
    A4 Tyrael      3 successful resurrects, 8-25s each

When the A5->A4 trip for Tyrael fails, resurrect() fell back to the current
act — Qual-Kehk — which has never once succeeded. That fallback is not a second
chance, it is a guaranteed ~100s loss, and bot.py retries it once, doubling the
cost.

Observed 2026-08-28 01:18:

    town.repair          ok    106.5s   (larzuk timed out twice)
    town.resurrect_merc  fail  212.8s   (two Qual-Kehk hunts)
    FAIL> Maintenance timeout after 328s before [gamble]

That pair took the town visit past the 240s maintenance timeout and killed a
game that was otherwise healthy. The trigger was A5_WP not being found, so the
Tyrael travel never happened.

Now it returns False immediately and runs mercless for the game; the merc is
re-checked next game and the existing cross-game breaker still applies.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 01:19:27 +02:00
alexpolo1andClaude Opus 5 83c211bd5f fix(pickit): stealth pickup-skip spun on one item until the phase timed out
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]>
2026-08-28 01:11:49 +02:00
alexpolo1andClaude Opus 5 31dacab9e3 test: pin the act-margin invariant for BOTH act-deciding functions
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]>
2026-08-28 01:03:01 +02:00
alexpolo1andClaude Opus 5 0a4575ea6a fix(town): apply the Bug 28 act-margin rule to wait_for_town_spawn too
Bug 28 fixed detect_current_act to require the winning town marker to beat the
best marker from any OTHER act by 5pp before committing to an act. Its twin,
wait_for_town_spawn, was left committing to whatever search_and_wait_stable
returned with best_match=True — the exact defect, in the function that runs
FIRST every game and sets the act for everything after it.

Observed live 2026-08-28: the character spawned in Harrogath and this reported
"at a4_town_start" every game. The bot then ran A4 logic in Act 5 — repaired at
"a4_halbu", hunted an A4 waypoint that was not there
("Wanted to select ['A4_WP','A4_WP_2'], but could not find it"), and failed
go_to_act(5) while already standing in Act 5. The error screenshot is
unambiguously Harrogath: wooden roofs, braziers, Larzuk's forge.

Scored on that frame: A5_TOWN_1 0.644 vs A4_TOWN_5 0.548 — a 9.6pp margin for
A5. The old code had no margin requirement at all.

Returning None on an ambiguous spawn is the safe outcome: the caller falls
through to detect_current_act and then to the route's home act, whereas a
wrong act here poisons every subsequent step of the game.

Both sides are scored on ONE image. The first version compared the winner's
score from the original match against a rival scored on a later grab, which is
not a comparison — the character can move between them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 00:59:13 +02:00
alexpolo1andClaude Opus 5 f0f50a9332 perf(town): stop pending sales from triggering the A5->A4 vendor trip
Measured over the full log (264 maintenance cycles):

    town.buy_consumables   74.1s avg   33 runs   8 fails (24%)
    town.repair            16.8s avg  158 runs   0 fails

buy_consumables from A5 is an A5->A4 waypoint round trip. Jamella herself is
not the problem — 267 clicks against 2 timeouts — the navigation is, and it
also leaves the character in Act 4, where the return trip has been producing
click_red_portal failures.

Selling never justified that trip. The repair step a few entries below already
sells (sell_items is one of its triggers) and it runs at Larzuk IN ACT 5, with
158 runs and zero failures. Ordering is buy -> stash -> repair, and sell_items
is recomputed after stash, so pending sales still reach Larzuk in the same
maintenance pass; anything missed is still pending next game.

Checked the obvious alternative first and rejected it on evidence: switching
buy_consumables to A5 Malah to avoid the trip entirely. Malah measures 54
clicks against 24 timeouts (31% failure) with hover scores topping out at 0.675
— never reaching the 0.98-1.00 cluster real hits produce. The "Malah
unreliable" comment predates the Bug 30 threshold fix but is still true, and
switching would have traded a 24% failure for a 31% one.

Consumables still trigger the trip; only sales no longer do.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 00:54:49 +02:00
Alex 2966ba4f7b Merge pull request #29 from alexpolo1/feat/stealth-wiring
fix(stealth): make the stealth subsystem actually execute
2026-08-28 00:53:11 +02:00
alexpolo1andClaude Opus 5 8aa150d6a3 test: use a neutral character name in the personality seed test
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]>
2026-08-28 00:47:23 +02:00
alexpolo1andClaude Opus 5 371abafa35 fix(stealth): actually wire the per-game chicken roll, and make the manifest check call sites
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]>
2026-08-28 00:43:01 +02:00
alexpolo1andClaude Opus 5 00e047db98 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]>
2026-08-28 00:35:31 +02:00
alexpolo1andClaude Opus 5 4bdc5b90db fix(input): an unbound skill key is a no-op, not a thread-killing ValueError
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]>
2026-08-28 00:18:39 +02:00
alexpolo1andClaude Opus 5 5211564df4 fix(stealth): manifest reports a 0% behaviour as disabled, not wired
skill_mistake printed 'wired - skill keys only' while its chance was 0. A
behaviour that can never roll is OFF whatever its call sites look like, and a
status line that overstates coverage is the exact defect this manifest exists
to catch.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 00:17:20 +02:00
alexpolo1andClaude Opus 5 e42211231e fix(paladin): stop pressing unbound skill keys — F4 was toggling the loot filter
The profile blanks vigor/holy_shield/cleansing in its [fohdin] section, but
config.py builds the paladin skill config from params.ini [paladin] and only
accepts overrides from a profile [paladin] SECTION. A blank in [fohdin] never
reaches it, so vigor=f4, holy_shield=f2 and cleansing=f9 survived on a
character that has none of those skills.

paladin.pre_move() then does:

    should_cast_vigor = self._skill_hotkeys["vigor"] and not is_right_skill_selected(["VIGOR"])
    if should_cast_vigor and not can_teleport:
        keyboard.send(self._skill_hotkeys["vigor"])

is_right_skill_selected(["VIGOR"]) can never be true without the skill, and
this character cannot teleport, so F4 was sent on EVERY move. On this client F4
is the loot filter toggle — the bot flipped it continuously all session, and an
error screenshot caught the skill box showing F4 as the active skill.

Fixed with a [paladin] section in the profile blanking all three. The miscast
candidate list narrows itself as a result (f1,f3,f5,f6,f7,f8) since blanks no
longer look like bound skills.

Also disables skill_mistake_chance. Even with a correct candidate list, a
miscast swaps the active skill or aura mid-fight, and the attack sequences rely
on ending in a known skill state. Small stealth value against a real risk of a
stray press landing on something that is not a skill at all — which is exactly
what happened here. One line to re-enable.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 00:15:33 +02:00
alexpolo1andClaude Opus 5 c3557678d5 feat(stealth): implement the four-phase remediation end to end
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]>
2026-08-28 00:02:55 +02:00
alexpolo1andClaude Opus 5 da83f71bef fix(stealth): make configured stealth behaviour actually run
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]>
2026-08-27 23:52:45 +02:00
Alex 3d153e67f5 Merge pull request #28 from alexpolo1/stable
fix: A5 waypoint HUD false-positive, safe merc-panel dismiss, Tyrael for merc resurrect
2026-08-27 22:29:49 +02:00
alexpolo1 f70e118f88 Merge remote-tracking branch 'origin/main' into stable 2026-08-27 22:25:22 +02:00
alexpolo1andClaude Opus 5 11186e2c7d fix(town): A5 waypoint HUD false-positive, safe merc-panel dismiss, Tyrael for resurrect
Three linked failures that together took the nightmare run from ~8% to ~37% failures.

1. A5_WP matched the HUD. main's recaptured a5_wp.png (14f5876, pulled in by my merge)
   scores 0.966 on the belt/mana-orb area at (980, 656) — above threshold and higher than
   the real stone — so every search locked onto the HUD, clicked it, and open_wp burned
   ~83s escalating through anchors before giving up. buy_consumables hit 222-253s and
   tripped the 240s maintenance timeout.
   Fix: search the waypoint within ui_roi[cut_skill_bar] so the HUD can never compete
   (select_by_template gained an optional roi for this), and search BOTH captures with
   best_match — main's new one and the pre-merge a5_wp_2.png, which reaches 0.73-0.98 here
   against the new one's 0.50-0.58. Neither setup has to lose. 222s -> 33s.

2. My own regression, introduced an hour earlier. The merc check pressed 'o' and closed
   the panel only when MercPanelText matched, leaving the CHRONICLE panel (what 'o' opens
   on this client) up all game. I "fixed" that with an unconditional esc — which is worse:
   with nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not match, so
   nothing closed that either and every later search saw a menu instead of the town.
   Fix: always toggle 'o' back. 'o' opened it, so 'o' closes it, and it cannot open
   something new. Symmetry, not detection.

3. resurrect_npc=a4_tyrael (new config, mirrors repair_npc). Measured today:
       A4 Tyrael     ok 8.2s, ok 24.9s   -  0 errors, ever
       A5 Qual-Kehk  fail 113.6s / 52.0s / 163.1s / 72.6s  -  43 errors
   Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC
   in the route. Falls back to the current act if the trip to A4 fails.

Verified live: 4 games, 0 failures, town 17-23s (was 178-230s), approach 11-15s.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 21:37:33 +02:00
alexpolo1andClaude Opus 5 6ac1105fc7 fix(merc): always dismiss the panel the merc check opens (CHRONICLE stayed up all game)
resurrect_merc confirms a live merc by pressing 'o' and looking for MercPanelText, then
closed the panel ONLY when that check passed:

    keyboard.send("o")
    merc_panel_open = is_visible(ScreenObjects.MercPanelText)
    if merc_panel_open:
        keyboard.send("o")     # the only path that closed anything

On this client 'o' (skill slot 54) opens the CHRONICLE collection panel, not the merc panel.
MercPanelText never matched, the closing keypress was never sent, and Chronicle stayed open
for the rest of the game — a large centred panel that blanks every later template match. The
run then died on click_red_portal after ~66s of clicking at a covered screen.

The health manager's guard did not catch it either: it looks for LeftPanel/RightPanel, and
Chronicle is centred and matches neither.

Fix: always dismiss whatever appeared. When MercPanelText is absent, send esc, then re-check
LeftPanel/RightPanel and esc again if something is still up.

Diagnosed straight from the new FAIL> record, which named the culprit without any log
archaeology:

    FAIL> g90 r83 | at=False | step=resurrect_merc | shot=...
    FAIL> g90 r83 | trail: town.repair(12s) > town.maintenance(18s) > run.approach!(66s)

The step named in the failure reason was click_red_portal; the step that actually caused it
was resurrect_merc, three entries earlier in the trail. That is exactly the case the trail
was added for.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 20:17:38 +02:00
Alex cad7d898a4 Merge pull request #27 from alexpolo1/stable
ci: don't fail the build on coverage artifact storage quota
2026-08-27 19:40:49 +02:00
alexpolo1 4de8f6192e Merge remote-tracking branch 'origin/main' into stable 2026-08-27 19:39:56 +02:00
alexpolo1andClaude Opus 5 7763afdcb8 ci: don't fail the build when the coverage artifact upload hits the storage quota
Every test step passed; only 'Upload coverage' failed with

    Failed to CreateArtifact: Artifact storage quota has been hit.
    Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.

That is an account storage condition, not a broken build, and it was marking the whole run
red. The artifact is a convenience, not a gate, so the step is now continue-on-error.

The underlying quota still needs clearing (Settings -> Billing -> Storage, or let old
artifacts age out) for coverage reports to reappear.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 19:22:35 +02:00
Alex c0e5241eb9 Merge pull request #26 from alexpolo1/stable
run_pindle repair: 100% failure to ~8%, plus run timing instrumentation
2026-08-27 19:13:50 +02:00
alexpolo1andClaude Opus 5 831e0d9319 docs: refresh post-mortem commit hashes after the pre-PR history rewrite
The webhook scrub rewrote the 18 stable-only commits, so the hashes quoted in the
post-mortem no longer resolved. Updated, and the rewrite itself is noted inline so the
change of hashes is not a mystery later.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 19:10:46 +02:00
alexpolo1andClaude Opus 5 489f779849 merge: bring origin/main into stable ahead of the PR
main carried 19 commits of separate feature work (baal_xp, cold_plains, melee_hunt,
launch/tools) while stable carried the run_pindle repair. One real conflict, in
_run_wrapper, where both branches added a line at the same point:

    stable      self.tl("run", "approach", "start")   <- run timeline
    origin/main self._current_run = run_obj           <- baal_xp run tracking

Independent, so both are kept. config/params.ini and src/config.py auto-merged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 19:10:19 +02:00
alexpolo1andClaude Opus 5 402c768fc8 docs: add the run_pindle post-mortem (26-27 Aug 2026)
Narrative record of the incident: failure rate 100% -> ~8%, character 32 -> 69, cycle time
185-250s -> ~52s across 17 commits. CLAUDE.md already carries the per-bug detail as Bugs
23-30; this is the part that does not fit a bug entry — the order things happened in, why
the silent failures cost far more than the loud ones, and the four times my own obvious
answer was wrong.

Two files:
  docs/postmortem_pindle_2026-08-27.md    canonical, diffable
  docs/postmortems/pindle-2026-08-27.html source of the published artifact, kept in-repo
                                          so it survives the scratchpad and stays editable

The corrections section is the reason this is worth committing rather than leaving as a
list of fixes. Each wrong turn looked correct in isolation:
  - the red-portal guard: the portal renders on BOTH sides, so "portal visible => still in
    town" blocked every genuine entry
  - XP as proof of a boss kill: it only proves something died (minions, merc kills)
  - "the template is degenerate": the Qual-Kehk asset scored 1.000 against a frame where
    the tag actually renders; the THRESHOLD was wrong
  - selling the charms: 673 blocked-sell lines were the guard working, not a clog, and
    disabling it vendored five resistance charms

Also records the durable lesson: every one of these cost hours because the log held events
rather than structure. That is what the TL>/FAIL>/digest instrumentation exists to fix.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 19:03:26 +02:00
alexpolo1andClaude Opus 5 2e7517f9b8 docs: how to read FAIL> records and tune runs from the timing digest
Documents the two additions from b8158c0 in the terms they are actually used for.

FAIL>: read the trail, not just the reason. The step named in the reason is the one that
blew up and is frequently not the one that caused the problem — the worked example shows
an approach failing only after a merc resurrect had already burned 114s and stranded the
character. Also explains the "note:" line (multiple failing steps in one game usually
means the first caused the second).

Timing digest: a table for reading the signal rather than the numbers — high avg + low
count + high fail is a broken step retrying into a timeout (the worst kind, pure waste);
high avg + high count is the real cost centre worth tuning; a rising fail count between
consecutive reports is a regression or a drifting template; a step vanishing from the list
means it stopped running, so check for a skip reason before assuming it was fixed.

Records why umbrella entries and the stlth phase are excluded from rankings, so the
exclusion is not "fixed" later by someone who reads it as a bug.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 18:22:31 +02:00
alexpolo1andClaude Opus 5 9325c9cc63 feat(discord): 2-hourly timing + failure digest, and self-contained FAIL> records
Two additions on top of the TL> timeline.

1. FAIL> records. A bare "Approach failed [step: click_red_portal]" names the step that
   blew up, but that is frequently not the step that cost the time or caused the problem.
   Each failed game now emits a self-contained record:

     FAIL> g7 r5 | Approach failed for run_pindle [step: click_red_portal]
     FAIL> g7 r5 | at=a5_larzuk | step=resurrect_merc | shot=./log/screenshots/error/...png
     FAIL> g7 r5 | slowest: town.resurrect_merc=114s, run.approach=72s, town.repair=19s
     FAIL> g7 r5 | trail: game.spawn > town.repair(19s) > town.resurrect_merc!(114s) > run.approach!(72s)
     FAIL> g7 r5 | note: 2 failing steps this game: town.resurrect_merc, run.approach

   grep "FAIL>" log/log.txt. The trail is the last 12 timed steps of that game with "!"
   marking failures, so a failure is diagnosable from the log without replaying it.

2. Periodic Discord digest, default every 2h (general.discord_timing_report_h, 0 disables).
   Aggregated from the same timeline the log uses, so the report and the log cannot drift:

     **Timing report** - last 2.0h
     Games: 92 (81 ok, 11 failed - 12.0%)
     Avg town 31s | approach 46s | battle 21s | cycle ~98s
     __Slowest steps (avg)__
     `town.resurrect_merc     `   103s  x7  (5 fail)
     `run.approach            `    46s  x92  (9 fail)
     __Failures by step__
     `run.approach            ` 9
     __Stealth__
     `afk_break               ` x2  10m total

   Sent at game end (a natural boundary; games are ~60s so granularity is fine) and the
   window resets on each send, so every report covers exactly the period since the last.

Ranking excludes umbrella entries ("maintenance", the run_name step) since they contain
the others and would always top the list, and excludes the stlth phase since an AFK break
is deliberate idling — it gets its own section instead. Report failures are caught and
logged non-fatally; nothing here can end a run.

Verified by rendering both formats against synthetic data rather than waiting for a live
failure to be the first test.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 18:21:36 +02:00
alexpolo1andClaude Opus 5 8da4c8eb9d feat(log): time every phase and fold stealth into one spawn-to-loot timeline
Extends the town timeline to the whole cycle and times every step:

    grep "TL>" log/log.txt

    TL> g2 r1 | game  | spawn             | ok    | at a5_town_start
    TL> g2 r1 | town  | inspect_inventory | ok    | took=3.0s   | in pack=2 keep=0 sell=2
    TL> g2 r1 | town  | repair            | ok    | took=19.3s  | at a5_larzuk
    TL> g2 r1 | town  | item_sell         | ok    | SOUL IMPALER @ (928, 465)
    TL> g2 r1 | town  | resurrect_merc    | fail  | took=113.6s | NPC not reachable
    TL> g2 r1 | town  | maintenance       | ok    | took=137.6s | at a5_larzuk
    TL> g2 r1 | run   | run_pindle        | start | from a5_larzuk
    TL> g2 r1 | run   | approach          | fail  | took=71.9s  | step=click_red_portal
    TL> g2 r2 | game  | end               | fail  | Approach failed [step: click_red_portal]

- phases: game (start / spawn / end), town (all maintenance steps), run (approach /
  battle / loot), stlth.
- every terminating line carries took=Ns; "start" stamps the clock in Bot._tl_starts
  keyed by (phase, step). The sample above pays for itself immediately: a failed game
  spent 113.6s of its 137.6s town visit on a merc resurrect that failed.
- stealth decisions are tracked: afk_break (timed across the sleep), skip_run and
  wrong_waypoint.

Bot.timeline() is a static entry point so utils/stealth.py and inventory/personal.py can
emit without importing Bot at module level (that would be circular). Both use a lazy
guarded import and it no-ops when no Bot is live. Item sells/stashes/drops now route
through it too, so they share the game/run counters and column widths instead of being a
separately formatted line.

Prefix moved TOWN> -> TL> now that it spans more than town.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 18:07:33 +02:00
alexpolo1andClaude Opus 5 f01610e53f feat(log): constant town timeline — every maintenance step start/ok/skip/fail
Town maintenance was only traceable by piecing together scattered messages, and a step
that silently did nothing was indistinguishable from one that never ran. Every step now
emits the same stable, machine-readable line:

    grep "TOWN>" log/log.txt

    TOWN> g2 r2 | maintenance      | start | at a5_town_start
    TOWN> g2 r2 | town_heal        | start
    TOWN> g2 r2 | inspect_inventory | ok    | in pack=0 keep=0 sell=0 gold_full=False
    TOWN> g2 r2 | buy_consumables  | start | needs id=0 tp=0 hp=4 mana=0 rejuv=0 | sell_pending=0
    TOWN> g2 r2 | buy_consumables  | ok    | at a4_jamella | after: Consumables(...)
    TOWN> g2 r2 | stash_items      | skip  | nothing kept and gold not full
    TOWN> g2 r2 | repair           | skip  | no repair due and nothing to sell
    TOWN> g2 r2 | resurrect_merc   | start
    TOWN> g2 r2 | gamble           | skip  | stash not full / gambling not configured
    TOWN> g2 r2 | maintenance      | ok    | done in 21s | at a4_jamella

Covers shop, id, stash, repair, sell, resurrect and gamble. status is start|ok|skip|fail,
and skip states the reason. Steps carry useful detail: consumable needs before and after
buying, pack contents and keep/sell counts, repair trigger, items left in the pack after
stashing, and total maintenance duration.

Individual item transfers mirror into the same stream from transfer_items() as
item_sell / item_stash / item_drop, so vendoring a rare or stashing a rune appears inline
with the steps around it.

_step() also sets _maintenance_step, so the existing failure-reporting path is unchanged.
The ">" in the prefix is deliberate: a bare "TOWN" collides with template names such as
A5_TOWN_0.

Immediately useful — the first two games after this landed showed "done in 225s" with
buy_consumables failing against "done in 21s" with it succeeding.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 17:38:33 +02:00
alexpolo1andClaude Opus 5 e976749910 fix(npc): raise grid-sweep name-tag threshold so it stops clicking noise
SWEEP_TAG_THRESHOLD was 0.4. A rendered name tag matches almost perfectly, so anything
mediocre is noise — and at 0.4 the noise won: the sweep stopped at the first match over
threshold, clicked empty ground, and gave up. Scores measured across a full day:

    akara 0.980   halbu 0.995   malah 0.990   larzuk 0.996    <- real, dialogue opened
    qual_kehk 0.424   malah 0.501   larzuk 0.494              <- false, clicked nothing

Real hits cluster at 0.98-1.00, false ones at 0.42-0.50. Raised to 0.7, in the gap with
margin either side. This is why qual_kehk failed 100% (5 timeouts in 5 attempts) while
akara succeeded 177 times.

Also corrects Bug 29 in CLAUDE.md, which blamed the Qual-Kehk asset. That was wrong. I
walked the char to the NPC with the project's own Pather, hovered a grid capturing
full-res frames, found the one where QUAL-KEHK renders, and scored the stored template
against it: 1.000 raw and 0.997 through the color_filter path npc_manager actually uses.
The template was never the problem.

NAME_TAG_THRESHOLD (0.26, the hover path) is deliberately left alone — Akara genuinely
hovers at ~0.28 per Bug 3. The two thresholds serve different paths.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 17:12:17 +02:00
alexpolo1andClaude Opus 5 dfacb12393 fix(main): catch EOFError on shutdown so a clean exit stops logging a fake crash
The final input() that holds a console build open is guarded by stdin.isatty() inside a
try, but the except clause was (OSError, ValueError). EOFError is exactly what input()
raises when stdin is closed or non-interactive, and isatty() can still report True in
detached or redirected launches. Every shutdown therefore ended with:

    ERROR Uncaught exception:
    Traceback (most recent call last):
      File "src\main.py", line 345, in <module>
        input()
    EOFError: EOF when reading a line

which reads like a crash while the process was in fact exiting normally after a Force
Exit. Added EOFError to the caught tuple.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:50:46 +02:00
alexpolo1andClaude Opus 5 2402c18e06 fix(inventory): stash protected items the pickit rejected instead of hoarding them
A protected item the pickit rejects could never leave the pack. sell and drop are both
blocked by _is_protected(), and the stash filter was "keep == True" only — so it stayed
in whatever slot it landed in, permanently. Measured: 673 blocked sells across just 7
charms in one session, and one vendor trip had 5 of 6 items blocked.

The stash filter now also takes protected items, but ONLY from the loot columns. A
charm's bonus applies from the inventory, and the pickit cannot distinguish a wanted res
charm from junk — LAPIS SMALL CHARM OF VITA (+20 life, cold res 7%) logs "Discarding"
purely because the rule wants coldresist >= 11. So position is the intent signal: charms
parked in the RESERVED columns are treated as deliberate keepers and left alone (the
click guard makes them untouchable anyway), while freshly looted ones in the loot columns
get stashed and free their slot. Nothing is sold or dropped.

Also reverts two config changes from earlier today that were wrong:
  - protect_charms_from_sell back to 1. Setting it to 0 vendored LARGE CHARM OF FIRE,
    STOUT SMALL CHARM, SMALL CHARM OF FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS
    SMALL CHARM OF VITA before it was caught. Charms give resistances from the inventory;
    the blocked-sell log lines are the guard working, not a bug to fix by selling.
  - num_loot_columns back to 4. Raising it to 6 shrinks restricted_inventory_area, which
    is where both tomes must live (common.tome_state only searches there) — the wrong
    direction when the books need room.

protect_shields_from_sell stays 0: shields give no inventory bonus and the equipped one
is protected positionally.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:04:26 +02:00
alexpolo1andClaude Opus 5 4a5459ab15 config: let the pickit decide on charms, freeing inventory slots for the cube
protect_charms_from_sell=1 refused to vendor or drop anything with "charm" in its name,
regardless of the pickit verdict. With shields unblocked (4bbd43d) this became the sole
remaining clog: 673 blocked sells across just 7 charms in one session — LAPIS SMALL CHARM
OF VITA 233x, LARGE CHARM OF FIRE 226x, STOUT SMALL CHARM OF STRENGTH 126x — the same
items re-judged and re-blocked every game. One vendor trip had 5 of 6 items blocked, so
those slots were permanently occupied and the cube had nowhere to go.

Config-only change: the guard in inventory/personal.py::_is_protected() is UNCHANGED and
still reads this flag, so setting it back to 1 restores the old behaviour with no code
edit. Kept deliberately for later.

Risk accepted and noted in the config comment: unlike shields there is no positional
safety net for charms (the equipped-area click guard does not apply), so a pickit misread
will sell a good charm. Charms worth keeping belong in the stash, not the inventory.

Verified live after restart: the exact five charms that had been blocked all session sold
on the first vendor trip — LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF FLAME,
STOUT SMALL CHARM OF STRENGTH, LAPIS SMALL CHARM OF VITA — with 0 blocked sells.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 15:56:53 +02:00
alexpolo1andClaude Opus 5 a213509a03 fix(a5): stop burning two 4s timeouts before walking to the waypoint
"Wanted to select A5_WP, but could not find it" fired 321 times in one 362-game session —
by volume the single largest error in the log — and it was self-inflicted, not a bad
template.

open_wp starts with two speculative direct scans, for the case where the char spawns next
to the stone after a Pindle TP-back. _try_click_wp searched for A5_WP and, when the search
found nothing, called select_by_template ANYWAY. That spins out its full 4s timeout and
logs the ERROR before the code has even tried walking to the waypoint. Measured:

    15:26:41  Health Manager pausing
    15:26:46  ERROR Wanted to select A5_WP   <- 4.8s, char still at town start
    15:26:51  ERROR Wanted to select A5_WP   <- 4.6s
    15:26:51  Traverse from a5_town_start to a5_wp
    15:26:55  Select A5_WP (73.4% confidence)  <- works fine once it walks there

~9.4s wasted per waypoint use. Raising the ID-scroll threshold (ef15ccf) pushed A4 vendor
trips to 158 in that session, each needing the waypoint both ways, which is why this
became the dominant log line.

Fix: _try_click_wp takes require_visible, and the two speculative pre-scans pass it — no
stone on screen means return False immediately instead of timing out. The post-traverse
calls keep the old behaviour, since the char should be standing on the waypoint by then
and deserves the full timeout.

Verified live: 8 waypoint uses, 0 "Wanted to select A5_WP" errors, waypoint step down to
~4s. 6 games, 0 failures since restart.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 15:38:57 +02:00
alexpolo1andClaude Opus 5 938554e622 fix(maintenance): top up ID scrolls at 8 remaining instead of 2
should_buy("id", min_remaining=3) means "buy when 3 or fewer remain", so the bot ran the
20-scroll Tome of Identify down to 2 before making a vendor trip. That threshold was set
when only a handful of item types were picked up; the pickit's rare catch-all now spends
a scroll on every rare, so the tome drains far faster.

Running dry is not cosmetic: an unidentified rare is never sold. inspect_items marks it
need_id, and the sell branch requires "not (box.keep or box.need_id)" — so it is carried
instead, occupying a slot until an ID is possible.

Raised to 8, matching the tp threshold already evaluated on the same trip, so the top-up
piggybacks on a vendor visit that was happening anyway. The A5->A4 trip this can trigger
is also materially safer now that detect_current_act refuses to guess the act (Bug 28),
which was the original reason for keeping the threshold low.

Verified live: the trip fired at id=13 (7 remaining) where the old threshold would have
waited for id=17, completed at Jamella, and the need went 13 -> 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 08:01:40 +02:00
alexpolo1andClaude Opus 5 b7ce002e4e config: stop blocking shield sales — 130 blocked sells clogged the pack
protect_shields_from_sell=1 never vendored any item with "shield" in its detected name.
That is redundant safety: the EQUIPPED shield is already protected positionally by
mouse._is_clicking_safe(), which cancels any click landing in
ui_roi[equipped_inventory_area] while the inventory is open. The name rule only ever hit
shields sitting in the inventory grid, which the pickit had already judged.

Cost measured over one day: 130 blocked sells — FIEND SHIELD 64, AERIN SHIELD 41,
HERALDIC SHIELD 23, plus DRAGON/ANCIENT. The same shields were re-evaluated and
re-blocked every game ("Discarding FIEND SHIELD." immediately followed by "Blocked sell
for protected item: FIEND SHIELD"), so they could never leave the pack and permanently
occupied slots. The rare catch-all added in the pickit made this much worse, since rare
shields are now picked up.

Verified live after restart: "Confirmed sell 'FIEND SHIELD'" on the very item that had
been blocked 64 times, followed by HATCHET HANDS, LONG SWORD, LIGHT GAUNTLETS,
DEMONHIDE BOOTS and FLAIL — six sales in five minutes with zero blocks.

Charm protection (protect_charms_from_sell) is left ON: charms live in the inventory by
design and a misread charm cannot be un-sold.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 06:40:15 +02:00
alexpolo1andClaude Opus 5 fa72da56e3 fix(merc): stop re-hunting an undetectable resurrect NPC every game (~80s each)
In nightmare the merc dies most games, so resurrect_merc runs constantly — and Qual-Kehk
detection was failing 100% of the time (5 timeouts in 5 attempts, 107 hover attempts over
12 games). Each failed hunt costs ~40s and the code retried once, so a dead merc cost
~80s in EVERY game. Game length blew out to 185-250s against a normal ~60s.

GameStats._merc_resurrect_failed did not help: log_start_game resets it, so it only ever
suppressed a second attempt within one game. Nothing carried across games.

The name tag template is degenerate rather than merely stale — every grid-sweep "hit"
reported the identical score at unrelated positions:
    found name tag at (255, 227)  (score 0.424)
    found name tag at (1110, 100) (score 0.424)
    found name tag at (930, 310)  (score 0.424)
so "found" is meaningless; it is matching uniform background.

Fix is cost containment, not detection: a cross-game circuit breaker on GameStats that
log_start_game deliberately does NOT reset — _merc_resurrect_fail_streak and
_merc_resurrect_skip_until, with Bot._MERC_RESURRECT_FAIL_LIMIT=2 and
_MERC_RESURRECT_SKIP_GAMES=15. The retry is also skipped once the streak is >=1, since
that is a second guaranteed-futile 40s hunt. Both counters clear on any successful
resurrect so a transient failure cannot permanently disable resurrecting.

Simulated over 30 games with an undetectable NPC: 60 hunts -> 4 (~40 min -> ~2.7 min).
Measured live: the breaker engaged on game 2 and game times went 250s / 185s -> 14s, 14s,
43s, 71s, 111s.

Still open: recapturing qual_name_tag_white.png is the actual fix for detection.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 06:26:01 +02:00
alexpolo1andClaude Opus 5 abe0a54849 fix(town): don't decide the act on a 1.7pp template margin — A5 pathing ran inside Act 4
Biggest single cause of run failures: 25% of games failed, dominated by
"Approach failed for run_pindle [step: click_red_portal]", because the character was
running A5 pathing while physically in Act 4.

Chain: buy_consumables travels A5 -> A4 for Jamella ("Malah unreliable", 32x in one
session), repair then runs in A4 too, and Run Pindle's go_to_act(5, a4_town_start) asked
detect_current_act() to verify. It answered A5 while standing in Act 4, so go_to_act
"corrected" the assumption and skipped the travel entirely. 11 failures traced directly
to that trip in a single session.

Root cause: detect_current_act committed to whatever marker cleared 0.68 first. On a real
Act 4 failure frame:
    A5_TOWN_1   0.636   <- phantom, always at (1046,40), top-right corner
    A4_TOWN_5   0.619   <- the genuine marker for the act actually occupied
A 1.7 percentage point gap decided the act.

Fix: the winning marker must now also beat the best marker from ANY OTHER act by
_ACT_DETECT_MARGIN (0.05). Below that it logs "ambiguous ... refusing to guess the act"
and returns None. That is the safe answer: every caller treats None as "keep the assumed
act", so go_to_act keeps a4_town_start, sees it differs from the target, and actually
travels. Refusing to answer yields correct behaviour; guessing wrong does not.

Validated live: failure rate 25% -> 9% (10 successes, 1 failure). The first game of the
validation run was the exact failing case — repair starting from a4_town_start — and it
recovered and completed. Verified offline against 4 real Act 4 failure frames: all now
refuse instead of claiming A5.

The one remaining failure is a different cause: open_npc_menu timing out on qual_kehk
during merc resurrect (Bugs 3/4/6/7 family), not act desync.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 06:04:39 +02:00
alexpolo1andClaude Opus 5 4db82cfcd6 fix(pindle): don't hard-fail the portal retry when no town marker is visible
The retry path I added treated detect_current_act() returning None as "we are not in
act 5" and aborted the run. None only means no TOWN_MARKERS template is on screen, which
happens routinely by the red portal in Harrogath's NE corner — the same blind spot that
makes on_init bail there.

All 6 occurrences in the 2026-08-26 19:0x session were "detected None"; not one was an
actual wrong act. Five of them landed consecutively, tripped the 5-strike circuit breaker,
disabled run_pindle and stopped an otherwise healthy 31-game session (level 32 -> 43,
526k gold stashed, 26/31 runs successful).

Fix: None now falls back to `loc`, the act-5 location already confirmed by go_to_act(5) at
the top of the same approach() call — a failed portal click cannot move the character
between acts. A genuinely different detected act still travels to act 5 as before, so the
Bug 9 desync protection is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 20:58:58 +02:00
alexpolo1andClaude Opus 5 97703acf85 config: disable gem transmuting; document the live pickit set and keep/id/sell interaction
Gem conversion is off: [transmute] transmute= is now empty, which makes run_transmutes()
bail at "No gem tiers configured". That path also defeats force=True
(tools/gem_transmute.py), unlike transmute_every_x_game=0. Rejuv potion conversion is
unaffected — it runs independently via town_manager.convert_rejuv_potions().

Docs: record that the ACTIVE pickit set is config/bnip/Den gode.bnip and that it is
gitignored, so config/default.bnip edits have no effect on runs — an easy hour to lose.
Also document how should_pickup / should_id / should_keep interact, since that is what
makes a trailing catch-all rule work (pickup ignores the '#' clause, should_keep returns
on first match, so specific rules above still win).

Loot rule changes themselves live in the gitignored pickit file and are not in this commit:
  - catch-all "[Quality] == Rare # [Strength] >= 999" so every rare is picked up and
    identified, kept only if a specific rule matches, otherwise vendored for gamble gold.
    Previously only 7 rare types had any rule, so rare weapons/armour/helms/shields were
    never picked up and never sold.
  - Flawless and Perfect gem lines uncommented (all gem rules had been commented out, so
    no gems were being collected at all).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 19:04:50 +02:00
alexpolo1andClaude Opus 5 5df9ff4732 fix(transmute): gem conversion ran in the PERSONAL tab — tab click missed the tab bar
convert_all_gems never reached the GEMS tab. It logged "expected result ... not found in
GEMS convert panel; trying first slot fallback" on every one of 800+ iterations and kept
going, ctrl+shift+clicking blind into the personal stash grid.

Root cause: GEMS_TAB_Y = 100. Measured off the live client, the stash tab labels occupy
y=63..78 and the stash GRID starts at y~87 — so every tab-switch click landed on a stash
slot, not a tab. The X constants were already correct; only Y was wrong, by ~30px.
Measured centres: PERSONAL 68 | SHARED 144 | GEMS 220 | MATERIALS 295 | RUNES 370, y=70.

Second, independent bug found alongside it: params.ini had stash_tabs=6 with only 5 tabs
on screen. tab_properties() divides the bar by that count, giving centres of
63/127/192/256/320/384 against real centres of 68/144/220/295/370 — tabs 2, 3 and 4 were
clicking the gaps between tabs. Set to 5.

Tab switches are no longer fire-and-forget: _switch_to_tab() confirms the tab actually
became active, retries up to 3x, and returns False; convert_all_gems now aborts rather
than converting in the wrong tab. Active-tab detection measures the cell BACKGROUND
(p30 > 52; active ~67, inactive ~38) rather than glyph brightness — text brightness
scales with label length, so an active "GEMS" peaks at 167 while "PERSONAL" hits 215 and
any glyph threshold misreads the short label as inactive.

Verified live: four consecutive PERSONAL<->GEMS switches, each confirmed, using the real
class constants and detector.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 18:26:55 +02:00