Compare commits

..
139 Commits
Author SHA1 Message Date
alex 34ad263ef6 Add libgl1 and libglib2.0-0 for opencv
CI / Install & Test (push) Waiting to run
2026-09-05 11:13:16 +00:00
alex d9f68bef33 Fix: move pywintypes import into Windows-only block with Linux stub
CI / Install & Test (push) Waiting to run
2026-09-05 11:07:49 +00:00
alex ecb8418df8 Add platform marker to pywin32
CI / Install & Test (push) Waiting to run
2026-09-05 09:22:45 +00:00
alex e66896e499 Skip checkout (workspace pre-populated)
CI / Install & Test (push) Waiting to run
2026-09-05 09:16:48 +00:00
alex 70d7c7dcf2 Fix: use requirements.txt, disable pip cache
CI / Install & Test (push) Waiting to run
2026-09-05 09:16:06 +00:00
alex bc5ff03970 Fix GITHUB_API_URL
CI / Install & Test (push) Waiting to run
2026-09-05 08:45:13 +00:00
alex 545fd501d5 Disable npm/pip cache (act_runner cache bug)
CI / Install & Test (push) Waiting to run
2026-09-05 08:38:57 +00:00
alex edb59dd5a4 Fix CI for ubuntu-latest
CI / Install & Test (push) Failing after 6m25s
2026-09-05 06:57:34 +00:00
3ed47ed822 fix: raise maintenance budget for cross-act routes; document Trav findings (#40)
CI / Install & Test (Windows) (push) Waiting to run
* fix: raise maintenance budget for cross-act routes; document Trav findings

Travincal leaves the character in Act 3, but maintenance relocates it to
Act 4 (stash at a4_tyrael_stash, repair at a4_halbu) and the next run then
needs the A3 waypoint again -- two cross-act waypoint trips per game.

That travel is not attributed to any timed step, so a FAIL> trail can show
~38s of work inside a 250s maintenance window. The 240s budget (tuned when
Pindle kept all town business in one act) was tripped 5 times in one
session, each costing a whole game. Raised to 420s.

Also documents, in HANDOVER.md:

- The open_wp failures are NOT a stale template. Scored a5_wp.png against
  all three real failure frames: 0.96-0.98 full-frame, but that match is
  the belt/mana-orb HUD false positive the a5.py comment already documents.
  Inside the real cut_skill_bar ROI it scores 0.436-0.480 against a 0.55
  threshold at scattered positions -- noise. The waypoint is genuinely not
  on screen; the character never reaches it. Recapturing would fix nothing.
  Scoring a template without the ROI the code actually uses produces a
  confident wrong answer.

- PR #39's pather abort is merged, live, and finally firing (1 abort in 21
  games, after being 0-for-152 while it looked correct).

- Keeping Trav town business in Act 3 is the real fix for both the timeouts
  and the waypoint failures. a3.py already reports can_buy_pots/can_heal/
  can_stash as True, so the Act 4 trip is not a missing capability.

- FoH vs hammerdin on the council, and the [fohdin]/[paladin] section trap:
  concentration and redemption lived in [fohdin], which hammerdin does not
  read, so both would have gone silently unbound after a respec.

Co-Authored-By: Claude Opus 5 <[email protected]>

* docs: post-mortem for the hell Travincal / hammerdin session

Narrative write-up of 29 Aug 2026: run_trav on hell, FoHdin -> hammerdin.

Outcome: health chickens 3-in-8 -> 0-in-11, battle 64-68s -> 41-60s,
failure rate 12% -> 9%, zero deaths throughout.

The document covers the four times the obvious answer was wrong:

1. Battle time did not move after the respec, which looked like failure.
   hammerdin.kill_council casts for a FIXED duration with no kill detection,
   so the clock cannot report damage -- loot proved the respec worked.

2. a5_wp.png scored 0.96-0.98 full-frame on every failure frame, which reads
   as "template fine, search broken". That match is the belt/mana-orb HUD
   false positive the a5.py comment already documents. Inside the real
   cut_skill_bar ROI it scores 0.436-0.480 against a 0.55 threshold at
   wandering positions -- noise. The waypoint was never on screen.

3. Ormus: not masked (4-channel but fully opaque, so no mask is passed), and
   not a threshold problem (in-ROI best 0.342, background level -- lowering
   the bar would recreate the Bug 4 false-positive clicks). A3 town has
   frames with ZERO pather landmarks over threshold, so the character never
   arrives. One cause, three symptoms.

4. Reverses this session's own earlier recommendation to move Trav town
   business into Act 3. That was reasoned from a3.py reporting can_buy_pots/
   can_heal/can_stash as True -- a capability check, not evidence about
   pathing. A3 is the worst-supported town in the project.

Also documents three settings that were live but inert: binds under [fohdin]
which hammerdin never reads, casting_frames=8 (unreachable -- a paladin's
fastest cast is 10 frames, so every cast was cut short), and repair_npc=
a5_larzuk, which Bug 12 set to keep repair in-act for Pindle but which forces
a cross-act trip on Trav (35.8s vs 9.0s).

And a caution on the key auto-detector: it reported the wrong stand_still
bind both before and after a live remap, consistent with a stale .keyo read.

Co-Authored-By: Claude Opus 5 <[email protected]>

* docs: correct stale hammerdin migration notes in HANDOVER (Codex review)

Addresses Codex feedback on PR #40: the handover still described the character
as needing conversion to hammerdin with both skills blank, while the same commit
verified type=hammerdin and concentration='f6' loading live.

The conflict was worse than reported. The stale "Two things only you can do"
section instructed the operator to rebind F7 -> Concentration, but F7 is
Battle Orders (CTA). Following it would have overwritten the CTA bind and cost a
large share of the life pool -- the exact problem this session was fixing.

- Replaces the migration paragraph with the verified end state, and records that
  Blessed Hammer lives on LEFT-CLICK, not an F-key (_cast_hammers puts the aura
  on the right slot, so a hammer hotkey would replace it).
- Notes that concentration/redemption had to move into [paladin]; under [fohdin]
  a hammerdin never reads them and both would be silently unbound.
- Adds the full verified bind map so F7 cannot be reassigned by mistake.
- Rewrites the operator TODOs: fire resist is superseded (Zaka + Mara took
  chickens 3-in-8 -> 0-in-11); the live items are lightning/poison resist and
  the 75% FCR breakpoint, with the casting_frames mapping (75%=11, 60%=12,
  <48%=13) so cast timing tracks any gear change.

Co-Authored-By: Claude Opus 5 <[email protected]>

* feat: baal_xp farm — game browser join, hide-and-wait state, config

- src/ui/game_browser.py: retry-wrapped join flow (Play -> Join Game tab ->
  OCR game list -> click row -> loading/InGame confirm), wait_for_in_game
- src/bot.py: 'baal_xp' state + run_baal_xp/end_run transitions;
  on_run_baal_xp: fast_save_and_exit -> hero select -> join public game ->
  corpse/nopickup/pre_buff -> walk to hide spot -> wait loop (XP every 30s,
  HP check, death recovery, InGame-miss re-anchor) -> leave -> recover to
  own game; on_end_run short-circuit straight to maintenance
- src/config.py: [baal_xp] section (enabled, game_name_filter, max_wait_s,
  xp_threshold, min_hp_pct, hide_x/y, join_timeout_s) with profile overrides
- config/params.ini: [baal_xp] section, order=run_baal_xp, run_trav
- profile paladalla: order=run_baal_xp, run_trav (profile routes override
  base config — without this the route never activates)
- stealth: daily budget cap (persisted, restart-proof), buy at A4 Jamella
  from A3 too (Ormus measured unreliable), session budget 20h

* fix: import mouse in bot.py (baal_xp hide-spot click)

* feat(stealth): hard per-day runtime cap that survives restarts, and quit D2R

session_budget_h is per PROCESS. Every restart re-rolls it, so a bot that gets
restarted -- by the user, or by the restart-on-crash path -- can run all day and
never trip it. That is exactly the signal a runtime cap is meant to remove. It
also silently failed in practice: params.ini still carried the temporary
levelling value session_budget_h = 20, which rolls 13-27h, so the live config
could not stop the bot within a day at all. Restored to 8.

Adds daily_budget_h, keyed on the CALENDAR DATE and persisted to
log/.daily_runtime.json, so restarts cannot hand out a fresh allowance.

Design points that matter:

- The rolled target is stored WITH the date. Re-rolling per process would make
  restarting a way to draw a bigger budget; once a day's target is chosen it is
  fixed until the date changes.
- Jitter subtracts ONLY. daily_budget_h is a ceiling, not an average: a
  two-sided roll on 8h could hand out 9h, which is not what "cap it at 8 hours"
  means. 8 with 0.12 jitter now runs 7.0-8.0h and never more. Verified over 300
  rolls: min 7.04h, max exactly 8.00h.
- Time before the first tick in a process is not counted, so a crash
  under-counts rather than over-counts -- the safe direction for a cap.

Also closes D2R when the cap trips (daily_budget_close_game, default on). The
check runs in on_end_game AFTER save-and-exit, so the character is at the menu
with nothing in progress and nothing is lost -- this is not the mid-game force
kill that leaves D2R unenterable. A bot parked at character select for 16h is
itself the pattern the cap exists to remove; a real player quits the game.

The stealth manifest reports daily_budget by checking the CALL SITE in bot.py,
not just the config value -- the lesson from chicken_variance and the AFK break
that was 0-for-225 while the manifest said "wired".

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: alexpolo1 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-02 10:13:46 +02:00
Alex ec6599f6d3 Merge pull request #39 from alexpolo1/fix/pather-abort-never-fired
fix: the heading-rejection abort never actually fired
2026-08-29 00:52:03 +02:00
alexpolo1andClaude Opus 5 b8a36e8231 docs: operating handover — run, diagnose, and what is still outstanding
Written so the bot can be run without me: control commands and their gotchas,
how to tell a normal break from a stuck bot, the break-length multiplier, the
health-check greps, the temporary settings to revert, and the two things only
the user can do (F7 rebind, fire resist).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-29 00:16:52 +02:00
alexpolo1andClaude Opus 5 1c19b8bdc8 fix(pather): scan for the threshold BEFORE the anti-stuck move
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]>
2026-08-28 22:21:06 +02:00
alexpolo1andClaude Opus 5 f43824f11d fix(pather): the heading-rejection abort never actually fired
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]>
2026-08-28 22:03:44 +02:00
Alex b5922b6b19 Merge pull request #38 from alexpolo1/fix/game-menu-guard
fix: the in-game ESC menu — one bug behind three separate incidents
2026-08-28 19:30:08 +02:00
alexpolo1andClaude Opus 5 a139fb3230 fix(view): pause the panel check during save_and_exit — the menu guard raced the shutdown
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]>
2026-08-28 19:26:45 +02:00
alexpolo1andClaude Opus 5 914a107a72 fix(health): detect and close the in-game ESC menu — one bug behind three incidents
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]>
2026-08-28 18:53:15 +02:00
Alex 9c06cd8474 Merge pull request #37 from alexpolo1/release/v0.10.0
release: v0.10.0
2026-08-28 17:05:38 +02:00
alexpolo1andClaude Opus 5 e17b8c6c94 release: v0.10.0 — the stealth subsystem actually runs
69 commits since v0.9.1. Most of what landed was found by measuring which
behaviours FIRED, not by reading code: AFK breaks had gone 0-for-225 against a
configured 5%/game, eight [stealth] settings were declared in params.ini and
never loaded into Config(), and several functions had no callers at all.

Nightmare failure rate ~11% -> 1.0% over 100 games; hell went from dying on
game 4 to 0.0% over 59.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 17:04:38 +02:00
Alex 3597c01b17 Merge pull request #36 from alexpolo1/fix/walk-hud-guard
fix: walk() had the same unguarded HUD click as move()
2026-08-28 17:04:11 +02:00
alexpolo1andClaude Opus 5 ee39aa5dbf test: scope the HUD-click invariant to every movement method, not just move()
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]>
2026-08-28 16:57:01 +02:00
alexpolo1andClaude Opus 5 e6ba9ad830 fix(char): guard walk() against HUD clicks too, and set a >=5h session budget
walk() carried the identical unguarded click that move() had:

    x, y = convert_abs_to_monitor(pos_abs)
    mouse.move(x, y, randomize=5, ...)

Same fix, same ordering: jitter first via _hud_safe_target, then click with
randomize=0. walk() is reached from bot.py's walk-back-to-town path and from
poison_necro, so it was a live second route to the same loot-filter toggle.

Found while verifying PR #35 had landed — grepping main for randomize= showed a
third mouse.move in i_char.py that the move()-scoped tests did not cover.

session_budget_h 10 -> 8 for a >=5h run. The value is rolled at 0.65-1.35x, so
setting 5 would AVERAGE five hours but could stop after 3.25. 8 gives a
5.2-10.8h window, which guarantees the five while keeping the variation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 16:54:40 +02:00
Alex 48f46fba7f Merge pull request #35 from alexpolo1/fix/hud-click-guard
fix: move() clicked into the HUD and toggled the loot filter
2026-08-28 15:09:53 +02:00
alexpolo1andClaude Opus 5 bc398c471f fix(char): apply move jitter BEFORE the HUD guard, not after
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]>
2026-08-28 15:04:01 +02:00
alexpolo1andClaude Opus 5 91e201c5f6 fix(char): move() clicked into the HUD and toggled the loot filter
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]>
2026-08-28 14:57:18 +02:00
Alex 74c3050163 Merge pull request #31 from alexpolo1/feat/prices
prices: diablo2.io scraper + discord report + FG date fix
2026-08-28 14:47:10 +02:00
Alex ce394c2cb6 Merge pull request #34 from alexpolo1/fix/restart-loop-cap
fix: bound the restart loop, and cap the real AFK idle at ~15m
2026-08-28 14:46:54 +02:00
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
alexpolo1andClaude Opus 5 3942ad4cf5 docs: how to tell an AFK break from a stuck bot, and why break lengths lie
Two things that cost real time today and are invisible from the screen.

The bot sits at the D2R character-select menu during a NORMAL AFK break —
breaks happen between games, after save-and-exit. The stuck case looks
identical there, and `status` cannot separate them either, because it reports
the game controller rather than what the bot is doing. The tell is the log
filling with "select_char: Could not find online/offline tabs" and "Restarting
bot" every ~20s; a healthy break is simply quiet.

And the configured break length is not the real one. maybe_afk_break calls
wait(minutes*60, minutes*60*1.5) and wait() then applies its own jitter (up to
1.44x), so they compound:

    planned 11.9m -> took=1167.7s (19.5m)
    planned 20:56 -> took=1531.1s (25.5m)

afk_break_max_m = 12 therefore meant "up to ~26 minutes", and ~25m is what left
D2R unable to re-enter. Documented with the multiplier to apply before deciding
any break duration is safe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 14:42:12 +02:00
alexpolo1andClaude Opus 5 913eeeff75 fix(stealth): cap the real AFK idle at ~15m, not ~26m
afk_break_max_m is not the ceiling it looks like. maybe_afk_break calls

    wait(minutes*60, minutes*60*1.5)

and wait() then applies its own jitter (up to 1.44x), so the two compound. A
configured 12 minutes can idle for roughly 26.

Measured today:
    planned 11.9m -> took=1167.7s (19.5m)
    planned 20:56 -> took=1531.1s (25.5m)   [scheduled break]

A ~25m idle is what left D2R at character select on 2026-08-28, unable to
re-enter, with the bot spawning a replacement process every ~20s until four
instances were stacked. 19.5m resumed cleanly, so the tolerated limit sits
somewhere between.

12 -> 7 puts the real worst case at 7 x 1.5 x 1.44 = 15.1m, inside the range
proven to resume, while keeping the 2m floor and the variation intact.

Left the compounding itself alone deliberately: the double randomisation is
good cover, and only its unbounded tail was the problem.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 14:36:33 +02:00
alexpolo1andClaude Opus 5 dfd64b8f4d fix(bot): bound the restart loop — a persistent failure restarted forever
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]>
2026-08-28 14:35:54 +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
alexpolo1 5b386391df prices: diablo2.io scraper + discord report + FG date fix + refreshed data
New tools:
- tools/diablo2io_price_scraper.py: scrapes public diablo2.io trade
  listings (browsetrades.php), extracts item/WTS/WTB/desc/price
  mentions, merges into daily_prices.json as third source
- tools/discord_price_report.py: posts daily price summary embed to
  Discord webhook (top FG, top trade, movers vs last report)
- tools/discord_price_scraper.py: Discord channel price scraper
  (needs bot token; not wired into pipeline yet)

Fix:
- tools/improve_fg_estimates.py: parse post date from page HTML
  instead of file mtime (fixes day-bucketing on re-downloads)

Data (2026-08-28):
- d2jsp: 808 topics, 21 with parseable dates
- FG: day_6 (Cham 7.5), day_7 (Aldur 20, Gul 65, Ist 70, Anni 575)
- Traderie: 500 listings, 157 items
- diablo2.io: 150 listings, 28 items
- Pushed 229 prices to .96, prices.alw.dk rebuilt 08:14
2026-08-28 10:19:02 +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
alexpolo1andClaude Opus 5 98e12c0e00 fix(pindle): restore Pindle runs — portal template, fake success, wall-walk, false chicken
run_pindle had a 100% failure rate, and worse, was reporting success while doing
nothing. Five root causes, found by recording the client and replaying the pather's
own matching against the captured failure frames.

1. a5_red_portal.png was the only MASKED template in a5_town/ (4-chan, 60.9% opaque)
   because a hover tooltip had been baked into the capture and hidden with alpha.
   That routed it to cv2.matchTemplate(TM_CCOEFF_NORMED, mask=...), which OpenCV only
   supports for TM_SQDIFF/TM_CCORR_NORMED — hence 0.50-0.60 scores and match positions
   that wandered onto unrelated scenery. Recaptured as a plain 3-channel opaque crop of
   the portal's upper arch (the lower ring is occluded by branches).
   Present 0.949-1.000 / absent 0.398-0.514, straddling the 0.68 threshold.

2. pindle.approach() opened with an "already in Pindle area?" shortcut. Harrogath
   scenery scores 0.76-0.79 on PINDLE_7, over its 0.62 bar, so it fired in town and
   returned A5_PINDLE_START without ever clicking the portal — the bot "killed Pindle"
   in town for five straight games with zero loot and zero XP while logging
   runs_failed_total: 0. Shortcut removed; entry is proven by the loading screen.

3. pather.find_abs_node_pos fell back to a 0.55 first-match search that fabricated node
   positions (A5_TOWN_1 at 0.60-0.62 on scenery, three different frames, three different
   phantom positions), steering the char into the town wall. Raised to 0.62, forced
   best_match, and added a per-node heading gate that rejects a low-confidence match
   implying a >90 deg reversal. Confident matches are never gated.

4. Walking onto the waypoint opened the WP panel, which health_manager counted toward a
   chicken — 3 of 6 games died at full health. WP panels are now escaped without
   counting, bounded at 6 attempts.

5. pindle retry re-pathed from a hardcoded A5_TOWN_START; it now verifies the act first.

Verified live: +349,890 XP over baseline, loot drops (Ring, gold), 6 games,
runs_failed_total 0. Docs updated with all four bugs, template asset conventions, and
how to verify a boss run actually killed something (XP delta + loot, never failed:false).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 12:30:32 +02:00
Alex e9b6c71607 Merge pull request #25 from alexpolo1/baalxp
fix(skill_preflight): add fohdin build support alongside hammerdin
2026-08-26 06:03:27 +02:00
alexpolo1 c26f249268 fix(input): snap cursor to exact target before click (SetCursorPos can land 1-2px off)
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.
2026-08-25 00:26:44 +02:00
alexpolo1 a014fc538e fix(baal_xp): harden handler — real DeathManager, stealth hide-click,
exception safety, on_end_run town shortcut, config enabled parse

- bot.py: GameRecovery(None) -> GameRecovery(DeathManager()) (None crashed
  on death-screen handling); hide spot now clicks once (char actually walks
  there); wait loop breaks on any non-InGame screen (death/kick) instead of
  only MainMenu; whole handler wrapped in try/except with best-effort
  recovery so the bot never stops; on_end_run() short-circuits for baal_xp
  (char is already in town — skip TP logic, go straight to maintenance)
- config.py: enabled parsed as plain bool (was bool(int(str)) crash on
  'true'); empty game_name_filter no longer crashes float()/int() overrides
- game_browser.py: drop unused imports (keyboard, Config, focus_d2r_window,
  select_screen_object_match)
2026-08-24 23:13:00 +02:00
alexpolo1 09bfd330e6 feat(baal_xp): join public Baal games, hide, collect XP, leave
- src/ui/game_browser.py: new module for game browser interaction
  (Play button → Join Game tab → OCR game list → click → loading)
- src/config.py: [baal_xp] section with enabled, game_name_filter,
  max_wait_s, xp_threshold, min_hp_pct, hide_x/y, join_timeout_s
- config/params.ini: [baal_xp] section + route doc
- src/bot.py: baal_xp state, on_run_baal_xp handler (8-phase cycle:
  leave own game → hero select → join public game → wait in-game →
  corpse/nopickup/pre_buff → walk to hide spot → wait loop (XP/HP/timer)
  → leave → recover to own game), _recover_to_own_game helper

Enable by adding run_baal_xp to [routes] order in params.ini.
2026-08-24 22:30:04 +02:00
alexpolo1andClaude Opus 5 ce930f2a76 feat(tools): add launch_d2r — direct exe launch with a Play-button fallback
Launching D2R by hand kept failing in two different ways, both silent:

1. D2R.exe direct is fast but can come up with "Cannot Connect to Server" when
   the client has no Battle.net session.
2. The launcher's Play button always yields an authenticated client, but a
   hardcoded coordinate for it clicks the DESKTOP whenever the launcher has
   moved, been minimised to tray, or is DPI-scaled — which opened unrelated
   applications rather than reporting a failure.

So: try the exe, fall back to Play, and find Play by COLOUR rather than a fixed
point. It is the large saturated-blue block in the launcher; sampled live it is
HSV ~(104, 255, 122), and the value channel being that low is why a naive
"bright blue" threshold matches nothing.

show_launcher() also restores/maximises the window first, since the button
cannot be found while the launcher is hidden in the tray.

Never passes params.ini launch_options: those resolve to "-mod profile -txt",
and -mod puts D2R in offline mode where ladder does not exist.

client_size() imports utils.misc for its side effect of setting per-monitor DPI
awareness. Without it GetClientRect returns logical pixels, so a correct
1280x720 client reads as 1024x576 under 125% scaling and looks like a
resolution fault that is not there.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 08:30:39 +02:00
alexpolo1 c8a6657b0a feat(cold_plains): map-based walk-back + self-sustain for no-TP levelling chars
- return_to_town() walks pather nodes [705, 702] (A1 outdoor->town) in
  reverse, using the pather's auto-recovery sweep to handle mis-positioning
- _drink_if_needed() drinks belt HP/mana potions mid-run so a lvl 1 sorc
  doesn't die before it can walk back to town
- bot.py: _current_run tracks the active run object; on_end_run() calls
  run_obj.return_to_town() for no-TP chars instead of wasting time on
  tp_town() retries
2026-08-22 20:41:12 +02:00
alexpolo1andClaude Opus 5 1d8a6657e2 fix(melee_hunt): make move/scan/attack a fixed rotation, not a fallback
Movement was the else-branch of "did the scan find anything", and motion
detection almost always finds something, so roamed was 0 in every single run.
She never relocated — she churned on one spot re-detecting the same movement,
which is why XP crawled (140 xp in 6 minutes) and why she appeared to ignore
the rest of the map.

Make the rotation unconditional: move N steps (--move-clicks, default 3), then
scan, then fight until clear or --max-engagements, then repeat.

Also stop her running past targets. At range she now force-moves toward the
target instead of left-clicking: a left-click that lands on ground rather than
on the monster is a MOVE order, so any small offset in the motion-blob centre
turned an attack into a walk-past. Only inside --melee-dist does she click, and
then with stand-still held so a swing can never be reinterpreted as movement.

Adds --max-engagements, which the rotation referenced but was never defined as
an argument — that raised AttributeError mid-run and aborted the session.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 20:09:20 +02:00
alexpolo1andClaude Opus 5 81c8d65375 fix(set_controls_keyo): also cover builds without skill_preflight rules
get_build_skill_checks() only knows hammerdin/fohdin/blizz_sorc. For any other
build (basic, basic_ranged, ...) it returns an empty list, so the tool fell back
to [char] keys alone and never checked the build's own hotkeys — e.g. a
basic_ranged character reported only town_portal, leaving right_attack and
buff_1 unverified.

When no preflight rules exist, read the section named after the build type
instead. Only applies when the rule-based lookup found nothing, so hammerdin and
blizz_sorc are unaffected.

Verified across three builds against real .keyo files:
  hammerdin    (profile1)   10/10 ok
  blizz_sorc   (ding)        7/7  ok
  basic_ranged (ding_level)  3/3  ok  — was 1/3

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 19:44:46 +02:00
alexpolo1andClaude Opus 5 28b7c210d8 fix(set_controls_keyo): verify the active build's skills, not a hardcoded list
SKILL_KEYS was a fixed Paladin list read via Config().char.get(). Build skills
live in their own sections ([hammerdin], [blizz_sorc], [sorceress]), not [char],
so that lookup returned empty for all of them and they were never checked.

In practice the tool only ever verified 3 keys — teleport, battle_orders and
battle_command — for every build. A hammerdin's blessed_hammer/concentration/
redemption/vigor/conviction/holy_shield went unverified, and a sorc got no
build coverage at all (blizzard, ice_blast, static_field, energy_shield,
telekinesis all silently skipped).

Derive the list from skill_preflight.get_build_skill_checks() instead, which
already resolves each build's skills out of the right config section, and keep
[char]-level keys (teleport, town_portal) alongside. battle_orders/
battle_command are now gated on cta_available so a character without a Call to
Arms doesn't report two permanent false "unbound" entries.

Also print the build alongside the file so it is obvious what is being checked.

Verified against two real .keyo files:
  hammerdin (fistman) — 10/10 keys ok, was reporting only 3
  blizz_sorc (ding)   —  7/7 keys ok, was reporting only 1

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 19:44:46 +02:00
alexpolo1andClaude Opus 5 aa48c29df8 fix(set_controls_keyo): select .keyo by char_name, not the profile name
find_keyo() matched the .keyo filename against general["name"], which is the
bot profile ("profile1"), not the character ("fistman"). No file ever starts
with the profile name, so it silently fell through to files[0] — the
alphabetically first .keyo in Saved Games.

In practice that meant `testbed.py keyo` verified Burr114743261.keyo while the
bot itself read Fistman211469871.keyo (key_detector correctly uses char_name),
reporting 3 unbound keys for a character that isn't being played. Running
--fix would have WRITTEN those binds into the wrong character's file.

Now keys off char_name (falling back to name), and normalises both sides
before prefix-matching, since D2R suffixes the file with an account id
("Fistman211469871.keyo") — the same approach key_detector._find_key_file
already uses.

Also stop falling back to an arbitrary character when char_name is set but
matches nothing: raise with the list of files found instead. Silently
verifying/writing someone else's bindings is worse than failing.

Verified: testbed.py keyo now reports the correct file and
"Controls layer matches params.ini".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 19:44:46 +02:00
alexpolo1andClaude Opus 5 f1b0f6439c feat(tools): add melee_hunt — motion-based monster scan with staff attacks
target_detect.get_visible_targets cannot see ordinary monsters. FILTER_RANGES
holds two HSV bands only — "poison" (hue 38-70) and "frozen" (hue 110-120) —
so it finds monsters already tinted by Poison Nova or Holy Freeze and nothing
else. Measured against a live Fallen and zombie in Blood Moor: 0 targets at
every radius from 300 to 1280. That is why run_level, run_cold_plains and
firebolt_roam all roam without ever attacking on a fresh character.

Scan by motion instead: D2 monsters animate continuously, so differencing two
frames ~180ms apart lights them up against static terrain. The HUD and a radius
around the character are masked out to drop her own animation. Verified live —
38 engagements in 2 minutes and a confirmed Fallen kill.

Attacks with plain left-click, which walks to and hits the target: correct for
a clvl 1 sorceress with a staff and no skills (a level 1 character has zero
skill points, so there is nothing to cast).

Includes potion handling via the same meters.get_health the bot's health_manager
uses: drinks a healing potion at or below --heal-at (default 0.30). When nothing
is found it takes --move-clicks steps (default 3) before rescanning, rather than
rescanning after every single step.

Also carries tools/manual_drive.py onto this branch — it was committed only on
feat/manual-drive-seasonal-char, so it vanished from the working tree here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 19:20:58 +02:00
alexpolo1 cf4c557a26 docs(bot): clarify walk-back town marker semantics in _walk_back_to_town 2026-08-22 14:51:07 +02:00
alexpolo1 7a7950e752 feat(bot): support no-TP levelling chars (cold-plains-only grinder)
- on_init: cold-plains-only profiles skip town-marker detection and start
  directly (char is already standing in A1 town), same as run_level-only
- on_end_run: chars with no teleport at all (pre-clvl-18) walk back to
  town instead of failing on TP; _walk_back_to_town() walks south in
  steps checking for town markers, then maintenance's open_wp()
  re-detects the physical act and traverses from there

Enables the bloodmoor profile: lvl 1 sorc firebolt grinder that walks
out to Cold Plains, scans for enemies, kills, and walks back.
2026-08-22 14:11:05 +02:00
alexpolo1andClaude Opus 5 fc9a7bfae1 fix(misc): make focus_d2r_window actually win the foreground
Windows refuses SetForegroundWindow from a process that does not already own
the foreground. The refusal is silent-ish and was swallowed, so the window never
came forward and every subsequent key and click went to whatever window WAS
focused. Measured on a live client: pressing the inventory key changed 0.89 mean
pixel value (i.e. nothing happened); after the fix, 16.44.

Attach our input queue to the current foreground thread for the duration of the
call — the documented way round the foreground lock — and return True only if
D2R actually ended up foreground, instead of returning True for "no exception
was raised".

Also harden tools/firebolt_roam.py with two preflights, because both failure
modes are silent — the character just stands there:
  - refuse to start if D2R cannot be brought to the foreground
  - refuse to start if the attack hotkey does not change the right-skill icon,
    i.e. no skill is bound to it (--skip-skill-check overrides)

Verified live: with nothing bound to F1 the tool now exits with an actionable
message rather than roaming silently casting nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 09:20:45 +02:00
alexpolo1andClaude Opus 5 fd82f0ff05 feat(tools): add firebolt_roam — standalone roam-and-cast for levelling
src/run/cold_plains.py needs a waypoint, the town manager and the bot state
machine. None of that is usable for a clvl 1 character standing in Blood Moor,
which has no waypoint at all.

This starts from wherever the character already is: scan with target_detect ->
cast the configured skill at the nearest target -> roam if nothing is visible.
No waypoints, no town, no state machine.

    python tools/firebolt_roam.py --key f1 --minutes 10

The skill is a plain hotkey, so Fire Bolt now and Fireball at clvl 12 is the
same command. Stop key (default F12) is polled between every cast, and there is
a hard --minutes budget.

Explicitly NOT included: potion, chicken or death handling. Documented in the
module docstring — it will keep casting while dying, so it wants supervision.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 09:00:55 +02:00
alexpolo1andClaude Opus 5 394aca35f4 fix(messages): don't let an optional Discord import take down the whole bot
discord_embeds.py imported discord at module level. discord pulls in aiohttp,
which builds a default SSL context at import time, which loads the Windows
certificate store. On this machine that raises

    ssl.SSLError: [ASN1: NOT_ENOUGH_DATA] not enough data (_ssl.c:4030)

for EVERY certificate (65/65 across the CA/ROOT/MY stores — not one bad cert,
but ssl.load_verify_locations(cadata=...) failing wholesale against the env's
OpenSSL 3.6.3, which is much newer than this Python 3.10 build expects).

Because ui_manager -> messages -> discord is on the import path of every run,
that made the bot — and any tool importing target_detect or screen — impossible
to start. An optional notification dependency should never do that.

Import it defensively instead: on failure log a warning, set DISCORD_AVAILABLE
False, and substitute inert Embed/Color/File stand-ins so the send_* methods
still build their payloads without special-casing every call site. _get_webhook
returns None when unavailable and _send_embed already early-returns on that, so
nothing is transmitted.

Verified every notification path (message/death/chicken/stash/gold/error) is a
silent no-op rather than a crash, and that target_detect/screen/input_layer now
import cleanly.

This restores startup; it does not fix Discord itself. That needs the env's
openssl pinned back to something this Python supports.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 09:00:55 +02:00
alexpolo1andClaude Opus 5 e091a3cca4 feat(run): add cold_plains — roam an outdoor area and kill with one skill
Every existing run targets a boss and assumes endgame damage and (mostly)
teleport, so none of them are usable while levelling. run_level is closest but
hardcodes plain left-click attacks, which does nothing for a caster.

cold_plains takes the waypoint to a configurable Act 1 area, then loops:
scan with target_detect -> cast one configured skill at the nearest target ->
loot -> roam if nothing is visible. The bot's normal end_run TPs it home.

It sends its own hotkey rather than going through the build's _skill_hotkeys,
so it works with any build. A blizz_sorc profile can run it with Fire Bolt long
before Blizzard (clvl 24) exists, and re-binding to Fireball at clvl 12 is a
one-line config change.

Guards, so an unattended run cannot wedge:
- empty attack_hotkey fails in approach() with step "no_attack_hotkey" rather
  than roaming for three minutes doing no damage
- max_runtime_s caps total battle time
- max_engagements caps casts per step, so an immune or misdetected target
  cannot pin the run in one spot

Disabled by default: routes are built from [routes] order, and the new run is
not in it, so Config().routes.get() returns None and the entry is filtered out.
Opt in by adding run_cold_plains to the order.

PREREQUISITE, documented in params.ini: the destination waypoint must already be
discovered. A fresh character has none, so it needs one manual walk out first.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 08:47:16 +02:00
alexpolo1 f69d5c01c4 fix(skill_preflight): add fohdin build support alongside hammerdin
- Changed char_type check from == to in (hammerdin, fohdin)
- Uses getattr(config_instance, char_type, {}) to load correct build config
- Enables proper skill preflight validation for FOH paladin builds
2026-08-08 13:48:02 +02:00
alexpolo1 14f5876524 fix: recapture A5 waypoint template (112x125, 24KB vs old 201x120, 68KB)
Old template was too large and included background elements that changed
between D2R versions, causing template matching to fail at all thresholds.
New crop isolates just the waypoint stone itself.
2026-08-08 11:28:46 +02:00
alexpolo1 9e4ef6721a fix(test): skip log analyzer tests that require historical failure data on CI 2026-08-08 10:05:08 +02:00
alexpolo1 c04c60ead7 release: v0.9.1 — production cleanup
- Restore FIRST-RUN.md onboarding guide
- Pin environment.yml to requirements.txt (was loose 24 packages)
- Remove debug screenshots from git (debug_*.png -> .gitignore)
- Remove .hermes/ dev plans from git (already in .gitignore)
- Archive botty_next/ test harness to alexpolo1/my-botty-tools
- Archive docs/legacy-go/ to alexpolo1/my-botty-tools
- Move 14 dev tools from repo root to tools/ directory
- Extend .gitignore to prevent dev artifacts re-entering root
- Promote stable branch with all bug fixes from main
- Bump version 0.8.4 -> 0.9.1
2026-08-08 09:59:31 +02:00
alexpolo1 35aa13473e Remove personal profile references from repo files
- config/params.ini: name=fistman -> name=profile1
- config/active_profile.txt: burr -> profile1
- src/ui/character_select.py: genericize OCR comment
- Add generic profile1 and profile2 profile configs
2026-08-07 20:59:52 +02:00
alexpolo1 e03f866fbb fix: improve resilience for pindle, A5 WP, stash, NPC, and Diablo battle
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
2026-08-07 16:21:07 +02:00
alexpolo1 968c75fefd feat: self-healing test framework with log analysis and auto-fixing
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.
2026-08-07 15:25:17 +02:00
alexpolo1 7839b4ed82 fix: replace GetAsyncKeyState polling with WH_KEYBOARD_LL hook
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
2026-08-07 14:59:22 +02:00
alexpolo1 75e88dbdc2 fix: don't skip bot startup when SetWindowPos is denied
enforce_d2r_window() now returns False when D2R is elevated, but the
if-gate caused all subsequent setup (window position, health manager,
death manager, game thread) to be skipped. Removed the if-gate so
startup continues regardless of window move result.
2026-08-07 14:46:47 +02:00
alexpolo1 c5d9fe4541 fix: catch SetWindowPos access denied in all window functions
Wraps SetWindowPos in try/except pywintypes.error in move_d2r_window,
set_d2r_always_on_top, and restore_d2r_window_visibility. When D2R runs
as Administrator, Windows denies the call. Bot now continues gracefully
instead of crashing.
2026-08-07 14:33:07 +02:00
alexpolo1 648dc1b25c fix: add missing pywintypes import in misc.py
The SetWindowPos try/except catches pywintypes.error but the
module was never imported, causing NameError.
2026-08-07 14:30:30 +02:00
alexpolo1 00e759ec0a fix: catch SetWindowPos access denied when D2R runs elevated
Wrapping SetWindowPos in try/except pywintypes.error in both
set_d2r_always_on_top() and restore_d2r_window_visibility(). When
D2R runs as Administrator and the bot runs as standard user,
Windows denies the call, causing a crash. Now it logs a debug
message and continues gracefully.
2026-08-07 14:15:05 +02:00
alexpolo1 6b044a17ea fix: add direct WP scan before pathing in A5 open_wp (#4)
After Pindle fast-save/exit, the character spawns near the Nihlathak
portal area. During maintenance, identify() often fails and resets
_curr_loc to A5_TOWN_START, causing the pather to navigate from the
wrong starting position. The WP stone is often already visible on
screen, but the bot wasted 45s trying node-based pathing first.

Add an immediate full-screen WP scan as step 0 before any pathing.
This finds the WP directly when it's visible, bypassing stale
location tracking entirely.
2026-08-07 13:48:41 +02:00
alexpolo1 8f9185cde3 test: add 11 tests for install.bat file references and version consistency; fix pyproject.toml version 0.8.1->0.8.4 2026-08-07 09:54:24 +02:00
alexpolo1 02e9f7856a test: add 36 tests for health_manager, death_manager, ui.meters, config, inventory.belt, game_controller, game_recovery, item.pickit, char 2026-08-07 09:12:21 +02:00
alexpolo1 15524e1608 ci: use 3-channel image for botty OCR test 2026-08-07 08:31:34 +02:00
alexpolo1 6bcfa1af35 ci: disable crop_pad in botty OCR test (grayscale image) 2026-08-07 08:28:10 +02:00
alexpolo1 523cb54e46 ci: fix f-string escape in botty OCR test 2026-08-07 08:24:47 +02:00
alexpolo1 7431807ee5 ci: set pytesseract.tesseract_cmd directly in test code 2026-08-07 08:22:48 +02:00
alexpolo1 40ee587fb0 ci: set PYTESSERACT_TESSERACT_CMD for choco-installed Tesseract 2026-08-07 08:18:52 +02:00
alexpolo1 d1ba551832 ci: install Tesseract and test OCR (pytesseract + botty ocr module) 2026-08-07 08:16:51 +02:00
alexpolo1 3f8e08296e ci: fix module names (run.diablo, run.pindle) and add more run imports 2026-08-07 08:10:06 +02:00
alexpolo1 4e730e0c5b ci: add core import verification and botty module import tests 2026-08-07 08:06:26 +02:00
alexpolo1 cef59a7df2 ci: fix coverage step, combine coverage run+xml, add artifact upload 2026-08-07 07:53:50 +02:00
alexpolo1 78f9d07545 fix(ci): combine coverage into test step so data persists 2026-08-07 07:10:40 +02:00
alexpolo1 eeb620696b fix(ci): add pywin32 to requirements.txt for pip installs 2026-08-07 07:06:47 +02:00
alexpolo1 0c246245c4 fix(ci): replace conda with pip, remove smoke test, build only on tags 2026-08-07 07:04:18 +02:00
alexpolo1andClaude Opus 4.8 3d12a75b72 feat(install): actionable error messages instead of dead ends
Every fatal message told the user THAT something failed but not what to do
about it. The worst was "conda env create failed. See output above." --
useless when run_install_capture.bat redirects that output to a 56 KB log.

Each error now names the likely cause and the concrete next step:
  - download failed      -> the URL tried, firewall/proxy hint, manual-install
                            fallback that install.bat will detect on re-run
  - truncated download   -> got N bytes vs expected ~78 MB, bad file deleted
  - installer failed     -> antivirus/UAC hint, how to run it by hand
  - conda found but dead -> the exact command to reproduce the real error
  - env create failed    -> disk/network/antivirus causes, plus the
                            "env remove -n botty -y" recovery for a half
                            finished install
  - pip install failed   -> notes the env itself is fine and a re-run resumes
  - python.exe missing   -> explains partial env, gives the recovery commands
  - find_python.bat      -> distinguishes "never installed" from "install.bat
                            did not finish", pointing at the capture log

Added a shared ":fail" exit so every fatal path ends with how to produce a
full log for a bug report, and states that nothing else was changed.

Also added a disk-space pre-flight before env creation: under 3 GB now
fails immediately with a clear message instead of letting conda die halfway
through with an opaque error; 3-6 GB warns. The environment needs ~4 GB
plus ~1 GB of downloads.

Verified: install.bat still completes with exit 0; the pre-flight was
exercised at real, simulated-2 GB and simulated-5 GB levels and all three
branches render and exit correctly; find_python.bat still resolves; 140
tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-06 22:29:23 +02:00
alexpolo1andClaude Opus 4.8 7cb15837d4 fix(install): window closed instantly on success, hiding the result
install.bat had 9 pause statements on failure paths and none on the
success path. The README tells users to double-click install.bat, so on a
successful install the console vanished the moment it finished -- a new
user never saw "Installation complete", the OCR verdict, or the dependency
verification, and had no way to tell whether it had worked.

Added pause to the success path.

run_install_capture.bat redirects stdout to install_log.txt, so that new
pause would have blocked behind the redirect: an empty window silently
waiting on a keypress the user cannot see. It now feeds stdin from nul,
reports success/failure with the log path, and pauses itself.

Verified non-interactive: run_install_capture.bat completes in ~28s with
exit 0 and no hang.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-06 21:22:36 +02:00
alexpolo1andClaude Opus 4.8 ab3f5633fc fix(test): bat-file tests failed on the stable branch
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]>
2026-08-06 19:08:43 +02:00
alexpolo1andClaude Opus 4.8 81f160d400 docs: record Bug 22 (tesserocr libdeflate DLL chain) in bug reference
Includes the pefile import-chain technique that found it, since WinError
126 names the importing DLL and never the missing dependency.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-06 19:00:01 +02:00
alexpolo1andClaude Opus 4.8 7fd0555468 fix(install): permanent fix for tesserocr DLL load failure
tesserocr never loaded -- install.bat always reported "tesserocr: not
available (DLL issue)" and the bot ran on the pytesseract fallback, which
shells out to tesseract.exe per OCR call instead of using the in-process
C++ API.

Root cause, found by walking the import table with pefile:
  tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll
  -> libdeflate.dll  <- MISSING
Current conda-forge libdeflate (>=1.20) installs the library as
"deflate.dll", but the older tiff.dll from the tesseract=4.x stack still
imports the previous name "libdeflate.dll". Nothing provided that name, so
tiff.dll failed to load and every DLL above it failed with WinError 126
("The specified module could not be found") -- which is why the error
looked like a missing module even though every file was present.

Fix: install libdeflate explicitly alongside tesseract=4.*, then copy
deflate.dll to the legacy name libdeflate.dll when that name is absent.
Same library, same exports.

Verified: removing the alias reproduces the failure exactly; running
install.bat recreates it and the installer now reports "tesserocr: OK
(fast path)". tesserocr initialises and performs real OCR with both bundled
models, and the bot logs "OCR backend: tesserocr (primary)" at startup.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-06 18:59:31 +02:00
alexpolo1andClaude Opus 4.8 b627172f1e chore: gitignore install_log.txt
run_install_capture.bat writes install_log.txt into the repo root. It was
untracked but not ignored, so it showed up as noise in git status and was
easy to commit by accident.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 22:05:43 +02:00
alexpolo1andClaude Opus 4.8 512f63e0e5 docs: record install.bat bugs 20 and 21 in the permanent bug reference
Both were found by running install.bat under simulated clean-machine
conditions (conda removed, winget stripped from PATH, Tesseract hidden).

Bug 20: an unescaped ")" in an echo inside a parenthesised block aborted
the script at parse time, killing the conda direct-download path -- the
only path available without winget.

Bug 21: winget defaulted to machine scope, so the installer needed admin
and failed silently on a normal double-click.

Also documents the two recurring batch pitfalls with an awk audit command,
and the measured limitation that Tesseract has no per-user install path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 22:00:59 +02:00
alexpolo1andClaude Opus 4.8 6a07865f43 docs(install): record measured Tesseract /D= behaviour
Testing the direct-download fallback with Tesseract absent and winget
unavailable showed the official installer self-elevates and its elevated
relaunch discards /D=, so it always installs machine-wide to
"C:\Program Files\Tesseract-OCR" regardless of TS_DEST.

The previous comment claimed this path gave a per-user install needing no
admin rights, which is not true: there is no per-user install path with
the official Tesseract installer, and it requires admin/UAC. Corrected the
comment rather than the code -- /D= is harmless as best-effort, and both
find_tesseract and src\d2r_image\ocr.py already search the machine-wide
and per-user locations, so either outcome works at runtime.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 21:59:38 +02:00
alexpolo1andClaude Opus 4.8 a539d3a236 fix(install): unescaped parens killed the conda direct-download path
The Miniforge direct-download fallback -- the only path available on a
clean machine without winget -- could never complete. install.bat aborted
with ". was unexpected at this time." immediately after running the
Miniforge installer, so conda was installed but the botty env was never
created and the bot was unusable.

Cause: line 133 echoed "(exit code %errorlevel%)" inside a parenthesised
if-block. An unescaped ")" inside a block terminates the block, leaving
"." as a stray token. cmd parses the entire if-block when it reaches it,
so this fired even when the installer SUCCEEDED and the block body was
never meant to run -- verified with a minimal repro: the unescaped form
exits 255 on a false condition, the escaped form exits 0.

Fix: escape as ^(exit code %errorlevel%^), matching the convention the
rest of the file already uses ("^(fast path^)"). Audited every echo
inside a block; this was the only remaining unescaped instance.

Found by running install.bat with winget removed from PATH to simulate a
clean Windows 10 machine.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 21:51:23 +02:00
alexpolo1andClaude Opus 4.8 710c1c709a fix(install): batch parse error aborted install.bat at OCR stage
Live clean-install test caught a syntax bug in the new Tesseract setup
block: install.bat died with "so was unexpected at this time." right
after "Setting up OCR...", so OCR setup and the whole dependency
verification stage never ran.

Cause: "::" comment lines placed INSIDE parenthesised if-blocks. Two
problems compound there -- a "::" line inside a ( ) block is itself a
parse error, and any parenthesis in the comment text closes the block
early. The text "(non-zero when already installed), so after each" left
"so" as a stray token.

Fix: move every comment out of the parenthesised blocks, in both the
Tesseract block and the conda winget block added earlier. The conda one
had survived only because its text happened to contain no parentheses.

Verified: install.bat now runs to completion with exit 0 --
  Tesseract: C:\Program Files\Tesseract-OCR\tesseract.exe
  pytesseract: OK (tesserocr: DLL issue, expected)
  cv2/mss/numpy/transitions/rapidfuzz/pydantic/pytesseract/yaml/discord: OK
  All dependencies verified.
140 tests pass against the freshly created env.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 21:38:23 +02:00
alexpolo1andClaude Opus 4.8 61a88d2968 fix(install): make Tesseract/OCR setup work on clean Win10/Win11
Follow-up to the conda scope fix: the same admin/winget assumptions broke
the OCR backend, which is what actually carries item text reading since
tesserocr's MSVC DLL chain commonly fails to load.

- install.bat installed Tesseract via `winget install` with no --scope,
  i.e. machine-wide into "C:\Program Files", which requires admin. On a
  clean non-admin box this failed and left NO working OCR backend at all
  (tesserocr already fails), so OCR_READY=0 and item reading was dead.
  Now: winget machine scope -> winget --scope user -> direct download of
  the official NSIS installer with a per-user /D= target. Also stops
  trusting winget's exit code (non-zero when already installed) and
  re-resolves tesseract.exe after each attempt.
- The downloaded installer is size-checked (~50 MB; <20 MB = failed
  download) before being executed, matching the Miniforge handling.
- Added a :find_tesseract subroutine that resolves tesseract.exe from
  Program Files, Program Files (x86), %LOCALAPPDATA%\Programs,
  %ProgramData% and PATH. Verification now uses the resolved path instead
  of the hardcoded "C:\Program Files" one.
- ocr.py: added Program Files (x86) and the per-user
  %LOCALAPPDATA%\Programs\Tesseract-OCR location to the runtime search
  order, since per-user installs are not on PATH.
- run_botty.bat: only export PYTESSERACT_TESSERACT_CMD when the file
  exists, falling back to the per-user path, so a stale machine-wide
  value cannot shadow a valid per-user install.

Verified on this machine: all install.bat dependency imports OK
(cv2/mss/numpy/transitions/rapidfuzz/pydantic/pytesseract/yaml/discord),
pytesseract resolves tesseract 5.5.0, osdetect reports the win11 profile,
config loads, 140 tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 21:28:02 +02:00
Alex c1367dbfbd Merge pull request #3 from alexpolo1/fix/install-conda-user-scope
fix(install): install conda without admin + verify git download
2026-08-05 21:54:52 +02:00
alexpolo1andClaude Opus 4.8 a2e2acfbde fix(install): install conda without admin + verify git download
install.bat could fail to auto-install conda on a fresh, non-admin
machine:

- winget install used the default (machine) scope, landing conda in
  %ProgramData% and requiring elevation. A normal double-click without
  admin failed silently and conda never installed. Add --scope user so
  it installs to %USERPROFILE%\miniforge3 with no admin needed.
- winget returns non-zero when the package is already present, so its
  exit code was unreliable. Rescan for conda.exe after winget and only
  fall through to the direct download when it is genuinely missing.
- The GitHub (git) download fallback never validated the file before
  running it: a truncated download or an HTML error page served with a
  200 would be launched as the "installer" and silently do nothing. Add
  a size check (<40 MB => failed download, clear error + bail) plus a
  pre-download cleanup of any stale temp file.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-05 17:07:50 +02:00
alexpolo1andClaude Sonnet 4.6 e8a9cdc6cd fix: resolve 'module object is not callable' in item parser
rapidfuzz 3.x moved levenshtein out of rapidfuzz.string_metric (removed)
into rapidfuzz.distance.Levenshtein, but Levenshtein is now a module, not
a function. The fallback alias pointed at the module — fix it to bind the
.distance method directly.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 12:05:23 +02:00
161 changed files with 17501 additions and 1360 deletions
+184 -142
View File
@@ -1,167 +1,209 @@
name: Botty - CI
on:
workflow_dispatch:
pull_request:
push:
branches: [main, mine]
# Pushing a version tag (e.g. `git tag v0.8.5 && git push --tags`) builds,
# smoke-tests, then creates the GitHub release with the zip attached — all
# in one run. If the build fails, no release is ever created.
tags: ['v*']
name: CI
# Default GITHUB_TOKEN is read-only; the build job's release step needs
# contents:write to create the release and attach the built zip
# (else HTTP 403 "Resource not accessible by integration").
permissions:
contents: write
on:
push:
branches: [main, stable]
pull_request:
branches: [main, stable]
# Cancel in-progress runs on new push
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
runs-on: windows-latest
install-and-test:
name: Install & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout (skipped - workspace pre-populated)
run: echo "Using pre-populated workspace"
- name: Setup Miniconda Python 3.10
uses: conda-incubator/setup-miniconda@v3
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
activate-environment: botty
channel-priority: strict
environment-file: environment-win11.yml
use-only-tar-bz2: false
python-version: "3.10"
# cache disabled for act_runner
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Tesseract OCR
run: |
sudo apt-get update
sudo apt-get install -y tesseract-ocr libgl1 libglib2.0-0
- name: Python version
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat activate botty
python -c "import sys; print(sys.version)"
run: python -c "import sys; print(sys.version)"
- name: Syntax check
shell: powershell
env:
PYTHONPATH: ./src
run: python -m compileall -q src tools test scripts
- name: Verify core imports
env:
PYTHONPATH: ./src
run: |
$ErrorActionPreference = "Stop"
C:\Miniconda\condabin\conda.bat activate botty
python -m compileall -q src tools test scripts
python -c "
import sys
sys.path.insert(0, 'src')
modules = [
'config',
'logger',
'screen',
'pather',
'template_finder',
'game_stats',
'health_manager',
'death_manager',
'd2r_image',
'item',
'item.pickit',
'transmute',
'shop',
'char',
'utils',
'utils.os_detect',
'messages',
]
for mod in modules:
try:
__import__(mod)
print(f' {mod}: OK')
except ImportError as e:
print(f' {mod}: FAILED - {e}')
sys.exit(1)
print('All core imports successful.')
"
- name: Tests
shell: powershell
- name: Verify botty runs (import main modules)
env:
PYTHONPATH: ./src
run: |
python -c "
import sys, os
sys.path.insert(0, 'src')
import ssl
ssl.SSLContext.load_default_certs = lambda *a, **k: None
from version import __version__
print(f' Version: {__version__}')
from config import Config
print(' Config: OK')
from game_controller import GameController
print(' GameController: OK')
from bot import Bot
print(' Bot: OK')
from run.diablo import Diablo
print(' Diablo run: OK')
from run.pindle import Pindle
print(' Pindle run: OK')
from run.arcane import Arcane
print(' Arcane run: OK')
from run.vizier import Vizier
print(' Vizier run: OK')
print('All botty entry modules import successfully.')
"
- name: Verify OCR (pytesseract)
env:
PYTHONPATH: ./src
run: |
python -c "
import sys, os, tempfile
sys.path.insert(0, 'src')
import pytesseract
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
import cv2
import numpy as np
version = pytesseract.get_tesseract_version()
print(f' Tesseract version: {version}')
# Create a simple test image with text
img = np.full((50, 200), 255, dtype=np.uint8)
cv2.putText(img, 'Hello Botty', (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 1)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
cv2.imwrite(f.name, img)
text = pytesseract.image_to_string(f.name, lang='eng').strip()
print(f' OCR result: {text}')
print('OCR pytesseract: OK')
"
- name: Verify OCR (botty ocr module)
env:
PYTHONPATH: ./src
PYTESSERACT_TESSERACT_CMD: /usr/bin/tesseract
run: |
python -c "
import sys, os
sys.path.insert(0, 'src')
# Import the botty OCR module - it reads PYTESSERACT_TESSERACT_CMD from env
from d2r_image.ocr import image_to_text, pytesseract
import cv2
import numpy as np
import tempfile
if pytesseract is None:
print(' pytesseract not available — skipping')
sys.exit(0)
# Create test image (3-channel for invert)
img = np.full((50, 200, 3), 255, dtype=np.uint8)
cv2.putText(img, '123', (80, 35),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
# Run through botty's image_to_text
results = image_to_text(
[img],
model='hover-eng_inconsolata_inv_th_fast',
psm=7,
crop_pad=False,
invert=True,
threshold=25,
)
result_text = results[0].text if results else 'empty'
print(f' OCR result: {result_text}')
print('OCR botty module: OK')
"
- name: Log analyzer tests (self-healing framework)
env:
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
$ErrorActionPreference = "Stop"
C:\Miniconda\condabin\conda.bat activate botty
python -m coverage run -m pytest -v
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
- name: Coverage report
shell: powershell
- name: Tests with coverage
env:
PYTHONPATH: ./src
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
$ErrorActionPreference = "Stop"
C:\Miniconda\condabin\conda.bat activate botty
python -m coverage xml --ignore-errors
python -m coverage run -m pytest -v --tb=short --ignore=test/auto/test_self_healing.py --ignore=test/smoke_test.py --ignore=test/test_version_consistency.py
python -m coverage xml --ignore-errors || true
build:
needs: test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Miniconda Python 3.10
uses: conda-incubator/setup-miniconda@v3
with:
python-version: '3.10'
activate-environment: botty
channel-priority: strict
environment-file: environment-win11.yml
use-only-tar-bz2: false
- name: Install Tesseract (bundled into the release for click-and-run OCR)
shell: powershell
run: |
$ErrorActionPreference = "Stop"
choco install tesseract --no-progress -y
if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) {
throw "Tesseract install did not produce tesseract.exe"
}
- name: Build exe
shell: powershell
env:
BOTTY_NO_RENAME: '1'
PYTHONPATH: ./src
run: |
C:\Miniconda\condabin\conda.bat activate botty
python build.py --conda_path C:\Miniconda
- name: Verify Tesseract was bundled
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
$tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe"
if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" }
Write-Host "Bundled: $tess"
- name: Launch smoke test (built executables)
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
if (-not $BOTTY_DIR) { throw "No botty_v* build directory found." }
$mainExe = Join-Path $BOTTY_DIR "main.exe"
$shopperExe = Join-Path $BOTTY_DIR "shopper.exe"
if (-not (Test-Path $mainExe)) { throw "Missing $mainExe" }
if (-not (Test-Path $shopperExe)) { throw "Missing $shopperExe" }
$procs = @()
try {
$mainProc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 6
if ($mainProc.HasExited) { throw "main.exe exited early with code $($mainProc.ExitCode)" }
$procs += $mainProc
$shopperProc = Start-Process -FilePath $shopperExe -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 6
if ($shopperProc.HasExited) { throw "shopper.exe exited early with code $($shopperProc.ExitCode)" }
$procs += $shopperProc
}
finally {
foreach ($p in $procs) {
if ($p -and -not $p.HasExited) {
Stop-Process -Id $p.Id -Force
}
}
}
- name: Prepare release zip
shell: powershell
run: |
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
Write-Host "Botty dir: $BOTTY_DIR"
Get-ChildItem -Path $BOTTY_DIR -Recurse | Select-Object -Property FullName, Length
$ZIP = "${BOTTY_DIR}.zip"
Compress-Archive -Path "${BOTTY_DIR}\*" -DestinationPath $ZIP -Force
Write-Host "Release zip: $ZIP"
Get-Item $ZIP | Select-Object -Property FullName, Length
- name: Upload build artifacts
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
continue-on-error: true
with:
name: botty-build
path: botty_v*/
name: coverage-report
path: coverage.xml
retention-days: 7
# On a version-tag push, create the release (if absent) and attach the
# zip atomically. Runs only after the build + smoke test above succeed.
- name: Create release and upload zip
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: botty_v*.zip
fail_on_unmatched_files: true
generate_release_notes: true
+24
View File
@@ -30,8 +30,29 @@ data/d2jsp_pages/
data/d2jsp_topic_urls.txt
# Debug files
debug_*.png
debug_forum.html
# Dev tools (moved to tools/)
asset_extractor.py
asset_manager.py
build.py
desktop_snap.py
development.md
quest_debug.py
quest_plan.md
quest_screenshot_tool.py
references/improvements.md
run_asset_extractor.bat
run_chipped_gems.bat
run_gems_all.bat
run_quest_debug.bat
screenshot_tool.py
start_bot_detached.bat
start_botty.ps1
test_plan.md
update.bat
# Generated/runtime price data (updated automatically by bot)
config/fg_daily_estimates.json
config/fg_prices.json
@@ -100,3 +121,6 @@ src/input_layer/bridge_input.py
.hermes/
fixtures/
test/run/
# Installer output (generated by run_install_capture.bat)
install_log.txt
+21
View File
@@ -53,6 +53,27 @@ Handles D2R window detection and screenshot capture via MSS library. Converts be
Template matching via OpenCV (`cv2.matchTemplate`). Searches against pre-captured asset templates in `assets/templates/`. Returns match position and validity.
**Asset conventions — read before adding or recapturing any template:**
- Every `.png` under the `TEMPLATE_PATHS` roots is loaded recursively and keyed by its
**uppercased filename** (`a5_red_portal.png``A5_RED_PORTAL`). Keys are one flat
namespace across all roots, so filenames must be globally unique — and **never leave a
backup or scratch `.png` anywhere under `assets/`**, or it silently becomes a live
template.
- **Keep templates fully opaque (3-channel, or 4-channel with no zero-alpha pixel).**
`alpha_to_mask` only produces a mask when the image has 4 channels *and* contains a
fully transparent pixel; that mask is then passed to
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)`. **OpenCV only properly supports
masks for `TM_SQDIFF` and `TM_CCORR_NORMED`** — masked `TM_CCOEFF_NORMED` returns
unreliable scores and wandering match positions. A masked template will appear to
"work" in isolation and then fail at random. See CLAUDE.md Bug 23.
- Crop something **structurally stable and unoccluded**. Animated or partly hidden
features (a swirling portal's interior, a ring occluded by scenery) make poor anchors.
- **Validate on held-out frames**: build the crop from one capture, score it against
*other* captures, and — critically — against frames where the subject is **absent**.
A template that scores high on both is matching background, not the subject. Aim for a
clear gap straddling the 0.68 default threshold.
### UI Detection (`src/ui/`)
| File | Detects |
+1029
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
# Botty First Run Guide
After running `install.bat` and seeing "Installation complete!", follow these steps.
---
## Step 1 — Create your character profile
Botty uses **profiles** to store your character setup. Each profile is a single file in `config/profiles/`.
### Find your character type
Open `config/params.ini` in Notepad, scroll to `[char]`, find `type=`:
| If you play... | Set `type=` to |
|---|---|
| Hammerdin Paladin | `hammerdin` |
| FoH Paladin | `fohdin` |
| Blizzard Sorceress | `sorceress` |
| Bone Necromancer | `necro` |
| Traps Assassin | `trapsin` |
| Amazon | `amazon` |
| Barbarian | `barbarian` |
| Warlock Druid | `warlock` |
### Set your keybinds
In `config/params.ini`, scroll to `[char]` and match these to your D2R key bindings:
```ini
[char]
type=hammerdin ; your build (see table above)
tp_key=b ; Teleport key
stand_still=capslock ; Hold to stand still (capslock is common)
show_belt=k ; Open belt
weapon_switch=w ; Swap weapons (for CTA builds)
force_move=e ; Force movement through mobs
```
**Important:** `stand_still` cannot be `shift` (conflicts with merc healing). `capslock` is the recommended default.
### Pick your runs
Scroll to `[routes]` in `config/params.ini`:
```ini
[routes]
; Enable the runs you want (1 = on, 0 = off)
run_pindle=1 ; Act 5 Nihlathak temple red portal boss
run_countess=0 ; Act 1 Forgotten Tower
run_andariel=0 ; Act 1 Catacombs
run_arcane=0 ; Act 2 Arcane Sanctuary / Summoner (teleport recommended)
run_trav=0 ; Act 3 Travincal council
run_mephisto=0 ; Act 3 Durance of Hate
run_diablo=0 ; Act 4 Chaos Sanctuary (teleport recommended)
run_vizier=0 ; Act 4 Chaos, Vizier only (faster than full Diablo)
run_nihlathak=0 ; Act 5 Halls of Vaught (teleport recommended)
run_eldritch=0 ; Act 5 Frigid Highlands, Eldritch only
run_eldritch_shenk=0 ; Act 5 Eldritch then Shenk in the same game
; Execution order (only includes enabled runs)
order=run_pindle
```
> There is no `run_shenk` option — Shenk is Act 5 and is farmed via
> `run_eldritch_shenk`. The commented route list at the top of the `[routes]`
> section in `params.ini` is always the authoritative set of names.
Common combinations:
- **Pindle only:** `run_pindle=1`, `order=run_pindle`
- **Pindle + Arcane:** `run_pindle=1`, `run_arcane=1`, `order=run_pindle, run_arcane`
- **Full farm:** Enable all you want, set `order=` to your preferred sequence
### Set difficulty
In `[general]`:
```ini
difficulty=hell ; normal, nightmare, or hell
```
Start with `nightmare` if you're testing. Use `hell` for serious farming.
---
## Step 2 — Save as a profile (optional but recommended)
Profiles survive git pulls. To save your setup:
1. Create a folder: `config/profiles/mychar/`
2. Create `config/profiles/mychar/profile.ini` with:
```ini
[general]
name=mychar
difficulty=hell
[char]
type=hammerdin
tp_key=b
stand_still=capslock
show_belt=k
weapon_switch=w
force_move=e
[routes]
run_pindle=1
order=run_pindle
```
3. Select it. The profile is chosen by `config/active_profile.txt`, **not** by
`name=` in `params.ini` (that field is only a display/label value). Either:
- start the bot and press **`end`** to cycle to your profile, or
- put the profile folder name on the first line of `config/active_profile.txt`
At startup the bot logs which one it loaded:
`Profile active: mychar (config\profiles\mychar\profile.ini)`
---
## Step 3 — Launch
1. **Start D2R** — set to 1280x720 windowed mode
2. **Log in** and select your character
3. **Double-click `run_botty.bat`**
4. The bot shows a hotkey menu — press **`f11`** to start or pause it. Press **`f12`** to stop.
> The hotkey menu printed at startup is always the source of truth — it reflects
> your actual `config/params.ini` keys. Do **not** press `insert`: that is
> `restore_settings_from_backup_key`, which overwrites your current D2R settings
> with a previously saved backup.
---
## Step 4 — Optional: Discord notifications
To get bot status in Discord:
1. Create a Discord webhook: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL
2. In `config/params.ini`, under `[general]`:
```ini
custom_message_hook=https://discord.com/api/webhooks/YOUR/WEBHOOK_URL
discord_log_chicken=1
discord_log_errors=1
```
---
## Troubleshooting
**"Could not find botty conda environment"** — Run `install.bat` first.
**"D2R is not running"** — Launch D2R before running `run_botty.bat`, or set `auto_login=1` in params.ini with your Battle.net credentials.
**Bot can't see templates / everything fails** — Make sure D2R is 1280x720 windowed, not fullscreen or borderless.
**OCR not working**`install.bat` sets up OCR automatically. Check the install output for "tesserocr: OK" or "pytesseract: OK". At least one must work.
**Check logs:** Open `log/log.txt` in Notepad for detailed output.
---
## Quick reference: what to edit
| File | What to change |
|---|---|
| `config/params.ini` | `type=`, keybinds, `difficulty=`, routes, Discord webhook |
| `config/profiles/*/profile.ini` | Per-character overrides (optional) |
| `config/game.ini` | **Don't edit** — templates and UI coordinates |
| `config/shop.ini` | **Don't edit** — vendor buy/sell lists |
+323
View File
@@ -0,0 +1,323 @@
# Handover — 2026-08-29
Everything you need to run this yourself. Written after a long debugging session;
`CLAUDE.md` has the deep detail, this is the operating manual.
---
## Running the bot
```bash
run_botty.bat # starts the process (idle)
python scripts/hermes_bot_control.py start # begins playing
python scripts/hermes_bot_control.py status # running=X paused=Y
python scripts/hermes_bot_control.py stop # exits the process
```
**`start` is idempotent** (fixed 2026-08-28) — pressing it twice is safe. `pause`
and `toggle` are the toggle. Before the fix, a repeated `start` paused the bot
and `status` still said `running=True`; that cost ~5 hours once.
**Always verify with `status`, not the "OK: command sent" reply.** And check the
log actually moves — `=== BOT START ===` is the proof it began a game.
### Restarting after a code or config change
A running bot does **not** pick up edits. Python loads modules at process start.
```bash
python scripts/hermes_bot_control.py stop
# wait until nothing is listening:
netstat -ano | grep 18899
run_botty.bat
python scripts/hermes_bot_control.py start
```
If `stop` times out, retry it — the socket occasionally needs two attempts. Only
force-kill as a last resort: killing mid-game leaves D2R in a state the bot
cannot re-enter, and you then have to save+exit to the main menu by hand.
**Only ever run one instance.** Two both bind the control socket and fight over
start/pause, and the logs become nonsense. Check with `tasklist | grep -i python`.
---
## Is it stuck, or just idling?
The bot sits at the D2R **character-select menu** during a normal break. The
stuck case looks identical. Do not judge by the screen.
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
```
| | Normal break | Stuck |
|---|---|---|
| `status` | `running=True paused=True` | the same |
| `select_char` errors | none | present |
| `Restarting bot` | none | every ~20s |
| Log | quiet | new process repeatedly |
**The tell is the log filling with restart lines, not the menu.**
### Break lengths are longer than they look
`maybe_afk_break` calls `wait(m, m*1.5)` and `wait()` applies its own jitter (up
to 1.44x). They compound:
| planned | actual |
|---|---|
| 3.9m | 7.1m |
| 11.9m | 19.5m |
| 20:56 | 25.5m |
So **multiply any break setting by 1.5 x 1.44** before deciding it is safe. A
~25 minute idle is what left D2R unable to re-enter once. `afk_break_max_m` is
capped at 7 for this reason (=> ~15m worst case).
---
## Health check
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt > /tmp/c.log
echo "games $(grep -c 'game | start' /tmp/c.log) | failed $(grep -c 'game | end .*fail' /tmp/c.log) | deaths $(grep -c 'You have died' /tmp/c.log) | crashes $(grep -c 'Uncaught exception' /tmp/c.log)"
```
Useful greps:
| what | grep |
|---|---|
| Step-by-step timeline | `grep "TL>" log/log.txt` |
| Failure records | `grep "FAIL>" log/log.txt` |
| Stealth manifest at startup | `grep "STEALTH>" log/log.txt` |
| Mana threshold crossings | `grep "MANA>" log/log.txt` |
| Level / exp | `ls -t log/stats/mini_stats_*.json \| head -1` |
**`FAIL>` gives the whole story of a failed game** — reason, location, the three
slowest steps, and a breadcrumb trail with `!` marking failures. Read the trail,
not just the reason: the step that blew up is often not the one that caused it.
---
## Temporary settings to revert
| file | setting | now | should be |
|---|---|---|---|
| `config/params.ini` | `session_budget_h` | **20** | 8 |
| `config/params.ini` | `difficulty` | hell | your call |
`session_budget_h = 20` was raised for a levelling push. It rolls to 13-27h,
which largely disables the stop-for-the-day behaviour that the session-rhythm
work exists to provide. **Put it back to 8 once you have the levels.** It is
uncommitted, so `git checkout config/params.ini` reverts it.
---
## Outstanding
**PR #39 is merged and live** (`ec6599f`). Confirmed working 2026-08-29: the
pather abort fired for the first time (`aborting traverse` 1, `taking a random
guess` 8, over 21 games) after being 0-for-152 while it looked correct.
**Travincal (hell) status — 2026-08-29:** 21 games, 2 failed (9.5%), 0 deaths.
Council dies; 10 of 13 runs produced loot. Both failures were `open_wp`, but
with *different* waypoints — `A1_WP` once, `A5_WP` once — because the character's
act varies between games. The A5 case follows a Larzuk repair (stale believed
location, Bug 16 family). Waypoint template matching is now on the critical path
for every Trav game, where Pindle never used it at all.
**FoH is the wrong build for Travincal.** Blessed Hammer does magic damage and
the council are not magic immune; FoH's holy-bolt half only damages *undead*, so
against the living council half its output does nothing. That is the 2-3k vs
~10k gap — not a tuning issue. `atk_len_trav` was raised 6.0 -> 10.0 in the
profile as a stopgap (40s min / 120s max across the four attack sequences).
**Done 2026-08-29** — respecced and verified live: `type=hammerdin`,
`concentration=f6`, `redemption=f3`. Note **Blessed Hammer is NOT on an F-key**:
`_cast_hammers` puts the aura on the right slot and spams left-click, so Hammer
lives on left-click permanently — pressing a hammer hotkey would replace the aura.
`concentration` and `redemption` had to move into `[paladin]`; they were under
`[fohdin]`, which a hammerdin never reads, so both would have been silently
unbound. `atk_len_trav` is now 3.0 (the fight is a fixed clock, so lower = less
exposure, not less damage).
**Trav costs two cross-act waypoint trips per game.** The run leaves the
character in Act 3; maintenance relocates it to Act 4 (stash at `a4_tyrael_stash`,
repair at `a4_halbu`) and the next run then needs the A3 waypoint again. That
travel is *not attributed to any timed step*, so a `FAIL>` trail can show ~38s of
work inside a 250s maintenance window — do not read the trail as the whole cost
here. This blew the 240s budget 5 times; `max_maintenance_time_s` raised to 420.
It also explains the `open_wp` failures: Trav exercises waypoint templates on
every game, where Pindle walked to an in-act portal and never touched them.
Not yet investigated: `a3.py` reports `can_buy_pots`/`can_heal`/`can_stash` all
True, so the trip to Act 4 is *not* a missing A3 capability — something in the
maintenance chain relocates the character. Keeping town business in Act 3 would
remove both waypoint trips and is the real fix if Trav becomes the main route.
(Cosmetic: `a3.py` defines `can_identify` twice, identically — harmless.)
**The `open_wp` failures are NOT a template problem — proven 2026-08-29.**
Scored `a5_wp.png` against all three real failure frames:
* full-frame, no ROI: 0.96-0.98 at (924, 594) — but that is the **belt/mana-orb
HUD false positive** the `a5.py` comment already documents, not the stone. The
`cut_skill_bar` ROI exists precisely to exclude it. Do not "confirm" this
template by scoring without the ROI; it produces a confident wrong answer.
* inside the real `cut_skill_bar` ROI (0,0,1284,653): **0.436-0.480** against a
0.55 threshold, at scattered positions (1,401) / (712,519) / (32,520). Wandering
match positions = noise. `a5_wp_2` (masked, 4-channel) scores 0.26-0.29.
The waypoint is genuinely **not on screen**: the character never reaches it.
Recapturing the template would fix nothing. The cause is stale believed location
after town business happens in a different act (Bug 16 family), consistent with
the `taking a random guess` lines in the same failures.
**Therefore: keeping Trav town business in Act 3 is the real fix**, not a
performance tweak. It removes the A5 waypoint dependency, the two cross-act
trips, and the maintenance-timeout pressure in one change. 7 of 8 waypoint
misses were `A5_WP`; `A1_WP` was a single one-off.
**A3 town landmark coverage is the root cause of the Trav failures (2026-08-29).**
Measured against the two `info_npc_menu_timeout` frames where the bot was stuck:
| frame | a3_town landmarks >=0.68 | >=0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
With zero landmarks over threshold the pather cannot localise at all — hence
6 `taking a random guess` across 2 traverses to `a3_ormus`. The character then
ends up somewhere arbitrary, which produces all three symptoms from one cause:
Ormus not in his ROI, the waypoint not on screen, and maintenance burning its
budget.
Ormus himself is fine: best match INSIDE his ROI (444,13,372,318) is 0.342/0.334
— noise — while the global best (0.479/0.508) sits *outside* it. Do NOT lower
the body threshold; 0.34 is background level and dropping the bar there invites
the Bug 4 false-positive clicks. The NPC is not there to be found.
**CORRECTION to the earlier entry in this file:** "keeping Trav town business in
Act 3 is the real fix" was wrong. That was based on `a3.py` reporting
`can_buy_pots`/`can_heal`/`can_stash` as True — a capability check, not evidence
about pathing. A3 is in fact the worst-supported town in the project; moving more
work into it makes things worse. The fix is A3 landmark coverage.
New pather node templates need the character's absolute position at capture time,
so they cannot be made from saved frames — this needs a live capture pass in A3.
**BELT WAS HALF EMPTY — found 2026-08-30 after the session's only death.**
```ini
belt_hp_columns=1 # 4 healing potions
belt_mp_columns=1 # 4 mana potions
belt_rejuv_columns=2 # 8 slots for potions VENDORS DO NOT SELL
```
Rejuvenation potions cannot be bought in D2 — they only drop. So the bot asked
for `rejuv=8` on every restock, never got them, and ran Travincal on **4 healing
potions** with half the belt permanently empty. 50 `Failed to drink rejuv` events
in 200 games. The death: drank at 47%, chickened at 33.8%, died in the gap with
nothing left to drink. Many of the 37 chickens were likely "belt empty", not burst
damage.
Now `belt_hp_columns=2 / mp=1 / rejuv=1` (8 healing, 4 mana, 4 slots for dropped
rejuvs — `convert_rejuv=1` cubes them to Full). **Verify after restart that the
HP columns actually fill.**
**Repair NPC — settled by measurement (2026-08-30):**
| NPC | result |
|---|---|
| `a5_larzuk` | 6 fail / ~250 — **2.4%** ← use this |
| `a4_halbu` | 6 fail / ~50 — **12%** |
| `a1_charsi` | 0 for 3 — pathing never reaches her |
Bug 12's original reasoning holds: Halbu detection is unreliable even after Bug
30's threshold fix. Charsi failed for a different reason — `Traverse from
a1_wp_north to a1_charsi` fails, so `open_npc_menu` never runs and her "she does
not move" advantage is never tested. A1's wp->charsi route is broken like A3.
**A `repair_npc` code fix went in:** the cross-act destination used to be
hardcoded to A5, so the setting was silently ignored on every route not already
standing in A5. It is now honoured (`town_manager.repair()`).
**Damage profile at hell Travincal** (confirmed from screenshots): council cast
**Hydra (fire), Lightning/Charged Bolt, and Frost Nova (cold)**. Lightning is both
the most common and the weakest resist (~48% with Mara) — **Thundergod's Vigor**
(+10 max lightning res, 20% absorb, +20 vit) is the targeted upgrade, ahead of
Verdungo's. Fire is well covered by Dwarf Star's absorb.
**Do NOT raise `atk_len_trav` to reduce the "running around".** Hydras are
stationary fire turrets, so `kill_council`'s repositioning is actively dodging
sustained damage. 3.0 is correct.
**Merc blocks gold pickups.** `Mizan says: I can't use that` = a pickit click
landing on the merc. ~4 gold piles lost per 18 games. The bot retries twice then
moves on; not worth fixing.
**Lightning charm rules added** to `config/bnip/Den gode.bnip` (gitignored,
backup at `.bak`), 558 -> 561 expressions. The existing resist rules sum all four
resists (`>= 12` for smalls), and a max single-res lightning small charm is 11 —
so pure lightning charms were picked up and then **vendored**. Two older rules
existed but each required a second stat (`[Maxhp] >= 10`, `[Fhr] >= 3`).
**Current bind map (verified live 2026-08-29) — do NOT rebind F7:**
| key | skill | | key | skill |
|---|---|---|---|---|
| F1 | Battle Command (CTA) | | F5 | Teleport |
| F2 | Holy Shield | | F6 | **Concentration** |
| F3 | Redemption | | F7 | **Battle Orders (CTA)** |
| F4 | Town Portal (not a skill) | | F8 | free (Vigor if wanted) |
Blessed Hammer is on **left-click**, not an F-key. An earlier version of this file
said to bind Concentration to F7 — that would overwrite Battle Orders and cost a
large part of the life pool.
**Two things only you can do:**
1. **Resistances** — lightning 23% / poison 12% are the survivability ceiling.
Herald of Zakarum + Mara's took health chickens from 3-in-8 to **0-in-11**, so
this is largely addressed; the remaining holes are gear, not config.
2. **75% FCR** — currently 60% (HotO 40 + Trang's 20). The 48→75 breakpoint means
everything between is wasted; Arachnid Mesh in place of Goldwrap closes it.
**If FCR changes, update `casting_frames`**: 75% = 11 frames, 60% = 12, and
below 48% = 13. The bot derives its cast wait from that number, so a wrong
value cuts every buff short.
**Known-stale:** the `CONVICTION` preflight template scores 45.9% while the bind
is provably correct, so every startup logs a false alarm. Cosmetic.
---
## Checking your binds after any gear change
```bash
python tools/testbed.py spellbook --assets
```
Hovers the whole bind grid, reads each skill from its tooltip and each hotkey
from the icon corner, and prints which config keys disagree with the game. Exits
1 on a mismatch. This exists because an Enigma put Teleport on F5 and displaced
Conviction, and `conviction=f5` would have teleported the character mid-fight.
---
## The one habit worth keeping
Most of what went wrong here was **configured behaviour that never executed**,
and nothing reported it. AFK breaks were 0-for-225 at a configured 5%. The
manifest said `wired` throughout.
When something should be happening and you are not sure it is, **count it**:
```bash
grep -c "<the thing>" log/log.txt
```
A zero where you expected a number is the most informative result in this
project. It found the AFK break, the loot-filter clicks, the Chronicle panel,
and the pather abort — twice.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+70 -777
View File
@@ -1,18 +1,80 @@
{
"generated_at": "2026-06-17T07:19:33.572415+00:00",
"mode": "offline",
"generated_at": "2026-08-31T06:30:30.597153+00:00",
"mode": "offline-improved",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-12",
"days": 60,
"topics_scanned": 853,
"ladder_start_date": "2026-05-20",
"days": 21,
"topics_scanned": 20,
"skipped": {
"blocked": 2,
"no_topic": 0,
"no_date": 848,
"no_item": 4
},
"filters": {
"min_price_fg": 5.0,
"trim_fraction": 0.2
},
"estimates": {
"day_1": {},
"day_2": {},
"day_3": {},
"day_4": {},
"day_5": {},
"day_6": {},
"day_7": {},
"day_6": {
"Cham Rune": {
"median_fg": 7.5,
"avg_fg": 10.0,
"trimmed_mean_fg": 7.5,
"trimmed_median_fg": 7.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 4,
"raw_samples": 6
}
},
"day_7": {
"Aldur's Advance": {
"median_fg": 20.0,
"avg_fg": 21.7,
"trimmed_mean_fg": 20.0,
"trimmed_median_fg": 20.0,
"min_fg": 20.0,
"max_fg": 25.0,
"samples": 3,
"raw_samples": 6
},
"Ist Rune": {
"median_fg": 70.0,
"avg_fg": 94.0,
"trimmed_mean_fg": 66.7,
"trimmed_median_fg": 70.0,
"min_fg": 20.0,
"max_fg": 250.0,
"samples": 5,
"raw_samples": 16
},
"Gul Rune": {
"median_fg": 65.0,
"avg_fg": 65.0,
"trimmed_mean_fg": 65.0,
"trimmed_median_fg": 65.0,
"min_fg": 20.0,
"max_fg": 110.0,
"samples": 2,
"raw_samples": 4
},
"Unid Anni": {
"median_fg": 575.0,
"avg_fg": 575.0,
"trimmed_mean_fg": 575.0,
"trimmed_median_fg": 575.0,
"min_fg": 550.0,
"max_fg": 600.0,
"samples": 2,
"raw_samples": 2
}
},
"day_8": {},
"day_9": {},
"day_10": {},
@@ -26,775 +88,6 @@
"day_18": {},
"day_19": {},
"day_20": {},
"day_21": {},
"day_22": {},
"day_23": {},
"day_24": {},
"day_25": {},
"day_26": {},
"day_27": {},
"day_28": {},
"day_29": {},
"day_30": {},
"day_31": {
"CTA": {
"median_fg": 100.0,
"avg_fg": 358.0,
"min_fg": 1.0,
"max_fg": 1199.0,
"samples": 5
},
"Cham Rune": {
"median_fg": 30.0,
"avg_fg": 121.0,
"min_fg": 5.0,
"max_fg": 450.0,
"samples": 5
},
"Lo Rune": {
"median_fg": 25.0,
"avg_fg": 38.8,
"min_fg": 5.0,
"max_fg": 100.0,
"samples": 4
},
"Um Rune": {
"median_fg": 75.0,
"avg_fg": 99.6,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 10
},
"Lem Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Sorc Torch": {
"median_fg": 100.0,
"avg_fg": 100.0,
"min_fg": 100.0,
"max_fg": 100.0,
"samples": 1
},
"Aldur's Advance": {
"median_fg": 27.5,
"avg_fg": 43.8,
"min_fg": 20.0,
"max_fg": 100.0,
"samples": 4
},
"Stealth RW": {
"median_fg": 55.0,
"avg_fg": 55.0,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 2
},
"Ist Rune": {
"median_fg": 95.0,
"avg_fg": 112.2,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 12
},
"Mal Rune": {
"median_fg": 100.0,
"avg_fg": 112.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 13
},
"Pul Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Unid Anni": {
"median_fg": 550.0,
"avg_fg": 416.7,
"min_fg": 100.0,
"max_fg": 600.0,
"samples": 3
},
"Dual Leech Ring": {
"median_fg": 85.0,
"avg_fg": 85.0,
"min_fg": 70.0,
"max_fg": 100.0,
"samples": 2
},
"Gul Rune": {
"median_fg": 25.0,
"avg_fg": 63.1,
"min_fg": 5.0,
"max_fg": 200.0,
"samples": 8
},
"Key": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Insight": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Ohm Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vampire Gaze": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"White": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Shako": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Black": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vex Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Skin": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Spirit": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Bone": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Jah Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Moser": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ber Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Arach": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"5/5 Facet": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"Mara": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
}
},
"day_32": {},
"day_33": {},
"day_34": {},
"day_35": {},
"day_36": {},
"day_37": {
"Thresher": {
"median_fg": 32.5,
"avg_fg": 133.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 210
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 71.5,
"min_fg": 0.0,
"max_fg": 1300.0,
"samples": 318
},
"Spirit": {
"median_fg": 25.0,
"avg_fg": 78.3,
"min_fg": 0.0,
"max_fg": 1300.0,
"samples": 237
},
"Wind": {
"median_fg": 30.0,
"avg_fg": 175.0,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 60
},
"Arach": {
"median_fg": 40.0,
"avg_fg": 184.2,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 210
},
"Sorc Torch": {
"median_fg": 50.0,
"avg_fg": 166.2,
"min_fg": 10.0,
"max_fg": 2500.0,
"samples": 64
},
"Griffon": {
"median_fg": 60.0,
"avg_fg": 254.7,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 111
},
"Guardian's Light": {
"median_fg": 50.0,
"avg_fg": 342.6,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 236
},
"Skin": {
"median_fg": 30.0,
"avg_fg": 206.8,
"min_fg": 0.0,
"max_fg": 3000.0,
"samples": 242
},
"Viper Gorge": {
"median_fg": 30.0,
"avg_fg": 75.9,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 231
},
"Lo Rune": {
"median_fg": 30.0,
"avg_fg": 113.6,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 105
},
"Enigma": {
"median_fg": 20.0,
"avg_fg": 182.4,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 49
},
"Bone": {
"median_fg": 40.0,
"avg_fg": 244.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 264
},
"Um Rune": {
"median_fg": 45.0,
"avg_fg": 187.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 191
},
"Lem Rune": {
"median_fg": 20.0,
"avg_fg": 48.7,
"min_fg": 0.0,
"max_fg": 750.0,
"samples": 126
},
"Crescent": {
"median_fg": 10.0,
"avg_fg": 63.0,
"min_fg": 5.0,
"max_fg": 600.0,
"samples": 70
},
"Ist Rune": {
"median_fg": 45.0,
"avg_fg": 74.9,
"min_fg": 3.0,
"max_fg": 750.0,
"samples": 173
},
"Highlord": {
"median_fg": 25.0,
"avg_fg": 113.0,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 70
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 70.0,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 161
},
"Mal Rune": {
"median_fg": 30.0,
"avg_fg": 67.0,
"min_fg": 3.0,
"max_fg": 750.0,
"samples": 166
},
"Insight": {
"median_fg": 40.0,
"avg_fg": 189.9,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 155
},
"Pul Rune": {
"median_fg": 30.0,
"avg_fg": 69.6,
"min_fg": 5.0,
"max_fg": 750.0,
"samples": 113
},
"CTA": {
"median_fg": 50.0,
"avg_fg": 251.5,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 171
},
"Blessed": {
"median_fg": 30.0,
"avg_fg": 180.3,
"min_fg": 7.0,
"max_fg": 1200.0,
"samples": 36
},
"Ohm Rune": {
"median_fg": 25.0,
"avg_fg": 39.8,
"min_fg": 3.0,
"max_fg": 400.0,
"samples": 119
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 47.0,
"min_fg": 1.0,
"max_fg": 450.0,
"samples": 58
},
"Vampire Gaze": {
"median_fg": 10.0,
"avg_fg": 110.6,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 80
},
"Giant": {
"median_fg": 20.0,
"avg_fg": 96.1,
"min_fg": 0.0,
"max_fg": 600.0,
"samples": 70
},
"Ber Rune": {
"median_fg": 60.0,
"avg_fg": 253.1,
"min_fg": 5.0,
"max_fg": 1200.0,
"samples": 58
},
"Key": {
"median_fg": 20.0,
"avg_fg": 83.1,
"min_fg": 1.0,
"max_fg": 1200.0,
"samples": 114
},
"Shako": {
"median_fg": 30.0,
"avg_fg": 113.6,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 208
},
"Pcomb SK": {
"median_fg": 160.0,
"avg_fg": 685.0,
"min_fg": 5.0,
"max_fg": 3000.0,
"samples": 48
},
"Sacred": {
"median_fg": 35.0,
"avg_fg": 166.1,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 244
},
"Light Jewel": {
"median_fg": 80.0,
"avg_fg": 234.0,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 39
},
"Fist": {
"median_fg": 20.0,
"avg_fg": 51.7,
"min_fg": 15.0,
"max_fg": 120.0,
"samples": 9
},
"Vex Rune": {
"median_fg": 30.0,
"avg_fg": 97.2,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 132
},
"Unid Anni": {
"median_fg": 35.0,
"avg_fg": 45.0,
"min_fg": 3.0,
"max_fg": 150.0,
"samples": 96
},
"Light SK": {
"median_fg": 40.0,
"avg_fg": 383.2,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 41
},
"Mara": {
"median_fg": 50.0,
"avg_fg": 146.1,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 132
},
"Cold SK": {
"median_fg": 50.0,
"avg_fg": 162.9,
"min_fg": 7.0,
"max_fg": 1200.0,
"samples": 45
},
"Black": {
"median_fg": 30.0,
"avg_fg": 106.7,
"min_fg": 0.0,
"max_fg": 2400.0,
"samples": 199
},
"Jah Rune": {
"median_fg": 60.0,
"avg_fg": 267.5,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 87
},
"Troll": {
"median_fg": 30.0,
"avg_fg": 32.1,
"min_fg": 1.0,
"max_fg": 75.0,
"samples": 66
},
"Ward": {
"median_fg": 10.0,
"avg_fg": 21.8,
"min_fg": 1.0,
"max_fg": 50.0,
"samples": 45
},
"White": {
"median_fg": 20.0,
"avg_fg": 37.6,
"min_fg": 3.0,
"max_fg": 300.0,
"samples": 126
},
"Stealth RW": {
"median_fg": 32.5,
"avg_fg": 31.7,
"min_fg": 5.0,
"max_fg": 50.0,
"samples": 24
},
"Doom": {
"median_fg": 50.0,
"avg_fg": 285.3,
"min_fg": 45.0,
"max_fg": 1500.0,
"samples": 30
},
"Gul Rune": {
"median_fg": 30.0,
"avg_fg": 50.4,
"min_fg": 0.0,
"max_fg": 280.0,
"samples": 136
},
"Death's Fathom": {
"median_fg": 110.0,
"avg_fg": 96.7,
"min_fg": 60.0,
"max_fg": 120.0,
"samples": 12
},
"Unid Torch": {
"median_fg": 60.0,
"avg_fg": 49.5,
"min_fg": 5.0,
"max_fg": 120.0,
"samples": 39
},
"Sanctuary": {
"median_fg": 85.0,
"avg_fg": 82.5,
"min_fg": 5.0,
"max_fg": 170.0,
"samples": 24
},
"Grief": {
"median_fg": 50.0,
"avg_fg": 170.2,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 56
},
"Night": {
"median_fg": 250.0,
"avg_fg": 209.0,
"min_fg": 20.0,
"max_fg": 700.0,
"samples": 15
},
"5/5 Facet": {
"median_fg": 50.0,
"avg_fg": 313.3,
"min_fg": 10.0,
"max_fg": 1300.0,
"samples": 15
},
"Treachery": {
"median_fg": 20.0,
"avg_fg": 157.0,
"min_fg": 5.0,
"max_fg": 1300.0,
"samples": 30
},
"Pala Torch": {
"median_fg": 50.0,
"avg_fg": 215.6,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 43
},
"Bulwark": {
"median_fg": 50.0,
"avg_fg": 440.0,
"min_fg": 50.0,
"max_fg": 1300.0,
"samples": 10
},
"Fortitude": {
"median_fg": 70.0,
"avg_fg": 915.0,
"min_fg": 20.0,
"max_fg": 3500.0,
"samples": 16
},
"Cham Rune": {
"median_fg": 20.0,
"avg_fg": 22.5,
"min_fg": 5.0,
"max_fg": 60.0,
"samples": 53
},
"War Traveler": {
"median_fg": 20.0,
"avg_fg": 33.0,
"min_fg": 5.0,
"max_fg": 80.0,
"samples": 33
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 29.3,
"min_fg": 5.0,
"max_fg": 90.0,
"samples": 63
},
"Moser": {
"median_fg": 12.5,
"avg_fg": 12.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 16
},
"Hellfire": {
"median_fg": 60.0,
"avg_fg": 53.3,
"min_fg": 20.0,
"max_fg": 80.0,
"samples": 12
},
"Conviction": {
"median_fg": 20.0,
"avg_fg": 13.7,
"min_fg": 1.0,
"max_fg": 20.0,
"samples": 6
},
"Terror Key": {
"median_fg": 5.0,
"avg_fg": 6.7,
"min_fg": 5.0,
"max_fg": 10.0,
"samples": 12
},
"Hate Key": {
"median_fg": 5.0,
"avg_fg": 6.7,
"min_fg": 5.0,
"max_fg": 10.0,
"samples": 12
},
"Dual Leech Ring": {
"median_fg": 50.0,
"avg_fg": 198.8,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 16
},
"Cyclone": {
"median_fg": 300.0,
"avg_fg": 306.7,
"min_fg": 20.0,
"max_fg": 600.0,
"samples": 12
},
"Fury": {
"median_fg": 30.0,
"avg_fg": 76.9,
"min_fg": 5.0,
"max_fg": 250.0,
"samples": 32
}
},
"day_38": {},
"day_39": {},
"day_40": {},
"day_41": {},
"day_42": {},
"day_43": {},
"day_44": {},
"day_45": {},
"day_46": {},
"day_47": {},
"day_48": {},
"day_49": {},
"day_50": {},
"day_51": {},
"day_52": {},
"day_53": {},
"day_54": {},
"day_55": {},
"day_56": {},
"day_57": {},
"day_58": {},
"day_59": {},
"day_60": {}
"day_21": {}
}
}
+7
View File
@@ -130,6 +130,13 @@ corpse=459,195,414,213
chat_icon=7,555,43,41
left_panel_header=0,0,455,56
right_panel_header=830,0,455,56
; A CENTRED panel (Chronicle, and anything else that opens mid-screen) puts its
; close button outside both header ROIs above — the Chronicle's X sits at
; (952, 56), inside right_panel_header's x-range but exactly at its 56px height
; boundary, so the match centre falls out. Nothing then closes it, and a
; centred panel blanks every later template search: measured as a 66s
; click_red_portal failure on 2026-08-28 (Bug 31's residual).
center_panel_header=400,0,620,92
npc_dialogue=456,0,30,150
bind_skill=516,619,251,29
quest_skill_btn=284,455,710,121
+209 -40
View File
@@ -20,7 +20,7 @@
; If you chicken/die repeatedly, drop to Nightmare first.
difficulty=hell
; name: bot profile name used in logs/messages and mod launch option replacement
name=fistman
name=profile1
; randomize_runs: 0 = run in listed order, 1 = shuffle run order
randomize_runs=0
; target_tz: target Terror Zone id (leave as default unless you know the mapping)
@@ -55,6 +55,10 @@ discord_log_errors=1
discord_status_runs=10
; discord_status_count: legacy fallback, send periodic status every X games (blank/0 disables)
discord_status_count=20
; discord_timing_report_h: post a timing + failure digest to Discord every N hours
; (0 disables). Aggregated from the same TL> timeline the log uses, so the report and the
; log can never disagree. The window resets on every send.
discord_timing_report_h=2
; pickup_drought_window: warn/alert after this many consecutive games with zero
; item pickups. Raise this if you run a strict pickit and 0-pickup streaks are
; expected/normal for you (a fast boss-only rush route with a tight filter can
@@ -65,9 +69,9 @@ message_api_type=discord
; breaks
; break_length_m: scheduled break duration in minutes (0 = disabled)
break_length_m=0
break_length_m=15
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
max_runtime_before_break_m=0
max_runtime_before_break_m=120
; timers / fail handling
; d2r_path: Diablo II: Resurrected install path
@@ -83,7 +87,7 @@ max_game_length_s=900
; max_maintenance_time_s: if the town maintenance loop (heal/buy/stash/repair) takes
; longer than this many seconds, save-and-exit and rejoin a fresh game.
; Prevents the bot staying stuck in A5 town forever when NPCs or pathing fail.
max_maintenance_time_s=240
max_maintenance_time_s=420
; auto_downgrade_threshold: if combined chickens+deaths exceed this number within 1 hour,
; bot automatically lowers difficulty by one tier (hell->nightmare->normal) and restarts.
; Set to 0 to disable. (NOTE: feature is parsed but not yet active in bot logic)
@@ -135,12 +139,22 @@ error=1
[stealth]
; Multiplies all wait() calls by a random value in this range each call
; 1.0 = no change. Set range wider for more human-like timing variation.
wait_jitter_min = 0.85
; wait_jitter_* multiplies EVERY wait() in the codebase, not just mouse moves.
; The floor is clamped to jitter_min*0.8, so 0.85 allowed waits to come out 32%
; SHORT — the one place jitter stole time from an action instead of adding it
; between actions. 0.95 keeps waits at-or-longer (effective floor 0.76 vs the
; old 0.68, and the Gaussian centre sits above 1.0), so a wait can no longer
; expire before the UI it was waiting on has settled.
wait_jitter_min = 0.95
wait_jitter_max = 1.20
; Extra pixel variance added to every mouse click (on top of existing randomize=5)
; 0 = off, 10 = +/-10px extra random offset per click
click_variance = 8
; NOTE: click position randomization lives at the CALL SITES (npc_manager,
; ui/waypoint) where it is tuned to real button geometry — 2-3px for NPC
; hover targets, +/-9px inside a 47px waypoint button. There is deliberately
; no global click-offset knob: stacking one on top of those is what starts
; missing NPCs (see CLAUDE.md Bugs 3/4/6/7).
; Re-shuffle run order after completing a full rotation (vs only at session start)
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
@@ -156,7 +170,14 @@ skip_run_chance = 10
afk_break_chance = 5
; Break duration range in minutes
afk_break_min_m = 2
afk_break_max_m = 12
; 12 -> 7 (2026-08-28). The configured value is NOT the real ceiling:
; maybe_afk_break does wait(minutes*60, minutes*60*1.5) and wait() then applies
; its own jitter (up to 1.44x), so a "12 minute" break can idle for ~26 minutes.
; Measured: an 11.9m break ran 1167.7s = 19.5m, and a 20:56 scheduled break ran
; 25.5m. A ~25m idle is what left D2R at character select once, unable to
; re-enter, with the bot spawning a new process every ~20s.
; 7 caps the real-world worst case near 15m, inside the range proven to resume.
afk_break_max_m = 7
; Vary run/battle duration by a Gaussian factor (default +/-15%)
; 0.0 = no variation, 0.3 = up to +/-30% variation
@@ -167,6 +188,8 @@ micro_pause_min_ms = 20
micro_pause_max_ms = 120
; Vary kill time to avoid perfectly consistent boss fight durations
; Varies boss attack duration. Only ever LENGTHENS the fight (1.0-1.4x) —
; shortening it risks leaving a boss alive, which is a failed run, not stealth.
vary_kill_time = 1
; Human mouse curve complexity (1.0 = default, 0.5 = more direct, 1.5 = more winding)
@@ -174,8 +197,13 @@ human_curve_complexity = 1.0
; Arrival-to-click delay: human-like pause between mouse arriving and clicking (milliseconds)
; 50-800ms range simulates "is this the right thing?" hesitation
; Hesitation between arriving at a target and clicking it. OFF by default:
; it applies to EVERY click, so the old 800ms ceiling could add minutes per
; run. Enable with click_delay_enabled=1 if you want it; 250 is a realistic
; ceiling that stays affordable.
click_delay_enabled = 0
click_delay_min_ms = 50
click_delay_max_ms = 800
click_delay_max_ms = 250
; Key press duration variance: how long a key is held (milliseconds)
; Most presses are short (20-100ms), some linger (up to 200ms)
@@ -189,11 +217,57 @@ skill_hesitation_max_ms = 300
; Wrong waypoint chance: 2-3% of selecting wrong TP portal then correcting
; Humans occasionally misclick waypoint targets
; ── session rhythm ───────────────────────────────────────────────────────────
; The strongest remaining signal is not per-click timing: averaged over a
; session jitter converges, but a player who starts at the same hour, plays the
; same length and never does anything unproductive does not.
;
; session_budget_h: stop the bot after roughly this many hours (0 = unlimited).
; Actual length is rolled per run at 0.65-1.35x, so consecutive days differ.
; Raised to 10 for an overnight run: the budget is rolled at 0.65-1.35x, so 6
; could have stopped the bot after 3.9h and left it idle. 10 guarantees at
; least ~6.5h while keeping the day-to-day variation.
; Set to 8 for a >= 5h run (2026-08-28). The value is rolled at 0.65-1.35x, so
; session_budget_h=5 would average 5h but could stop after 3.25h. 8 gives a
; 5.2-10.8h window, guaranteeing the 5 hours while keeping day-to-day variation.
session_budget_h = 8
; daily_budget_h: HARD cap on total runtime per CALENDAR DAY (0 = unlimited).
; session_budget_h above is per PROCESS — a restart re-rolls it, so a bot that
; gets restarted (by you, or by the restart-on-crash path) can run all day and
; never trip it. This cap is persisted to log/.daily_runtime.json keyed on the
; date, so restarts cannot extend the day. The target is rolled ONCE per day
; (+/- daily_budget_jitter) and then FIXED, so restarting cannot re-roll a
; larger allowance. This is a CEILING, not an average: the jitter only ever
; subtracts, so daily_budget_h = 8 with 0.12 jitter runs 7.0-8.0h and NEVER
; more than 8. session_budget_h above can still stop a single process sooner.
daily_budget_h = 8
daily_budget_jitter = 0.12
; daily_budget_close_game: 1 = also close D2R when the daily cap trips (default).
; The cap fires in on_end_game, AFTER save-and-exit, so nothing is in progress and
; nothing is lost. A bot parked at character select for 16h is itself a pattern;
; a real player quits the game. Set 0 to leave D2R running.
; Small cursor movement during long idles. Between actions the cursor otherwise
; sits exactly where the last click left it.
idle_drift_enabled = 1
; Per-game chicken threshold spread. Only ever RAISES the threshold (safer) —
; a fixed 0.40 every game is a precise tell, but lowering it would cost deaths.
chicken_variance = 0.08
; Chance of an unproductive town action (open inventory, close it).
town_browse_chance = 0.06
; Chance of walking past an item the filter wanted. Costs real loot — keep low.
pickup_skip_chance = 0.02
wrong_waypoint_chance = 0.025
; Skill mistake chance: 1-2% chance of miscasting and correcting
; Simulates human error during combat
skill_mistake_chance = 0.015
; Presses a DIFFERENT bound skill before the intended one. Disabled: on a
; character mid-fight this swaps the active skill or aura, and the attack
; sequences rely on ending in a known skill state. The stealth value is small
; next to the risk of a stray keypress landing on something that is not a skill
; at all — which is exactly how the loot filter ended up being toggled all
; session (params.ini [paladin] vigor=f4, unbound on this char, F4 = filter).
; Set back to 0.015 to re-enable.
skill_mistake_chance = 0
[routes]
; Controls which farm runs Botty performs each game.
@@ -216,7 +290,8 @@ skill_mistake_chance = 0.015
; run_countess (Act 1 Forgotten Tower)
; run_mephisto (Act 3 Durance of Hate)
; run_baal (Act 5 Throne of Destruction)
order=run_pindle, run_diablo
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
order=run_baal_xp, run_trav
[char]
; ==========================
@@ -229,9 +304,9 @@ type=hammerdin
; belt_rows: in-game belt size (2/3/4)
belt_rows=4
; casting_frames: your char cast breakpoint (affects action timing)
casting_frames=8
casting_frames=11
; cta_casting_frames: cast breakpoint on CTA swap (if used)
cta_casting_frames=8
cta_casting_frames=11
; attack_frames: base attack animation timing for non-cast attacks
attack_frames=15
; cta_available: 1 if Call to Arms swap exists, else 0
@@ -244,7 +319,19 @@ safer_routines=1
; num_loot_columns: Number of empty columns from left to right of inventory to be used for looting.
; Store charms, etc. to the right of the inventory.
num_loot_columns=4
;
; This drives ui_roi[restricted_inventory_area] in config.py: the columns to the RIGHT of
; this count are protected — mouse._is_clicking_safe() cancels any click landing there, so
; the bot never sells, drops or moves anything in them. Both tomes MUST live in that
; reserved area: common.tome_state() only searches restricted_inventory_area for them.
; 3 columns = 12 loot slots, 28 reserved
; 4 columns = 16 loot slots, 24 reserved <- now
; 6 columns = 24 loot slots, 16 reserved (tried 2026-08-27, reverted — shrinking the
; reserve is the wrong direction when the
; books and the charms both live there)
; Lower this if the tomes or charms are running out of room; raise it only if the reserved
; area is provably empty.
num_loot_columns=5
; game hotkeys:
; NOTE: each key must match your in-game binding exactly
@@ -260,13 +347,13 @@ show_items=alt
; stand_still cannot be the default "shift" as it would interfere with merc healing
stand_still=capslock
; teleport: leave empty if you can't use
teleport=b
town_portal=6
teleport=f4
town_portal=f8
; call to arms settings:
; weapon_switch/battle_orders/battle_command only used when cta_available=1
weapon_switch=w
battle_orders=7
battle_command=8
battle_orders=f6
battle_command=f5
; ==========================
; ==== Optional configs ====
@@ -311,7 +398,7 @@ atk_len_nihlathak=4.0
atk_len_pindle=8.0
atk_len_shenk=4.0
atk_len_diablo=3.0
atk_len_trav=4.0
atk_len_trav=5.0
; Boss run attack lengths (per-character defaults in kill_* methods)
; Adjust these if your build is faster/slower against these bosses
@@ -332,9 +419,9 @@ cs_town_visits=0
kill_cs_trash=1
; Belt settings
belt_hp_columns=1
belt_hp_columns=2
belt_mp_columns=1
belt_rejuv_columns=2
belt_rejuv_columns=1
; Potion/chicken settings
take_health_potion=0.60
@@ -354,27 +441,55 @@ fill_shared_stash_first=0
; to gamble, add any/all of the following: circlet, ring, coronet, talon, amulet
gamble_items=
; open_chests: 1 = open clickable chests along path when possible
open_chests=1
open_chests=0
; pre_buff_every_run: 1 = always recast buffs at run start
pre_buff_every_run=1
; runs_per_repair: visit repair vendor every X runs (blank/0 disables)
runs_per_repair=5
runs_per_repair=50
; repair_npc: preferred repair vendor strategy.
; - a5_larzuk (recommended: stays in A5, no cross-act trip; falls back to Halbu)
; - a4_halbu (requires WP trip to A4 every repair — act desync risk if it fails)
; 2026-06-10: switched to a5_larzuk — session logs showed Halbu detection failing
; 100% (body score ~0.39) and each failed A4 trip desynced the bot's act state.
repair_npc=a5_larzuk
; resurrect_npc: which NPC revives the mercenary. Blank = whichever act the character is
; already in. Set to a4_tyrael to always use Act 4 Tyrael.
; Measured 2026-08-27 on this setup:
; 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. A failed hunt costs more than the whole A5->A4 trip, and one 163s hunt pushed
; the town visit past max_maintenance_time_s and killed the game.
resurrect_npc=a4_tyrael
; runs_per_stash: stash/sell every X runs (blank/0 disables)
runs_per_stash=4
runs_per_stash=1
; sell_junk: 1 = vendor non-keep items automatically
sell_junk=1
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name (recommended safety)
protect_shields_from_sell=1
sell_junk=0
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name.
; DISABLED 2026-08-27. This is redundant: 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 of leaving it on: 130 blocked sells in one day (FIEND SHIELD 64, AERIN SHIELD 41,
; HERALDIC SHIELD 23...). The same shields were re-evaluated and re-blocked every single
; game — "Discarding FIEND SHIELD." then "Blocked sell for protected item: FIEND SHIELD" —
; so they could never leave the pack and permanently occupied slots.
protect_shields_from_sell=0
; protect_charms_from_sell: 1 = never drop/vendor charms regardless of pickit verdict
; (recommended safety — a misread charm can't be un-dropped). Set to 0 to let your
; pickit rules decide keep/discard for charms same as any other item.
protect_charms_from_sell=1
; (safety net — a misread charm can't be un-sold). Set to 0 to let your pickit rules
; decide keep/discard for charms same as any other item.
;
; KEEP THIS AT 1. Charms only give their bonus while they sit in the INVENTORY, and the
; resistances matter in nightmare. Briefly set to 0 on 2026-08-27 to reclaim slots and
; that was wrong — it vendored LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF
; FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS SMALL CHARM OF VITA (+20 life,
; cold resist +7%) before it was reverted.
;
; The "673 blocked sells" in the log are NOT a bug to fix by selling — they are the guard
; doing its job, repeatedly, on charms the pickit does not have a keep rule for. If those
; slots are needed, the answer is a pickit rule that keeps the good charms and stops
; picking up junk ones, NOT disabling this guard.
protect_charms_from_sell=0
; pick_rares_for_gold: 1 = pick all yellow (rare) ground items; non-keep rares will be sold
; Requires sell_junk=1 to convert extra pickups into gold.
pick_rares_for_gold=0
@@ -386,14 +501,21 @@ pick_gold=1
stash_destination=0,1,2,3
; Add these possible gems to "transmute" to transmute them:
; chipped, flawed, standard, flawless
; DISABLED 2026-08-26: gems are stashed to the GEMS tab and left alone, no cube/convert
; runs. An empty value makes run_transmutes() bail at "No gem tiers configured", which
; also holds for force=True (tools/gem_transmute.py), unlike transmute_every_x_game=0.
; Re-enable by listing tiers again, e.g. transmute=flawless
transmute=flawless
;how often we want to run transmute routine(e.g. every 100 games)
transmute_every_x_game=60
; number of stash tabs (ROTW has more than 6, adjust as needed)
stash_tabs=6
transmute_every_x_game=800
; number of stash tabs — drives tab click geometry in inventory/common.tab_properties().
; MUST match the tab bar on screen. This client shows 5: PERSONAL SHARED GEMS MATERIALS RUNES.
; It was 6, which made tab_properties compute centres of 63/127/192/256/320/384 while the
; real label centres are 68/144/220/295/370 — tabs 2, 3 and 4 clicked the gaps between tabs.
stash_tabs=5
; potion transmute settings
; convert_rejuv: 1 = convert regular Rejuv Potions to Full Rejuv via cube (3 -> 1)
convert_rejuv=1
convert_rejuv=0
; min_rejuv_to_convert: minimum regular rejuv potions in inventory before starting conversion
min_rejuv_to_convert=6
@@ -443,10 +565,10 @@ hydra=f1
; ==== Builds: Paladin ====
; =========================
[paladin]
cleansing=f9
holy_shield=f2
redemption=f3
vigor=f4
cleansing=
holy_shield=f4
redemption=f2
vigor=f3
[fohdin]
; foh must be left skill, hotkey required
@@ -457,9 +579,9 @@ foh=f6
holy_bolt=f7
[hammerdin]
blessed_hammer=f1
concentration=f8
conviction=f5
blessed_hammer=f9
concentration=f1
conviction=
; =========================
; ==== Builds: Warlock ====
@@ -612,6 +734,53 @@ buff_1=
buff_2=
; ==== Run: Cold Plains clear ====
; Roams an outdoor area and kills with ONE configured skill. Works with any build
; and from clvl 1, so it is the levelling counterpart to the boss runs.
; Enable by adding run_cold_plains to [routes] order above.
;
; PREREQUISITE: the character must already have the destination waypoint.
; A fresh character has none - walk there once by hand first.
[cold_plains]
; Any Act 1 waypoint label from src/ui/waypoint.py _WAYPOINTS, e.g.
; "Cold Plains", "Stony Field", "Dark Wood", "Black Marsh".
area=Cold Plains
; Key holding the attack skill. NOTE Fireball is clvl 12 - on a fresh character
; put Fire Bolt (clvl 1) here and re-bind to Fireball later; nothing else changes.
attack_hotkey=
; Most sorc/caster skills cast on right click. Melee builds want "left".
attack_button=right
; How many roam-or-fight steps before heading back to town.
max_steps=12
; Casts per engagement before re-scanning for targets.
casts_per_target=4
; Ignore anything further than this many px from the character.
target_radius=600
; Delay between casts. Raise if you out-run your cast animation.
cast_delay=0.25
; Hard runtime cap per run, seconds - stops a bad area pinning the bot.
max_runtime_s=180
; Engagements per step, so an unkillable/misdetected target cannot stall the run.
max_engagements=8
[baal_xp]
; Baal XP farm: join public Baal games, hide, collect XP, leave. Repeat.
; Enable by adding run_baal_xp to [routes] order above.
enabled=1
; Substring to match against game names (case-insensitive). Empty = join first game.
game_name_filter=
; Max seconds to wait in a game before leaving (XP keeps ticking while you hide).
max_wait_s=900
; Leave early if XP gained in this game reaches this value.
xp_threshold=50000000
; Leave immediately if HP drops below this percentage (chicken).
min_hp_pct=40
; Screen position (client coords) to walk to and stand still while hiding.
hide_x=640
hide_y=360
; Max seconds to wait for a matching game to appear in the browser.
join_timeout_s=60
[advanced_options]
; startup hotkeys
; select_runs_key: open run selector UI
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
# Auto Skill + Attribute Allocation Plan
## Goal
Add an optional system that automatically assigns:
- skill points
- attribute points
based on:
- active character profile (`blizz_sorc`, `fohdin`, `hammerdin`, etc.)
- current character level
without breaking existing manual setups.
## Scope
- Planning and architecture for Botty repo.
- No forced behavior changes: feature must be opt-in.
## Requirements
1. Determine current level reliably at runtime.
2. Select a build template by character profile.
3. Apply points safely only when unspent points exist.
4. Record every allocation in logs/events for audit/replay.
5. Abort safely on uncertainty (wrong UI state, OCR mismatch, missing templates).
## Current Level Detection Strategy
### Primary path
Use `player_bar.get_experience()` (already used in `game_stats.log_exp`) to derive level from XP table.
### Secondary fallback
Open character panel (`C`) and OCR level/name line directly from upper-left panel region.
### Tertiary fallback
If OCR fails repeatedly:
- keep previous known good level for session,
- do **not** allocate points until confidence is restored.
### Confidence rules
- Require two consistent reads before first allocation in a session.
- Reject impossible jumps (e.g., +5 levels at once).
- Persist `last_known_level` in session stats snapshot.
## Build Template Model
Add config-backed build templates, e.g.:
- `config/auto_builds/blizz_sorc.ini`
- `config/auto_builds/hammerdin.ini`
- `config/auto_builds/fohdin.ini`
Each template defines per-level targets:
- desired skill totals by level milestone
- desired attribute distribution (str/dex/vit/ene)
Example concept:
- Level 1-17: early progression targets
- Level 18-29: mid-game unlock path
- Level 30+: core skill maxing order
## Runtime Flow
1. Enter town and open character/skill UI.
2. Detect level and unspent points.
3. Load template for `Config().char["type"]`.
4. Compute delta between current allocation and target-at-level.
5. Apply points stepwise:
- attributes first (optional toggle),
- skills second.
6. Verify post-apply state.
7. Log allocation summary and persist snapshot.
## Safety Guards
- Only run in town.
- Require stash/vendor windows closed.
- Hard cap per cycle (e.g., max 10 clicks per stat/skill group).
- On mismatch/timeout:
- stop allocation immediately,
- screenshot + structured error event,
- continue bot without crashing.
## Config Additions (Planned)
In `[char]` or new `[auto_build]` section:
- `auto_assign_skills=0/1`
- `auto_assign_attributes=0/1`
- `auto_build_profile=` (defaults to `char.type`)
- `auto_build_check_every_x_games=`
- `auto_build_safe_mode=1` (extra verification)
## Logging / Telemetry
Add structured events:
- `auto_build_check_started`
- `auto_build_level_detected`
- `auto_build_points_detected`
- `auto_build_applied`
- `auto_build_skipped`
- `auto_build_error`
Include:
- profile
- level
- points spent
- before/after snapshots
## UI / Input Dependencies
Need stable template references for:
- character panel level region
- unspent attribute points indicator
- unspent skill points indicator
- individual plus-buttons for stats/skills
## Test Plan
1. Unit tests:
- level-to-target mapping
- delta computation
- guard conditions
2. Integration dry-run mode:
- compute and log planned actions without clicking.
3. Live smoke tests per profile:
- `blizz_sorc`, `hammerdin`, `fohdin`
4. Regression:
- ensure normal runs unaffected with feature disabled.
## Inputs Needed From You
1. Screenshots for each supported class at:
- character panel open,
- skill tree open,
- visible unspent points.
2. Preferred leveling templates:
- exact skill priority order by level range.
- attribute rules (e.g., str to gear breakpoint, then vit).
3. Whether respec-aware logic is needed in v1.
## Rollout Phases
1. Phase 1: Level detection + dry-run planner only.
2. Phase 2: Attribute auto-assign (safer, fewer UI branches).
3. Phase 3: Skill auto-assign with full verification.
4. Phase 4: Expanded profile templates + docs.
## Definition of Done
- Feature is opt-in and stable for `blizz_sorc`, `hammerdin`, `fohdin`.
- Level detection is reliable with fallback behavior.
- No crash on detection/allocation failure.
- Full logs available for every auto-allocation decision.
+539
View File
@@ -0,0 +1,539 @@
# Codex Fix Analysis
Source files read:
- `docs/fix_plan.md`
- `src/run/nihlathak.py`
- `src/town/town_manager.py`
- `src/inventory/vendor.py`
- `src/char/i_char.py` (`src/char.py` does not exist in this repo; CTA is implemented here)
- `config/game.ini`
- Supporting files needed to trace the failures: `src/town/a1.py`, `src/town/a5.py`, `src/town/a4.py`, `src/npc_manager.py`, `src/pather.py`, and the CTA key section in `config/params.ini`
## Priority 1: Nihlathak approach fails
### Finding
The Nihlathak route has three brittle points:
1. `approach()` returns success immediately after clicking the waypoint and never verifies that the Halls of Pain actually loaded.
2. Level 1 layout detection has no fallback if `NI1_A`, `NI1_B`, or `NI1_C` is stale.
3. `traverse_nodes_fixed()` always returns `True`, so a bad static path in `config/game.ini` cannot be detected until the stairs click times out.
The `config/game.ini` Nihlathak path keys are present:
```ini
ni1_a=871,472, 1205,600, 1162,600, 1169,584, 1169,584, 1232,213, 1221,237, 1164,228, 1145,572, 1146,547, 1223,185
ni1_b=23,187, 23,187, 23,187, 23,187, 12,192, 10,192, 10,190, 123,70, 378,120
ni1_c=118,500, 158,602, 187,577, 217,563, 184,551, 70,413, 127,240, 154,493, 197,504, 218,545, 83,246, 45,526, 300,380
```
So the immediate code fix is not "add missing keys"; it is to verify waypoint/area entry and reduce the failure blast radius when stale templates or coordinates are encountered.
### Exact code that needs to change
`src/run/nihlathak.py`:
```python
wait(0.4)
if waypoint.use_wp("Halls of Pain"): # use Halls of Pain Waypoint (5th in A5)
return Location.A5_NIHLATHAK_START
return False
```
```python
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
if not template_match.valid:
return False
```
```python
self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char)
```
### Proposed fix
Replace the waypoint block with a verified load:
```python
wait(0.4)
if not waypoint.use_wp("Halls of Pain"):
return False
if not template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.55, timeout=8).valid:
Logger.error("Nihlathak approach: waypoint click did not land in Halls of Pain")
return False
return Location.A5_NIHLATHAK_START
```
Replace layout detection and static path traversal with a lower-threshold retry and an explicit path result check:
```python
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
if not template_match.valid:
Logger.warning("Nihlathak: strict NI1 layout detection failed, retrying with grayscale/lower threshold")
template_match = template_finder.search_and_wait(
["NI1_A", "NI1_B", "NI1_C"],
threshold=0.55,
best_match=True,
timeout=6,
use_grayscale=True,
)
if not template_match.valid:
return False
```
```python
if not self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char):
Logger.error(f"Nihlathak: failed static route {template_match.name.lower()}")
return False
```
If `search_and_wait()` does not support `use_grayscale` in this repo version, use this compatible form instead:
```python
if not template_match.valid:
start = time.time()
while time.time() - start < 6:
template_match = template_finder.search(
["NI1_A", "NI1_B", "NI1_C"],
grab(),
threshold=0.55,
best_match=True,
use_grayscale=True,
)
if template_match.valid:
break
wait(0.2)
```
That compatible form also needs imports:
```python
import time
from screen import grab, convert_abs_to_monitor
```
### Why it will work
This turns the approach from "clicked the waypoint, assume success" into "clicked the waypoint, confirm an NI1 layout is visible." If the waypoint interaction fails or lands somewhere unexpected, the run fails immediately instead of burning 600+ seconds.
The lower-threshold/grayscale retry handles the likely stale-template case without permanently weakening the first pass. The strict threshold still wins when templates are good; the fallback only runs when the current behavior would fail.
The static route check makes future changes safer. `traverse_nodes_fixed()` currently returns `True`, but guarding the call is still correct because it protects this runner if path traversal later gains real validation.
Fresh templates and re-recorded `ni1_*` coordinates are still required if the fallback logs low-confidence matches or reaches the wrong stairs side. The code fix limits total game loss and gives a useful failure point.
## Priority 2: Vendor trade button not found
### Finding
The failure is not in `src/inventory/vendor.py`; that file buys items after the vendor panel is already open. The failing log comes from `src/npc_manager.py`:
```python
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
```
The current town code also returns a vendor location even if pressing the trade button failed. For A5 Malah:
```python
def open_trade_menu(self, curr_loc: Location) -> Location | bool:
if not self._pather.traverse_nodes((curr_loc, Location.A5_MALAH), self._char, force_move=True): return False
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
```
And `press_npc_btn()` does not return `True` on success:
```python
if res.valid:
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button="left")
wait(0.04, 0.08)
center_mouse()
else:
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
keyboard.send("esc")
```
The template threshold is also hard-coded very high for white and blue text:
```python
filtered_inp_w, 0.85, roi=Config().ui_roi["cut_skill_bar"]
```
### Exact code that needs to change
`src/npc_manager.py`, `press_npc_btn()` needs to return a boolean and use a retry/fallback. A5/A1/A4 trade functions need to check that boolean or verify the vendor panel.
### Proposed fix
Replace `press_npc_btn()` with:
```python
def press_npc_btn(npc_key: Npc, action_btn_key: str) -> bool:
global npcs
for threshold in (0.85, 0.78):
img = grab()
img = escape_dialogue(img)
_, filtered_inp_w = color_filter(img, Config().colors["white"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["white"],
filtered_inp_w,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
)
if not res.valid and "blue" in npcs[npc_key]["action_btns"][action_btn_key]:
_, filtered_inp_b = color_filter(img, Config().colors["blue"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["blue"],
filtered_inp_b,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
)
if not res.valid:
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["white"],
img,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
use_grayscale=True,
)
if res.valid:
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button="left")
wait(0.2, 0.3)
center_mouse()
return True
if "red" in npcs[npc_key]["action_btns"][action_btn_key]:
img = grab()
_, filtered_inp_r = color_filter(img, Config().colors["red"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["red"],
filtered_inp_r,
0.78,
roi=Config().ui_roi["cut_skill_bar"],
)
if res.valid:
Logger.warning(f"Cannot afford {action_btn_key} (red button detected). Skipping...")
keyboard.send("esc")
wait(0.3)
return False
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
keyboard.send("esc")
return False
```
Then change A5 Malah trade from:
```python
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
```
to:
```python
if open_npc_menu(Npc.MALAH):
if press_npc_btn(Npc.MALAH, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A5_MALAH
return False
```
Make the same pattern in `src/town/a1.py`:
```python
if open_npc_menu(Npc.AKARA):
if press_npc_btn(Npc.AKARA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A1_AKARA
return False
```
and in `src/town/a4.py`:
```python
if open_npc_menu(Npc.JAMELLA):
if press_npc_btn(Npc.JAMELLA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A4_JAMELLA
return False
```
### Why it will work
The bot currently proceeds as if trade opened even when the button was not clicked. Returning `False` stops `TownManager.buy_consumables()` at the correct point:
```python
new_loc = self._acts[curr_act].open_trade_menu(curr_loc)
if not (new_loc and common.wait_for_left_inventory()): return False, items
```
The fallback search keeps the current exact template behavior first, then retries with a slightly lower threshold and grayscale. That covers text color/anti-aliasing differences without making every match permissive.
Verifying `ScreenObjects.GoldBtnVendor` makes the action result state-based. Even if the template click returns true, the caller only continues when the vendor panel is actually open.
Fresh `TRADE` / `TRADE_BLUE` templates are still recommended, but this code fix prevents false success and reduces sensitivity to minor UI rendering differences.
## Priority 3: Stash detection fails
### Finding
The stash failures are in act-specific methods, not in `TownManager.stash()` itself. A1 and A5 use default `IChar.select_by_template()` threshold `0.68`:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
```
```python
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
return False
```
The common selector only closes the waypoint menu for A5 stash templates:
```python
if type(template_type) == list and "A5_STASH" in template_type:
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
```
So A1 stash can fail when a waypoint/dialog is left open, and both A1/A5 have no lower-threshold retry.
### Exact code that needs to change
`src/town/a1.py`:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
```
`src/town/a5.py`:
```python
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
return False
```
`src/char/i_char.py`:
```python
if type(template_type) == list and "A5_STASH" in template_type:
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
```
### Proposed fix
Change the selector guard in `src/char/i_char.py` to handle all stash templates:
```python
templates = template_type if isinstance(template_type, list) else [template_type]
if any(template in ["A1_TOWN_0", "A5_STASH", "A5_STASH_2"] for template in templates):
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
wait(0.2, 0.3)
```
Change A1 stash to retry lower after the default threshold fails:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.68, timeout=4.0):
Logger.warning("A1 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.58, timeout=4.0):
return False
```
Change A5 stash similarly:
```python
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.68, timeout=4.0, telekinesis=True):
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.58, timeout=4.0, telekinesis=True):
return False
```
### Why it will work
The default threshold remains unchanged for normal cases. The lower threshold only runs after a specific stash attempt fails, which narrows the risk of false-positive clicks.
Closing the waypoint menu for A1 and A5 prevents stale UI overlays from blocking the stash templates. This directly addresses the logged sequence where town/stash templates are not found after other town interactions.
The success function already checks for stash/inventory gold buttons:
```python
found = is_visible(ScreenObjects.GoldBtnInventory, img)
found |= is_visible(ScreenObjects.GoldBtnStash, img)
```
That means a lower-threshold click must still produce the actual stash UI to count as success.
Fresh `A1_TOWN_0`, `A5_STASH`, and `A5_STASH_2` templates should still be captured if logs continue to show low match confidence. The code change makes the current templates less brittle and prevents open UI overlays from causing avoidable failures.
## Priority 4: CTA weapon switch fails
### Finding
The CTA code is in `src/char/i_char.py`. It depends on `Config().char["weapon_switch"]`, which comes from `config/params.ini`, not `config/game.ini`:
```ini
weapon_switch=w
battle_orders=f6
battle_command=f5
```
The current CTA routine has two reliability problems:
1. It invalidates active-skill cache implicitly by switching weapons but does not reset `_active_skill`.
2. It verifies the switch back by comparing a screenshot of the previous right-skill icon. That can fail when the same skill exists on both swaps, when the icon is visually similar, or when the UI updates slightly late.
Current code:
```python
while time.time() - start < 4:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
wait(0.2, 0.3)
if skills.is_right_skill_selected(["BC", "BO"]):
switch_sucess = True
break
else:
Logger.warning("Failed to find Battle Command, swapping weapons again.")
```
```python
while time.time() - start < 4:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
else:
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
return switch_sucess
```
### Exact code that needs to change
Add a helper inside `IChar` and use it whenever the weapon switch key is sent in `_pre_buff_cta()`.
### Proposed fix
Add this method to `IChar`:
```python
def _weapon_switch(self):
keyboard.send(Config().char["weapon_switch"])
self._set_active_skill("left", "")
self._set_active_skill("right", "")
wait(0.55, 0.65)
```
Change the first CTA-side check from:
```python
if skills.is_right_skill_selected(["BC", "BO"]):
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
```
to:
```python
if skills.is_right_skill_selected(["BC", "BO"]):
self._weapon_switch()
```
Change the switch-to-CTA loop from:
```python
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
```
to:
```python
self._weapon_switch()
keyboard.send(Config().char["battle_command"])
```
Change the switch-back loop from:
```python
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
else:
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
```
to:
```python
self._weapon_switch()
if not skills.is_right_skill_selected(["BC", "BO"]):
switch_sucess = True
break
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
```
Also fix the spelling while touching this code:
```python
switch_success = False
```
instead of:
```python
switch_sucess = False
```
### Why it will work
Resetting `_active_skill` after weapon swap prevents `_select_skill()` from skipping a hotkey press because it thinks the old right skill is still selected. Weapon swap changes the available skill bar, so the cache is no longer trustworthy.
The longer wait gives D2R more time to update the skill icon and weapon state before validation. The current 0.4 second delay is close to the UI transition timing and is likely why the failure is intermittent.
The switch-back validation now accepts the most direct state: the right skill is no longer Battle Command/Battle Orders. The old image comparison remains as a fallback, so this still handles cases where BC/BO detection briefly lags.
The configured key should be checked in `config/params.ini`, not `config/game.ini`. If the user's actual D2R weapon switch key is Caps Lock, then this line must change:
```ini
weapon_switch=w
```
to:
```ini
weapon_switch=capslock
```
Only do that if the in-game key binding is actually Caps Lock; otherwise leave it as `w`.
+57
View File
@@ -0,0 +1,57 @@
# d2jsp Semi-Automated Scraping Guide
Due to Cloudflare's aggressive bot protection, fully automated scraping is currently restricted. This project uses a **Semi-Automated (Offline) Workflow** that leverages your authenticated browser session to safely collect market data.
## Prerequisites
1. **Python Dependencies:** Ensure `keyboard` and `requests` are installed (included in `requirements.txt`).
2. **Browser:** Google Chrome is recommended.
3. **Authentication:** Be logged into [forums.d2jsp.org](https://forums.d2jsp.org/) in your browser.
---
## The Workflow
### 1. Initial Directory Setup
Before running the automation, you must set the default "Save As" path in your browser:
1. Open any topic on d2jsp.
2. Press `Ctrl + S`.
3. Navigate to your bot folder: `data/d2jsp_pages/`.
4. Save the file. Your browser will now remember this location as the default.
### 2. Collect Topic URLs
If you don't have a fresh list of URLs, run the collector on a saved forum listing page:
```powershell
python tools/d2jsp_topic_collector.py --offline-dir data/d2jsp_pages --out data/d2jsp_topic_urls.txt
```
### 3. Run Browser Automation
This script will open the first 20 topics from your list, wait for Cloudflare to pass, and simulate the save command.
```powershell
python tools/browser_auto_save.py
```
**Important:**
* Keep your browser as the active window.
* Do not move the mouse or type while the script is running.
* It will automatically `Ctrl + S` -> `Enter` -> `Ctrl + W` for each tab.
### 4. Generate Price Estimates
Once the HTML files are saved in `data/d2jsp_pages/`, run the offline scraper to update your bot's configuration:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-20 --offline-dir data/d2jsp_pages --out config/fg_daily_estimates.json
```
---
## Troubleshooting
### Cloudflare "Just a Moment" Loop
If the automation is too fast and hits a "Just a Moment" screen that doesn't resolve:
1. Increase the `time.sleep(8)` value in `tools/browser_auto_save.py`.
2. Manually solve one challenge in the browser to "warm up" the IP clearance.
### Files Not Saving to Correct Folder
If files are saving to your "Downloads" folder instead of `data/d2jsp_pages`, the browser's default path was reset. Repeat **Step 1** to fix it.
### Date Parsing Errors
If the scraper reports 0 topics scanned or dates are missing, ensure your browser language isn't translating the page, as the scraper expects English month names (Jan, Feb, Mar, etc.).
+91
View File
@@ -0,0 +1,91 @@
# Botty Fix Plan — 2026-06-05
## Symptoms (from today's logs)
- 4 sessions today: 31 + 1 + 9 + 6 games = 47 games total
- 51 deaths across all sessions
- 0 valuable items found (only charms, jewels, gold)
- Nihlathak run fails every time, ending entire games
- Vendor trade button never found (can't buy potions)
- Stash never opens (can't stash items)
- CTA weapon switch fails occasionally
---
## Priority 1: Nihlathak approach fails (game-ending)
**Error:** `Approach failed for run_nihlathak` — ends game after 600-650s
**Impact:** 3+ games ended per session
**Root cause:** Teleport activation times out, bot can't reach Nihlathak
**Fix:**
- Check `config/game.ini` for Nihlathak coordinates (`a5_nihlathak_*`)
- Likely missing or stale path nodes
- Re-record path with `node_recorder.py`
- May need fresh template images for Nihlathak area
**Files to check:**
- `config/game.ini` (Nihlathak section)
- `src/run/nihlathak.py`
- `assets/templates/nihlathak/`
---
## Priority 2: Vendor trade button not found
**Error:** `Could not find trade btn. Should not happen!`
**Impact:** Can't buy potions every town return
**Root cause:** Trade button template outdated or offset wrong for Hell difficulty UI
**Fix:**
- Take fresh screenshot of vendor trade window
- Update trade button template in `assets/templates/`
- Check if trade button position shifted between Normal/Nightmare/Hell
**Files to check:**
- `assets/templates/` (trade button template)
- `src/` (vendor interaction code — search for "trade btn")
---
## Priority 3: Stash detection fails
**Error:** `select_by_template: could not find ['A1_TOWN_0']` then `['A5_STASH', 'A5_STASH_2']`
**Impact:** Can't stash items, inventory fills with junk
**Root cause:** Stash template confidence threshold too high or template outdated
**Fix:**
- Take fresh screenshot of stash UI in Hell difficulty
- Update A1_TOWN_0, A5_STASH, A5_STASH_2 templates
- Consider lowering confidence threshold (currently 0.68)
**Files to check:**
- `assets/templates/a1_town/`
- `assets/templates/a5_stash/`
- Template matching threshold in code
---
## Priority 4: CTA weapon switch fails
**Error:** `_pre_buff_cta: switch to CTA slot failed — retrying`
**Impact:** Occasional, bot retries
**Root cause:** Weapon switch key or template unreliable
**Fix:**
- Verify weapon switch key binding (capslock per memory)
- Check CTA slot detection template
- May need more robust retry logic
**Files to check:**
- `src/` (search for `_pre_buff_cta`)
- `config/` (weapon_switch key)
---
## Approach
All four issues are template/coordinate problems — stale screenshots or wrong positions.
Fix pattern: screenshot current game UI at those locations, update templates.
Do in order: Nihlathak > vendor > stash > CTA.
+360
View File
@@ -0,0 +1,360 @@
# Hermes Takeover Guide
Practical handoff for continuing Botty development. Read every section before touching code — the trap patterns section alone will save you hours.
---
## 1) Working model
- Treat Botty as a **state machine app** with side effects across D2R UI automation, OCR parsing, inventory/sell/stash routines, and messaging (Discord/webhooks).
- Prefer **small, production-safe patches** over broad refactors.
- Prioritize runtime stability: avoid failing full runs because one subsystem is flaky.
- **Always reproduce from logs first** — most bugs leave a clear trail.
---
## 2) Daily workflow
1. Get fresh logs:
- `log/log.txt`
- `log/stats/events_*.jsonl`
- `log/stats/stats_*.log`
2. Confirm failure path in code with `rg`.
3. Patch narrowly with targeted edits.
4. Validate:
- `python -m compileall <changed files>`
- targeted pytest where available
5. Commit with a clear, single-purpose message.
---
## 3) Core debug commands
From repo root (PowerShell):
```powershell
# Tail errors/warnings
rg -n "ERROR|WARNING|Failed|chicken|exception" log/log.txt | tail -50
# Last 200 log lines
Get-Content log/log.txt | Select-Object -Last 200
# Search source code
rg -n "<keyword>" src/
# Compile check after edits
python -m compileall src/health_manager.py src/item/pickit.py
# Config smoke test (confirm a key's resolved value)
python -c "import sys; sys.path.insert(0,'src'); from config import Config; print(Config().char['show_belt'])"
# Run test suite
python -m pytest -q test/
```
Use `rg` first. It's the fastest way to localize faults.
---
## 4) High-risk areas
### 4.1 Repair/vendor flow
- A5 Larzuk detection is noisy and can fail.
- Current behavior: A5 normal flow → Larzuk direct template click → Act 4 Halbu fallback.
- `repair_npc=a4_halbu` config key skips Larzuk entirely.
Files: `src/town/a5.py`, `src/town/town_manager.py`, `src/bot.py`
### 4.2 Discord messaging
Known issue: `'NoneType' object has no attribute 'to_dict'`
Current fix: `src/messages/discord_embeds.py` `_send_embed` only passes `file=` when attachment exists. Plain text fallback on embed failure.
Config: `config/params.ini``[discord_events]`
### 4.3 Selling safety
Current protections:
- Sell logs include item names (not coordinates only).
- `protect_shields_from_sell=1` blocks selling items whose detected name includes "shield".
Files: `src/inventory/personal.py`, `config/params.ini`, `src/config.py`
### 4.4 XP logging
Two separate concerns:
1. OCR extraction: `src/ui/player_bar.py` — parser handles `I/l/|→1`, `O/o→0`.
2. XP status projection math: `src/game_stats.py` `_create_msg()` — zero denominators guarded.
### 4.5 HealthManager (rejuv / chicken logic) ⚠️
The most subtle source of false positives. See Section 10.2 for full details.
Key thresholds (params.ini `[char]`):
- `take_rejuv_potion_health = 0.80` — drink rejuv if HP ≤ 80%
- `take_rejuv_potion_mana = 0.55` — drink rejuv if mana ≤ 55%
- `chicken = 0.50` — flee if HP ≤ 50%
The "two juvs in 8s → chicken" check **must also verify HP was the trigger**, not just mana. Hammerdins burn mana fast; mana can legitimately trigger back-to-back rejuvs at full HP.
### 4.6 PickIt gold loop ⚠️
`_yoink_item()` **always returns `PickedUpResult.PickedUp`** regardless of actual success (line 129). This means pickup failures are only detectable through `_pick_up_item`'s same-ID or same-UID checks — and different gold pile amounts produce different IDs, bypassing those checks entirely.
Fix in place: `pick_up_items()` now blacklists `item.ID` in `_cached_pickit_items` on `PickedUpFailed`. Do not remove this case.
---
## 5) State machine (bot.py)
States: `initialization → hero_selection → town → [run state] → town → ...`
Full state list: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
Key transitions:
| Trigger | Source | Dest | Handler |
|---|---|---|---|
| `init` | initialization | initialization | `on_init` |
| `select_character` | initialization | hero_selection | `on_select_character` |
| `start_from_town` | initialization/hero_selection | town | `on_start_from_town` |
| `maintenance` | town | town | `on_maintenance` |
| `run_pindle` | town | pindle | `on_run_pindle` |
| `run_arcane` | town | arcane | `on_run_arcane` |
| `end_run` | any run state | town | `on_end_run` |
| `end_game` | town/any run state | initialization | `on_end_game` |
**`on_maintenance` guard**: If `_curr_loc` is None (e.g. after a chicken recovery), defaults to `A1_TOWN_START` with a warning log. Do not remove this guard.
**`end_game` vs `end_run`**: When TP charges run out, trigger `end_game` so the bot restarts and restocks in the next game — not `end_run` which tries to TP back and loops.
---
## 6) Config system
Priority order (highest first):
```
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
```
`custom.ini` is gitignored — user-only overrides. Never edit `game.ini` for user settings.
### Key detection flow
On every `Config()` instantiation:
1. Reads `Saved Games/Diablo II Resurrected/<charname>.keyo` binary file.
2. Parses slot-to-key mapping (`CHAR_BINDING_SLOTS` in `key_detector.py`).
3. Slot 41 = `show_belt`, slot 36 = `stand_still`, slot 44 = `weapon_switch`, etc.
4. Compares each detected key against params.ini value.
5. If they match → use detected key silently.
6. If they differ → logs `"Keeping configured key binding for X: 'params_val' (detected 'keyo_val')"` and keeps the params.ini value.
**What "Keeping..." means**: params.ini disagrees with the in-game binding. Usually indicates a config typo or stale params.ini. If you see `Keeping show_belt: 'k' (detected 'n')`, the fix is `show_belt=n` in params.ini `[char]`.
### Config singleton pattern
`Config` uses `__new__` with a `data_loaded` class variable. Calling `Config()` multiple times in the same process returns the same loaded instance. After editing params.ini at runtime, `Config().reload()` is needed (or restart the bot).
---
## 7) Current bot profile (Fistman — as of 2026-06-05)
- **Character**: Hammerdin (`src/char/paladin/hammerdin.py`)
- **Runs**: Pindle + Arcane Sanctuary
- **Key bindings** (from .keyo + params.ini):
- `show_belt = n` (belt hotkey)
- `stand_still = capslock` (overrides detected 'shift')
- `weapon_switch = w`
- `show_items = alt`
- `potion1..4 = 1,2,3,4`
- **Capabilities**: `can_teleport_natively = True` (set via `override_capabilities` in `[advanced_options]`)
- **Thresholds**: `take_rejuv_potion_health=0.80`, `take_rejuv_potion_mana=0.55`, `chicken=0.50`
---
## 8) Testing strategy by change type
### Messaging changes
```powershell
python -m pytest -q test/test_discord_embeds.py
```
### Config parsing changes
```powershell
python -m compileall src/config.py
python -c "import sys; sys.path.insert(0,'src'); from config import Config; c=Config(); print(c.char['show_belt'], c.char['stand_still'])"
```
### Inventory/sell logic
- Validate no exceptions in `inspect_items` / `transfer_items`.
- Log outputs should include item names (not just coordinates).
- Prefer dry functional checks from logs before gameplay runs.
### HealthManager changes
- Check that chicken thresholds still fire at the right HP%.
- Verify the two-rejuv check only triggers when `health_percentage <= take_rejuv_potion_health`.
### PickIt changes
- Confirm `_cached_pickit_items` is populated on both `PickedUp` (cached True) and `PickedUpFailed` (cached False).
- Confirm `_yoink_item` return value is not relied on for success detection.
---
## 9) Git hygiene
- Keep untracked: `.env`, `config/custom.ini`, `log/`, `log/screenshots/`
- Do not revert unrelated user changes.
- Commit frequently with single-purpose messages.
- `python -m compileall src/` must pass cleanly before committing.
---
## 10) Module internals and trap patterns
### 10.1 Config / key detection traps
**Trap**: `apply_key_bindings` runs *after* `self.char` is built in `config.py`. If you add a new key to `self.char` dict and it doesn't exist in `CHAR_BINDING_SLOTS`, the keyo detector won't touch it — but the user still needs to have it in params.ini.
**Trap**: "Keeping configured key binding" is NOT an error. It means the user explicitly configured something different from the game default. It becomes a problem only if the params.ini value is wrong (e.g., 'k' instead of 'n' for show_belt).
**Trap**: `Config()` is a singleton via `__new__`. The first call loads everything. Subsequent calls within the same process return the cached instance. Do NOT expect param changes at runtime to be visible without `Config().reload()`.
### 10.2 HealthManager rejuv traps
The rejuv logic in `start_monitor()`:
```python
if last_drink > 0.60: # minimum between rejuvs
if health <= take_rejuv_potion_health or mana <= take_rejuv_potion_mana:
drink_rejuv()
self._last_rejuv = time.time()
# Two juvs in 8 seconds → chicken ONLY if HP was the trigger
if last_drink < 8 and health_percentage <= Config().char["take_rejuv_potion_health"]:
self._do_chicken(img)
```
**Critical**: The `last_drink < 8` chicken check MUST also check `health_percentage`. Without it, any mana-triggered second rejuv (common for Hammerdins) will false-chicken at 99.9% HP. The fix is already in place — do not revert it.
**Timing**: The monitor polls every `3/25s * jitter(±20%)` ≈ 96144ms. At 25 FPS that's every 3 frames.
**Thread safety**: `_pause_state` and `_panel_check_paused` are protected by `_state_lock`. Module-level `get_pause_state()` / `set_pause_state()` functions delegate to the singleton. Always use these functions from external code.
### 10.3 PickIt ID/UID system
`GroundItem` has two identifiers:
```python
ID = slugify(f"{Name}_{'_'.join([str(v) for _,v in as_dict().items()])}")
# Includes Amount in the string. Two gold piles with different amounts = different IDs.
UID = f"{ID}_{'_'.join([str(v) for v in center])}"
# ID + screen position. Same pile at same coordinates = same UID.
```
**Trap**: `_pick_up_item`'s gold-fail detection uses `item.ID == prev.ID`. If two nearby gold piles have different amounts (e.g., 338g and 157g), they alternate as the "next" item and each one's ID never matches the previous, so the same-ID fail check never fires. The loop runs until timeout (20s).
**Trap**: `_yoink_item` ALWAYS returns `PickedUpResult.PickedUp`. It never returns `PickedUpFailed`. Pickup failures for teleport builds are silently swallowed.
**Fix in place**: `pick_up_items()` match block now has:
```python
case PickedUpResult.PickedUpFailed:
self._cached_pickit_items[item.ID] = False # blacklist this session
```
This stops the alternating-gold loop by blacklisting the item after the first confirmed failure.
### 10.4 Belt system open() key chain
`belt.open()` tries keys in this order:
```python
[config_val, "n", "k", "`", "~"] # deduplicated
```
If `show_belt = n` in params.ini, the first key tried is 'n'. If it works, no fallback keys appear in logs. If you see `"Trying to open belt with key: k"` it means 'n' failed — check if `show_belt` is actually set to 'n' and if the D2R window is focused.
### 10.5 TownManager location routing
`get_act_from_location(loc)` returns `None` for non-string inputs (e.g., `True`, `False`). The isinstance guard at line 36 (`if not isinstance(loc, str): return None`) prevents `AttributeError: 'bool' object has no attribute 'upper'`. Do not remove it.
All town methods that receive a `Location` return `False` (not `None`) on failure. Callers should check `if not new_loc` not `if new_loc is None`.
### 10.6 State machine: `end_game` vs `end_run`
`end_run` sends a TP, waits in town, does maintenance, then starts another run. If something prevents getting back to town (no TP scrolls, merc dead with no body, disconnected), `end_run` loops.
`end_game` saves and exits, restarts the game fresh. Use it when:
- TP charges = 0 (bot will restock on next game start)
- Unrecoverable in-game state
- Max consecutive failed runs reached
Triggering `end_run` when TP is gone causes an infinite "No TP charges left, trying to walk back" loop (pre-fix behavior).
### 10.7 distance calculation (processing_helpers.py)
The y-center of the screen for distance math is `screen_height / 2`, NOT `screen_width / 2`. Using the wrong dimension skews distance sorting for items on the top/bottom half of the screen. Fix is already applied.
---
## 11) Bugs fixed in 2026-06-04/05 session
All fixes were applied and verified by Python compile/config tests:
| Bug | File | Symptom in logs | Fix |
|---|---|---|---|
| `show_belt` wrong key (`n` instead of `k`) | `config/params.ini` | "Recovered belt hotkey using 'k'" on first game, then silent in-memory mutation | `show_belt=n``show_belt=k` |
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | Crash in `get_act_from_location` when `True`/`False` passed as loc | Added `isinstance(loc, str)` guard |
| No-TP → infinite loop | `src/bot.py` | "No TP charges left, trying to walk back" repeated forever | Trigger `end_game` instead of `end_run` on zero TP |
| Distance y-axis wrong | `src/d2r_image/processing_helpers.py` | Items sorted by wrong distance; far items picked first | `screen_width/2``screen_height/2` for y |
| `on_maintenance` crash with no location | `src/bot.py` | Crash after chicken recovery when `_curr_loc=None` | Guard: default to `A1_TOWN_START` if None |
| False-positive chicken on mana rejuv | `src/health_manager.py:133` | "Two juvs drank within 0.63s. Chicken, HP 99.9%!" | Added `and health_percentage <= take_rejuv_potion_health` |
| Gold pickup infinite loop | `src/item/pickit.py:241` | 338g/157g alternating in logs for 20s | Added `PickedUpFailed` case to blacklist `item.ID` |
| Health pots sold when needed | `src/inventory/personal.py:351` | "Discarding SUPER HEALING POTION." + "Confirmed sell SUPER HEALING POTION" despite health needs | Check `get_needs()` before dropping consumable; `continue` to skip sell/drop when pot is needed |
| No fill_from_inventory after failed buy | `src/bot.py` (after line 438) | Belt empty all game despite pots sitting in inventory; "Out of gold" then nothing fills belt | After buy_consumables block, call `fill_up_belt_from_inventory` + `update_pot_needs` when needs > 0 |
| Wrong weapon in combat after chicken mid-buff | `src/char/i_char.py` `_pre_buff_cta` | Character dies immediately; dies with CTA flail/shield instead of main weapon | Added BC skill-bar template verification after each `weapon_switch`; corrects slot if wrong at game start; retries once on failure |
---
## 12) Known pending issues (as of 2026-06-05)
- **C10** (IMPROVEMENTS.md): `kill_thread()` uses `PyThreadState_SetAsyncExc` — can leave locks inconsistent. Replace with `threading.Event` cooperative shutdown. High risk.
- **optipng pass on assets/**: Pending. Run `asset_manager.py batch` or `optipng -o7` on all PNGs.
- **Thread safety** (H14 in IMPROVEMENTS.md): `health_manager` and `death_manager` shared state — Lock is now present in HealthManager but verify all paths use it.
- **PickedUpResult enum gap** (M14): Values are 0,1,3,4,5. Value 2 is missing. Non-critical but confusing.
- **Gold vicious cycle**: Low gold → can't buy pots → health empty → more chickens/deaths → less gold. Monitor runs after the personal.py + bot.py fix — if the cycle still triggers, also check that `inspect_items` isn't being called with vendor_open=True before `fill_up_belt_from_inventory`.
---
## 13) Fast triage mapping
| Symptom in logs | Where to look | Likely cause |
|---|---|---|
| "Recovered belt hotkey using 'k'" on game 1, then silent | `config/params.ini` | `show_belt=n` should be `show_belt=k` |
| "Two juvs drank... Chicken" at HP > 80% | `src/health_manager.py:133` | Missing HP check on two-rejuv condition |
| Gold pile (XYZg) repeating 5+ times | `src/item/pickit.py:241` | `PickedUpFailed` case missing; item not blacklisted |
| "Failed to pick up X" then same X again immediately | `_yoink_item` / `_cached_pickit_items` | Blacklist not being set on failure |
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | isinstance guard removed or bypassed |
| "No TP charges left, trying to walk back" (repeating) | `src/bot.py` around `end_run` | `end_run` triggered when should be `end_game` |
| "No current location set" | `src/bot.py on_maintenance` | `_curr_loc` was None after chicken/recovery |
| Discord embed errors | `src/messages/discord_embeds.py` | `file=` kwarg passed when attachment is None |
| Repair fail loops | `src/town/a5.py` + `town_manager.py` | Larzuk template noise; check A4 fallback path |
| "Failed to log exp" | `src/ui/player_bar.py` | OCR misread; check for `I/l``1` ambiguity |
| Sell includes wrong items | `src/inventory/personal.py` | `protect_shields_from_sell` or item filter issue |
| "Discarding SUPER HEALING POTION" + "Confirmed sell..." | `src/inventory/personal.py:351` | Consumable sold despite belt need — fixed by get_needs() guard |
| Belt needs stay health=3/mana=3 game after game; pots never drunk | `src/bot.py` after buy_consumables + `personal.py:351` | Health pots sold during inspect; no fill_from_inventory fallback |
| "started on CTA slot" in logs; dies in first seconds of run | `src/char/i_char.py _pre_buff_cta` | Game saved with CTA slot active (interrupted buff). Use `BC` template check at startup to detect and correct |
| Character enters run at partial HP (e.g. 40% after chicken) | `src/bot.py on_maintenance` | Health manager paused in town; no town-heal loop. Check `meters.get_health` and drink belt pots in maintenance before `update_pot_needs` |
---
## 14) "Done" checklist for a fix
- [ ] Reproduced from logs
- [ ] Root cause identified in source
- [ ] Patch applied in smallest reasonable scope
- [ ] `python -m compileall <changed_files>` passes
- [ ] Target tests pass (or explicitly explain why unavailable)
- [ ] Python smoke test confirms the fix (e.g., `Config().char['show_belt']`)
- [ ] Behavior documented here or in README/params if user-visible
---
If you need to continue immediately: start from latest `main`, run a short bot session, then inspect only the newest 200300 log lines before changing anything.
+83
View File
@@ -0,0 +1,83 @@
# Linux Port Plan (Botty)
## Goal
Make Botty runnable on Linux in phased steps, with clear checkpoints and minimal regressions for current Windows users.
## Current status
Botty is currently Windows-first. Full gameplay flow does not run on Linux due to:
- Windows input stack (`win_input`, Win32 hotkey polling).
- Windows process/window management (`taskkill`, Win32 window APIs, `os.startfile`).
- Windows dependency assumptions (`pywin32`, Windows tesserocr wheel guidance).
- Windows path/env assumptions (`APPDATA`, `C:\...`, `D2R.exe`, `.bat` scripts).
## Principles
- Keep Windows behavior unchanged while adding Linux support.
- Introduce platform abstractions before replacing implementations.
- Land small, testable phases.
- Prefer graceful `NotImplemented` behavior over hard crashes on unsupported paths.
## Phase 1: Platform abstraction layer
1. Add a `platform_adapter` module with interfaces for:
- Input (keyboard/mouse send + hotkeys)
- Window management (find game window, set top-most, geometry)
- Process control (start/stop/check D2R/Battle.net)
2. Route existing Windows calls through adapters.
3. Add Linux stub implementations that fail gracefully with actionable logs.
4. Add unit tests for adapter selection and fallback behavior.
## Phase 2: Linux-safe startup and tooling
1. Add Linux entry script (`run_botty.sh`) and dependency checker shell script.
2. Update startup to avoid Windows-only calls unless platform is Windows.
3. Normalize path handling to `pathlib` where feasible.
4. Ensure `main.py` can start on Linux without immediate import/runtime crashes.
## Phase 3: Linux input backend
1. Implement Linux input backend (X11/Wayland-compatible strategy):
- Candidate libs: `pynput`, `python-xlib`, or tool-backed approach (`xdotool` for X11).
2. Match required Botty features:
- Key press/hold/release
- Mouse move/click with jitter and timing controls
- Hotkey registration/polling
3. Add integration tests/mocks for input primitives.
## Phase 4: Linux screen/window backend
1. Validate capture compatibility for `mss` under target Linux desktop/session.
2. Implement Linux window discovery/focus/geometry handling.
3. Rework DPI/coordinate normalization independent of Win32 APIs.
4. Add diagnostics tool to verify coordinates, capture ROI, and template matching on Linux.
## Phase 5: Process and launcher integration
1. Linux-compatible process management (replace `taskkill` paths).
2. Replace `os.startfile` launcher logic with cross-platform process spawning.
3. Add platform-specific config defaults for game executable path conventions.
## Phase 6: Dependency and OCR strategy
1. Split dependencies by platform (base + windows extras + linux extras).
2. Document Linux OCR setup (tesseract/leptonica packages + python bindings).
3. Add CI matrix entries:
- Windows: full current pipeline
- Linux: import/startup + unit/integration subset first, expand later
## Phase 7: Feature parity validation
1. Verify end-to-end flows:
- Start game, run cycle, maintenance, save/exit, restart handling
2. Validate pickit, stash/sell, discord messaging, stats logging.
3. Benchmark timing-sensitive routines and tune Linux defaults.
## Risk register
- Wayland restrictions can block synthetic input/screen capture depending on compositor.
- Template matching thresholds may differ due to capture pipeline differences.
- Hotkey handling behavior can differ across desktop environments.
- OCR reliability can vary based on font rendering stack.
## Suggested delivery milestones
1. **M1**: Linux no-crash startup + stubs + docs.
2. **M2**: Linux input backend functional in sandbox diagnostics.
3. **M3**: Linux screen/window backend and maintenance loop stable.
4. **M4**: End-to-end run support in supported Linux environments.
## Acceptance criteria
- Botty starts on Linux and logs clear capability status.
- No Windows-only hard failures on Linux code paths.
- Core run loop can execute in a supported Linux environment.
- Windows behavior remains stable and covered by existing tests/CI.
+154
View File
@@ -0,0 +1,154 @@
# Paladalla Melee Hunt Test Log
Date: 2026-08-23
Workspace: `C:\Users\alex\my-botty`
Character/profile: `paladalla`
Script: `tools/melee_hunt.py`
## Current State
- Testing was stopped at the user's request.
- No `melee_hunt` Python process was running when checked.
- Paladalla was alive in the Rogue Encampment at the end of the session.
- The four visible belt potions were manually consumed. The last screenshot shows
partial life and an empty belt, so refill and fully heal before the next run.
- The game must not be controlled automatically until the user asks to resume.
Final-state screenshot: [healed_from_belt.png](../log/screenshots/manual/healed_from_belt.png)
## Commands Used
The successful short run used:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left
```
The later long tests used:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 1440
```
`F12` is intended to stop the script. It worked during one run but was not
reliable while an in-game overlay was open. `Ctrl+C` in the script terminal was
the reliable emergency stop.
## Verified Results
### Initial ten-minute run
- Started outside town and completed its configured timer normally.
- Result: `engaged 107, roamed 15`.
- XP changed from `261/500` to `357/500`.
- Final reported life was `69%`.
- The process then exited because the default duration is ten minutes. Paladalla
later died because nothing was controlling or protecting the character.
### Corpse recovery and level gain
- The death prompt was cleared and the game was cycled through Save and Exit.
- The corpse appeared beside Paladalla in town and was recovered successfully.
- Weapon and attack slots were restored.
- Paladalla was manually routed across the bridge into the Blood Moor.
- Manual movement/attack clicks later produced a level-up. The next script start
read XP as `564/1500`, confirming level 2 and working melee damage.
Evidence:
- [corpse_recovered.png](../log/screenshots/manual/corpse_recovered.png)
- [blood_moor_ready2.png](../log/screenshots/manual/blood_moor_ready2.png)
- [deep_moor_restart.png](../log/screenshots/manual/deep_moor_restart.png)
## Failures Found
### 1. Ten-minute default looked like a crash
The first hunt was not dead or hung. It reached its default ten-minute deadline
and exited cleanly. Long unattended tests must pass an explicit `--minutes`
value or implement a true continuous mode.
### 2. Friendly bridge NPC was treated as motion target
The first 24-hour attempt remained near the town bridge. At the two-minute
checkpoint it showed repeated attacks, full life, and unchanged XP
(`357/500`). The screenshot showed it attacking the friendly rogue near the
bridge. Motion alone cannot distinguish that NPC from a monster.
Evidence: [hunt_2min_checkpoint.png](../log/screenshots/manual/hunt_2min_checkpoint.png)
Workaround used: stop the script and move several screens deeper into the Blood
Moor before restarting. A permanent NPC rejection/stall detector is still
needed.
### 3. Animated HUD controls were treated as targets
After reaching level 2, the pulsing new-stat/new-skill controls appeared above
the original HUD mask. The motion detector could target that region. During the
second long attempt an in-game Loot Filter overlay opened and the script kept
issuing clicks behind it.
Evidence: [restart_live_40s.png](../log/screenshots/manual/restart_live_40s.png)
### 4. No-potion condition was unsafe
Life fell through `18%`, `14%`, `10%`, `8%`, and eventually `0%`. The script
logged `no healing potion in belt!` but continued its loop. Belt detection also
reported no potion even though red potions were visibly present, so belt
detection or its profile assumptions need separate investigation.
### 5. XP OCR became invalid after level-up/UI changes
The log reported XP OCR values such as `'-'` and `''`, then incorrectly printed
`*** LEVEL UP *** now 0/0`. Invalid OCR should not be interpreted as a level-up,
and `xp_last` should retain the last valid sample.
## Code Changes Made
`tools/melee_hunt.py` was changed in two ways:
1. The target scan bottom boundary was raised from `640` to `510`, excluding the
animated level-up/stat controls and the interactive HUD from motion targets.
2. Failure to detect or use a healing potion at the configured health threshold
now raises `SafetyStop`, exits the hunt loop, and prints a safety-stop reason.
The edited script passed:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe -m py_compile tools\melee_hunt.py
```
These changes were not runtime-verified after editing because testing was
stopped. The narrower scan may ignore monsters near the bottom of the viewport;
that tradeoff must be checked visually.
## Next Test Checklist
1. Confirm no old hunt process is running.
2. Refill the belt with healing potions and fully heal at Akara.
3. Save and Exit, re-enter, and recover any corpse if one appears.
4. Spend or dismiss the new stat/skill notifications if practical.
5. Walk across the bridge and several screens into the Blood Moor before launch.
6. Start with a short five-minute test, not a 24-hour run:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 5
```
7. Capture and inspect screenshots at launch, two minutes, and five minutes.
8. At each checkpoint verify: outside town, no modal open, attacking a hostile,
XP increasing, life stable, movement occurring, and no repeated target lock.
9. Force a controlled low-health/no-potion condition only after normal combat is
proven, and verify `SAFETY STOP` exits immediately.
10. Use `Ctrl+C` if any modal opens, XP remains unchanged across two checkpoints,
life drops rapidly, or the character approaches the town bridge NPC.
## Follow-up Engineering Work
- Add a stall detector based on unchanged XP plus repeated target coordinates.
- Temporarily blacklist a target position after excessive swings without XP.
- Reject invalid XP samples instead of converting them into `0/0` level-ups.
- Investigate why `belt.drink_potion("health", ...)` missed visible red potions.
- Detect death and modal overlays explicitly and stop before sending more input.
- Consider an explicit `--continuous` option instead of using a large minute
count.
+145
View File
@@ -0,0 +1,145 @@
# Post-mortem — run_pindle total failure (2627 Aug 2026)
**Outcome:** failure rate 100% → ~8%. Character 32 → 69. Cycle time 185250s → ~52s.
18 commits, `98e12c0``402c768`.
(Hashes changed on 27 Aug: history was rewritten before the PR to purge a Discord
webhook that had been committed into `config/params.ini` by mistake.)
Published rendering: `docs/postmortems/pindle-2026-08-27.html`
(artifact: https://claude.ai/code/artifact/00668e28-dbb8-40e8-b585-db4c8b790d0a)
Per-bug detail with code references lives in `CLAUDE.md` under **Known Bugs and Fixes**
(Bugs 2330). This document is the narrative: what happened, in what order, and what to do
differently.
---
## 1. The expensive part: a green log is not evidence
The original symptom was loud and honest — every run died on
`Wanted to select A5_RED_PORTAL, but could not find it`.
After the portal template was fixed, the logs went clean **and stayed wrong**. Five
consecutive games produced:
```
INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)
INFO Loot from run_pindle: nothing picked up
INFO End game. Elapsed time: 50.98s
"runs_failed_total": 0
```
Every line green. The character was standing in the middle of Harrogath swinging at nothing.
**What gave it away was not the log — it was the XP counter.** 6,779,050 before five
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
`nothing picked up` is not variance, it is a run that never happened.
> A boss run's success is only provable by things the bot cannot fake: an item in the loot
> line, or a jump in `current_exp`. `failed:false` is the bot's *opinion*, derived from the
> very check that was lying.
---
## 2. The causal chain
Four of the first five causes were one failure propagating. Fixing only the visible end
would have left the rest armed.
```
Portal template is masked (scores 0.50-0.60, needs 0.68, matches the wrong place)
-> portal click fails, approach re-paths from a hardcoded town start it no longer occupies
-> landmarks miss, the pather's 0.55 fallback invents a node position from scenery
-> char walks at the phantom and wedges on the town wall, or onto the waypoint stone
-> waypoint panel opens, health manager reads it as a threat, chickens at full HP
```
---
## 3. Root causes, in order of cost
| Bug | Cause | Severity |
|---|---|---|
| 24 | "Already in Pindle area?" shortcut fired **in town** (0.760.79 on `PINDLE_7` vs its 0.62 bar), returned success without clicking the portal | **Silent** |
| 28 | `detect_current_act` committed on a **1.7pp margin** (`A5_TOWN_1` 0.636 vs `A4_TOWN_5` 0.619) → A5 pathing ran inside Act 4. **25% of games** | **Silent** |
| 30 | NPC grid sweep `SWEEP_TAG_THRESHOLD=0.4` settled for noise (real hits 0.981.00, false 0.420.50) | Loud |
| 23 | `a5_red_portal.png` was the only **masked** template in its folder → unsupported masked `TM_CCOEFF_NORMED` | Loud |
| 25 | Pather 0.55 **first-match** fallback fabricated node positions → walked into the town wall | Loud |
| 29 | Undetectable resurrect NPC re-hunted every game (~80s), per-game flag reset by `log_start_game` | Loud |
| 26 | Waypoint panel counted toward a chicken — 3 of 6 games died at full HP | Loud |
| 27 | `GEMS_TAB_Y=100` missed the tab row (labels at y 6378, grid starts y≈87) → 800+ silent warnings | Loud |
| 9b | Pindle retry re-pathed from a hardcoded `A5_TOWN_START` | Loud |
The two marked **Silent** cost the most, because they reported success.
---
## 4. Four corrections — times the obvious answer was wrong
These are recorded because in each case the wrong answer looked correct.
**1. The red-portal guard.** To stop the false "already in Pindle area" I added: *if the red
portal is on screen we must still be in town.* The portal is visible from **both sides**
the return portal renders inside the temple. The guard blocked every genuine entry. It
traded a silent false-success for an honest false-failure: an improvement, still a bug.
**2. XP as proof of a boss kill.** An XP gain proves *something* died — the minion pack and
the mercenary's kills both count. Confirmation needed a loot line naming an actual drop.
**3. "The template is degenerate."** I blamed the Qual-Kehk asset for a repeated identical
0.424 score. Walking the character to the NPC and scoring the stored template against a
frame where the tag renders gave **1.000** (0.997 through the real `color_filter` path). The
template was perfect; the *threshold* was wrong.
> A repeated identical score means the search is settling for **noise**. Check the threshold
> before blaming the asset.
**4. Selling the charms.** I read 673 blocked-sell lines as a clog to clear and disabled
`protect_charms_from_sell`. It vendored five resistance charms before it was caught. The
blocked lines were **the guard working**, and the pickit's `Discarding` verdict was never a
judgement that they were junk — `LAPIS SMALL CHARM OF VITA` failed its rule only because the
rule wanted `coldresist >= 11` and it had 7.
---
## 5. What actually made this expensive
Every bug above cost hours because the log recorded **events, not structure**. There was no
way to ask "where did this game spend its time?" without reading thousands of lines.
That is now fixed — see `CLAUDE.md`*The run timeline* and *Diagnosing a failure*:
- **`grep "TL>"`** — one fixed-width line per step, spawn to loot, every step timed.
- **`grep "FAIL>"`** — a self-contained failure record: reason, location, screenshot, the
three slowest steps, and a breadcrumb trail of the game with `!` marking failures.
- **A Discord digest every 2h** (`general.discord_timing_report_h`), aggregated from the same
timeline so report and log cannot drift.
The first thing the timeline surfaced, unprompted:
```
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 | approach | fail | took=71.9s | step=click_red_portal
```
That failed game spent **113.6s of its 137.6s town visit** on a merc resurrect that achieved
nothing — 83% of the time, on a step that was not the one that ended the game.
---
## 6. Rules carried forward
1. **Verify boss runs with XP delta and loot lines**, never `failed:false`.
2. **Never mask part of a template** to hide an artifact — masked `TM_CCOEFF_NORMED` is
unsupported in OpenCV and fails unpredictably. Recapture it clean.
3. **Prove a template fails on a frame where the subject is definitely visible** before
recapturing anything.
4. **Confirm any click that changes UI mode** — tab, page, panel. An unverified click that
"should" have worked fails silently forever.
5. **Never let a detector guess on a thin margin.** Refusing to answer is usually safe; a
wrong confident answer skips the recovery path entirely.
6. **Read repeated warnings as a system working**, not a clog to clear.
7. **Restart the bot after editing `src/` or config** — both load at process start.
8. **A "we might already be there" fast path is dangerous** when its test can false-positive
at the origin. It converts a loud failure into a silent one.
+220
View File
@@ -0,0 +1,220 @@
# Post-mortem — run_trav on hell, FoH → hammerdin (29 Aug 2026)
**Outcome:** health chickens 3-in-8 → **0-in-11**. Battle 6468s → **4160s**.
Failure rate 12% → 9%. Deaths: 0 throughout. Character level 85.
Per-bug detail with code references lives in `CLAUDE.md`; operating instructions in
`HANDOVER.md`. This document is the narrative: what happened, in what order, and the
four times the obvious answer was wrong.
---
## 1. The build was wrong, and the timing data could not tell us
Travincal ran green on FoHdin — loot on 10 of 13 runs — but killed slowly. The user's
observation was blunt and correct: *"we only do 2-3k dmg with foh"*.
The reason is a game rule, not a bot bug. **Fist of the Heavens deals lightning damage
plus a holy bolt, and holy bolt only damages undead.** Travincal council members are
living. Half of FoH's output was landing on a target type immune to it. Against Pindle
(undead) FoH is excellent, which is exactly why nothing looked wrong until the route
changed.
After the respec, battle time barely moved — 64.0s and 65.5s versus FoH's 5068s. That
looked like the respec had failed. It had not:
```python
def _cast_hammers(self, time_in_s: float, aura: str = "concentration"):
start = time.time()
while (time.time() - start) < time_in_s: # fixed clock, no kill detection
```
`hammerdin.kill_council` casts for `3 × atk_len + 1.6` seconds **regardless of how fast
things die**. FoHdin's timer was mob-detection-driven and was hitting its ceiling;
hammerdin's is a hard-coded duration. Two different mechanisms landing on a similar
number.
> Comparing a fixed-duration fight against a detection-driven one tells you nothing about
> damage. The evidence that the respec worked was **loot on every run** (a RAL rune, 8
> Large Charms, ~30k gold), not the clock. Lowering `atk_len_trav` — not raising it — is
> the lever on a fixed-duration attack.
---
## 2. Scoring a template without the ROI the code uses gives a confident wrong answer
Three games died on `Approach failed for run_trav [step: open_wp]`. Following the
project's own advice, `a5_wp.png` was scored offline against the three real failure
frames:
```
a5_wp 0.980 at (924, 594)
a5_wp 0.962 at (924, 594)
a5_wp 0.971 at (924, 594)
```
0.960.98, identical position, every frame. Read naively this says: *the template is
perfect, so the search must be broken.* That conclusion was stated, and it was wrong.
`a5.py` already carries the answer in a comment: the recaptured `a5_wp.png` matches the
**belt/mana-orb HUD** at ~0.966 — higher than the real stone — which is why the search is
constrained to the `cut_skill_bar` ROI in the first place. Rescoring inside that ROI:
| frame | best `a5_wp` | position |
|---|---|---|
| g1 | 0.480 | (1, 401) |
| g20 | 0.440 | (712, 519) |
| g32 | 0.436 | (32, 520) |
0.4360.480 against a 0.55 threshold, at **wandering positions** — the signature of noise.
The waypoint was genuinely not on screen. The bot was telling the truth; the character had
never reached it.
> **Always score a template through the same ROI, colour filter and threshold the code
> uses.** A full-frame search finds whatever the ROI exists to exclude, and hands you a
> 0.98 that means the opposite of what it appears to mean. Recapturing this template would
> have fixed nothing and cost a day.
---
## 3. Ormus: three plausible causes, all wrong
The bot then stalled in Act 3 hunting Ormus, scoring `body 0.3580.393` against a 0.40
threshold — tantalisingly close, exactly the Akara pattern from Bug 3.
**Wrong answer #1 — "the templates are masked" (Bug 23).** All six Ormus body templates
are 4-channel, and the match positions wandered between frames — the Bug 23 signature
precisely. But a channel audit across every NPC showed the alpha is **fully opaque**, so
`alpha_to_mask` returns no mask and matching is normal. 4-channel is not the same as
masked.
**Wrong answer #2 — "lower the body threshold."** The global best match was 0.479/0.508,
above the 0.40 bar. But those matches sat *outside* Ormus's ROI `[444, 13, 372, 318]`, at
(531,415) and (375,445). Inside the ROI the best was **0.342 / 0.334** — background level.
Lowering the bar to catch that would have recreated the Bug 4 false-positive clicks.
**Wrong answer #3 — "keep Trav town business in Act 3."** This had been recommended
earlier in the same session, reasoned from `a3.py` reporting `can_buy_pots` / `can_heal` /
`can_stash` as `True`. That is a *capability* check and says nothing about pathing.
The actual cause, measured against the two frames where the bot was stuck:
| frame | `a3_town` landmarks ≥0.68 | ≥0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
**Zero landmarks over threshold.** The pather cannot localise at all, emits
`taking a random guess` (6 times across 2 traverses to `a3_ormus`), and the character ends
up somewhere arbitrary. One cause, three symptoms: Ormus not in his ROI, the waypoint not
on screen, and maintenance burning its budget.
A3 is the **worst-supported town in the project**. Moving more work into it would have made
things worse. The recommendation was reversed.
> Ormus was never the problem, the threshold was never the problem, and the mask was never
> the problem. The NPC was not there to be found. Before adjusting any detection
> parameter, establish that the subject is actually on screen.
---
## 4. Config that silently does nothing
Three separate settings were live-but-inert, each in a different way.
**Section scoping.** `concentration` and `redemption` were defined under `[fohdin]`.
`Config` builds `hammerdin` from `[hammerdin]` then applies `[paladin]` last:
```python
self.hammerdin.update(paladin_base_cfg) # [paladin] wins
```
`[fohdin]` is never read by a hammerdin. Both binds would have been **silently unbound**
after the respec — `redemption` invisibly, and `concentration` degrading every hammer to
aura-less damage that would have looked exactly like the FoH problem just respecced away
from.
**An impossible breakpoint.** `casting_frames=8`, from which the bot derives
`_cast_duration = frames * 0.04 + 0.01`. A paladin's fastest cast is **10 frames** at 125%
FCR; 8 is unreachable. At 60% FCR the real value is 12 frames, so the bot was waiting
0.33s for casts that take 0.49s — moving on mid-cast and cutting buffs short, including
the Battle Orders that supply a large share of a 1347-life pool.
**Right setting, wrong route.** `repair_npc=a5_larzuk` was set by Bug 12 specifically to
keep repair in-act for Pindle. On Trav the character is in A3/A4, so the same setting
*forces* the cross-act trip it was created to prevent: 35.8s at Larzuk versus 9.0s at
Halbu.
> A setting that was correct for one route can be actively harmful on another. Bug 12's
> reasoning (*stay in-act*) was right; its **value** was route-specific and nobody had
> revisited it.
---
## 5. Do not trust the key auto-detector
`stand_still` was configured `capslock`; the detector reported the game used `shift`. That
looked like a clear mismatch, and it was "fixed" to `shift`. The character immediately
began running instead of standing to cast.
After the user remapped the game to `shift`, the detector reported **`capslock`**.
| | detector says | actual in-game bind |
|---|---|---|
| before remap | `shift` | `capslock` |
| after remap | `capslock` | `shift` |
Wrong both times, and specifically reporting the value *not* in use — consistent with a
stale `.keyo` read (D2R writes that file on exit). This is why the code deliberately
prefers config over detection and merely logs the disagreement.
> `Keeping configured key binding for X` is **not** a warning that config is wrong. The
> only authority on a keybind is the user and the live game.
---
## 6. What actually fixed it
| change | effect |
|---|---|
| respec FoH → hammerdin | council no longer half-immune to the attack |
| Herald of Zakarum + Mara's Kaleidoscope | chickens 3-in-8 → **0-in-11** |
| `casting_frames` 8 → 12 | buffs stop being cut mid-cast |
| `concentration=f6`, `redemption=f3` moved to `[paladin]` | binds actually read |
| `atk_len_trav` 6.0 → 3.0 | less time standing inside the council |
| `max_maintenance_time_s` 240 → 420 | cross-act town trips stop killing games |
| `repair_npc``a4_halbu` | repair 35.8s → ~9s |
The user's own diagnosis — *"we need the block from zaka and mara so its a ressistance
problem"* — was the single highest-impact change of the night. Bot configuration cannot
compensate for 23% lightning resist and 1347 life while standing inside three hell council
members.
---
## 7. Still open
**A3 pather landmark coverage** is the sole remaining failure cause. It needs a live
capture pass: node templates require the character's **absolute position at capture time**,
so they cannot be produced from saved screenshots. `A5_TOWN_1` (6568% against a 0.68 bar)
is the same class, still outstanding.
**Gear:** 75% FCR is one Arachnid Mesh away (currently 60%, and 48→75 is the next
breakpoint — everything between is wasted). Lightning resist at 23% and poison at 12%
remain the survivability ceiling.
---
## The lessons, compressed
1. A fixed-duration attack loop cannot report damage. Measure kills by **loot**.
2. Score templates through the **ROI the code uses**, or you will confidently find the
false positive the ROI exists to exclude.
3. Before tuning a detection threshold, prove the **subject is on screen**.
4. `can_do_x() == True` is a capability, not evidence that pathing works.
5. Config sections are scoped: a bind under the old class is silently unbound after a
respec.
6. Cast-timing settings must track FCR. An unreachable breakpoint means every cast is
cut short.
7. A setting that is correct for one route can be harmful on another.
8. The key auto-detector reads a file the game writes **on exit**. It is not authoritative.
+726
View File
@@ -0,0 +1,726 @@
<title>Pindle Run Post-Mortem</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<style>
:root{
--ground:#EDF0F4;
--surface:#FFFFFF;
--surface-2:#E3E8EF;
--ink:#15181E;
--ink-2:#3D4652;
--slate:#5A6675;
--hair:#CBD3DE;
--accent:#B01B2E;
--accent-soft:#F6E4E7;
--good:#1B6B52;
--good-soft:#DFEFE8;
--warn:#8A5A0B;
--warn-soft:#F7EBD6;
--shadow:0 1px 2px rgba(21,24,30,.06),0 8px 24px -12px rgba(21,24,30,.18);
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--ground:#101317;
--surface:#171B21;
--surface-2:#1F242C;
--ink:#E6EAF0;
--ink-2:#BAC3CF;
--slate:#8B97A6;
--hair:#2A313A;
--accent:#FF5F70;
--accent-soft:#2C1A1F;
--good:#4FC79B;
--good-soft:#14261F;
--warn:#E0A44A;
--warn-soft:#2A2114;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"]{
--ground:#101317;
--surface:#171B21;
--surface-2:#1F242C;
--ink:#E6EAF0;
--ink-2:#BAC3CF;
--slate:#8B97A6;
--hair:#2A313A;
--accent:#FF5F70;
--accent-soft:#2C1A1F;
--good:#4FC79B;
--good-soft:#14261F;
--warn:#E0A44A;
--warn-soft:#2A2114;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
}
*{box-sizing:border-box}
body{
margin:0;
background:var(--ground);
color:var(--ink);
font-family:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-size:16.5px;
line-height:1.65;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1080px;margin:0 auto;padding:0 24px 96px}
.col{max-width:672px;margin-inline:auto}
h1,h2,h3{font-family:Fraunces,Georgia,serif;text-wrap:balance;margin:0}
h1{font-size:clamp(2.1rem,5.2vw,3.15rem);font-weight:600;line-height:1.08;letter-spacing:-.015em}
h2{font-size:clamp(1.4rem,3vw,1.75rem);font-weight:600;line-height:1.2}
h3{font-size:1.06rem;font-weight:600;line-height:1.3}
p{margin:0}
a{color:var(--accent)}
code,.mono{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace}
.eyebrow{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:500;
letter-spacing:.16em;text-transform:uppercase;color:var(--slate);
}
/* ---- masthead ---- */
header{padding:72px 0 40px;border-bottom:1px solid var(--hair)}
.head-inner{display:flex;flex-direction:column;gap:20px}
.lede{font-size:1.12rem;color:var(--ink-2);max-width:64ch}
.verdict{
display:inline-flex;align-items:center;gap:10px;align-self:flex-start;
background:var(--good-soft);color:var(--good);
border:1px solid color-mix(in srgb,var(--good) 30%,transparent);
border-radius:2px;padding:7px 13px;
font-family:"IBM Plex Mono",monospace;font-size:.76rem;font-weight:600;letter-spacing:.06em;
}
.verdict::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--good)}
/* ---- metric band ---- */
.metrics{
display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));
gap:1px;background:var(--hair);border:1px solid var(--hair);
margin:40px 0 0;
}
.metric{background:var(--surface);padding:18px 20px;display:flex;flex-direction:column;gap:5px}
.metric .k{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--slate);font-family:"IBM Plex Mono",monospace}
.metric .v{font-family:Fraunces,serif;font-size:1.6rem;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
.metric .n{font-size:.8rem;color:var(--slate)}
.metric.hi .v{color:var(--good)}
.metric.lo .v{color:var(--accent)}
section{padding-top:64px}
.section-head{display:flex;flex-direction:column;gap:8px;margin-bottom:26px}
.body-flow{display:flex;flex-direction:column;gap:18px}
/* ---- the trap callout ---- */
.trap{
background:var(--accent-soft);
border-left:3px solid var(--accent);
padding:22px 24px;display:flex;flex-direction:column;gap:12px;
}
.trap h3{color:var(--accent)}
/* ---- log evidence ---- */
.log{
background:var(--surface);border:1px solid var(--hair);
padding:16px 18px;overflow-x:auto;
font-family:"IBM Plex Mono",monospace;font-size:.79rem;line-height:1.75;
color:var(--ink-2);white-space:pre;
}
.log .ok{color:var(--good);font-weight:600}
.log .bad{color:var(--accent);font-weight:600}
.log .dim{color:var(--slate)}
figure{margin:0;display:flex;flex-direction:column;gap:9px}
figcaption{font-size:.82rem;color:var(--slate)}
/* ---- causal chain ---- */
.chain{display:flex;flex-direction:column;gap:0;margin-top:8px}
.node{
background:var(--surface);border:1px solid var(--hair);
padding:14px 18px;display:flex;gap:14px;align-items:baseline;
}
.node .tag{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;
color:var(--accent);letter-spacing:.05em;flex-shrink:0;min-width:52px;
}
.node .txt{font-size:.93rem;color:var(--ink-2)}
.node strong{color:var(--ink);font-weight:600}
.arrow{
height:22px;width:1px;background:var(--hair);margin-left:34px;position:relative;flex-shrink:0;
}
.arrow::after{
content:"";position:absolute;bottom:0;left:-3px;
border-left:3.5px solid transparent;border-right:3.5px solid transparent;
border-top:5px solid var(--hair);
}
.node.terminal{border-color:color-mix(in srgb,var(--accent) 45%,var(--hair));background:var(--accent-soft)}
.node.terminal .txt strong{color:var(--accent)}
/* ---- cause cards ---- */
.causes{display:flex;flex-direction:column;gap:14px}
.cause{
background:var(--surface);border:1px solid var(--hair);box-shadow:var(--shadow);
display:flex;flex-direction:column;
}
.cause-head{
display:flex;gap:14px;align-items:flex-start;
padding:20px 24px 16px;border-bottom:1px solid var(--hair);
}
.bugid{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;letter-spacing:.06em;
color:var(--surface);background:var(--ink);padding:4px 8px;border-radius:2px;flex-shrink:0;
margin-top:3px;
}
.cause-head .titles{display:flex;flex-direction:column;gap:3px}
.cause-head .file{font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate)}
.cause-body{padding:20px 24px 24px;display:flex;flex-direction:column;gap:16px}
.field{display:flex;flex-direction:column;gap:5px}
.field .lbl{
font-family:"IBM Plex Mono",monospace;font-size:.67rem;letter-spacing:.13em;
text-transform:uppercase;color:var(--slate);
}
.field p{font-size:.94rem;color:var(--ink-2)}
.field.fix .lbl{color:var(--good)}
.sev{
margin-left:auto;flex-shrink:0;font-family:"IBM Plex Mono",monospace;
font-size:.66rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
padding:4px 8px;border-radius:2px;white-space:nowrap;margin-top:3px;
}
.sev.silent{background:var(--accent-soft);color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent)}
.sev.loud{background:var(--warn-soft);color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 30%,transparent)}
/* ---- tables ---- */
.tablewrap{overflow-x:auto;border:1px solid var(--hair);background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:.86rem}
th,td{text-align:left;padding:10px 16px;border-bottom:1px solid var(--hair)}
th{
font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.1em;
text-transform:uppercase;color:var(--slate);font-weight:500;background:var(--surface-2);
}
tbody tr:last-child td{border-bottom:none}
td.num{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
td .yes{color:var(--good);font-weight:600}
td .no{color:var(--accent);font-weight:600}
/* ---- checklist ---- */
.checks{display:flex;flex-direction:column;gap:1px;background:var(--hair);border:1px solid var(--hair)}
.check{background:var(--surface);padding:15px 20px;display:flex;gap:13px;align-items:flex-start}
.check .mark{color:var(--good);font-weight:700;flex-shrink:0;line-height:1.6}
.check.dont .mark{color:var(--accent)}
.check .ct{font-size:.92rem;color:var(--ink-2)}
.check .ct strong{color:var(--ink)}
/* ---- wrong turns ---- */
.turn{
border:1px dashed var(--hair);background:transparent;
padding:18px 20px;display:flex;flex-direction:column;gap:8px;
}
.turn .lbl{font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--warn)}
.turn p{font-size:.92rem;color:var(--ink-2)}
footer{
margin-top:80px;padding-top:26px;border-top:1px solid var(--hair);
display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;
font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate);
}
@media (max-width:640px){
header{padding-top:48px}
.cause-head{flex-wrap:wrap}
.sev{margin-left:0}
}
</style>
<div class="wrap">
<header>
<div class="col head-inner">
<span class="eyebrow">Post-mortem · run_pindle · 2627 Aug 2026</span>
<h1>The bot reported five perfect runs and killed nothing</h1>
<p class="lede">
<code>run_pindle</code> was failing 100% of the time. Then a fix made it worse in the
most expensive way possible: it started reporting success. Nine root causes, four wrong
turns of my own, and the instrumentation built so the next one takes minutes.
</p>
<span class="verdict">Resolved &middot; 17 commits</span>
</div>
<div class="col">
<div class="metrics">
<div class="metric lo">
<span class="k">Failure rate</span>
<span class="v">100%</span>
<span class="n">at the start</span>
</div>
<div class="metric hi">
<span class="k">Now</span>
<span class="v">~8%</span>
<span class="n">1 in 28 approaches</span>
</div>
<div class="metric hi">
<span class="k">Character</span>
<span class="v">32 → 69</span>
<span class="n">37 levels</span>
</div>
<div class="metric hi">
<span class="k">Cycle time</span>
<span class="v">~52s</span>
<span class="n">was 185250s</span>
</div>
</div>
</div>
</header>
<section>
<div class="col section-head">
<span class="eyebrow">The expensive part</span>
<h2>A green log is not evidence</h2>
</div>
<div class="col body-flow">
<p>
The original symptom was loud and honest: every run died on
<code>Wanted to select A5_RED_PORTAL, but could not find it</code>. After the template
was fixed, the logs went clean — and stayed wrong. Five consecutive games produced this:
</p>
<figure>
<div class="log"><span class="ok">INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)</span>
<span class="ok">INFO Loot from run_pindle: nothing picked up</span>
<span class="ok">INFO End game. Elapsed time: 50.98s</span>
<span class="dim"> "runs_failed_total": 0</span></div>
<figcaption>Every line green. The character was standing in the middle of town, swinging at nothing.</figcaption>
</figure>
<div class="trap">
<h3>What gave it away</h3>
<p>
Not the logs — the <strong>XP counter never moved</strong>. 6,779,050 before five
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
<code>nothing picked up</code> is not variance, it is a run that never happened.
</p>
</div>
<p>
A boss run's success is only provable by things the bot cannot fake: an item in the loot
line, or a jump in <code>current_exp</code>. The <code>failed:false</code> flag is the
bot's <em>opinion</em>, derived from the very template check that was lying.
</p>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">How one bad asset cascaded</span>
<h2>The causal chain</h2>
</div>
<div class="col body-flow">
<p>
Four of the first five causes are one failure propagating. This is a real dependency
chain, so fixing only the visible end would have left the rest armed.
</p>
<div class="chain">
<div class="node">
<span class="tag">CAUSE</span>
<span class="txt"><strong>Portal template is masked</strong> — scores 0.500.60, below the 0.68 it needs, matching the wrong place entirely.</span>
</div>
<div class="arrow"></div>
<div class="node">
<span class="tag">THEN</span>
<span class="txt">Portal click fails, so the approach <strong>re-paths from a hardcoded town start</strong> the character no longer occupies.</span>
</div>
<div class="arrow"></div>
<div class="node">
<span class="tag">THEN</span>
<span class="txt">Landmarks miss, so the pather's <strong>0.55 fallback invents a node position</strong> from unrelated scenery.</span>
</div>
<div class="arrow"></div>
<div class="node">
<span class="tag">THEN</span>
<span class="txt">The character walks toward the phantom and <strong>wedges against the town wall</strong> — or onto the waypoint stone.</span>
</div>
<div class="arrow"></div>
<div class="node terminal">
<span class="tag">RESULT</span>
<span class="txt">The waypoint panel opens, the health manager reads it as a threat, and <strong>chickens a game at full HP</strong>.</span>
</div>
</div>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">Root causes</span>
<h2>Nine bugs, in order of cost</h2>
</div>
<div class="col causes">
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 24</span>
<div class="titles">
<h3>An optimistic shortcut faked every kill</h3>
<span class="file">src/run/pindle.py — approach()</span>
</div>
<span class="sev silent">Silent</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
<code>approach()</code> opened with an "are we already there?" shortcut that ran
before clicking the portal. Harrogath scenery near the portal scores
<strong>0.760.79 on <code>PINDLE_7</code></strong> — above its 0.62 bar — so the
shortcut fired <em>in town</em>, returned <code>A5_PINDLE_START</code> without ever
clicking the portal, and the bot ran <code>kill_pindle()</code> in the middle of
Harrogath.
</p>
</div>
<div class="field fix">
<span class="lbl">Fix</span>
<p>Shortcut deleted. Entry is proven only by the portal click's loading screen. Real temple entries score <strong>85.888.7%</strong>, clear of the town false positives.</p>
</div>
<div class="field">
<span class="lbl">Generalise</span>
<p>A "we might already be there" fast path is dangerous whenever its test can false-positive at the origin. It converts a loud failure into a silent one — the worst trade in an unattended system.</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 28</span>
<div class="titles">
<h3>The act was decided by a 1.7-point margin</h3>
<span class="file">src/town/town_manager.py — detect_current_act()</span>
</div>
<span class="sev silent">Silent</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
The biggest cause of ordinary failures: <strong>25% of games</strong>. A consumables
trip sends the character A5→A4, and <code>go_to_act(5)</code> then asked
<code>detect_current_act()</code> to verify. It committed to whatever cleared 0.68
first:
</p>
</div>
<figure>
<div class="log"><span class="bad">A5_TOWN_1 0.636</span> <span class="dim">&lt;- phantom, always at (1046, 40), the top-right corner</span>
<span class="ok">A4_TOWN_5 0.619</span> <span class="dim">&lt;- the genuine marker for the act actually occupied</span></div>
<figcaption>A 1.7 percentage point gap decided which act the bot believed it was in — so it skipped the travel and ran A5 pathing inside Act 4.</figcaption>
</figure>
<div class="field fix">
<span class="lbl">Fix</span>
<p>
The winner must now beat the best marker <strong>from any other act</strong> by 5pp.
Below that it refuses to answer. Refusing is the safe outcome: callers treat
<code>None</code> as "keep the assumed act", so <code>go_to_act</code> actually
travels. <strong>Failure rate 25% → 9%.</strong>
</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 30</span>
<div class="titles">
<h3>The NPC search settled for noise</h3>
<span class="file">src/npc_manager.py — grid sweep</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
<code>SWEEP_TAG_THRESHOLD = 0.4</code>. A rendered name tag matches almost perfectly,
so anything mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the
first match over threshold, clicked empty ground, and gave up.
</p>
</div>
<div class="tablewrap">
<table>
<thead><tr><th>NPC</th><th>Score</th><th>Outcome</th></tr></thead>
<tbody>
<tr><td>akara</td><td class="num">0.980</td><td><span class="yes">real — dialogue opened</span></td></tr>
<tr><td>halbu</td><td class="num">0.995</td><td><span class="yes">real</span></td></tr>
<tr><td>larzuk</td><td class="num">0.996</td><td><span class="yes">real</span></td></tr>
<tr><td>qual_kehk</td><td class="num">0.424</td><td><span class="no">false — clicked nothing</span></td></tr>
<tr><td>malah</td><td class="num">0.501</td><td><span class="no">false</span></td></tr>
<tr><td>larzuk</td><td class="num">0.494</td><td><span class="no">false</span></td></tr>
</tbody>
</table>
</div>
<div class="field fix">
<span class="lbl">Fix</span>
<p>Raised to <strong>0.7</strong>, in the gap with margin either side. This is why qual_kehk failed 100% (5 timeouts in 5) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 23</span>
<div class="titles">
<h3>The only masked template in the folder</h3>
<span class="file">assets/templates/a5_town/a5_red_portal.png</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
The capture had the portal's hover tooltip baked in, and alpha was used to hide it.
That made <code>alpha_to_mask</code> return a mask, routing the search to
<code>cv2.matchTemplate(…, TM_CCOEFF_NORMED, mask=…)</code> — a combination
<strong>OpenCV only supports for <code>TM_SQDIFF</code> and
<code>TM_CCORR_NORMED</code></strong>.
</p>
</div>
<figure>
<div class="log">a5_red_portal.png 4 chan <span class="bad"> 60.9% opaque MASKED</span> <span class="dim">&lt;- the only one that failed</span>
a5_town_1..12.png 3 chan <span class="ok">100.0% opaque no mask</span> <span class="dim">&lt;- every one that works</span></div>
<figcaption>Found by auditing the folder rather than the file: every working template is opaque; the failing one was the sole outlier.</figcaption>
</figure>
<div class="field fix">
<span class="lbl">Fix</span>
<p>Recaptured as a plain 3-channel opaque crop of the portal's <strong>upper arch</strong> — the lower ring is occluded by branches. Present 0.9491.000, absent 0.3980.514.</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 25</span>
<div class="titles">
<h3>The pather invented positions and walked into a wall</h3>
<span class="file">src/pather.py — find_abs_node_pos()</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
When the primary 0.68 search missed, the fallback dropped to a <strong>0.55
first-match</strong> full-image search. <code>A5_TOWN_1</code> scored 0.600.62 on
unrelated scenery at three different coordinates across three consecutive frames.
The landmarks were healthy where genuinely visible (<code>A5_TOWN_6 @ 0.93</code>) —
only the fallback was lying.
</p>
</div>
<div class="field fix">
<span class="lbl">Fix — three parts</span>
<p>
Threshold to <strong>0.62</strong>; fallback forced to <code>best_match=True</code>
(it was <code>False</code>, so ambiguous frames were decided by <em>dict ordering</em>);
and a heading gate rejecting any <em>low-confidence</em> match implying a &gt;90°
reversal.
</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 29</span>
<div class="titles">
<h3>An undetectable NPC cost ~80s every game</h3>
<span class="file">src/bot.py · src/game_stats.py</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
In nightmare the merc dies most games, so resurrect ran constantly — and it retried
once. <code>_merc_resurrect_failed</code> is reset by <code>log_start_game</code>, so
it only ever suppressed a second attempt <em>within one game</em>. Game length blew
out to 185250s against a normal ~60s.
</p>
</div>
<div class="field fix">
<span class="lbl">Fix</span>
<p>
A cross-game circuit breaker that <code>log_start_game</code> deliberately does
<em>not</em> reset. Over 30 games with an undetectable NPC: <strong>60 hunts → 4</strong>.
Measured live, game times went 250s / 185s → 14s, 14s, 43s, 71s.
</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 26·27</span>
<div class="titles">
<h3>Two clicks that missed by ~30 pixels</h3>
<span class="file">src/health_manager.py · src/transmute/transmute.py</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Waypoint panel counted as a chicken</span>
<p>A mis-steered pather walks the char over the waypoint stone, opening its panel. The panel check counted it twice and killed the game — <strong>3 of 6 games died this way at full health</strong>. Now escaped without counting, bounded at six attempts.</p>
</div>
<div class="field">
<span class="lbl">Gem conversion ran in the wrong tab</span>
<p>
<code>GEMS_TAB_Y = 100</code>, but the stash tab labels occupy <strong>y 6378</strong>
and the grid starts at y≈87 — so every tab-switch click landed on a stash slot. The
whole routine ran in PERSONAL, logging the same warning <strong>800+ times</strong>
without one error. Tab switches are now verified, not fire-and-forget.
</p>
</div>
</div>
</article>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">Course corrections</span>
<h2>Four times I was wrong</h2>
</div>
<div class="col body-flow">
<p>
Each looked correct in isolation, and each was caught by the user or by checking rather
than by reasoning. They are recorded because the wrong answer is often the obvious one.
</p>
<div class="turn">
<span class="lbl">1 — the red-portal guard</span>
<p>
To stop the false "already in Pindle area", I added: if the red portal is on screen we
must still be in town. <strong>The portal is visible from both sides</strong> — the
return portal renders inside the temple. The guard blocked every genuine entry. It
traded a silent false-success for an honest false-failure, which is an improvement and
still a bug.
</p>
</div>
<div class="turn">
<span class="lbl">2 — XP as proof of a boss kill</span>
<p>An XP gain proves <em>something</em> died — the minion pack and the mercenary's kills both count. Confirmation required a loot line naming an actual drop.</p>
</div>
<div class="turn">
<span class="lbl">3 — "the template is degenerate"</span>
<p>
I blamed the Qual-Kehk asset for a repeated identical 0.424 score. Walking the character
to the NPC and scoring the stored template against a frame where the tag renders gave
<strong>1.000</strong>. The template was perfect; the <em>threshold</em> was wrong. A
repeated identical score means the search is settling for noise — check the threshold
before blaming the asset.
</p>
</div>
<div class="turn">
<span class="lbl">4 — selling the charms</span>
<p>
I read 673 blocked-sell log lines as a clog to clear and disabled the guard. It vendored
five resistance charms before it was caught. <strong>The blocked lines were the guard
working</strong>, and the pickit's "Discarding" verdict was never a judgement that they
were junk — <code>LAPIS SMALL CHARM OF VITA</code> failed its rule only because the rule
wanted <code>coldresist &gt;= 11</code> and it had 7.
</p>
</div>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">The durable part</span>
<h2>Instrumentation, so the next one takes minutes</h2>
</div>
<div class="col body-flow">
<p>
Every bug above cost hours because the log recorded events, not <em>structure</em>. The
last commits fix that: one fixed-width, machine-readable line per step, spawn to loot,
every step timed.
</p>
<figure>
<div class="log"><span class="dim">grep "TL&gt;" log/log.txt</span>
TL&gt; g2 r1 | game | spawn | ok | at a5_town_start
TL&gt; g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2
TL&gt; g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
TL&gt; g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
<span class="bad">TL&gt; g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable</span>
TL&gt; g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
<span class="bad">TL&gt; g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal</span></div>
<figcaption>
That failed game spent <strong>113.6s of its 137.6s town visit</strong> on a merc
resurrect that achieved nothing — 83% of the time, on a step that was not the one that
ended the game. That proportion was invisible before.
</figcaption>
</figure>
<p>
Failures get a self-contained record (<code>grep "FAIL&gt;"</code>) carrying the reason,
location, screenshot, the three slowest steps, and a breadcrumb trail of the whole game
with <code>!</code> marking failures. <strong>The step named in the reason is frequently
not the one that caused the problem</strong> — the trail is what shows that.
</p>
<p>
A digest posts to Discord every two hours, aggregated from the same timeline so the report
and the log cannot drift apart.
</p>
<figure>
<div class="log">**Timing report** - last 2.0h
Games: 12 (11 ok, 1 failed - 8.3%)
Avg town 20s | approach 14s | battle 19s | cycle ~52s
__Slowest steps (avg)__
`town.resurrect_merc ` 33s x6 (2 fail)
`run.approach ` 14s x28 (1 fail)</div>
<figcaption>Real measured output. Rankings exclude umbrella steps and deliberate stealth pauses, which would otherwise always come first and say nothing.</figcaption>
</figure>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">Prevention</span>
<h2>Rules carried into the docs</h2>
</div>
<div class="col body-flow">
<div class="checks">
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Verify boss runs with XP delta and loot lines.</strong> Never with <code>failed:false</code> — that is the bot agreeing with itself.</span>
</div>
<div class="check dont">
<span class="mark"></span>
<span class="ct"><strong>Never mask part of a template to hide an artifact.</strong> Masked <code>TM_CCOEFF_NORMED</code> is unsupported in OpenCV and fails unpredictably.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Prove a template fails on a frame where the subject is definitely visible</strong> before recapturing it. A repeated identical score means the threshold is too low, not that the asset is bad.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Confirm any click that changes UI mode</strong> — a tab, a page, a panel. An unverified click that "should" have worked will fail silently forever.</span>
</div>
<div class="check dont">
<span class="mark"></span>
<span class="ct"><strong>Never let a detector guess on a thin margin.</strong> Refusing to answer is usually the safe outcome; a wrong confident answer skips the recovery path entirely.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Read repeated warnings as a system working, not a clog to clear.</strong> 673 blocked sells were a guard doing its job.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Restart the bot after editing <code>src/</code> or config.</strong> Python loads modules and config at process start; a running bot keeps executing the old code.</span>
</div>
</div>
</div>
</section>
<footer>
<div class="col" style="display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;width:100%">
<span>my-botty · branch <strong>stable</strong> · 17 commits</span>
<span>9 root causes · 4 corrections · verified live</span>
</div>
</footer>
</div>
+1 -24
View File
@@ -8,27 +8,4 @@ dependencies:
- pkg-config
- rapidfuzz=2.15.1
- pip:
- pyinstaller
- opencv-python==4.5.5.64
- transitions
- mss==7.0.1
- numpy==1.26.4
- mouse
- keyboard
- beautifultable
- pytweening
- requests
- coverage
- pytest
- pytest-env
- pytest-mock
- pyparsing
- graphviz
- psutil
- cryptography
- pillow
- discord.py
- parse
- typing_extensions
- colorama
- dataclasses-json
- -r requirements.txt
+9 -2
View File
@@ -22,8 +22,15 @@ for %%C in (
)
)
echo ERROR: Could not find botty conda environment.
echo Run install.bat first.
echo.
echo ERROR: The 'botty' Python environment is not installed yet.
echo.
echo Fix: double-click install.bat in this folder and wait for
echo "Installation complete!", then try again.
echo.
echo Already ran install.bat? Then it did not finish successfully.
echo Run run_install_capture.bat and check install_log.txt for the error.
echo.
pause
exit /b 1
+1
View File
@@ -0,0 +1 @@
+11
View File
@@ -0,0 +1,11 @@
P3
8 8
255
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 255 0 0 0 255 0 0 0 255 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 0 255 0 0 0 255 255 0 0 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 0 0 255 255 255 0 0 255 255 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+6
View File
@@ -0,0 +1,6 @@
P3
3 3
255
255 0 0 0 255 0 0 0 255
0 255 0 0 0 255 255 0 0
0 0 255 255 255 0 0 255 255
+283 -45
View File
@@ -59,13 +59,26 @@ echo This is a one-time setup (~100 MB). Please wait.
echo.
:: --- Method 1: winget (cleanest; available on Win10 1709+ and all Win11) ---
:: --scope user is critical: without it winget defaults to a machine-wide
:: install (lands in %ProgramData%) which REQUIRES admin. A normal double-click
:: (no elevation) then fails silently and conda never installs. --scope user
:: installs to %USERPROFILE%\miniforge3 with no admin needed — which is also one
:: of the locations :rescan_conda searches.
:: winget returns non-zero when the package is already installed, so don't
:: trust the exit code -- rescan for conda and only fall through to the direct
:: download if it's genuinely still missing. Keep this comment outside the block:
:: a "::" line inside a ( ) block is a parse error.
winget --version >nul 2>&1
if %errorlevel% equ 0 (
echo Installing via winget...
winget install --id CondaForge.Miniforge3 --exact --silent ^
winget install --id CondaForge.Miniforge3 --exact --silent --scope user ^
--accept-package-agreements --accept-source-agreements
if !errorlevel! equ 0 goto :rescan_conda
echo winget install failed — falling back to direct download.
for %%C in (
"%LOCALAPPDATA%\miniforge3\Scripts\conda.exe"
"%USERPROFILE%\miniforge3\Scripts\conda.exe"
"%ProgramData%\miniforge3\Scripts\conda.exe"
) do if exist %%C goto :rescan_conda
echo winget did not produce a usable conda — falling back to direct download.
echo.
)
@@ -78,6 +91,7 @@ if %errorlevel% equ 0 (
set "MF_INSTALLER=%TEMP%\Miniforge3-installer.exe"
set "MF_URL=https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe"
del /q "%MF_INSTALLER%" >nul 2>&1
echo Downloading Miniforge3...
curl -Lk --progress-bar "%MF_URL%" -o "%MF_INSTALLER%" 2>&1
if %errorlevel% neq 0 (
@@ -87,23 +101,52 @@ if %errorlevel% neq 0 (
)
if not exist "%MF_INSTALLER%" (
echo.
echo ERROR: Could not download Miniforge3. Check your internet connection, or
echo install it manually then re-run install.bat:
echo https://github.com/conda-forge/miniforge/releases/latest
echo ERROR: Could not download Miniforge3 ^(the Python/conda runtime^).
echo.
pause
exit /b 1
echo Nothing was downloaded from:
echo %MF_URL%
echo Usual causes: no internet, or a firewall/proxy blocking github.com.
echo.
echo Workaround: install Miniforge yourself, then run install.bat again.
echo It will detect it and carry on:
echo https://github.com/conda-forge/miniforge/releases/latest
echo ^(pick Miniforge3-Windows-x86_64.exe and keep the default location^)
goto :fail
)
:: Verify the download is a real installer, not a truncated file or an HTML
:: error page served with a 200. The Miniforge installer is ~78 MB; anything
:: under 40 MB means the download failed even though a file exists.
for %%A in ("%MF_INSTALLER%") do set "MF_SIZE=%%~zA"
if not defined MF_SIZE set "MF_SIZE=0"
if %MF_SIZE% LSS 41943040 (
echo.
echo ERROR: The Miniforge3 download is incomplete and was not run.
echo Got: %MF_SIZE% bytes
echo Expected: about 78 MB
echo.
echo The connection dropped, or a proxy/filter returned an error page
echo instead of the file. The bad file has been deleted.
echo.
echo Try again on a different network, or install Miniforge yourself and
echo re-run install.bat:
echo https://github.com/conda-forge/miniforge/releases/latest
del /q "%MF_INSTALLER%" >nul 2>&1
goto :fail
)
echo Installing Miniforge3 (this takes ~1 minute)...
start /wait "" "%MF_INSTALLER%" /S /InstallationType=JustMe /AddToPath=0 /RegisterPython=0 /NoShortcuts=1 /NoRegistry=1
if %errorlevel% neq 0 (
echo.
echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).
echo Try running it manually: %MF_INSTALLER%
echo ERROR: The Miniforge3 installer did not complete ^(exit code %errorlevel%^).
echo.
pause
exit /b 1
echo Usual causes: antivirus blocked it, or a UAC prompt was declined.
echo.
echo Run it by hand to see the real error, accepting any prompts:
echo %MF_INSTALLER%
echo then run install.bat again.
goto :fail
)
del /q "%MF_INSTALLER%" >nul 2>&1
echo Miniforge3 installed.
@@ -127,10 +170,18 @@ for %%C in (
goto :found_conda
)
)
echo ERROR: Miniforge3 installed but conda.exe still not found. Please restart
echo the installer or contact support.
pause
exit /b 1
echo ERROR: Miniforge3 reported success but conda.exe cannot be found.
echo.
echo install.bat looked in every standard location, including:
echo %%LOCALAPPDATA%%\miniforge3\Scripts\conda.exe
echo %%USERPROFILE%%\miniforge3\Scripts\conda.exe
echo %%ProgramData%%\miniforge3\Scripts\conda.exe
echo.
echo If you installed conda somewhere custom, the simplest fix is to
echo install Miniforge to its default location:
echo https://github.com/conda-forge/miniforge/releases/latest
echo then run install.bat again.
goto :fail
:found_conda
echo Found conda: %CONDA_EXE%
@@ -141,11 +192,16 @@ echo Testing conda...
"%CONDA_EXE%" --version >nul 2>&1
if %errorlevel% neq 0 (
echo.
echo ERROR: conda found but failed to run. Your conda installation may be broken.
echo Try re-installing Miniforge: https://github.com/conda-forge/miniforge
echo ERROR: conda was found but will not run.
echo Location: %CONDA_EXE%
echo.
pause
exit /b 1
echo The install is damaged or blocked. Try, in order:
echo 1. Run this by hand to see the real error:
echo "%CONDA_EXE%" --version
echo 2. Check antivirus is not quarantining conda
echo 3. Reinstall Miniforge, then run install.bat again:
echo https://github.com/conda-forge/miniforge/releases/latest
goto :fail
)
echo conda is working.
echo.
@@ -168,14 +224,48 @@ for %%C in (
)
)
:: --- Pre-flight: free disk space ---
:: The botty env is ~4 GB once conda unpacks it, plus ~1 GB of downloaded
:: package archives. Running out midway makes conda fail with a long, opaque
:: error, so check up front and say so plainly instead.
set "FREE_GB="
for /f "delims=" %%A in ('powershell -NoProfile -Command "[int]((Get-PSDrive %SystemDrive:~0,1%).Free/1GB)" 2^>nul') do set "FREE_GB=%%A"
if defined FREE_GB (
echo Free disk space: %FREE_GB% GB
if %FREE_GB% LSS 3 (
echo.
echo ERROR: Not enough free disk space on %SystemDrive%
echo Free now: %FREE_GB% GB
echo Needed: about 6 GB ^(the botty environment is ~4 GB, plus
echo ~1 GB of package downloads^)
echo.
echo Free up space and run install.bat again. Quick wins:
echo - Empty the Recycle Bin
echo - Windows Settings ^> System ^> Storage ^> Temporary files
echo - Uninstall apps you no longer use
goto :fail
)
if %FREE_GB% LSS 6 (
echo WARNING: Only %FREE_GB% GB free. The install needs about 6 GB and
echo may fail partway. Free up space if it does.
)
)
:: --- Create botty env ---
echo Creating 'botty' conda environment...
"%CONDA_EXE%" env create -f "%ENV_FILE%"
if %errorlevel% neq 0 (
echo.
echo ERROR: conda env create failed. See output above.
pause
exit /b 1
echo ERROR: Could not create the 'botty' conda environment.
echo.
echo The detailed reason is in the conda output above this message.
echo Most common causes:
echo - Out of disk space ^(needs about 6 GB free^)
echo - No internet, or a company/school network blocking conda-forge
echo - Antivirus blocking conda while it unpacks files
echo - A previous half-finished install: run this to clear it, then retry
echo "%CONDA_EXE%" env remove -n botty -y
goto :fail
)
:check_env
@@ -198,9 +288,16 @@ for %%C in (
)
echo.
echo ERROR: botty env was created but python.exe was not found.
pause
exit /b 1
echo ERROR: The 'botty' environment exists but its python.exe is missing.
echo.
echo This usually means the environment was only partly created -- often
echo because the install ran out of disk space, or antivirus removed files
echo while conda was unpacking them.
echo.
echo Fix: delete the environment and install again:
echo "%CONDA_EXE%" env remove -n botty -y
echo install.bat
goto :fail
:env_ready
echo Botty Python: %PYTHON%
@@ -215,9 +312,17 @@ echo Installing Botty requirements from %REQ_FILE%...
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off -r "%REQ_FILE%"
if %errorlevel% neq 0 (
echo.
echo ERROR: pip requirements install failed for %REQ_FILE%.
pause
exit /b 1
echo ERROR: Could not install the Python packages from %REQ_FILE%.
echo.
echo The failing package and reason are in the pip output above.
echo Most common causes:
echo - No internet, or a proxy/firewall blocking pypi.org
echo - Out of disk space
echo - Antivirus blocking pip while it writes files
echo.
echo The conda environment itself is fine -- you can just run
echo install.bat again; it will skip straight to this step.
goto :fail
)
echo.
@@ -249,9 +354,14 @@ for %%C in (
goto :env_found
)
)
echo ERROR: Could not find botty env directory.
pause
exit /b 1
echo ERROR: Could not locate the 'botty' environment folder.
echo.
echo Python was found but the environment directory around it was not,
echo which means the conda install is in an unexpected layout.
echo Fix: remove and recreate the environment:
echo "%CONDA_EXE%" env remove -n botty -y
echo install.bat
goto :fail
:env_found
set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_ENV_DIR%\DLLs;%BOTTY_ENV_DIR%\Scripts"
@@ -267,8 +377,27 @@ set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_EN
:: Remove any existing pip/wheel tesserocr
"%PYTHON%" -m pip uninstall tesserocr -y >nul 2>&1
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs
"%CONDA_EXE%" install -n botty "tesseract=4.*" -c conda-forge -y >nul 2>&1
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs.
:: libdeflate is required explicitly: the libtiff that ships with tesseract 4.x
:: imports libdeflate, and without it the whole chain below fails to load.
"%CONDA_EXE%" install -n botty "tesseract=4.*" libdeflate -c conda-forge -y >nul 2>&1
:: libdeflate.dll compatibility alias.
:: THIS IS WHAT MAKES tesserocr WORK. The DLL chain is:
:: tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll -> libdeflate.dll
:: Current conda-forge libdeflate (>=1.20) installs the library as "deflate.dll",
:: but the older tiff.dll from the tesseract=4.x stack still imports the previous
:: name "libdeflate.dll". Nothing provides that name, so tiff.dll fails to load,
:: and every DLL above it in the chain fails with WinError 126 ("The specified
:: module could not be found") -- which surfaced as tesserocr being permanently
:: unavailable and the bot silently falling back to the slower pytesseract.
:: Copying deflate.dll to the old name satisfies the import; the exported symbols
:: are the same library, verified by tesserocr initialising and running real OCR.
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
copy /y "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" >nul
)
)
:: conda tesseract.exe crashes on Win10 and Win11 — disable it, keep the DLLs
if exist "%BOTTY_ENV_DIR%\Library\bin\tesseract.exe" (
@@ -288,23 +417,83 @@ if exist "dependencies\tesserocr.cp310-win_amd64.pyd" (
)
:: --- Backend 2: pytesseract (reliable fallback) ---
:: Needs tesseract.exe from winget. Works on any Python version.
:: On Win10: winget may not be available -- if install fails, offer manual link.
:: This is the backend that actually carries OCR on most machines (tesserocr's
:: MSVC DLL chain frequently fails), so it must install without admin rights and
:: without winget -- neither is guaranteed on a clean Win10 box.
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off pytesseract >nul 2>&1
if %errorlevel% neq 0 (
echo WARNING: Could not install pytesseract Python wrapper.
)
if not exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
echo Installing Tesseract OCR via winget...
winget install --id tesseract-ocr.tesseract --silent --accept-package-agreements --accept-source-agreements 2>nul
if !errorlevel! neq 0 (
echo winget failed -- on Windows 10 you may need to install manually:
echo https://github.com/tesseract-ocr/tesseract/releases
echo Download the win64 installer, run it, keep the default install path.
:: Try winget machine scope, then user scope -- the latter needs no admin.
:: winget's exit code is unreliable, being non-zero when the package is already
:: installed, so after each attempt we re-resolve tesseract.exe instead of
:: trusting errorlevel.
:: NOTE: comments must stay OUTSIDE the parenthesised blocks below. A "::" line
:: inside a ( ) block is a parse error, and any parenthesis in the comment text
:: closes the block early.
call :find_tesseract
if not defined TESS_EXE (
echo Installing Tesseract OCR...
winget --version >nul 2>&1
if !errorlevel! equ 0 (
winget install --id tesseract-ocr.tesseract --exact --silent ^
--accept-package-agreements --accept-source-agreements >nul 2>&1
call :find_tesseract
if not defined TESS_EXE (
winget install --id tesseract-ocr.tesseract --exact --silent --scope user ^
--accept-package-agreements --accept-source-agreements >nul 2>&1
call :find_tesseract
)
)
)
:: Last resort: direct download of the official NSIS installer. Covers clean
:: Win10 machines where winget is missing or fails.
:: The size check guards against a truncated download or an HTML error page;
:: the installer is ~50 MB.
:: NOTE on /D=: measured behaviour is that the official Tesseract installer
:: self-elevates (it requests admin) and the elevated relaunch DISCARDS /D=, so
:: it always lands machine-wide in "C:\Program Files\Tesseract-OCR" regardless
:: of TS_DEST. /D= is kept as best-effort only. The practical consequence is
:: that Tesseract needs admin/UAC -- there is no per-user install path with the
:: official installer. Either outcome is fine at runtime because find_tesseract
:: and src\d2r_image\ocr.py both search the machine-wide and per-user paths.
:: /D= must still come last and unquoted, and breaks on paths with spaces, so
:: it is only passed when the target path has none.
if not defined TESS_EXE (
echo winget unavailable or failed -- downloading Tesseract directly...
set "TS_INSTALLER=%TEMP%\tesseract-setup.exe"
set "TS_URL=https://github.com/tesseract-ocr/tesseract/releases/download/5.5.0/tesseract-ocr-w64-setup-5.5.0.20241111.exe"
set "TS_DEST=%LOCALAPPDATA%\Programs\Tesseract-OCR"
del /q "!TS_INSTALLER!" >nul 2>&1
curl -Lk --progress-bar "!TS_URL!" -o "!TS_INSTALLER!" 2>&1
if not exist "!TS_INSTALLER!" (
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$ProgressPreference='SilentlyContinue'; try { Invoke-WebRequest -Uri '!TS_URL!' -OutFile '!TS_INSTALLER!' -UseBasicParsing; exit 0 } catch { exit 1 }"
)
set "TS_SIZE=0"
if exist "!TS_INSTALLER!" for %%A in ("!TS_INSTALLER!") do set "TS_SIZE=%%~zA"
if !TS_SIZE! GEQ 20971520 (
echo !TS_DEST! | find " " >nul
if !errorlevel! equ 0 (
start /wait "" "!TS_INSTALLER!" /S
) else (
start /wait "" "!TS_INSTALLER!" /S /D=!TS_DEST!
)
del /q "!TS_INSTALLER!" >nul 2>&1
call :find_tesseract
)
)
if defined TESS_EXE (
echo Tesseract: !TESS_EXE!
) else (
echo WARNING: Tesseract could not be installed automatically. Install manually:
echo https://github.com/tesseract-ocr/tesseract/releases
echo Download the win64 installer, run it, then re-run install.bat.
)
:: --- Verify OCR: at least one backend must work ---
echo.
echo Checking OCR backends...
@@ -319,8 +508,8 @@ if %errorlevel% == 0 (
echo tesserocr: not available ^(DLL issue -- bot will use pytesseract instead^)
)
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'C:\Program Files\Tesseract-OCR\tesseract.exe'; pytesseract.get_tesseract_version()" >nul 2>&1
if defined TESS_EXE (
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'!TESS_EXE!'; pytesseract.get_tesseract_version()" >nul 2>&1
if !errorlevel! == 0 (
echo pytesseract: OK ^(reliable fallback^)
set "OCR_READY=1"
@@ -384,3 +573,52 @@ echo Installation complete!
echo Run botty with: run_botty.bat
echo ============================================
echo.
:: Hold the window open. Every failure path already pauses, but the success
:: path did not -- so a user double-clicking install.bat from Explorer saw the
:: console vanish the instant it finished and never got to read the result or
:: the dependency/OCR verification above. Redirected runs are unaffected:
:: run_install_capture.bat feeds stdin from the log redirect, and any
:: non-interactive run should invoke this script with "< nul".
pause
goto :eof
:: --- Shared failure exit ---
:: Every fatal path jumps here with "goto :fail" after printing what went wrong
:: and how to fix it. This adds the one instruction that makes a bug report
:: actionable: how to produce a full log.
:fail
echo.
echo ------------------------------------------------------------
echo INSTALL FAILED - nothing else was changed on your PC.
echo.
echo Still stuck? Produce a full log and include it when asking
echo for help:
echo 1. Double-click run_install_capture.bat
echo 2. Attach the install_log.txt it creates
echo ------------------------------------------------------------
echo.
pause
exit /b 1
:: --- Resolve tesseract.exe into TESS_EXE ---
:: Checks machine-wide (winget default / manual install) and per-user (winget
:: --scope user / our direct NSIS fallback) locations, plus PATH. Sets TESS_EXE
:: to the first hit, or clears it if none found.
:find_tesseract
set "TESS_EXE="
for %%T in (
"C:\Program Files\Tesseract-OCR\tesseract.exe"
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
"%ProgramData%\Tesseract-OCR\tesseract.exe"
) do (
if exist %%T (
set "TESS_EXE=%%~T"
goto :eof
)
)
for /f "delims=" %%T in ('where tesseract 2^>nul') do (
set "TESS_EXE=%%T"
goto :eof
)
goto :eof
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "botty"
version = "0.8.1"
version = "0.10.0"
description = "Pixelbot for Diablo 2 Resurrected"
requires-python = ">=3.10,<3.11"
dependencies = [
+2 -1
View File
@@ -1,6 +1,7 @@
[pytest]
pythonpath =
.
src
env =
PYTHONPATH=./src
PYTHONPATH=./src:.
RUN_ENV=test
+1
View File
@@ -1,4 +1,5 @@
aiohappyeyeballs==2.6.2
pywin32==311; sys_platform=="win32"
aiohttp==3.14.1
aiosignal==1.4.0
async-timeout==5.0.1
+9 -2
View File
@@ -17,9 +17,16 @@ set "CONDA_PREFIX=%_ENV%"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
set "SSL_CERT_DIR="
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation)
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation).
:: Only export the path if it actually exists -- on machines where Tesseract was
:: installed per-user (no admin), it lives under %LOCALAPPDATA%\Programs instead,
:: and src\d2r_image\ocr.py resolves that itself.
set "TESSDATA_PREFIX=%_ENV%\Library\share"
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
) else if exist "%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe" (
set "PYTESSERACT_TESSERACT_CMD=%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
)
echo Launching Botty ...
"%PYTHON%" "%BOTTY_DIR%src\main.py"
+17 -1
View File
@@ -1,2 +1,18 @@
@echo off
call "%~dp0install.bat" > "%~dp0install_log.txt" 2>&1
:: Runs install.bat and captures everything to install_log.txt for support.
:: stdin is fed from nul so install.bat's trailing "pause" (and any failure
:: pause) cannot silently block behind the redirected output -- otherwise the
:: user would see an empty window waiting on a keypress they cannot see.
echo Installing and writing a full log to install_log.txt ...
echo This can take several minutes. Please wait.
call "%~dp0install.bat" < nul > "%~dp0install_log.txt" 2>&1
set "RC=%ERRORLEVEL%"
echo.
if "%RC%"=="0" (
echo Install finished. Full log: "%~dp0install_log.txt"
) else (
echo Install FAILED with exit code %RC%. Send this file for support:
echo "%~dp0install_log.txt"
)
echo.
pause
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python
"""Hermes bot control — send commands to botty via TCP socket on 127.0.0.1:18899.
Usage:
python scripts/hermes_bot_control.py start # start/pause bot
python scripts/hermes_bot_control.py pause # toggle pause
python scripts/hermes_bot_control.py stop # stop bot
python scripts/hermes_bot_control.py status # get bot status
python scripts/hermes_bot_control.py logs [n] # last n log lines
python scripts/hermes_bot_control.py errors [n] # last n error lines
python scripts/hermes_bot_control.py runs # run stats
"""
import sys
import os
import time
import subprocess
import glob
import socket
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
LOG_FILE = os.path.join(PROJECT_ROOT, 'log', 'log.txt')
SOCKET_PORT = 18899
def send_command(cmd):
"""Send a command to the bot's control socket."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3.0)
s.connect(('127.0.0.1', SOCKET_PORT))
s.sendall(cmd.encode())
response = s.recv(1024).decode().strip()
s.close()
if response:
print(response)
else:
print(f"OK: command '{cmd}' sent")
except socket.timeout:
print(f"ERROR: no response from bot (socket timeout)")
except ConnectionRefusedError:
print(f"ERROR: bot not listening on port {SOCKET_PORT} (is it running?)")
except Exception as e:
print(f"ERROR: {e}")
def read_logs(n=20):
if not os.path.exists(LOG_FILE):
print("No log file found")
return
result = subprocess.run(["tail", "-n", str(n), LOG_FILE],
capture_output=True, text=True)
print(result.stdout)
def read_errors(n=10):
if not os.path.exists(LOG_FILE):
print("No log file found")
return
result = subprocess.run(["grep", "-E", "ERROR|WARNING|Failed|failed|ERROR.*step", LOG_FILE],
capture_output=True, text=True)
lines = result.stdout.strip().split('\n')
for line in lines[-n:]:
print(line)
def run_stats():
stats_dir = os.path.join(PROJECT_ROOT, 'log', 'stats')
stats_files = glob.glob(os.path.join(stats_dir, 'stats_*.log'))
if stats_files:
latest = max(stats_files, key=os.path.getmtime)
with open(latest) as f:
print(f.read())
else:
print("No stats files found")
def check_status():
send_command('status')
if __name__ == '__main__':
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
cmd = sys.argv[1]
if cmd in ('start', 'pause', 'stop'):
send_command(cmd)
elif cmd == 'status':
check_status()
elif cmd == 'logs':
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
read_logs(n)
elif cmd == 'errors':
n = int(sys.argv[2]) if len(sys.argv) > 2 else 10
read_errors(n)
elif cmd == 'runs':
run_stats()
else:
print(f"Unknown command: {cmd}")
print(__doc__)
sys.exit(1)
+847 -39
View File
File diff suppressed because it is too large Load Diff
+61 -5
View File
@@ -36,6 +36,19 @@ class IChar:
}
self._use_safer_routines = Config().char["safer_routines"]
def atk_len(self, name: str) -> float:
"""Attack window for a boss, with stealth kill-time variance applied.
THE single correct way to read an atk_len_* value. Reading
Config().char["atk_len_x"] directly works but silently opts that boss
out of vary_kill_time the failure mode this whole subsystem keeps
producing. randomize_run_duration only ever LENGTHENS (1.0-1.4x), so a
varied window can never cut a kill short.
"""
from utils.stealth import randomize_run_duration
key = name if name.startswith("atk_len_") else f"atk_len_{name}"
return randomize_run_duration(float(Config().char[key]))
def _set_active_skill(self, mouse_click_type: str = "left", skill: str =""):
self._active_skill[mouse_click_type] = skill
@@ -130,7 +143,8 @@ class IChar:
success_func: Callable = None,
timeout: float = 8,
threshold: float = 0.68,
telekinesis: bool = False
telekinesis: bool = False,
roi: list = None
) -> bool:
"""
Finds any template from the template finder and interacts with it
@@ -138,6 +152,10 @@ class IChar:
:param success_func: Function that will return True if the interaction is successful e.g. return True when loading screen is reached, defaults to None
:param timeout: Timeout for the whole template selection, defaults to None
:param threshold: Threshold which determines if a template is found or not. None will use default form .ini files
:param roi: Optional [left, top, width, height] to restrict the search. Pass
ui_roi["cut_skill_bar"] for WORLD objects: the HUD is static, so a template
that happens to resemble part of it wins every frame and the real object is
never clicked. Cost a whole evening of A5_WP failures on 2026-08-27.
:return: True if success. False otherwise
"""
templates = template_type if isinstance(template_type, list) else [template_type]
@@ -148,7 +166,7 @@ class IChar:
wait(0.2, 0.3)
start = time.time()
while timeout is None or (time.time() - start) < timeout:
template_match = template_finder.search(template_type, grab(), threshold=threshold)
template_match = template_finder.search(template_type, grab(), threshold=threshold, roi=roi)
if template_match.valid:
Logger.debug(f"Select {template_match.name} ({template_match.score*100:.1f}% confidence)")
mouse.move(*template_match.center_monitor)
@@ -213,7 +231,41 @@ class IChar:
if skills.wait_until_right_skill_selected("TELE_ACTIVE") == False:
Logger.error("timeout waiting for tele skill to activate")
@staticmethod
def _hud_safe_target(pos_monitor: tuple[float, float], jitter: int) -> tuple[int, int]:
"""Jitter FIRST, then snap out of the HUD. Returns a point to click with
randomize=0.
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
construction lies exactly on the mask boundary. Applying mouse.move's
randomize AFTER it therefore pushes the cursor straight back inside:
randomize=3 offsets each axis by randrange(-3, 3), i.e. -3..+2. That
made the loot-filter clicks intermittent rather than fixed the first
version of this guard had exactly that hole, and its test only checked
the guard's output, never the cursor that was finally clicked.
Order matters: jitter, then guard, then move with no further
randomisation. The human-like offset is preserved; the guarantee is not
given away.
"""
x, y = int(pos_monitor[0]), int(pos_monitor[1])
if jitter and jitter > 0:
x += random.randrange(-jitter, jitter)
y += random.randrange(-jitter, jitter)
return get_closest_non_hud_pixel((x, y), "monitor")
def move(self, pos_monitor: tuple[float, float], force_tp: bool = False, force_move: bool = False):
# Never click into the HUD. Neither branch below did this, so a move
# target low on the screen landed on the interface — most visibly the
# loot-filter toggles at the bottom-left (screen x 395-560, y 692-712),
# which a right-click flips. Reported after Larzuk trips: he stands on
# the left of Harrogath, so moves to and from him aim at that corner.
#
# 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, while the teleport branch clicks the raw
# target. The pather's anti-stuck path already guarded this; move() did
# not.
factor = Config().advanced_options["pathing_delay_factor"]
if "teleport" in Config().char and Config().char["teleport"] and (
force_tp
@@ -223,7 +275,8 @@ class IChar:
)
):
self._set_active_skill("right", "teleport")
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, delay_factor=[factor*0.1, factor*0.14])
tx, ty = self._hud_safe_target(pos_monitor, 3)
mouse.move(tx, ty, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
mouse.click(button="right")
wait(self._cast_duration, self._cast_duration + 0.02)
@@ -237,7 +290,8 @@ class IChar:
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
x, y = self._hud_safe_target((x, y), 5)
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
if force_move:
keyboard.send(Config().char["force_move"])
@@ -255,7 +309,9 @@ class IChar:
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
# Same HUD guard as move(). walk() had the identical unguarded click.
x, y = self._hud_safe_target((x, y), 5)
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
if force_move:
keyboard.send(Config().char["force_move"])
+7 -7
View File
@@ -128,7 +128,7 @@ class FoHdin(Paladin):
def kill_pindle(self) -> bool:
atk_len_dur = float(Config().char["atk_len_pindle"])
atk_len_dur = self.atk_len("atk_len_pindle")
pindle_pos_abs = convert_screen_to_abs(Config().path["pindle_end"][0])
cast_pos_abs = [pindle_pos_abs[0] * 0.80, pindle_pos_abs[1] * 0.80]
@@ -162,7 +162,7 @@ class FoHdin(Paladin):
def kill_council(self) -> bool:
atk_len_dur = float(Config().char["atk_len_trav"])
atk_len_dur = self.atk_len("atk_len_trav")
keyboard.send(self._skill_hotkeys["conviction"])
wait(.15)
@@ -184,7 +184,7 @@ class FoHdin(Paladin):
def kill_eldritch(self) -> bool:
eld_pos_abs = convert_screen_to_abs(Config().path["eldritch_end"][0])
atk_len_dur = float(Config().char["atk_len_eldritch"])
atk_len_dur = self.atk_len("atk_len_eldritch")
self._generic_foh_attack_sequence(default_target_abs=eld_pos_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, default_spray=70)
@@ -202,7 +202,7 @@ class FoHdin(Paladin):
def kill_shenk(self):
atk_len_dur = float(Config().char["atk_len_shenk"])
atk_len_dur = self.atk_len("atk_len_shenk")
# traverse to shenk
keyboard.send(self._skill_hotkeys["conviction"])
@@ -221,7 +221,7 @@ class FoHdin(Paladin):
def kill_nihlathak(self, end_nodes: list[int]) -> bool:
atk_len_dur = Config().char["atk_len_nihlathak"]
atk_len_dur = self.atk_len("atk_len_nihlathak")
# Move close to nihlathak
self._pather.traverse_nodes(end_nodes, self, timeout=0.8, do_pre_move=False)
if self._select_skill("blessed_hammer"):
@@ -238,7 +238,7 @@ class FoHdin(Paladin):
def kill_summoner(self) -> bool:
# Attack
atk_len_dur = Config().char["atk_len_arc"]
atk_len_dur = self.atk_len("atk_len_arc")
self._generic_foh_attack_sequence(min_duration=atk_len_dur, max_duration=atk_len_dur*2, default_spray=80)
self._activate_cleanse_redemption()
return True
@@ -784,7 +784,7 @@ class FoHdin(Paladin):
def kill_diablo(self) -> bool:
### APPROACH ###
### ATTACK ###
atk_len_dur = float(Config().char["atk_len_diablo"])
atk_len_dur = self.atk_len("atk_len_diablo")
Logger.debug("Attacking Diablo at position 1/1")
diablo_abs = [100,-100] #hardcoded dia pos.
self._generic_foh_attack_sequence(default_target_abs=diablo_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, aura="concentration", foh_to_holy_bolt_ratio=2)
+9 -2
View File
@@ -1278,14 +1278,14 @@ class Hammerdin(Paladin):
Logger.debug("Waiting for Diablo to appear...")
start = time.time()
diablo_found = False
while (time.time() - start) < 15:
while (time.time() - start) < 20:
if get_visible_targets():
diablo_found = True
Logger.info("Diablo has spawned, engaging!")
break
wait(0.5, 0.6)
if not diablo_found:
Logger.warning("Diablo did not appear within 15s, attacking anyway")
Logger.warning("Diablo did not appear within 20s, attacking anyway")
### ATTACK WITH CONCENTRATION ###
# Concentration is the Blessed Hammer damage synergy and a party aura the
# merc benefits from. Conviction does nothing for magic-damage hammers, and
@@ -1295,10 +1295,17 @@ class Hammerdin(Paladin):
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
Logger.debug("Attacking Diablo at position 1/1")
self._cast_hammers(Config().char["atk_len_diablo"], "concentration")
# Re-verify targets mid-fight; if Diablo moved, reposition
if get_visible_targets():
pos_m = convert_abs_to_monitor((0, 0))
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
self._move_and_attack((60, 30), Config().char["atk_len_diablo"], "concentration")
self._move_and_attack((-60, -30), Config().char["atk_len_diablo"], "concentration")
wait(0.1, 0.15)
self._cast_hammers(1.2, "redemption")
# Final redemption burst to ensure kill
wait(0.1, 0.2)
self._cast_hammers(0.8, "redemption")
### LOOT ###
# force=True: Diablo is dead; his death animation/lingering effects register
# as targets and the mobs-alive guard would skip his drops entirely.
+5
View File
@@ -30,6 +30,11 @@ class Paladin(IChar):
self._action_frame = 6
def cast_buffs(self, casting_delay: float):
# Skip entirely when Holy Shield is unbound. keyboard.send() tolerates
# the empty key now, but the right-click after it would still fire and
# cast whatever happens to be on the right slot instead.
if not self._skill_hotkeys.get("holy_shield"):
return
keyboard.send(self._skill_hotkeys["holy_shield"])
wait(0.04, 0.1)
mouse.click(button="right")
+65 -1
View File
@@ -26,6 +26,8 @@ class Config:
ui_pos = {}
routes = {}
routes_order = []
cold_plains = {}
baal_xp = {}
char = {}
colors = {}
shop = {}
@@ -259,6 +261,7 @@ class Config:
"custom_message_hook": self._select_val("general", "custom_message_hook"),
"custom_loot_message_hook": self._select_val("general", "custom_loot_message_hook"),
"discord_status_count": False if not self._select_val("general", "discord_status_count") else int(self._select_val("general", "discord_status_count")),
"discord_timing_report_h": 2.0 if not self._select_val("general", "discord_timing_report_h") else float(self._select_val("general", "discord_timing_report_h")),
"discord_status_runs": False if not self._select_optional("general", "discord_status_runs", "") else int(self._select_optional("general", "discord_status_runs", "")),
"discord_log_chicken": bool(int(self._select_val("general", "discord_log_chicken"))),
"discord_log_errors": bool(int(self._select_optional("general", "discord_log_errors", "1"))),
@@ -295,7 +298,6 @@ class Config:
self.stealth = {
"wait_jitter_min": float(self._select_optional("stealth", "wait_jitter_min", "0.85")),
"wait_jitter_max": float(self._select_optional("stealth", "wait_jitter_max", "1.20")),
"click_variance": int(self._select_optional("stealth", "click_variance", "8")),
"reshuffle_each_rotation": bool(int(self._select_optional("stealth", "reshuffle_each_rotation", "1"))),
"skip_run_chance": int(self._select_optional("stealth", "skip_run_chance", "10")),
"afk_break_chance": int(self._select_optional("stealth", "afk_break_chance", "5")),
@@ -306,6 +308,37 @@ class Config:
"micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")),
"human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")),
"vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))),
# These eight were present in params.ini but never loaded here, so
# utils.stealth's cfg.get(key, <hardcoded>) always won and editing
# params.ini had NO effect. They only looked correct because the
# hardcoded fallbacks happened to match the shipped ini values.
"click_delay_min_ms": int(self._select_optional("stealth", "click_delay_min_ms", "50")),
"click_delay_max_ms": int(self._select_optional("stealth", "click_delay_max_ms", "250")),
"key_press_min_ms": int(self._select_optional("stealth", "key_press_min_ms", "20")),
"key_press_max_ms": int(self._select_optional("stealth", "key_press_max_ms", "200")),
"skill_hesitation_min_ms": int(self._select_optional("stealth", "skill_hesitation_min_ms", "80")),
"skill_hesitation_max_ms": int(self._select_optional("stealth", "skill_hesitation_max_ms", "300")),
"wrong_waypoint_chance": float(self._select_optional("stealth", "wrong_waypoint_chance", "0.025")),
"skill_mistake_chance": float(self._select_optional("stealth", "skill_mistake_chance", "0.015")),
# Opt-in switches for behaviours that alter WHERE or WHEN a click
# lands. Both default OFF: the per-call-site randomize= values in
# npc_manager/waypoint are hand-tuned against real button geometry,
# and stacking a global offset on top is what starts missing NPCs.
"click_delay_enabled": bool(int(self._select_optional("stealth", "click_delay_enabled", "0"))),
# Session rhythm — the signals per-action jitter cannot reach.
"session_budget_h": float(self._select_optional("stealth", "session_budget_h", "0")),
# Hard per-CALENDAR-DAY cap. session_budget_h is per process, so a restart
# resets it and a bot restarted all day still runs all day. This one is
# persisted to disk and survives restarts. 0 = unlimited.
"daily_budget_h": float(self._select_optional("stealth", "daily_budget_h", "0")),
"daily_budget_jitter": float(self._select_optional("stealth", "daily_budget_jitter", "0.12")),
# Close D2R when the daily cap trips. A bot process idling at the character
# select screen for 16h is itself a signal; a real player quits the game.
"daily_budget_close_game": bool(int(self._select_optional("stealth", "daily_budget_close_game", "1"))),
"idle_drift_enabled": bool(int(self._select_optional("stealth", "idle_drift_enabled", "1"))),
"chicken_variance": float(self._select_optional("stealth", "chicken_variance", "0.08")),
"town_browse_chance": float(self._select_optional("stealth", "town_browse_chance", "0.06")),
"pickup_skip_chance": float(self._select_optional("stealth", "pickup_skip_chance", "0.02")),
}
self.routes = {}
@@ -373,6 +406,7 @@ class Config:
"runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")),
"runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")),
"repair_npc": self._select_optional("char", "repair_npc", "a4_halbu").strip().lower(),
"resurrect_npc": self._select_optional("char", "resurrect_npc", "").strip().lower(),
"gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","),
"sell_junk": bool(int(self._select_val("char", "sell_junk"))),
"protect_shields_from_sell": bool(int(self._select_optional("char", "protect_shields_from_sell", "1"))),
@@ -382,6 +416,36 @@ class Config:
"enable_no_pickup": bool(int(self._select_val("char", "enable_no_pickup"))),
"safer_routines": bool(int(self._select_val("char", "safer_routines"))),
}
# Cold Plains clear run
self.cold_plains = dict(self.configs["config"]["parser"]["cold_plains"])
if "cold_plains" in self.configs["profile"]["parser"]:
self.cold_plains.update(dict(self.configs["profile"]["parser"]["cold_plains"]))
if "cold_plains" in self.configs["custom"]["parser"]:
self.cold_plains.update(dict(self.configs["custom"]["parser"]["cold_plains"]))
# Baal XP farm (join public games, hide, collect XP, leave)
self.baal_xp = {
"enabled": self._select_optional("baal_xp", "enabled", "0") not in (None, "", "0", "false", "False"),
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", "") or "",
"max_wait_s": float(self._select_optional("baal_xp", "max_wait_s", "900")),
"xp_threshold": int(self._select_optional("baal_xp", "xp_threshold", "50000000")),
"min_hp_pct": float(self._select_optional("baal_xp", "min_hp_pct", "40")),
"hide_x": int(self._select_optional("baal_xp", "hide_x", "640")),
"hide_y": int(self._select_optional("baal_xp", "hide_y", "360")),
"join_timeout_s": float(self._select_optional("baal_xp", "join_timeout_s", "60")),
}
for _baal_xp_src in ("profile", "custom"):
if "baal_xp" in self.configs[_baal_xp_src]["parser"]:
_baal_xp_over = self.configs[_baal_xp_src]["parser"]["baal_xp"]
for k in list(self.baal_xp.keys()):
if k not in _baal_xp_over:
continue
raw = _baal_xp_over[k]
if k == "enabled":
self.baal_xp[k] = raw not in (None, "", "0", "false", "False")
elif isinstance(self.baal_xp[k], str):
self.baal_xp[k] = raw if raw is not None else ""
else:
self.baal_xp[k] = type(self.baal_xp[k])(raw)
# Sorc base config
sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"])
if "sorceress" in self.configs["profile"]["parser"]:
+2 -1
View File
@@ -5,7 +5,8 @@ from d2r_image.processing_data import Runeword
try:
from rapidfuzz.string_metric import levenshtein
except ImportError:
from rapidfuzz.distance import Levenshtein as levenshtein
from rapidfuzz.distance import Levenshtein as _Levenshtein_mod
levenshtein = _Levenshtein_mod.distance
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
from bnip.NTIPAliasStat import NTIPAliasStat as NTIP_STATS
from logger import Logger
+5
View File
@@ -46,6 +46,11 @@ try:
os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release
shutil.which("tesseract"),
r"C:\Program Files\Tesseract-OCR\tesseract.exe",
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
# Per-user installs (winget --scope user, or install.bat's direct NSIS
# fallback) land here and are not on PATH -- needed on machines where
# the user has no admin rights.
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Tesseract-OCR", "tesseract.exe"),
]
_cmd = next((c for c in _candidates if c and os.path.isfile(c)), None)
if _cmd:
+2 -2
View File
@@ -169,8 +169,8 @@ class GameController:
Logger.warning("Your D2R settings differ from the requiered ones. Please use Auto Settings to adjust them. The differences are:")
Logger.warning(f"{diff}")
set_d2r_always_on_top()
if enforce_d2r_window(5, 98):
find_and_set_window_position(force=True)
enforce_d2r_window(5, 98)
find_and_set_window_position(force=True)
self.setup_screen()
self.start_health_manager_thread()
self.start_death_manager_thread()
+9
View File
@@ -97,6 +97,11 @@ class GameStats:
self._last_status_report_run = 0
self._last_failure_reason = None
self._merc_resurrect_failed = False
# Cross-game merc-resurrect circuit breaker. Qual-Kehk detection can fail 100% of
# the time (stale name-tag template -> a degenerate constant 0.424 match), and the
# per-game flag above does not stop that being retried every game at ~40s a hunt.
self._merc_resurrect_fail_streak = 0
self._merc_resurrect_skip_until = 0
# Auto-downgrade tracking: timestamps of chickens+deaths
self._downgrade_events: deque[float] = deque()
os.makedirs("log/stats", exist_ok=True)
@@ -271,6 +276,10 @@ class GameStats:
self._game_counter += 1
self._timer = time.time()
self._merc_resurrect_failed = False
# NOTE: _merc_resurrect_fail_streak / _merc_resurrect_skip_until are deliberately
# NOT reset here. They are the cross-game circuit breaker — resetting the per-game
# flag alone meant a permanently undetectable resurrect NPC was re-hunted (twice,
# ~40s each) in every single game.
# Clear the previous game's failure reason so this game's events can't be
# attributed to a stale reason from an earlier failure.
self._last_failure_reason = None
+115 -3
View File
@@ -18,6 +18,9 @@ from ui_manager import ScreenObjects, is_visible
class HealthManager:
_instance = None
# Consecutive esc attempts allowed against a waypoint panel before it stops being
# treated as benign. Reset as soon as a poll sees no panel at all.
_MAX_WP_PANEL_ESCAPES = 6
def __init__(self):
HealthManager._instance = self
@@ -33,6 +36,9 @@ class HealthManager:
self._callback = None
self._last_chicken_screenshot = None
self._count_panel_detects = 0
self._count_wp_panel_detects = 0
self._count_center_panel_detects = 0
self._mana_below_threshold = False
def stop_monitor(self):
self._do_monitor = False
@@ -50,6 +56,7 @@ class HealthManager:
# game don't cause an immediate chicken on the next game's first panel.
with self._state_lock:
self._count_panel_detects = 0
self._count_wp_panel_detects = 0
def get_pause_state(self):
with self._state_lock:
@@ -155,7 +162,7 @@ class HealthManager:
f"but HP is safe at {(health_percentage*100):.1f}%; not chickening"
)
chicken_threshold = Config().char["chicken"]
chicken_threshold = get_game_chicken_threshold()
if loot_priority_active():
chicken_threshold = max(chicken_threshold * 0.5, LOOT_PRIORITY_HP_FLOOR)
if health_percentage <= chicken_threshold:
@@ -172,6 +179,22 @@ class HealthManager:
self._last_health = time.time()
# check mana
last_drink = time.time() - self._last_mana
# Issue #23 instrumentation. "1 mana potion per game,
# never 2" was measured over 70 games, but the cause
# was undecidable: mana is only logged when a potion
# is DRUNK, so a second dip that failed to trigger
# looks identical to mana never dipping twice. Log
# every crossing of the threshold, whether or not it
# results in a drink.
_mana_low = mana_percentage <= Config().char["take_mana_potion"]
if _mana_low and not self._mana_below_threshold:
Logger.debug(
f"MANA> crossed below {Config().char['take_mana_potion']:.2f} "
f"at {mana_percentage*100:.1f}% "
f"(last drink {last_drink:.1f}s ago, gate {lp_mp_potion_delay}s) "
f"-> {'will drink' if last_drink > lp_mp_potion_delay else 'BLOCKED by gate'}"
)
self._mana_below_threshold = _mana_low
if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay:
wait(0.05, 0.1)
if belt.drink_potion("mana", stats=[health_percentage, mana_percentage]):
@@ -196,8 +219,70 @@ class HealthManager:
if belt.drink_potion("health", merc=True, stats=[merc_health]):
self._last_merc_heal = time.time()
# Close any open panels that might block detection
# Close any open panels that might block detection
# A CENTRED panel (Chronicle) matches neither header ROI, so
# nothing ever closed it — and a centred panel blanks every
# later template search. Measured 2026-08-28: Chronicle open,
# A5_RED_PORTAL unfindable, 66s approach failure. It is not a
# threat, so escape it without counting toward a chicken,
# bounded the same way as the waypoint panel.
# The in-game ESC menu. A stray esc opens it, and its LOOT
# FILTER / CHRONICLE / OPTIONS buttons sit at screen centre
# where movement clicks land — which is how the loot filter
# got toggled, how Chronicle blocked every template match,
# and how the bot ended up in the video options with a
# "settings have changed" modal. It has no close button, so
# CenterPanel cannot detect it. esc toggles it shut.
if not self.get_panel_check_paused() and is_visible(ScreenObjects.GameMenu, img):
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"In-game menu open (its buttons sit under movement clicks) — closing it "
f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("In-game menu would not close — treating as a blocking panel")
if not self.get_panel_check_paused() and is_visible(ScreenObjects.CenterPanel, img):
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"Centred panel open (blocks all template matching) — closing it "
f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("Centred panel would not close — treating as a blocking panel")
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
# A waypoint panel is self-inflicted: the pather walked the char
# over the WP stone. That is a navigation problem, not a threat,
# and chickening on it threw away healthy games. Escape it without
# counting — but stay bounded, so a WP panel we genuinely cannot
# close still falls through to the normal chicken path.
if is_visible(ScreenObjects.WaypointLabel, img):
self._count_wp_panel_detects += 1
if self._count_wp_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"Waypoint panel open (walked onto the WP) — closing it "
f"({self._count_wp_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("Waypoint panel would not close — treating as a blocking panel")
self._count_panel_detects += 1
if self._count_panel_detects >= 2:
self._count_panel_detects = 0
@@ -205,7 +290,15 @@ class HealthManager:
self._do_chicken(img)
continue
Logger.debug("Found an open panel. Closing it.")
common.close()
# Send Escape directly — more reliable than common.close() which
# only checks inventory_is_open() and may miss belt/panel states
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
else:
# Screen is clear, so any waypoint panel we escaped did close.
self._count_wp_panel_detects = 0
self._count_center_panel_detects = 0
fn_end = time.perf_counter()
# Target ~15 FPS polling with anti-cheat jitter
@@ -244,6 +337,25 @@ def get_pause_state():
return HealthManager._instance.get_pause_state()
return True
_game_chicken_threshold = None
def set_game_chicken_threshold(value):
"""Per-game chicken threshold, rolled by the BOT thread at game start.
The health manager thread is a read-only monitor by design and must not do
the rolling itself see the threading rules in CLAUDE.md.
"""
global _game_chicken_threshold
_game_chicken_threshold = value
def get_game_chicken_threshold() -> float:
if _game_chicken_threshold is not None:
return _game_chicken_threshold
return Config().char["chicken"]
def set_pause_state(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
+136 -15
View File
@@ -7,13 +7,101 @@ Import as:
This is a drop-in replacement for the existing `import keyboard` and
`from utils.custom_mouse import mouse` patterns.
On non-Windows (Docker/Linux), uses bridge_input to talk to a Windows host
via TCP. Set BOTTY_BRIDGE_HOST / BOTTY_BRIDGE_PORT env vars.
"""
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
from .mouse_impl import mouse
import re as _re
from logger import Logger
import os as _os
import threading as _threading
if _os.name == "nt":
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
else:
from .bridge_input import (
_get_vk, key_down, key_up, key_press, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
# bridge_input has no send_key; provide stub
def send_key(key):
key_press(key)
if _os.name == "nt":
from .mouse_impl import mouse
else:
# In bridge mode, mouse_impl imports from win_input which won't work.
# Provide a thin wrapper that delegates to bridge_input.
class _BridgeMouse:
def move_to(self, x, y): mouse_move(x, y)
def click(self, button="left"): mouse_click(button)
def down(self, button="left"): mouse_down(button)
def up(self, button="left"): mouse_up(button)
def wheel(self, clicks): mouse_wheel(clicks)
def get_pos(self): return get_cursor_pos()
mouse = _BridgeMouse()
# ─── Stealth key classification ───────────────────────────────────────────────
# Skill casts may carry human hesitation. Potion/belt keys MUST NOT: they are
# driven by health_manager at low HP, so any added latency is a death risk.
#
# The previous gate was `vk in range(ord('1'), ord('0') + 1)`. ord('1')=49 and
# ord('0')=48, so that range is EMPTY and never matched. Only the string
# fallback fired — and it listed the digits, i.e. the potion keys. The gate was
# therefore aimed at the exact inverse of its intent: every potion press got
# 80-300ms of hesitation and a 1.5% chance of pressing a different potion first,
# while actual skill casts (f1-f12) were never touched.
_SKILL_KEY_RE = _re.compile(r"^f([1-9]|1[0-2])$")
_key_class_cache = {}
def _never_stealth_keys() -> set:
"""Potion and belt keys — exempt from all stealth timing, by construction."""
if "never" not in _key_class_cache:
keys = {"1", "2", "3", "4"}
try:
from config import Config
char = Config().char
for name in ("potion1", "potion2", "potion3", "potion4", "show_belt"):
val = char.get(name)
if val:
keys.add(str(val).strip().lower())
except Exception:
pass
_key_class_cache["never"] = keys
return _key_class_cache["never"]
def _bound_skill_keys() -> list:
"""Skill hotkeys actually bound by the active character."""
if "skills" not in _key_class_cache:
found = set()
try:
from config import Config
cfg = Config()
section = getattr(cfg, str(cfg.char.get("type", "")).lower(), None)
if isinstance(section, dict):
for val in section.values():
k = str(val).strip().lower()
if _SKILL_KEY_RE.match(k):
found.add(k)
except Exception:
pass
_key_class_cache["skills"] = sorted(found - _never_stealth_keys())
return _key_class_cache["skills"]
def _is_skill_key(key: str) -> bool:
k = str(key or "").strip().lower()
if k in _never_stealth_keys():
return False
return bool(_SKILL_KEY_RE.match(k))
class _Keyboard:
"""
@@ -74,16 +162,22 @@ class _Keyboard:
except Exception:
return False
# Press a random skill key first (simulates miscast)
# Press a different BOUND SKILL first (simulates a miscast). The old code
# built its candidate list from range(ord('1'), ord('0')+1) — an empty
# range — so random.choice() raised and the except branch always ran,
# picking from ['1'..'5']: potion keys. A "miscast" that drinks a potion
# is not a miscast, it is a bug with a costume on.
import random
candidates = [k for k in _bound_skill_keys() if k != str(key).strip().lower()]
if not candidates:
return False
wrong_key = random.choice(candidates)
try:
from config import Config
skill_keys = list(range(ord('1'), ord('0') + 1)) # Keys 1-0
wrong_key = chr(random.choice(skill_keys))
if wrong_key == key:
wrong_key = chr(random.choice(skill_keys))
from utils.stealth import _tl
_tl("skill_mistake", "ok", f"miscast {wrong_key} before {key}")
except Exception:
wrong_key = random.choice(['1', '2', '3', '4', '5'])
pass
# Send the wrong key
wrong_vk = _get_vk(wrong_key)
@@ -99,9 +193,19 @@ class _Keyboard:
"""
Send a key press event with stealth timing.
An EMPTY key is a no-op, not an error. An unbound optional skill is a
legitimate configuration a FoHdin with no Holy Shield, no Vigor, no
Cleansing and there are 90+ `keyboard.send(self._skill_hotkeys[x])`
call sites across the paladin classes alone, almost none of which check
first. Raising here kills the whole bot thread for a skill the
character was never meant to cast. Guard once, at the boundary.
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
Supports do_release=False (hold key) and do_press=False (release-only).
"""
if key is None or str(key).strip() == "":
Logger.debug("keyboard.send: empty key (unbound skill) — skipping")
return
self._stealth_before()
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
@@ -144,8 +248,10 @@ class _Keyboard:
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
# Tier 2 stealth: skill hesitation (only for skill hotkeys 1-0)
if vk in range(ord('1'), ord('0') + 1) or key in ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0'):
# Tier 2 stealth: hesitation + miscast, SKILL HOTKEYS ONLY.
# Potion/belt keys are excluded by _is_skill_key — see the note above
# _SKILL_KEY_RE for why the old digit-based gate did the opposite.
if _is_skill_key(key):
self._skill_rotation_hesitation()
self._maybe_skill_mistake(key)
@@ -176,11 +282,22 @@ class _Keyboard:
def add_hotkey(self, key: str, callback, suppress: bool = False):
"""Register a global hotkey callback."""
if _os.name != "nt":
# In Docker, hotkeys are not available — no-op
return
from .hotkey import add_hotkey as _add_hotkey
_add_hotkey(key, callback, suppress=suppress)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
if _os.name != "nt":
# In Docker, block forever (or until SIGTERM) — bot runs headless
import signal
event = _threading.Event()
signal.signal(signal.SIGTERM, lambda *_: event.set())
signal.signal(signal.SIGINT, lambda *_: event.set())
event.wait()
return
from .hotkey import wait as _wait
return _wait(key, suppress=suppress)
@@ -190,11 +307,15 @@ class _Keyboard:
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events (dev tools only)."""
if _os.name != "nt":
return
from .hotkey import hook as _hook
return _hook(callback, suppress=suppress)
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing (used by npc_auto_label.py)."""
if _os.name != "nt":
return
from .hotkey import pause as _pause
return _pause(seconds, suppress)
+85 -21
View File
@@ -1,7 +1,8 @@
"""
Global hotkey polling via GetAsyncKeyState.
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
No kernel driver - pure user-mode polling thread.
Global hotkey via keyboard library's WH_KEYBOARD_LL hook.
Intercepts all keystrokes regardless of which window has focus.
Falls back to GetAsyncKeyState polling if the keyboard library
fails to install its hook (e.g. antivirus interference).
"""
import threading
import time
@@ -9,24 +10,81 @@ import ctypes
from ctypes import wintypes
from .win_input import _get_vk, VK_MAP, user32
try:
import keyboard as _keyboard
_HAS_KEYBOARD = True
except Exception:
_HAS_KEYBOARD = False
class _HotkeyManager:
def __init__(self):
self._callbacks = {} # vk -> [(key_str, callback), ...]
self._running = False
self._thread = None
self._suppress = {} # vk -> bool (suppress key after callback fires)
self._suppress = {} # vk -> bool
self._lock = threading.Lock()
self._suppressed = set() # vks currently being held down in suppress mode
self._held = set() # vks seen down on the previous poll (edge-trigger)
self._held = set()
self._suppressed = set()
self._poll_thread = None
# Track which keys are registered with the keyboard library
self._keyboard_callbacks = {} # vk -> keyboard callback wrapper
def _ensure_running(self):
if not self._running:
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
if self._running:
return
self._running = True
if _HAS_KEYBOARD:
# Use keyboard library's global hook - works even when D2R has focus
try:
self._keyboard_hook = _keyboard.hook(self._keyboard_callback, suppress=False)
return
except Exception:
pass # Fall through to polling
# Fallback: GetAsyncKeyState polling (only works when bot has focus)
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self._poll_thread.start()
def _keyboard_callback(self, event):
"""Callback from keyboard library's global hook."""
if event.event_type != _keyboard.KEY_DOWN:
return
# Map keyboard event name to VK code
vk = None
try:
# keyboard library uses names like 'f11', 'f12', etc.
vk = _get_vk(event.name)
except Exception:
return
if vk is None:
return
with self._lock:
if vk not in self._callbacks:
return
if vk in self._held or vk in self._suppressed:
return
self._held.add(vk)
entries = list(self._callbacks[vk])
for key_str, cb in entries:
try:
cb()
except Exception:
import traceback
traceback.print_exc()
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Suppress: block the key from reaching the app
event.suppress()
break
def _poll_loop(self):
"""Poll GetAsyncKeyState for registered hotkeys."""
"""Fallback: poll GetAsyncKeyState when global hook is unavailable."""
while self._running:
with self._lock:
items = list(self._callbacks.items())
@@ -36,7 +94,6 @@ class _HotkeyManager:
self._held.discard(vk)
continue
if vk in self._held or vk in self._suppressed:
# Still held since last poll - fire only on the down edge
continue
self._held.add(vk)
for key_str, cb in entries:
@@ -48,20 +105,18 @@ class _HotkeyManager:
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Wait for key release if suppressed
if self._suppressed:
still_suppressed = set()
for vk in self._suppressed:
state = user32.GetAsyncKeyState(vk)
if not (state & 0x8000):
# Key released
pass
else:
still_suppressed.add(vk)
self._suppressed = still_suppressed
from utils.misc import wait as _wait
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
_wait(0.018, 0.024)
def add_hotkey(self, key: str, callback, suppress: bool = False):
vk = _get_vk(key)
@@ -115,15 +170,12 @@ class _HotkeyManager:
from utils.misc import wait as _wait
_wait(0.018, 0.024)
else:
# keyboard.wait() with no key blocks forever (keeps main thread alive
# while the daemon hotkey/bot threads run) - match that semantic.
while True:
time.sleep(1.0)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events.
This is a simplified version - polls all known keys and calls callback.
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
Simplified polling version for dev tools."""
def _poll_all():
while self._running:
for vk in range(1, 256):
@@ -142,7 +194,6 @@ class _HotkeyManager:
from utils.misc import wait as _wait
_wait(0.02, 0.02)
self._ensure_running()
# Run hook in its own thread
t = threading.Thread(target=_poll_all, daemon=True)
t.start()
@@ -151,6 +202,16 @@ class _HotkeyManager:
from utils.misc import wait as _wait
_wait(seconds, seconds)
def stop(self):
"""Uninstall the hook and stop threads."""
self._running = False
if _HAS_KEYBOARD and hasattr(self, '_keyboard_hook'):
try:
_keyboard.unhook(self._keyboard_hook)
except Exception:
pass
# Singleton
_hotkey_manager = _HotkeyManager()
@@ -173,3 +234,6 @@ def hook(callback, suppress: bool = False):
def pause(seconds: float = 0, suppress: bool = False):
return _hotkey_manager.pause(seconds, suppress)
def stop_hotkeys():
_hotkey_manager.stop()
-35
View File
@@ -203,34 +203,6 @@ class mouse:
_native_move(int(point[0]), int(point[1]))
mouse.sleep(delta)
@staticmethod
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause -> randomized position -> endpoint wobble."""
try:
from utils.stealth import randomize_click_position, add_micro_pause, endpoint_wobble
rx, ry = randomize_click_position(x, y)
add_micro_pause()
except Exception:
from logger import Logger
Logger.warning("[Stealth] randomize_click_position/add_micro_pause failed, using manual fallback")
try:
from config import Config
variance = Config().stealth["click_variance"]
except Exception:
variance = 0
rx = x + random.randint(-variance, variance)
ry = y + random.randint(-variance, variance)
mouse.move(rx, ry, absolute=absolute, randomize=5 + variance, delay_factor=delay_factor)
try:
from utils.stealth import endpoint_wobble
wx, wy = endpoint_wobble(rx, ry)
_native_move(wx, wy)
except Exception:
from logger import Logger
Logger.warning("[Stealth] endpoint_wobble failed, skipping micro-adjustment")
pass
@staticmethod
def _is_clicking_safe():
try:
@@ -262,13 +234,6 @@ class mouse:
if button != "left" or mouse._is_clicking_safe():
# DISABLED STEALTH FOR DEBUGGING
# try:
# from utils.stealth import apply_click_delay
# apply_click_delay()
# except Exception:
# from logger import Logger
# Logger.warning("[Stealth] apply_click_delay failed, falling back to manual delay")
# from utils.misc import wait as _wait
# _wait(0.05, 0.3)
from logger import Logger
Logger.debug(f"Executing native click: {button}")
_native_click(button)
+5
View File
@@ -300,6 +300,11 @@ def mouse_move(x: int, y: int):
Logger.warning(f"SetCursorPos failed for ({target_x}, {target_y}); continuing with current cursor position")
except Exception:
pass
# Ensure the cursor is exactly on target before any click (SetCursorPos can
# land 1-2px off on some DPI/virtual-desktop setups; D2R UI hitboxes are tight).
cur_x, cur_y = get_cursor_pos()
if abs(cur_x - target_x) > 0 or abs(cur_y - target_y) > 0:
user32.SetCursorPos(target_x, target_y)
def mouse_down(button: str = "left"):
"""Press mouse button down."""
+6
View File
@@ -54,6 +54,12 @@ def open(img: np.ndarray = None) -> np.ndarray:
opened = _try_open_with_click()
if not opened:
Logger.warning("Could not open belt after key and click recovery attempts")
# Force-close any partially-open belt to prevent panel detection from triggering chicken
try:
keyboard.send("esc")
wait(0.2, 0.3)
except Exception:
pass
return None
img = grab()
return img
+25 -1
View File
@@ -479,7 +479,24 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
Logger.error(f"transfer_items: Can't perform, vendor is not open")
case "stash":
if is_visible(ScreenObjects.GoldBtnStash, img):
filtered = [ item for item in items if item.keep == True ]
# Kept items, PLUS anything the protection guard has made both unsellable
# and undroppable. Without the second group a protected item the pickit
# rejected can never leave the pack: sell and drop are blocked and this
# filter used to exclude it, so it occupied its slot forever (673 blocked
# sells across 7 charms in one session).
#
# Scoped to the LOOT columns on purpose. A charm's bonus only applies from
# the inventory, and the pickit cannot tell a wanted res charm from junk —
# LAPIS SMALL CHARM OF VITA (+20 life, cold res 7%) is "Discarding" simply
# because the rule wants coldresist >= 11. So treat position as the intent
# signal: charms parked in the RESERVED columns are deliberate keepers and
# are left alone (the click guard makes them untouchable anyway), while
# freshly looted ones landing in the loot columns get stashed.
loot_cols = Config().char["num_loot_columns"]
filtered = [
item for item in items
if item.keep or (_is_protected(item) and item.column < loot_cols)
]
else:
Logger.error(f"transfer_items: Can't perform, stash is not open")
case _:
@@ -524,6 +541,13 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
# item successfully transferred, delete from list
item_label = f" '{item.name}'" if item.name else ""
Logger.debug(f"Confirmed {action}{item_label} at position {item.pos}")
# Mirror into the TOWN timeline so sells/stashes/drops of individual items
# appear in the same greppable sequence as the maintenance steps.
try:
from bot import Bot
Bot.timeline("town", f"item_{action}", "ok", f"{item.name or '?'} @ {item.pos}")
except Exception:
pass
for cnt, o_item in enumerate(items):
if o_item.pos == item.pos:
items.pop(cnt)
+35
View File
@@ -216,6 +216,30 @@ class PickIt:
self._prev_item_pickup_attempt = item
return self._yoink_item(item, char)
def _should_walk_past(self, item) -> bool:
"""Per-ITEM stealth skip, decided once and remembered.
pick_up_items loops until a timeout, re-locating items after every
pickup, so the same item is evaluated many times. Rolling on each
evaluation made the effective skip rate far higher than configured and
could re-roll an item already skipped.
The caller MUST advance item_count before continuing it is incremented
at the end of the loop body, so a bare `continue` re-evaluates the same
item until the pickit phase times out.
"""
if not hasattr(self, "_stealth_skipped"):
self._stealth_skipped = {}
key = getattr(item, "ID", None) or getattr(item, "Name", None)
if key is None:
return False
if key not in self._stealth_skipped:
from utils.stealth import should_skip_pickup
self._stealth_skipped[key] = should_skip_pickup()
if self._stealth_skipped[key]:
Logger.debug(f"Stealth: walking past {item.Name}")
return self._stealth_skipped[key]
def pick_up_items(self, char: IChar, force: bool = False) -> bool:
"""
To be called everytime the bot wants to pick up items
@@ -273,6 +297,14 @@ class PickIt:
cached_pickup = True
raw_expression = "pick_rares_for_gold=1 (rare quality override)"
if cached_pickup and not (self._ignore_consumable(item) or self._ignore_gold(item)):
# Stealth: occasionally walk past something the filter wanted.
# Decided ONCE per item id and remembered: this loop re-scans
# and re-evaluates until a timeout, so rolling per evaluation
# both compounds the real skip rate and re-rolls an item that
# was already skipped.
if self._should_walk_past(item):
item_count += 1
continue
Logger.debug(f"Pick up expression: {raw_expression}")
Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}")
pick_up_res = self._pick_up_item(char, item)
@@ -298,6 +330,9 @@ class PickIt:
if not pickup:
Logger.debug(f"Skip item {item.Name} at distance {item.Distance}: no matching pickit rule")
if pickup:
if self._should_walk_past(item):
item_count += 1
continue
Logger.debug(f"Pick up expression: {raw_expression}")
Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}")
pick_up_res = self._pick_up_item(char, item)
+128 -11
View File
@@ -60,6 +60,37 @@ def start_or_pause_bot(controllers: Controllers):
screen.start_detecting_window()
controllers.game.start()
def handle_hermes_command(data: str, controllers: Controllers):
"""Handle one hermes control-socket command. Returns a reply string or None.
'start' used to be a plain alias for 'pause' the same toggle. A caller
retrying a timed-out 'start' therefore PAUSED the bot. On 2026-08-28 that
left it frozen for 4h50m, and `status` still answered running=True because
that field reports the game controller, not Bot._pausing.
'start' is now idempotent: it starts a stopped bot, resumes a paused one,
and does nothing to a healthy one. Use 'pause'/'toggle' for the toggle.
"""
bot = getattr(controllers.game, "bot", None)
paused = bool(getattr(bot, "_pausing", False))
if data == "start":
if not controllers.game.is_running:
start_or_pause_bot(controllers)
elif paused:
start_or_pause_bot(controllers) # resume
return None # already running and not paused: no-op
if data in ("pause", "toggle"):
start_or_pause_bot(controllers)
return None
if data == "stop":
on_exit(controllers)
return None
if data == "status":
return f"running={controllers.game.is_running} paused={paused}"
return None
def start_or_stop_graphic_debugger(controllers: Controllers):
if controllers.debugger.is_running:
controllers.debugger.stop()
@@ -80,6 +111,11 @@ def on_exit(controllers: Controllers):
Logger.warning(f"Failed to save session report: {e}")
screen.stop_detecting_window()
restore_d2r_window_visibility()
try:
from input_layer.hotkey import stop_hotkeys
stop_hotkeys()
except Exception:
pass
os._exit(1)
def _log_platform_info():
@@ -92,8 +128,11 @@ def _log_platform_info():
Logger.info(f"Environment profile: {os_info.environment_file}")
Logger.info(f"Requirements profile: {os_info.requirements_file}")
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
try:
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
except ImportError:
mouse_mode = "bridge (Docker)"
Logger.info(f"Mouse input mode: {mouse_mode}")
# OCR backend check
@@ -143,15 +182,19 @@ def main():
startup_checks()
# Auto-launch D2R only when explicitly enabled in params.ini (auto_login=1)
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
if Config().general["auto_login"]:
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
# In Docker, D2R runs on the Windows host — skip process checks
if os.name == "nt":
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
if Config().general["auto_login"]:
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
else:
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
else:
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
Logger.info("D2R is already running")
else:
Logger.info("D2R is already running")
Logger.info("Running in Docker — D2R process check skipped (bridge server handles host interaction)")
print(f"============ Botty {__version__} [name: {Config().general['name']}] ============")
_profiles = Config.list_profiles()
@@ -201,6 +244,20 @@ def main():
keyboard.add_hotkey(Config().advanced_options['resume_key'], lambda: start_or_pause_bot(controllers))
keyboard.add_hotkey(Config().advanced_options["exit_key"], lambda: on_exit(controllers))
# Hermes Agent control socket — TCP server on localhost:18899
# Accepts commands: start, pause, stop, status
try:
_hermes_socket = __import__('socket').socket(__import__('socket').AF_INET, __import__('socket').SOCK_STREAM)
_hermes_socket.setsockopt(__import__('socket').SOL_SOCKET, __import__('socket').SO_REUSEADDR, 1)
_hermes_socket.settimeout(1.0)
_hermes_socket.bind(('127.0.0.1', 18899))
_hermes_socket.listen(5)
_hermes_socket.setblocking(False)
Logger.info("Hermes control socket listening on 127.0.0.1:18899")
except Exception as _e:
Logger.debug(f"Hermes control socket failed: {_e}")
_hermes_socket = None
def _cycle_profile():
profiles = Config.list_profiles()
if not profiles:
@@ -261,7 +318,63 @@ def main():
f.write(content)
keyboard.add_hotkey(Config().advanced_options['cycle_pickit_profile_key'], _cycle_pickit_profile)
keyboard.wait()
# In Docker, auto-start the bot instead of waiting for hotkey
if os.name != "nt":
Logger.info("Docker mode — auto-starting bot")
screen.start_detecting_window()
controllers.game.start()
# Wait for SIGTERM/SIGINT to shut down
keyboard.wait()
else:
# Poll loop: checks hermes control socket + waits for keyboard events
import threading
_shutdown_event = threading.Event()
def _hermes_poll():
"""Poll hermes control socket for commands."""
import select
while not _shutdown_event.is_set():
try:
if _hermes_socket is None:
time.sleep(0.5)
continue
rlist, _, _ = select.select([_hermes_socket], [], [], 0.5)
if rlist:
try:
conn, _ = _hermes_socket.accept()
# select() only says a connection is PENDING — the
# client's bytes may not have arrived yet. The accepted
# socket inherits the listener's non-blocking mode, so
# recv() raised BlockingIOError (WinError 10035), the
# error was swallowed, and the caller saw a timeout.
# That is the root of every "no response from bot
# (socket timeout)" and of the retry loops built around
# it — one of which paused the bot for 4h50m.
conn.setblocking(True)
conn.settimeout(2.0)
data = conn.recv(1024).decode().strip().lower()
_reply = handle_hermes_command(data, controllers)
if _reply is not None:
conn.sendall(_reply.encode())
conn.close()
except Exception as e:
# Never swallow this silently. A raising handler leaves
# the connection in CLOSE_WAIT and every control command
# times out, which reads exactly like "the bot is wedged"
# and cost a long debugging detour on 2026-08-28.
Logger.error(f"hermes command {data!r} failed: {type(e).__name__}: {e}")
try:
conn.close()
except Exception:
pass
except Exception as e:
Logger.debug(f"hermes poll loop error: {type(e).__name__}: {e}")
time.sleep(0.5)
if _hermes_socket is not None:
threading.Thread(target=_hermes_poll, daemon=True).start()
keyboard.wait()
if __name__ == "__main__":
@@ -276,5 +389,9 @@ if __name__ == "__main__":
try:
if __import__('sys').stdin and __import__('sys').stdin.isatty():
input()
except (OSError, ValueError):
except (OSError, ValueError, EOFError):
# EOFError is the one input() actually raises when stdin is closed or not
# interactive — isatty() can still report True in some detached/redirected
# launches. Leaving it out turned every shutdown into an "Uncaught exception"
# traceback in the log, which reads like a crash rather than a clean exit.
pass
+44 -2
View File
@@ -5,11 +5,51 @@ import cv2
from utils.log_rotation import safe_imwrite
import datetime
import traceback
import discord
from version import __version__
import numpy as np
from discord import SyncWebhook, Color
import json
# Discord is an OPTIONAL notification dependency, but importing it pulls in
# aiohttp, which builds a default SSL context at import time. On a machine whose
# OpenSSL is newer than this Python build expects, that raises
# ssl.SSLError: [ASN1: NOT_ENOUGH_DATA]
# while loading the Windows certificate store. Importing it eagerly therefore
# took the ENTIRE bot down at startup — ui_manager -> messages -> discord is on
# the import path of every run. Degrade to "no Discord notifications" instead.
try:
import discord
from discord import SyncWebhook, Color
DISCORD_AVAILABLE = True
DISCORD_IMPORT_ERROR = None
except Exception as _exc: # ImportError, ssl.SSLError, ...
import types as _types
# Inert stand-ins so the send_* methods can still build their payloads
# without special-casing every call site. _get_webhook returns None when
# Discord is unavailable and _send_embed already early-returns on that, so
# nothing is ever transmitted.
class _NullEmbed:
def __init__(self, *a, **k):
pass
def _noop(self, *a, **k):
return self
add_field = set_image = set_thumbnail = set_footer = set_author = _noop
class _NullColor:
def __getattr__(self, _name):
return lambda *a, **k: None
discord = _types.SimpleNamespace(Embed=_NullEmbed, File=lambda *a, **k: None)
SyncWebhook = None
Color = _NullColor()
DISCORD_AVAILABLE = False
DISCORD_IMPORT_ERROR = _exc
Logger.warning(
f"Discord notifications disabled — could not import discord ({type(_exc).__name__}: {_exc}). "
"The bot runs normally; only Discord messaging is off."
)
class DiscordEmbeds(GenericApi):
_logged_webhook_errors = set()
@@ -27,6 +67,8 @@ class DiscordEmbeds(GenericApi):
Logger.warning(f"Discord webhook disabled for URL '{hook_url}': {reason}")
def _get_webhook(self, hook_url: str):
if not DISCORD_AVAILABLE:
return None
hook = None
if not hook_url:
hook_url = Config().general['custom_message_hook']
+17 -1
View File
@@ -303,6 +303,12 @@ def open_npc_menu(npc_key: Npc) -> bool:
wait(0.3, 0.4)
return True
return False
# Also close the waypoint panel — an open WP covers the center of the screen
# and prevents NPC template matching.
if is_visible(ScreenObjects.WaypointLabel, grab()):
Logger.debug("open_npc_menu: closing waypoint panel before NPC search")
keyboard.send("esc")
wait(0.2, 0.3)
_close_open_panels()
# Search for npc name tags by hovering to all template locations that are found
start = time.time()
@@ -430,7 +436,17 @@ def open_npc_menu(npc_key: Npc) -> bool:
from screen import convert_screen_to_monitor
Logger.debug(f"NPC {npc_key} - body-template hunt timed out, starting grid hover sweep")
_close_open_panels()
SWEEP_TAG_THRESHOLD = 0.4
# Raised 0.4 -> 0.7 (2026-08-27). A rendered name tag matches almost perfectly, so
# anything mediocre is noise, and at 0.4 the noise won. Measured across a full day of
# sweeps — real hits that opened the dialogue vs false hits that clicked empty ground:
# akara 0.980 halbu 0.995 malah 0.990 larzuk 0.996 <- real
# qual_kehk 0.424 malah 0.501 larzuk 0.494 <- false
# qual_kehk failed 100% of the time (5 timeouts / 5 attempts) because every sweep
# stopped on a 0.424 match, clicked nothing, and gave up instead of carrying on to the
# position where the tag actually renders. Verified directly: the stored
# QUAL_NAME_TAG_WHITE template scores 1.000 on a frame where the tag IS shown, and
# 0.997 through the color_filter path — the template was never the problem.
SWEEP_TAG_THRESHOLD = 0.7
# Pass 1: the NPC's known ROI. Pass 2: the whole NPC search area — patch
# updates and pathing drift can put the NPC outside the stored ROI.
sweep_rois = [npcs[npc_key]["roi"]] if "roi" in npcs[npc_key] else []
+84 -6
View File
@@ -610,7 +610,23 @@ class Pather:
return True
def find_abs_node_pos(self, node_idx: int, img: np.ndarray, threshold: float = 0.68) -> tuple[float, float]:
# Low-confidence fallback for find_abs_node_pos. The old 0.55 first-match search
# fabricated node positions: A5_TOWN_1 scored 0.60-0.62 on unrelated scenery at
# (532,110) / (857,522) / (1209,86) across three consecutive failure frames, and the
# pather steered the char into the Harrogath wall on each. Raised, and forced to
# best_match so an ambiguous frame can no longer be decided by node dict ordering.
_FALLBACK_THRESHOLD = 0.62
# Consecutive heading-gate rejections before a traverse gives up. Four was the
# observed count before the pather started guessing and walked into the wall.
_MAX_HEADING_REJECTS = 3
def find_abs_node_pos(
self,
node_idx: int,
img: np.ndarray,
threshold: float = 0.68,
last_direction: tuple[float, float] = None,
) -> tuple[float, float]:
node = self._nodes[node_idx]
# Try with cropped ROI first (cuts skill bar for better matching)
template_match = template_finder.search(
@@ -621,13 +637,14 @@ class Pather:
roi=Config().ui_roi["cut_skill_bar"],
use_grayscale=True
)
confident = template_match.valid
# If cropped search fails, try full image with lower threshold to handle window offset drift
if not template_match.valid:
if not confident:
template_match = template_finder.search(
[*node],
img,
best_match=False,
threshold=0.55,
best_match=True,
threshold=self._FALLBACK_THRESHOLD,
use_grayscale=True
)
if template_match.valid:
@@ -637,9 +654,32 @@ class Pather:
node_pos_rel = self._get_node(node_idx, template_match.name)
node_pos_abs = self._convert_rel_to_abs(node_pos_rel, ref_pos_abs)
node_pos_abs = get_closest_non_hud_pixel(pos = node_pos_abs, pos_type="abs")
# A fallback match is by definition uncertain. While approaching a single node
# the heading should stay roughly stable, so a low-confidence match that wants
# us to walk backwards is a false positive, not a course correction — drop it
# and let the caller's recovery sweep run instead of walking into scenery.
if not confident and not self._heading_is_plausible(node_pos_abs, last_direction):
Logger.debug(
f"Pather: rejecting low-confidence {template_match.name} "
f"({template_match.score*100:.1f}%) for node {node_idx} — implies reversal"
)
self._heading_rejects = getattr(self, "_heading_rejects", 0) + 1
return None
self._heading_rejects = 0
return node_pos_abs
return None
@staticmethod
def _heading_is_plausible(node_pos_abs: tuple[float, float], last_direction: tuple[float, float]) -> bool:
"""True unless node_pos_abs points more than ~90 deg away from last_direction."""
if last_direction is None:
return True
mag = math.dist(node_pos_abs, (0, 0)) * math.dist(last_direction, (0, 0))
if mag == 0:
return True
dot = node_pos_abs[0] * last_direction[0] + node_pos_abs[1] * last_direction[1]
return (dot / mag) > 0
def traverse_nodes(
self,
path: tuple[Location, Location] | list[int],
@@ -658,6 +698,9 @@ class Pather:
:param force_move: Bool value if force move should be used for pathing
:return: Bool if traversed successful or False if it got stuck
"""
# Per-traverse counter. A stale count from a previous traverse would abort
# the next one immediately.
self._heading_rejects = 0
if len(path) == 0:
Logger.error("Path must be a list of integers or a tuple with start and end location!")
return False
@@ -700,6 +743,9 @@ class Pather:
did_force_move = False
teleport_count = 0
recovery_idx = 0
# Heading held only while approaching THIS node. Across nodes a >90 deg turn
# is normal, so the reversal check must not carry over from the previous one.
node_last_dir = None
while not continue_to_next_node:
img = grab(force_new=True)
# Handle timeout
@@ -732,6 +778,39 @@ class Pather:
Logger.error(f"Got stuck exit pather (node {node_idx}, recovery sweep exhausted)")
return False
# Repeated heading-gate rejections mean the only thing visible is a
# match we have decided not to trust. This MUST come before the
# anti-stuck force-move below: that path moves along last_direction,
# and when the character is already wedged against the Harrogath wall
# it shoves it further in. Measured 2026-08-28: four rejections of
# A5_TOWN_1 at 65-68%, then "random guess towards (-423, 247)", then
# the red portal was unreachable for the rest of the run. Declaring a
# position untrustworthy and then moving on an arbitrary vector are
# contradictory — abort and let the caller re-anchor.
if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS:
Logger.warning(
f"Pather: {self._heading_rejects} consecutive low-confidence rejections "
f"for node {node_idx} — aborting traverse instead of guessing"
)
self._heading_rejects = 0
return False
# SCAN FIRST, then decide. This must sit ahead of the anti-stuck
# block below, not after it: with two rejections already banked,
# that block force-moves along last_direction the moment 3.1s
# elapses — driving a wall-wedged character further in — before
# find_abs_node_pos has recorded the third rejection. Putting the
# scan after it left the exact guess this guard exists to prevent
# reachable on the threshold iteration.
node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold, last_direction=node_last_dir)
if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS:
Logger.warning(
f"Pather: {self._heading_rejects} consecutive low-confidence rejections "
f"for node {node_idx} — aborting traverse instead of guessing"
)
self._heading_rejects = 0
return False
# Sometimes we get stuck at rocks and stuff, after a few seconds force a move into the last known direction
if not did_force_move and time.time() - last_move > 3.1:
if last_direction is not None:
@@ -759,8 +838,6 @@ class Pather:
break
teleport_count += 1
# Find any template and calc node position from it
node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold)
if node_pos_abs is not None:
dist = math.dist(node_pos_abs, (0, 0))
if dist < Config().ui_pos["reached_node_dist"]:
@@ -770,6 +847,7 @@ class Pather:
x_m, y_m = convert_abs_to_monitor(node_pos_abs)
char.move((x_m, y_m), force_tp=force_tp, force_move=force_move)
last_direction = node_pos_abs
node_last_dir = node_pos_abs
last_move = time.time()
return True
+1
View File
@@ -10,3 +10,4 @@ from .baal import Baal
from .mephisto import Mephisto
from .andariel import Andariel
from .countess import Countess
from .cold_plains import ColdPlains
+231
View File
@@ -0,0 +1,231 @@
"""
Cold Plains clear leave town by waypoint and kill everything with one
configured attack skill.
Unlike the boss runs this has no fixed path and no boss. It roams the area,
casts a single configured skill at whatever `target_detect` reports, loots, and
returns. Two consequences worth knowing:
* It works with ANY build, because it sends its own hotkey rather than going
through the build's `_skill_hotkeys`. A blizz_sorc profile can run this with
Fire Bolt long before Blizzard (clvl 24) exists.
* It works from clvl 1, which the boss runs do not those assume endgame
damage and, mostly, teleport.
PREREQUISITE: the destination waypoint must already be discovered on the
character. A fresh character has none, so walk to Cold Plains once by hand
first; after that this run is self-sufficient.
"""
from collections import OrderedDict
import math
import random
import time
from char import IChar
from config import Config
from input_layer import keyboard, mouse
from inventory import belt as belt_mod
from item.pickit import PickIt
from logger import Logger
from pather import Pather, Location
from screen import convert_abs_to_monitor
from target_detect import get_visible_targets
from town.town_manager import TownManager
from ui import meters, waypoint
from utils.misc import wait
class ColdPlains:
"""Roam an outdoor area and kill with one configured skill."""
name = "run_cold_plains"
def __init__(
self,
pather: Pather,
town_manager: TownManager,
char: IChar,
pickit: PickIt,
runs: OrderedDict
):
self._pather = pather
self._town_manager = town_manager
self._char = char
self._pickit = pickit
self._runs = runs
self.approach_fail_step: str | None = None
cfg = Config().cold_plains
self._area = str(cfg.get("area", "Cold Plains")).strip() or "Cold Plains"
self._attack_hotkey = str(cfg.get("attack_hotkey", "")).strip()
self._attack_button = str(cfg.get("attack_button", "right")).strip().lower()
self._max_steps = int(cfg.get("max_steps", 12))
self._casts_per_target = int(cfg.get("casts_per_target", 4))
self._target_radius = int(cfg.get("target_radius", 600))
self._cast_delay = float(cfg.get("cast_delay", 0.25))
self._max_runtime_s = float(cfg.get("max_runtime_s", 180))
# Engagements per step, so a target we cannot actually kill (immune,
# out of reach, a misdetection) cannot pin the run in place forever.
self._max_engagements = int(cfg.get("max_engagements", 8))
# Self-sustain for levelling chars: drink belt potions during the run
# instead of waiting for town (a lvl 1 sorc dies before it gets there).
self._chicken = float(Config().char.get("chicken", 0.5))
self._take_mana = float(Config().char.get("take_mana_potion", 0.3))
self._last_mana_check = 0.0
# True once we've confirmed the character has no teleport at all —
# then the end-of-run trip home is a walk, not a TP.
self._no_tp = False
def approach(self, start_loc: Location, do_pre_buff: bool) -> bool | Location:
self.approach_fail_step = None
# Fail loudly here rather than roaming harmlessly for 3 minutes doing
# no damage — with no hotkey there is nothing to attack with.
if not self._attack_hotkey:
self.approach_fail_step = "no_attack_hotkey"
Logger.error(
"Cold Plains approach: [cold_plains] attack_hotkey is empty — "
"set it to the key holding your attack skill"
)
return False
loc = self._town_manager.go_to_act(1, start_loc)
if not loc:
self.approach_fail_step = "go_to_act1"
Logger.error("Cold Plains approach: go_to_act(1) failed")
return False
if do_pre_buff:
self._char.pre_buff()
# A levelling char has no teleport until clvl 18 — the end-of-run trip
# home is a walk, not a TP. Cache it so battle() can skip the TP wait.
self._no_tp = (not Config().char["teleport"]
and not self._char.capabilities.can_teleport_natively
and not self._char.capabilities.can_teleport_with_charges)
if not self._town_manager.open_wp(loc):
self.approach_fail_step = "open_wp"
Logger.error("Cold Plains approach: open_wp failed")
return False
wait(0.4, 0.6)
if not waypoint.use_wp(label=self._area):
self.approach_fail_step = f"use_wp_{self._area.lower().replace(' ', '_')}"
Logger.error(f"Cold Plains approach: use_wp({self._area!r}) failed")
return False
# No pather node for the open areas — the bot TPs home at end of run,
# so report the town it will arrive back in.
return Location.A1_TOWN_START
def return_to_town(self) -> Location:
"""Walk back to town (no-TP levelling chars) or no-op (TP chars).
The bot's on_end_run() normally TPs home. A pre-clvl-18 character has
no teleport, so this walks the pather's A1 outdoor->town nodes
(705 -> 702) in reverse, then verifies with a town-marker scan. The
pather's auto-recovery sweep handles mis-positioning — if a node
template can't be found, it walks an expanding pattern until it
re-acquires. If the pather fails entirely, fall back to the last
known town location: the next maintenance's open_wp() re-detects the
physical act and traverses from wherever the character actually is.
"""
if not self._no_tp:
return Location.A1_TOWN_START
Logger.info(f" {self._area}: no teleport — walking back to town via pather")
# Walk the A1 outdoor->town path in reverse: 705 (south WP) -> 702
# (north WP). The pather's auto-recovery sweep handles mis-positioning.
if self._pather.traverse_nodes([705, 702], self._char, timeout=5.0, force_move=True):
Logger.info(f" {self._area}: pather walk-back complete")
else:
Logger.warning(f" {self._area}: pather walk-back failed — continuing anyway")
# Verify we're actually in town before reporting success.
if self._town_manager.detect_current_act(timeout=3.0):
return Location.A1_TOWN_START
Logger.warning(f" {self._area}: no town marker after walk-back — continuing anyway")
return Location.A1_TOWN_START
def battle(self) -> bool | tuple[Location, bool]:
picked_up_items = False
deadline = time.time() + self._max_runtime_s
Logger.info(f"=== {self._area} clear: {self._max_steps} steps, key={self._attack_hotkey} ===")
for step in range(self._max_steps):
if time.time() > deadline:
Logger.info(f" {self._area}: runtime budget reached, heading back")
break
# Self-sustain: drink belt potions before fighting. A lvl 1 sorc
# dies before it can walk back to town, so the belt is the only
# heal source mid-run. Mana is checked less often (cast_delay
# already paces the firebolt spam).
self._drink_if_needed()
targets = get_visible_targets(radius_max=self._target_radius)
if targets:
Logger.info(f" Step {step + 1}/{self._max_steps}: {len(targets)} target(s)")
self._clear_visible(deadline)
picked_up_items |= self._pickit.pick_up_items(self._char)
else:
Logger.debug(f" Step {step + 1}/{self._max_steps}: nothing visible, roaming")
self._roam()
return (Location.A1_TOWN_START, picked_up_items)
def _drink_if_needed(self) -> None:
"""Drink belt potions when HP/mana drop below the configured thresholds."""
from screen import grab
img = grab()
hp = meters.get_health(img)
if hp <= self._chicken:
Logger.info(f" {self._area}: HP {hp * 100:.0f}% <= chicken {self._chicken * 100:.0f}% — drinking health")
if not belt_mod.drink_potion("health", stats=[hp, meters.get_mana(img)]):
Logger.warning(f" {self._area}: no health potion in belt at HP {hp * 100:.0f}%")
wait(1.5, 2.0) # let the potion tick
return
now = time.time()
if now - self._last_mana_check > 3.0:
self._last_mana_check = now
mp = meters.get_mana(img)
if mp <= self._take_mana:
Logger.info(f" {self._area}: mana {mp * 100:.0f}% <= {self._take_mana * 100:.0f}% — drinking mana")
if not belt_mod.drink_potion("mana", stats=[hp, mp]):
Logger.warning(f" {self._area}: no mana potion in belt at mana {mp * 100:.0f}%")
wait(1.0, 1.5)
def _clear_visible(self, deadline: float) -> None:
"""Cast at the nearest target until the area is clear or patience runs out."""
for _ in range(self._max_engagements):
if time.time() > deadline:
return
# A potion drink can leave the char at low HP mid-fight — re-check
# before every engagement so we don't walk into a pack on 20%.
self._drink_if_needed()
targets = get_visible_targets(radius_max=self._target_radius)
if not targets:
return
self._cast_at(targets[0].center_monitor)
def _cast_at(self, pos_monitor: tuple[float, float], spray: int = 8) -> None:
"""Select the configured skill, then cast it at a position a few times."""
keyboard.send(self._attack_hotkey)
wait(0.06, 0.12)
for _ in range(self._casts_per_target):
x = pos_monitor[0] + random.randint(-spray, spray)
y = pos_monitor[1] + random.randint(-spray, spray)
mouse.move(x, y, randomize=5, delay_factor=[0.2, 0.4])
mouse.click(button=self._attack_button)
wait(self._cast_delay, self._cast_delay * 1.4)
def _roam(self) -> None:
"""Step in a random direction to find new mobs.
y is halved because D2's projection is isometric — an equal x/y step
moves visibly further vertically than it does horizontally.
"""
angle = random.uniform(0, 2 * math.pi)
dist = random.randint(150, 260)
pos_abs = (math.cos(angle) * dist, math.sin(angle) * dist * 0.5)
self._char.walk(convert_abs_to_monitor(pos_abs), force_move=True)
wait(0.9, 1.4)
+29 -3
View File
@@ -32,7 +32,7 @@ class Pindle:
match = template_finder.search_and_wait_stable(
self._PINDLE_AREA_TEMPLATES,
threshold=0.62,
timeout=timeout,
timeout=max(timeout, 5.0),
confirmations=2,
suppress_debug=True,
)
@@ -65,14 +65,40 @@ class Pindle:
found_loading_screen_func = lambda: loading.wait_for_loading_screen(2.0)
# Re-detect window before template search
find_and_set_window_position(force=True)
# NO pre-click "are we already there?" shortcut. Harrogath scenery around the
# portal scores 0.76-0.79 on PINDLE_7 — above its 0.62 bar — so that shortcut
# fired in town, returned A5_PINDLE_START without ever clicking the portal, and
# the bot "killed Pindle" in town: five straight runs reported success with zero
# loot and zero XP. Entry must be proven by the portal click's loading screen.
if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False):
if self._verify_in_pindle_area():
if do_pre_buff:
self._char.pre_buff()
return Location.A5_PINDLE_START
Logger.warning("Pindle approach: first red portal click failed, retrying from town start")
Logger.warning("Pindle approach: first red portal click failed, re-verifying position before retry")
find_and_set_window_position(force=True)
if not self._pather.traverse_nodes((Location.A5_TOWN_START, Location.A5_NIHLATHAK_PORTAL), self._char):
# Never re-run A5 node pathing from an assumed location. The failed portal
# click leaves the char somewhere unknown, and the old hardcoded
# A5_TOWN_START start walked it into the Harrogath wall — and onto the
# waypoint, whose panel the health manager then read as a chicken.
retry_loc = self._town_manager.detect_current_act()
if retry_loc is None:
# No town marker in view. That is NOT evidence we left the act — it happens
# routinely by the portal in Harrogath's NE corner, where none of the
# TOWN_MARKERS render. `loc` was confirmed to be act 5 at the top of this
# run and a failed portal click cannot move us between acts, so reuse it.
# Treating None as "wrong act" hard-failed 6 runs and burned the 5-strike
# circuit breaker, ending an otherwise healthy 31-game session.
Logger.debug("Pindle approach: no town marker in view — reusing the act-5 location confirmed at run start")
retry_loc = loc
elif retry_loc != Location.A5_TOWN_START:
Logger.warning(f"Pindle approach: not confirmed in A5 (detected {retry_loc}) — traveling to act 5")
retry_loc = self._town_manager.go_to_act(5, retry_loc)
if not retry_loc:
self.approach_fail_step = "retry_traverse_to_portal"
Logger.error("Pindle approach: could not confirm A5 town position for retry")
return False
if not self._pather.traverse_nodes((retry_loc, Location.A5_NIHLATHAK_PORTAL), self._char):
self.approach_fail_step = "retry_traverse_to_portal"
return False
wait(0.5, 0.7)
+51 -10
View File
@@ -7,6 +7,8 @@ import template_finder
from utils.misc import wait
from ui_manager import ScreenObjects, is_visible
from logger import Logger
from config import Config
from input_layer import mouse
class A5(IAct):
@@ -89,11 +91,19 @@ class A5(IAct):
# Thresholds verified 2026-06-11: stash scores ~0.51 at current D2R settings
# (templates captured on older rendering). Safe to go low: stash_is_open_func
# gates success, so a false-positive click just fails the gate and retries.
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.60, timeout=4.0, telekinesis=True):
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.45, timeout=4.0, telekinesis=True):
return False
return Location.A5_STASH
# Try progressively lower thresholds: 0.60 -> 0.50 -> 0.40
for threshold in (0.60, 0.50, 0.40):
if self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=threshold, timeout=4.0, telekinesis=True):
return Location.A5_STASH
Logger.debug(f"A5 stash: threshold {threshold} failed, retrying lower")
# Final fallback: direct click on stash area without template, just click center
# of screen where stash chest should be after pathing to A5_STASH location
Logger.warning("A5 stash: all template thresholds failed, trying direct center click")
mouse.click(button="left")
wait(1.0, 1.5)
if stash_is_open_func():
return Location.A5_STASH
return False
def open_trade_and_repair_menu(self, curr_loc: Location) -> Location | bool:
from ui_manager import wait_until_visible
@@ -137,15 +147,46 @@ class A5(IAct):
deadline = time.time() + self._OPEN_WP_BUDGET_S
found_wp_func = lambda: is_visible(ScreenObjects.WaypointLabel)
def _try_click_wp(threshold: float = 0.55) -> bool:
# The waypoint is a WORLD object, so never let the HUD compete. main's recaptured
# a5_wp.png matches the belt/mana-orb area at 0.966 — above threshold and higher
# than the real stone — so every search locked onto (980, 656), clicked the HUD,
# and open_wp burned ~83s escalating through anchors before giving up. That took
# buy_consumables to 222-253s and tripped the 240s maintenance timeout.
_wp_roi = Config().ui_roi["cut_skill_bar"]
# Search BOTH waypoint captures and take the best. main recaptured a5_wp.png
# (125x112); on this client it only reaches ~0.50-0.58 on the real stone while the
# older capture (a5_wp_2.png, 201x120) historically hit 0.73-0.98. Rather than pick
# a winner and break the other setup, let best_match decide per frame.
_wp_tpl = ["A5_WP", "A5_WP_2"]
def _try_click_wp(threshold: float = 0.55, require_visible: bool = False) -> bool:
wait(0.4, 0.6)
# Direct full-screen scan first — if the WP stone is on screen, walk onto
# it and click, regardless of how we got here.
wp_match = template_finder.search("A5_WP", grab(), threshold=threshold)
if wp_match.valid:
wp_match = template_finder.search(_wp_tpl, grab(), threshold=threshold, roi=_wp_roi, best_match=True)
if not wp_match.valid:
# Nothing on screen. For the speculative pre-scan below, stop here:
# calling select_by_template anyway just spins out its 4s timeout and
# logs "Wanted to select A5_WP, but could not find it" before we have
# even tried walking there. That fired 321 times in one session (~9.4s
# and two scary ERROR lines per waypoint use) purely because the char
# was still at town start rather than standing on the stone.
if require_visible:
return False
else:
self._char.move(wp_match.center_monitor, force_move=True)
wait(0.4, 0.6)
return self._char.select_by_template("A5_WP", found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True)
return self._char.select_by_template(_wp_tpl, found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True, roi=_wp_roi)
# 0) Immediate direct WP scan — after TP-back from Pindle the char often spawns
# near the WP and a quick scan avoids all pathing issues from stale curr_loc.
# Use lower threshold (0.45) first to catch degraded template matches, then
# retry at 0.55 if needed. Both are speculative: skip instantly when the
# stone is not actually on screen.
if _try_click_wp(threshold=0.45, require_visible=True):
return True
if _try_click_wp(threshold=0.55, require_visible=True):
return True
# 1) Direct node path from the believed location.
if self._pather.traverse_nodes((curr_loc, Location.A5_WP), self._char, force_move=True):
@@ -176,7 +217,7 @@ class A5(IAct):
# the WP may now be on screen — try a direct scan.
if _try_click_wp(threshold=0.50):
return True
# Anchors exhausted within budget. Last resort: direct WP scan from current
# Anchors exhausted within budget. Last resort: direct WP scan from current
# position. If the WP stone is on screen despite the path failing, click it.
if time.time() < deadline:
Logger.warning("A5 open_wp: anchors failed — trying direct WP scan from current position")
+137 -13
View File
@@ -19,8 +19,24 @@ TOWN_MARKERS = [
"A1_TOWN_1", "A1_TOWN_3"
]
# marker -> the act-town Location it belongs to, so detect_current_act() can compare a
# candidate against the best marker from a DIFFERENT act.
TOWN_MARKERS_ACT = {
m: (Location.A5_TOWN_START if m.startswith("A5_") else
Location.A4_TOWN_START if m.startswith("A4_") else
Location.A3_TOWN_START if m.startswith("A3_") else
Location.A2_TOWN_START if m.startswith("A2_") else
Location.A1_TOWN_START)
for m in TOWN_MARKERS
}
class TownManager:
# How far the winning town marker must beat the best marker from another act before
# detect_current_act() will commit. Measured failure: A5_TOWN_1 0.636 vs A4_TOWN_5
# 0.619 while standing in Act 4 — 1.7pp. Anything under this is a coin flip.
_ACT_DETECT_MARGIN = 0.05
def __init__(self, a1: A1, a2: A2, a3: A3, a4: A4, a5: A5):
self._acts: dict[Location, IAct] = {
Location.A1_TOWN_START: a1,
@@ -70,7 +86,35 @@ class TownManager:
save_debug=True,
)
if template_match.valid:
return TownManager.get_act_from_location(template_match.name)
act = TownManager.get_act_from_location(template_match.name)
# Bug 28 again, in its twin. detect_current_act was fixed to require a
# margin over the best marker from any OTHER act; this function was left
# committing to whatever best_match returned. Measured live 2026-08-28:
# the character spawned in Harrogath and this reported a4_town_start every
# game, so the bot hunted an A4 waypoint that was not there and
# go_to_act(5) failed with the char already standing in Act 5.
#
# Same rule as detect_current_act: a marginal winner is not evidence.
if act is not None:
# Score BOTH sides on one image. Comparing the winner's score from
# the match above against a rival scored on a later grab is not a
# comparison at all — the character may have moved between them.
img = grab(force_new=True)
own = [m for m in TOWN_MARKERS if TOWN_MARKERS_ACT.get(m) == act]
rivals = [m for m in TOWN_MARKERS if TOWN_MARKERS_ACT.get(m) != act]
mine = template_finder.search(own, img, threshold=0.01, best_match=True)
rival = template_finder.search(rivals, img, threshold=0.01, best_match=True)
margin = (mine.score or 0.0) - (rival.score or 0.0)
if margin < self._ACT_DETECT_MARGIN:
Logger.warning(
f"wait_for_town_spawn: ambiguous — {mine.name} "
f"{(mine.score or 0)*100:.1f}% vs {rival.name} {(rival.score or 0)*100:.1f}% "
f"(margin {margin*100:.1f}pp); deferring to detect_current_act"
)
# Fall through to the caller's detection path rather than
# committing. A wrong act here poisons the whole game.
return None
return act
return None
def detect_current_act(self, timeout: float = 6) -> Location | None:
@@ -81,14 +125,40 @@ class TownManager:
whatever act it save+exited from, so a single desync poisons every later game
if locations are assumed instead of verified.
"""
template_match = template_finder.search_and_wait(
TOWN_MARKERS, best_match=True, timeout=timeout, suppress_debug=True
)
if template_match.valid:
act = TownManager.get_act_from_location(template_match.name)
Logger.debug(f"detect_current_act: matched {template_match.name} -> {act}")
return act
Logger.warning("detect_current_act: no town marker found — character may not be in town")
import time
deadline = time.time() + timeout
closest = None
while True:
img = grab(force_new=True)
best = template_finder.search(TOWN_MARKERS, img, threshold=0.01, best_match=True)
act = TownManager.get_act_from_location(best.name) if best.name else None
if act is not None and best.score >= 0.68:
# A marginal winner is not evidence. In Act 4 the A5_TOWN_1 template
# scores 0.636 on scenery in the top-right corner while the genuine
# A4_TOWN_5 scores 0.619 — a 1.7pp gap decided the act, and the bot ran
# A5 pathing inside Act 4 after every Halbu repair trip. Require the
# winner to beat the best marker from ANY OTHER act by a real margin.
rivals = [m for m in TOWN_MARKERS if TOWN_MARKERS_ACT.get(m) != act]
rival = template_finder.search(rivals, img, threshold=0.01, best_match=True)
margin = best.score - (rival.score or 0.0)
if margin >= self._ACT_DETECT_MARGIN:
Logger.debug(f"detect_current_act: matched {best.name} ({best.score*100:.1f}%) -> {act}")
return act
closest = (best.name, best.score, rival.name, rival.score, margin)
if time.time() >= deadline:
break
wait(0.2, 0.3)
if closest:
bn, bs, rn, rs, mg = closest
# Ambiguous is NOT the same as "wrong act". Callers treat None as "keep the
# assumed act", which makes go_to_act actually travel instead of wrongly
# early-returning — the safe outcome.
Logger.warning(
f"detect_current_act: ambiguous — {bn} {bs*100:.1f}% vs {rn} {rs*100:.1f}% "
f"(margin {mg*100:.1f}pp < {self._ACT_DETECT_MARGIN*100:.0f}pp); refusing to guess the act"
)
else:
Logger.warning("detect_current_act: no town marker found — character may not be in town")
return None
def wait_for_tp(self, curr_loc: Location):
@@ -255,6 +325,43 @@ class TownManager:
if curr_act is None:
Logger.error(f"resurrect: could not determine act from location {curr_loc}")
return False
# Prefer A4 Tyrael over A5 Qual-Kehk when configured. Tyrael stands on a fixed spot
# next to the A4 waypoint and detects reliably; Qual-Kehk is the worst NPC in the
# route. Measured over 2026-08-27:
# 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
# A failed Qual-Kehk hunt costs more than the whole A5->A4 waypoint trip, and one
# 163s hunt took the town visit past the 240s maintenance timeout and killed the
# game outright.
resurrect_pref = (Config().char.get("resurrect_npc") or "").lower()
if curr_act == Location.A5_TOWN_START and resurrect_pref in ("a4_tyrael", "tyrael", "act4"):
Logger.info("Resurrect preference is Act 4 Tyrael — traveling from A5")
a4_loc = self.go_to_act(4, curr_loc)
if a4_loc:
set_panel_check_paused(True)
res = self._acts[Location.A4_TOWN_START].resurrect(a4_loc)
if res:
return res
set_panel_check_paused(False)
# Travelled but failed — leave the breadcrumb so the caller re-anchors to
# A4 rather than assuming A5 (Bug 9).
self.last_known_loc = a4_loc
Logger.warning("Resurrect: Tyrael failed after travelling to A4")
return False
# Do NOT fall back to Qual-Kehk. Measured across the whole log:
# A5 Qual-Kehk 0 successful resurrects, 49 hunt timeouts
# A4 Tyrael 3 successful resurrects
# The fallback is not a second chance, it is a guaranteed ~100s loss,
# and the retry above doubles it. On 2026-08-28 that pair spent 213s
# and took the town visit past the 240s maintenance timeout, killing
# a game that was otherwise fine. Running mercless for one game is
# far cheaper; the merc is re-checked next game.
Logger.warning(
"Resurrect: could not travel to A4 for Tyrael — skipping the resurrect "
"rather than falling back to Qual-Kehk (0 successes in 49 attempts). "
"Continuing mercless this game."
)
return False
# check if we can resurrect in current act
if self._acts[curr_act].can_resurrect():
set_panel_check_paused(True)
@@ -458,12 +565,29 @@ class TownManager:
common.close()
return new_loc, items
return False, False
Logger.info("TownManager repair: going to A5 for repair")
new_loc = self.go_to_act(5, curr_loc)
# The current act cannot repair (e.g. A3 on a Travincal route), so we must
# travel. This used to be hardcoded to A5/Larzuk, which meant repair_npc was
# ignored on every route that does not already stand in A5 — the preference
# is only consulted in the A5 branch above. On run_trav that cost 22-37s per
# repair, and when Larzuk's menu failed to open the whole trip ran twice
# (~84s) and stranded the char in the wrong act, failing the following
# open_wp. Honour repair_npc here too.
_repair_dest = {
"a1_charsi": (1, Location.A1_TOWN_START, "Charsi"),
"charsi": (1, Location.A1_TOWN_START, "Charsi"),
"act1": (1, Location.A1_TOWN_START, "Charsi"),
"a4_halbu": (4, Location.A4_TOWN_START, "Halbu"),
"halbu": (4, Location.A4_TOWN_START, "Halbu"),
"act4": (4, Location.A4_TOWN_START, "Halbu"),
}
act_num, act_loc, npc_name = _repair_dest.get(
repair_preference, (5, Location.A5_TOWN_START, "Larzuk"))
Logger.info(f"TownManager repair: going to A{act_num} for repair ({npc_name})")
new_loc = self.go_to_act(act_num, curr_loc)
if not new_loc: return False, False
Logger.info("TownManager repair: opening Larzuk trade/repair menu")
Logger.info(f"TownManager repair: opening {npc_name} trade/repair menu")
set_panel_check_paused(True)
new_loc = self._acts[Location.A5_TOWN_START].open_trade_and_repair_menu(new_loc)
new_loc = self._acts[act_loc].open_trade_and_repair_menu(new_loc)
Logger.info(f"TownManager repair: open_trade_and_repair_menu returned {new_loc}")
if not new_loc:
set_panel_check_paused(False)
+59 -20
View File
@@ -239,28 +239,62 @@ class Transmute:
# ── New-UI (D2R named-tab stash) gem helpers ──────────────────────────
# Named-tab stash UI (1280x720). 5 tabs ~76px wide starting at x=33:
# PERSONAL≈71 SHARED≈147 GEMS≈223 MATERIALS≈299 RUNES≈375
PERSONAL_TAB_X = 71
GEMS_TAB_X = 223
GEMS_TAB_Y = 100
# Named-tab stash UI (1280x720), measured off the live client:
# labels occupy the row y=63..78, so TAB_Y must sit on that row. It was 100, which is
# ~20px BELOW the tab bar (the stash grid starts at y=87) — every "switch tab" click
# landed on a stash slot, the tab never changed, and the whole gem conversion ran in
# whichever tab happened to be open (PERSONAL). It failed silently for 800+ transmutes.
# PERSONAL 68 | SHARED 144 | GEMS 220 | MATERIALS 295 | RUNES 370
TAB_Y = 70
PERSONAL_TAB_X = 68
SHARED_TAB_X = 144
GEMS_TAB_X = 220
MATERIALS_TAB_X = 295
RUNES_TAB_X = 370
# The active tab is drawn with a lighter cell background. Measure the BACKGROUND
# (30th percentile of the label cell), not the glyphs: text brightness varies with
# label length — "PERSONAL" peaks at 215 while an active "GEMS" only reaches 167, so
# a glyph-based threshold misreads short labels as inactive.
# Measured across both states: active bg ≈ 67-68, inactive bg ≈ 38-39.
TAB_ACTIVE_BG = 52
GEMS_CONVERT_BUTTON = (225, 500)
GEMS_CONVERT_PANEL_ROI = (160, 296, 128, 160)
GEMS_CONVERT_FIRST_SLOT = (181, 318)
def _switch_to_personal_tab(self) -> None:
x, y = convert_screen_to_monitor((self.PERSONAL_TAB_X, self.GEMS_TAB_Y))
mouse.move(x, y)
self._wait()
mouse.click("left")
wait(0.4, 0.5)
def _tab_is_active(self, tab_x: int, img: np.ndarray = None) -> bool:
"""True if the stash tab centred on tab_x is the selected one."""
img = grab() if img is None else img
label = img[63:78, max(0, tab_x - 34):tab_x + 34]
if label.size == 0:
return False
gray = cv2.cvtColor(label, cv2.COLOR_BGR2GRAY)
return float(np.percentile(gray, 30)) > self.TAB_ACTIVE_BG
def _switch_to_gems_tab(self) -> None:
x, y = convert_screen_to_monitor((self.GEMS_TAB_X, self.GEMS_TAB_Y))
mouse.move(x, y)
self._wait()
mouse.click("left")
wait(0.4, 0.5)
def _switch_to_tab(self, tab_x: int, name: str, attempts: int = 3) -> bool:
"""Click a stash tab and confirm it actually became active.
Verified rather than fire-and-forget: an unverified tab click is what let gem
conversion run in the wrong tab indefinitely without a single error line.
"""
for attempt in range(attempts):
if self._tab_is_active(tab_x):
return True
x, y = convert_screen_to_monitor((tab_x, self.TAB_Y))
mouse.move(x, y)
self._wait()
mouse.click("left")
wait(0.4, 0.5)
if self._tab_is_active(tab_x):
return True
Logger.debug(f" {name} tab did not activate (attempt {attempt + 1}/{attempts})")
Logger.error(f"_switch_to_tab: could not activate the {name} stash tab")
return False
def _switch_to_personal_tab(self) -> bool:
return self._switch_to_tab(self.PERSONAL_TAB_X, "PERSONAL")
def _switch_to_gems_tab(self) -> bool:
return self._switch_to_tab(self.GEMS_TAB_X, "GEMS")
def _click_gems_tab_convert_button(self) -> None:
"""Click the native convert button in the GEMS tab panel."""
@@ -866,8 +900,11 @@ class Transmute:
Logger.error("convert_all_gems: stash not open")
return
# Switch to GEMS tab before counting and planning.
self._switch_to_gems_tab()
# Switch to GEMS tab before counting and planning. Abort if it did not take —
# counting and converting in the wrong tab is worse than not running at all.
if not self._switch_to_gems_tab():
Logger.error("convert_all_gems: GEMS tab not active — aborting instead of converting in the wrong tab")
return
wait(0.4, 0.5)
Logger.info("convert_all_gems: reading gem counts via OCR")
@@ -910,7 +947,9 @@ class Transmute:
if not self._reopen_stash():
Logger.error(" could not reopen stash -- aborting")
return
self._switch_to_gems_tab()
if not self._switch_to_gems_tab():
Logger.error(f" [{i+1}] GEMS tab not active -- aborting rather than clicking blind")
return
wait(0.3, 0.4)
if not self._ensure_gems_convert_panel_empty():
+1 -1
View File
@@ -236,7 +236,7 @@ def _select_char_by_ocr(char_name: str) -> bool:
if result and result[0].text:
detected = result[0].text.strip().lower()
Logger.debug(f"Row {row}: OCR detected '{detected}'")
# OCR mangles names badly ('fistman' reads as 'fabiman'), so
# OCR mangles names badly ('profile1' reads as 'pro1file1'), so
# fuzzy-match the first word of the row against the char name.
import difflib
first_line = detected.splitlines()[0].strip() if detected else ""
+247
View File
@@ -0,0 +1,247 @@
"""
Game browser: join a public game from the D2R main menu.
Flow: Play button (hover) -> Join Game tab -> scan game list (OCR) -> click game -> loading screen.
No dedicated templates exist for the browser UI, so this module relies on:
- ScreenObjects.PlayBtn for the Play button (existing template)
- Coordinate-based clicking for the "Join Game" tab (right of Play button)
- OCR for reading game names in the list
- ScreenObjects.Loading / InGame for confirming the join succeeded
"""
import time
from input_layer import mouse
from utils.misc import wait, cut_roi
from logger import Logger
from ui_manager import detect_screen_object, is_visible, ScreenObjects
from screen import grab, convert_screen_to_monitor, find_and_set_window_position, stop_detecting_window, start_detecting_window
from d2r_image import ocr
# Game list area (screen coords, 1280x720 client). The list occupies the center
# of the screen when the Join Game tab is active. Rows are ~28px tall.
_GAME_LIST_ROI = (150, 130, 980, 480)
_ROW_HEIGHT = 28
_JOIN_TAB_OFFSET_X = 360 # "Join Game" tab is ~360px right of the Play button center
def _ocr_row(img, roi) -> str:
"""OCR a single game-list row. Returns cleaned text or '' on failure."""
try:
row_img = cut_roi(img, roi)
result = ocr.image_to_text(
images=row_img,
model="hover-eng_inconsolata_inv_th_fast",
psm=7,
scale=1.2,
crop_pad=False,
erode=False,
invert=False,
threshold=0,
digits_only=False,
fix_regexps=True,
check_known_errors=False,
correct_words=False,
)
if result and result[0].text:
return result[0].text.strip()
except Exception as e:
Logger.debug(f"game_browser OCR row failed: {e}")
return ""
def _scan_game_list(img) -> list[dict]:
"""Scan the game list area and return a list of {name, row, y_center} entries."""
x, y, w, h = _GAME_LIST_ROI
games = []
num_rows = max(1, h // _ROW_HEIGHT)
for row in range(num_rows):
row_y = y + row * _ROW_HEIGHT
row_h = min(_ROW_HEIGHT + 4, y + h - row_y)
if row_h < 5:
break
text = _ocr_row(img, [x, row_y, w, row_h])
if text:
# Filter out obvious non-game text (headers, empty)
cleaned = text.replace(" ", "").lower()
if len(cleaned) >= 2 and not cleaned.isdigit():
games.append({"name": text, "row": row, "y_center": row_y + row_h // 2})
return games
def _click_game_row(y_center_screen: int) -> None:
"""Click a game row at the given screen y-coordinate (center of list horizontally).
Single click D2R's game browser joins on one click (no confirm dialog)."""
x, y, w, h = _GAME_LIST_ROI
click_x = x + w // 2
pos = convert_screen_to_monitor((click_x, y_center_screen))
mouse.move(*pos)
wait(0.15, 0.3)
mouse.click("left")
wait(0.2, 0.4)
def _open_join_tab() -> bool:
"""Click the 'Join Game' tab next to the Play button. Returns True if the
game list area appears to have populated (OCR finds at least one row)."""
# Find the Play button to anchor our click
m = detect_screen_object(ScreenObjects.PlayBtn)
if not m.valid:
Logger.error("game_browser: Play button not found — cannot locate Join Game tab")
return False
# Join Game tab is to the right of the Play button
tab_x = m.center[0] + _JOIN_TAB_OFFSET_X
tab_y = m.center[1]
pos = convert_screen_to_monitor((tab_x, tab_y))
Logger.debug(f"game_browser: clicking Join Game tab at screen ({tab_x}, {tab_y})")
mouse.move(*pos)
wait(0.2, 0.4)
mouse.click("left")
wait(1.0, 1.5) # give the list time to populate
# Verify: try OCR on the list area
img = grab()
games = _scan_game_list(img)
if games:
Logger.debug(f"game_browser: game list populated, {len(games)} entries visible")
return True
# List might be empty (no games) — that's still a valid "tab opened" state.
# We can't distinguish "empty list" from "wrong tab" without a template,
# so assume success and let the caller handle the no-games case.
Logger.warning("game_browser: game list empty after opening tab (no games or wrong tab)")
return True
def _join_attempt(name_filter: str, max_wait_s: float) -> bool:
"""One attempt to join a public game from the main menu.
:return: True if the join was initiated (loading screen or in-game detected).
"""
# Step 1: Make sure we're at the main menu with the Play button visible
start = time.time()
while True:
if is_visible(ScreenObjects.InGame):
Logger.warning("game_browser: already in a game")
return True
if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid:
break
if is_visible(ScreenObjects.MainMenu):
# Main menu visible but Play button not detected — wait for it to activate
wait(1, 2)
else:
Logger.error("game_browser: not at main menu")
return False
if time.time() - start > 30:
Logger.error("game_browser: Play button never appeared")
return False
# Step 2: Open the Join Game tab
if not _open_join_tab():
return False
# Step 3: Find and click a matching game
start = time.time()
while time.time() - start < max_wait_s:
img = grab()
games = _scan_game_list(img)
if games:
target = None
if name_filter:
for g in games:
if name_filter.lower() in g["name"].lower():
target = g
break
else:
target = games[0]
if target:
Logger.info(f"game_browser: joining '{target['name']}' (row {target['row']})")
_click_game_row(target["y_center"])
# Step 4: Wait for loading screen or in-game
return _wait_for_join()
Logger.debug(f"game_browser: {len(games)} games visible, none match filter '{name_filter}'")
else:
Logger.debug("game_browser: no games visible, waiting...")
wait(2, 4)
Logger.error(f"game_browser: no matching game found within {max_wait_s}s")
return False
def join_game(name_filter: str = "", max_wait_s: float = 60.0, attempts: int = 3) -> bool:
"""
Join a public game from the main menu.
Retries the whole browser flow up to `attempts` times: a single attempt can
fail for transient reasons (game full, kicked back to the menu, a missed
click, an empty list at that instant). The bot must never stop, so we keep
re-trying rather than giving up on the first failure.
:param name_filter: substring to match against game names (case-insensitive).
Empty string = join the first game in the list.
:param max_wait_s: how long each attempt waits for a matching game to appear.
:param attempts: how many times to retry the whole join flow before giving up.
:return: True if the join was initiated (loading screen or in-game detected).
"""
Logger.info(f"game_browser: joining game (filter='{name_filter or 'first'}', attempts={attempts})")
stop_detecting_window()
find_and_set_window_position(force=True)
try:
for attempt in range(1, attempts + 1):
try:
if _join_attempt(name_filter, max_wait_s):
return True
except Exception as e:
Logger.warning(f"game_browser: join attempt {attempt} raised {type(e).__name__}: {e}")
if attempt < attempts:
Logger.warning(f"game_browser: join attempt {attempt}/{attempts} failed — retrying")
# Give the client a moment to settle back at the main menu before
# re-anchoring the window and re-opening the browser.
wait(2, 4)
find_and_set_window_position(force=True)
Logger.error(f"game_browser: all {attempts} join attempts failed")
return False
finally:
start_detecting_window()
def _wait_for_join(timeout: float = 45.0) -> bool:
"""Wait for the loading screen or in-game state after clicking a game."""
start = time.time()
while time.time() - start < timeout:
if is_visible(ScreenObjects.Loading):
Logger.info("game_browser: loading screen detected — join in progress")
return True
if is_visible(ScreenObjects.InGame):
Logger.info("game_browser: already in game — join succeeded")
return True
# Server error or kicked back to menu?
if is_visible(ScreenObjects.ServerError):
Logger.error("game_browser: server error during join")
return False
# If we're back at the main menu with Play button, the join failed
if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid:
# Could be a transient state — give it a moment
wait(1, 2)
if (m2 := detect_screen_object(ScreenObjects.PlayBtn)).valid:
Logger.error("game_browser: back at main menu — join failed (game full/left)")
return False
wait(0.5, 1.0)
Logger.error(f"game_browser: no loading/in-game screen within {timeout}s")
return False
def wait_for_in_game(timeout: float = 60.0) -> bool:
"""Wait until we're actually in-game (InGame marker visible)."""
start = time.time()
while time.time() - start < timeout:
if is_visible(ScreenObjects.InGame):
Logger.info("game_browser: in-game confirmed")
return True
if is_visible(ScreenObjects.MainMenu):
Logger.error("game_browser: back at main menu — never entered game")
return False
wait(1, 2)
Logger.error(f"game_browser: InGame not detected within {timeout}s")
return False
+15
View File
@@ -71,6 +71,21 @@ def save_and_exit() -> bool:
Performes save and exit action from within game
:return: Bool if action was successful
"""
# Pause the panel check for the WHOLE sequence. This function deliberately
# opens the in-game ESC menu, and the health manager now closes that menu on
# sight (its LOOT FILTER / CHRONICLE / OPTIONS buttons sit under movement
# clicks). Callers only pause AFTER save_and_exit returns, so without this
# the guard raced the shutdown — observed as 75 escapes across 15 games,
# interleaved with the save/exit clicks.
from health_manager import set_panel_check_paused
set_panel_check_paused(True)
try:
return _save_and_exit_inner()
finally:
set_panel_check_paused(False)
def _save_and_exit_inner() -> bool:
# if exit button isn't detected already, press escape
attempts = 1
success = False
+16
View File
@@ -225,6 +225,22 @@ class ScreenObjects:
threshold=0.8,
use_grayscale=True
)
GameMenu=ScreenObject(
# The in-game ESC menu. It has NO close button, so CenterPanel cannot
# see it, and it carries LOOT FILTER / CHRONICLE / OPTIONS buttons at
# screen centre — where the HUD mask deliberately allows clicks. A
# stray esc opens it and the next movement click lands on a button.
ref=["SAVE_AND_EXIT_NO_HIGHLIGHT", "SAVE_AND_EXIT_HIGHLIGHT"],
roi="reduce_to_center",
threshold=0.8,
use_grayscale=True
)
CenterPanel=ScreenObject(
ref=["CLOSE_PANEL_2", "CLOSE_PANEL"],
roi="center_panel_header",
threshold=0.8,
use_grayscale=True
)
NPCDialogue=ScreenObject(
ref="NPC_DIALOGUE",
roi="npc_dialogue",
+71 -12
View File
@@ -43,10 +43,15 @@ if os.name == 'nt':
IsWindowVisible,
)
from win32api import GetMonitorInfo, MonitorFromWindow
import pywintypes
from win32process import GetWindowThreadProcessId
else:
# Linux stubs — these functions are never called outside os.name == 'nt' blocks,
# but they must exist for the module to import cleanly on non-Windows systems.
class _PywintypesStub:
class error(Exception):
pass
pywintypes = _PywintypesStub()
HWND_TOPMOST = None
SWP_NOMOVE = None
SWP_NOSIZE = None
@@ -183,7 +188,11 @@ def move_d2r_window(client_x, client_y, client_width=None, client_height=None):
outer_y = client_y - (client_top - wr_top)
outer_w = (client_width if client_width else (c_right - c_left)) + border_w
outer_h = (client_height if client_height else (c_bottom - c_top)) + border_h
SetWindowPos(hwnd, HWND_TOPMOST, outer_x, outer_y, outer_w, outer_h, SWP_SHOWWINDOW)
try:
SetWindowPos(hwnd, HWND_TOPMOST, outer_x, outer_y, outer_w, outer_h, SWP_SHOWWINDOW)
except pywintypes.error:
Logger.debug("SetWindowPos denied in move_d2r_window (D2R elevated) — skipping")
return False
Logger.debug(
f"Moved D2R client area to ({client_x}, {client_y}) "
f"with size {client_width or c_right - c_left}x{client_height or c_bottom - c_top}"
@@ -202,16 +211,49 @@ def enforce_d2r_window(client_x=5, client_y=98):
)
def focus_d2r_window():
"""Bring D2R to the foreground before sending UI clicks/keys."""
if os.name == 'nt':
for hwnd, _ in _find_d2r_hwnds():
try:
"""Bring D2R to the foreground before sending UI clicks/keys.
Windows refuses SetForegroundWindow from a process that does not already own
the foreground, and the refusal is SILENT it returns an error that is easy
to swallow, the window never comes forward, and every subsequent key and
click lands in whatever window IS focused. Attaching our input queue to the
current foreground thread lifts that restriction for the duration of the
call, which is the documented way round the foreground lock.
Returns True only if D2R actually ended up in the foreground.
"""
if os.name != 'nt':
return False
import ctypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
for hwnd, _ in _find_d2r_hwnds():
try:
if user32.IsIconic(hwnd):
ShowWindow(hwnd, SW_RESTORE)
SetForegroundWindow(hwnd)
fg = user32.GetForegroundWindow()
if fg == hwnd:
return True
except Exception as e:
Logger.warning(f"Could not focus D2R window: {e}")
return False
cur_tid = kernel32.GetCurrentThreadId()
fg_tid = user32.GetWindowThreadProcessId(fg, None) if fg else 0
attached = False
if fg_tid and fg_tid != cur_tid:
attached = bool(user32.AttachThreadInput(cur_tid, fg_tid, True))
try:
user32.AllowSetForegroundWindow(-1)
user32.BringWindowToTop(hwnd)
SetForegroundWindow(hwnd)
finally:
if attached:
user32.AttachThreadInput(cur_tid, fg_tid, False)
wait(0.15, 0.25)
if user32.GetForegroundWindow() == hwnd:
return True
Logger.warning("focus_d2r_window: D2R did not come to the foreground")
return False
except Exception as e:
Logger.warning(f"Could not focus D2R window: {e}")
return False
return False
def set_d2r_always_on_top():
@@ -222,7 +264,12 @@ def set_d2r_always_on_top():
found = False
for w in windows_list:
if "Diablo II" in w[1]:
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
try:
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
except pywintypes.error:
Logger.debug("SetWindowPos denied (D2R may be elevated) — skipping always-on-top")
found = True
break
Logger.debug("Set D2R to be always on top")
found = True
break
@@ -239,8 +286,12 @@ def restore_d2r_window_visibility():
EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list)
for w in windows_list:
if w[1] == "Diablo II: Resurrected":
SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
Logger.debug("Restored D2R window visibility")
try:
SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
except pywintypes.error:
Logger.debug("SetWindowPos denied on restore (D2R may be elevated) — skipping")
else:
Logger.debug("Restored D2R window visibility")
else:
Logger.debug('OS not supported, unable to set D2R always on top')
@@ -282,6 +333,14 @@ def wait(min_seconds, max_seconds = None):
center = (jitter_min + jitter_max) / 2
sigma = (jitter_max - jitter_min) / 4
jitter = random.gauss(center, sigma)
# Session personality: a constant bias for the whole run, so the mean
# differs between sessions instead of converging to the same value
# every time. Per-action jitter alone cannot produce that.
from utils.stealth import get_session_bias
jitter *= get_session_bias()
# Clamp LAST so the floor holds whatever the bias is. Clamping before
# the multiply let a 0.92x session push waits back under the floor —
# exactly the 32%-short behaviour raising wait_jitter_min removed.
jitter = max(jitter_min * 0.8, min(jitter_max * 1.2, jitter))
except Exception:
jitter = 1.0
+18 -4
View File
@@ -59,13 +59,19 @@ def get_build_skill_checks(config_instance: Config, char_type: str | None = None
char_type = char_type or config_instance.char.get("type", "")
checks = []
if char_type == "hammerdin":
build_cfg = getattr(config_instance, "hammerdin", {})
if char_type in ("hammerdin", "fohdin"):
build_cfg = getattr(config_instance, char_type, {})
# blessed hammer lives on left-click; auras/teleport go to right
# hotkeys select onto the RIGHT slot; left holds the hammer permanently
# hotkeys select onto the RIGHT slot; left holds the hammer permanently.
# NOT required: having no hotkey is the CORRECT configuration. _cast_hammers
# deliberately keeps the hammer off the F-keys — pressing one would select it
# onto the right slot and REPLACE the damage aura. Marking this required made
# every startup log "blessed_hammer has no configured hotkey" and advise running
# set_binds_from_params.py, which would bind it and break the aura. If a hotkey
# IS configured it is still visually validated below.
checks.append(SkillCheck(char_type, "blessed_hammer",
_configured_hotkey(build_cfg, "blessed_hammer"),
"BLESSED_HAMMER", "right", True))
"BLESSED_HAMMER", "right", False))
checks.append(SkillCheck(char_type, "concentration",
_configured_hotkey(build_cfg, "concentration"),
"CONCENTRATION", "right", True))
@@ -111,6 +117,14 @@ def _skill_roi(side: str, pad: int = 6) -> list[int]:
def _check_skill_icon(check: SkillCheck, threshold: float = 0.84) -> tuple[bool | None, str, float]:
template_name = _first_existing_template(TEMPLATE_ALIASES.get(check.skill, (check.template,)))
if template_name is None:
# Template missing — skip required skills (assume user bound correctly),
# log warning but don't block the bot.
if check.required:
Logger.warning(
f"Skill visual preflight: {check.skill} template {check.template} missing — "
f"assuming correct bind and continuing"
)
return True, check.template, 1.0
return None, check.template, -1.0
keyboard.send(check.hotkey)
+436 -82
View File
@@ -1,3 +1,5 @@
import hashlib as _hashlib
import os as _os
import random
import time
from input_layer import keyboard
@@ -6,6 +8,27 @@ from logger import Logger
from utils.misc import wait
# Fresh per process, so the session personality differs run to run while
# staying constant within one run.
_SESSION_SALT = int.from_bytes(_os.urandom(4), "big")
_session_bias = None
_manifest_logged = False
def _tl(step: str, status: str = "ok", detail: str = "") -> None:
"""Mirror stealth decisions into the run timeline (grep "TL>").
Imported lazily and guarded: utils.stealth is imported BY bot.py, so a module-level
import of Bot here would be circular, and stealth helpers are also called from
contexts with no live Bot.
"""
try:
from bot import Bot
Bot.timeline("stlth", step, status, detail)
except Exception:
pass
def maybe_afk_break():
"""Call after each run. Randomly takes an unscheduled AFK break."""
try:
@@ -15,7 +38,9 @@ def maybe_afk_break():
if random.randint(1, 100) <= cfg["afk_break_chance"]:
minutes = random.uniform(cfg["afk_break_min_m"], cfg["afk_break_max_m"])
Logger.info(f"[Stealth] Taking unscheduled AFK break for {minutes:.1f} minutes")
_tl("afk_break", "start", f"planned {minutes:.1f}m (chance {cfg['afk_break_chance']}%)")
wait(minutes * 60, minutes * 60 * 1.5)
_tl("afk_break", "ok", "resuming")
Logger.info("[Stealth] AFK break over, resuming")
@@ -27,35 +52,11 @@ def should_skip_run() -> bool:
return False
if cfg["skip_run_chance"] > 0 and random.randint(1, 100) <= cfg["skip_run_chance"]:
Logger.info("[Stealth] Randomly skipping this run for anti-detection")
_tl("skip_run", "ok", f"rolled skip (chance {cfg['skip_run_chance']}%)")
return True
return False
def randomize_click_position(x: int, y: int) -> tuple:
"""
Returns a slightly offset click position to simulate imperfect aim.
Uses a 2D Gaussian distribution so most clicks cluster near target
with rare larger misses (like real humans).
"""
try:
cfg = Config().stealth
variance = cfg["click_variance"]
except Exception:
variance = 8
# Gaussian distribution: most clicks land close, occasional larger miss
dx = int(random.gauss(0, variance / 2))
dy = int(random.gauss(0, variance / 2))
# Occasionally add a small directional bias (simulates hand drift)
if random.random() < 0.1:
bias = random.randint(2, max(1, variance // 2))
dx += bias * random.choice([-1, 1])
dy += bias * random.choice([-1, 1])
return x + dx, y + dy
def randomize_run_duration(base_duration: float) -> float:
"""
Varies the effective run duration to avoid bot-like consistency.
@@ -67,9 +68,17 @@ def randomize_run_duration(base_duration: float) -> float:
except Exception:
variance = 0.15
# Most runs complete within +/- 15% of base, with occasional outliers
factor = random.gauss(1.0, variance)
factor = max(0.7, min(1.4, factor)) # Clamp to 70%-140%
# Only ever LENGTHEN. A shortened attack window leaves the boss alive, which
# is a failed run rather than convincing behaviour — the asymmetry is
# deliberate, do not restore the 0.7 floor.
try:
if not Config().stealth.get("vary_kill_time", True):
return base_duration
except Exception:
return base_duration
factor = abs(random.gauss(0.0, variance)) + 1.0
factor = min(1.4, factor) # Clamp to 100%-140%
return base_duration * factor
@@ -94,17 +103,6 @@ def add_micro_pause():
# ─── Tier 1: Input-level stealth ─────────────────────────────────────────────
def endpoint_wobble(x: int, y: int) -> tuple[int, int]:
"""
Adds 2-5 pixel micro-adjustment before final click position (human hand tremor).
Called just before clicking to simulate endpoint micro-correction.
"""
# Small Gaussian wobble: most land within 3px, occasional 5px+
dx = int(random.gauss(0, 1.5))
dy = int(random.gauss(0, 1.5))
return x + dx, y + dy
def click_delay() -> float:
"""
Returns the delay (in seconds) between arriving at a target and clicking.
@@ -122,8 +120,17 @@ def click_delay() -> float:
return delay_ms / 1000.0
def click_delay_enabled() -> bool:
try:
return bool(Config().stealth.get("click_delay_enabled", False))
except Exception:
return False
def apply_click_delay():
"""Sleep for a human-like delay before clicking."""
if not click_delay_enabled():
return
wait(click_delay(), click_delay() * 1.2)
@@ -146,34 +153,6 @@ def key_press_duration(base_duration: float = 0.05) -> float:
return duration_ms / 1000.0
def human_key_press(key: str):
"""
Press a key with human-like timing: variable duration, micro-pause before and after.
Replaces direct keyboard.press/release for stealth.
"""
add_micro_pause()
duration = key_press_duration()
keyboard.press(key)
wait(duration, duration * 1.2)
keyboard.release(key)
add_micro_pause()
def human_keyboard_send(key: str):
"""
Send a key press with full human-like timing.
Replaces keyboard.send() for stealth.
"""
add_micro_pause()
duration = key_press_duration(0.03)
keyboard.press(key)
wait(duration, duration * 1.2)
keyboard.release(key)
add_micro_pause()
# ─── Tier 2: Behavior-level stealth ───────────────────────────────────────────
def should_wrong_waypoint() -> bool:
"""
2-3% chance of "getting lost" and selecting wrong waypoint first,
@@ -184,7 +163,10 @@ def should_wrong_waypoint() -> bool:
chance = cfg.get("wrong_waypoint_chance", 0.025)
except Exception:
chance = 0.025
return random.random() < chance
hit = random.random() < chance
if hit:
_tl("wrong_waypoint", "ok", f"deliberate misclick (chance {chance})")
return hit
def skill_rotation_hesitation() -> float:
@@ -217,22 +199,394 @@ def should_correct_skill_mistake() -> bool:
# ─── Tier 3: Session-level stealth ────────────────────────────────────────────
def get_personality_seed(char_name: str) -> int:
"""Stable per-character seed.
The previous version used the builtin hash(), which Python randomizes per
process (PYTHONHASHSEED) for str so it produced a DIFFERENT value every
session, the opposite of what its docstring promised. hashlib is stable
across processes and machines.
"""
Generate a deterministic seed from character name.
Each character has a unique "personality" different timing distributions,
different error rates but consistent across sessions.
"""
return hash(char_name) & 0xFFFFFFFF
digest = _hashlib.sha256(str(char_name).encode("utf-8")).digest()
return int.from_bytes(digest[:4], "big")
def apply_personality(char_name: str):
def get_session_bias() -> float:
"""A speed bias held constant for this whole session.
Real players are internally consistent within a session and different
between them a little quicker one evening, slower the next. Per-action
jitter alone cannot produce that: averaged over hundreds of actions it
converges to the same mean every session, which is itself a signature.
Derived from the character seed plus the session start, so it is stable for
the process lifetime and differs run to run. Range 0.9-1.15: deliberately
narrower on the fast side, since being systematically quicker is the
direction that breaks things.
"""
Configure random distributions based on character-specific personality.
Called once at bot start to seed per-character behavior.
global _session_bias
if _session_bias is None:
try:
from config import Config
name = str(Config().general.get("name") or Config().char.get("type") or "botty")
except Exception:
name = "botty"
rnd = random.Random(get_personality_seed(name) ^ _SESSION_SALT)
_session_bias = round(rnd.uniform(0.90, 1.15), 4)
Logger.info(f"[Stealth] Session personality: timing bias {_session_bias:.3f}x")
return _session_bias
# ─── Tier 4: observability ────────────────────────────────────────────────────
# Every defect in this subsystem was invisible for the same reason: a behaviour
# that never fires looks exactly like one whose roll has not come up yet. AFK
# breaks sat dead for 225 games behind that ambiguity. The manifest removes it
# by reporting, at startup, whether each behaviour has a REACHABLE call site —
# independently of whether it happens to fire.
def _count_calls(module_name: str, needle: str) -> int:
try:
import importlib, inspect
return inspect.getsource(importlib.import_module(module_name)).count(needle)
except Exception:
return -1
def manifest() -> list:
"""[(behaviour, configured, status)] — status is about REACHABILITY."""
try:
cfg = Config().stealth
except Exception:
return []
rows = []
def add(name, configured, wired, detail=""):
# A behaviour rolled at 0% is OFF, whatever its call sites look like.
# Reporting it as "wired" is the same misleading-status problem this
# manifest exists to eliminate.
if str(configured).strip() in ("0", "0.0", "0%", "0.0%"):
rows.append((name, "off", "disabled by config"))
return
if wired is None:
status = detail or "not enabled"
elif wired:
status = f"wired{(' - ' + detail) if detail else ''}"
else:
status = f"UNREACHABLE{(' - ' + detail) if detail else ''}"
rows.append((name, configured, status))
# afk_break: must roll on every way home, not just the teleport path.
# "maybe_afk_break()" with parens never matches the import line, so no adjustment.
afk = _count_calls("bot", "maybe_afk_break()")
add("afk_break", f"{cfg['afk_break_chance']}%", afk > 0, f"{afk} call sites")
skip = _count_calls("bot", "should_skip_run()")
add("skip_run", f"{cfg['skip_run_chance']}%", skip > 0, f"{skip} call sites")
# vary_kill_time reaches a boss only via IChar.atk_len().
try:
import importlib, inspect, pkgutil, char as char_pkg
adopted, total = [], []
for m in pkgutil.walk_packages(char_pkg.__path__, "char."):
try:
src = inspect.getsource(importlib.import_module(m.name))
except Exception:
continue
if 'Config().char["atk_len' in src or "self.atk_len(" in src:
total.append(m.name)
if "self.atk_len(" in src:
adopted.append(m.name)
detail = f"{len(adopted)}/{len(total)} char modules on IChar.atk_len()"
add("vary_kill_time", "on" if cfg["vary_kill_time"] else "off",
bool(adopted) if cfg["vary_kill_time"] else None, detail)
except Exception:
add("vary_kill_time", "on" if cfg["vary_kill_time"] else "off", None)
gated = _count_calls("input_layer", "_is_skill_key(key)") > 0
add("skill_hesitation", f"{cfg['skill_hesitation_min_ms']}-{cfg['skill_hesitation_max_ms']}ms",
gated, "skill keys only" if gated else "gate missing")
add("skill_mistake", f"{cfg['skill_mistake_chance']}", gated,
"skill keys only" if gated else "gate missing")
wp = _count_calls("ui.waypoint", "should_wrong_waypoint()")
add("wrong_waypoint", f"{cfg['wrong_waypoint_chance']}", wp > 0, f"{wp} call sites")
add("click_delay",
f"{cfg['click_delay_min_ms']}-{cfg['click_delay_max_ms']}ms" if cfg["click_delay_enabled"] else "off",
True if cfg["click_delay_enabled"] else None, "" if cfg["click_delay_enabled"] else "opt-in")
add("wait_jitter", f"{cfg['wait_jitter_min']}-{cfg['wait_jitter_max']}", True, "global")
budget_h = cfg.get("session_budget_h", 0)
add("session_budget", f"{budget_h}h" if budget_h else "0",
True if budget_h else None,
f"this run {session_budget_seconds()/3600:.1f}h" if budget_h else "unlimited")
# Report the call site, not just the config — the lesson from chicken_variance.
daily_h = cfg.get("daily_budget_h", 0)
daily_wired = _count_calls("bot", "daily_budget_exceeded(") > 0
if daily_h:
_b = daily_budget_seconds() / 3600
_u = _daily_read().get("seconds", 0.0) / 3600
detail = f"today {_u:.1f}/{_b:.1f}h" if daily_wired else "no call site in bot.py"
else:
detail = "unlimited"
add("daily_budget", f"{daily_h}h" if daily_h else "0",
(daily_wired if daily_h else None), detail)
run_s, brk_s = break_schedule()
add("scheduled_break", f"{run_s/60:.0f}m/{brk_s/60:.0f}m" if run_s else "0",
run_s > 0, "re-rolled each break" if run_s else "disabled in [general]")
add("idle_drift", "on" if cfg.get("idle_drift_enabled") else "0",
bool(cfg.get("idle_drift_enabled")), "bot thread only")
# Check the CALL SITE, not just the config value. Reporting this as "wired"
# from the config alone hid the fact that the roll was never wired at all —
# the precise failure this manifest exists to catch, committed by the
# manifest itself.
chick = _count_calls("bot", "set_game_chicken_threshold(") > 0
add("chicken_variance", f"+0-{cfg.get('chicken_variance', 0)}",
chick and float(cfg.get("chicken_variance", 0)) > 0,
"raises only, never lowers" if chick else "no call site in bot.py")
add("town_browse", f"{cfg.get('town_browse_chance', 0)}",
_count_calls("bot", "should_browse_town()") > 0, "town only")
add("pickup_skip", f"{cfg.get('pickup_skip_chance', 0)}",
_count_calls("item.pickit", "should_skip_pickup()") > 0, "2 decision points")
add("session_bias", f"{get_session_bias():.3f}x", True, "constant this run")
return rows
def log_manifest(once: bool = True):
"""One grep-able line per behaviour: grep "STEALTH>".
`=== BOT START ===` is emitted per GAME, not per process, so default to
logging this only the first time or it becomes nine lines of noise per run.
"""
seed = get_personality_seed(char_name)
# Don't reseed global random (breaks other things),
# but each stealth function uses its own local random call
# which gets the benefit of global state entropy.
# Personality is encoded in config ranges, not seeds.
pass
global _manifest_logged
if once and _manifest_logged:
return
_manifest_logged = True
rows = manifest()
if not rows:
Logger.warning("STEALTH> manifest unavailable — config not loaded")
return
for name, configured, status in rows:
line = f"STEALTH> {name:<18} {configured:<14} {status}"
if "UNREACHABLE" in status:
Logger.warning(line)
else:
Logger.info(line)
# ─── Tier 5: session rhythm and idle behaviour ────────────────────────────────
# The strongest remaining signal is not per-click timing. 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 unproductive.
_session_budget_s = None
def break_schedule() -> tuple:
"""(run_seconds, break_seconds) for the NEXT scheduled break.
Re-rolled each time, because a break at exactly 120 minutes every time is
still a pattern just a slower one than no break at all.
"""
try:
cfg = Config().general
run_m = float(cfg.get("max_runtime_before_break_m") or 0)
brk_m = float(cfg.get("break_length_m") or 0)
except Exception:
return (0, 0)
if run_m <= 0 or brk_m <= 0:
return (0, 0)
return (random.uniform(run_m * 0.7, run_m * 1.3) * 60,
random.uniform(brk_m * 0.6, brk_m * 1.6) * 60)
def session_budget_seconds() -> float:
"""How long this session should run before stopping. 0 = unlimited.
Fixed for the process, varied per run, so consecutive days differ.
"""
global _session_budget_s
if _session_budget_s is None:
try:
hours = float(Config().stealth.get("session_budget_h", 0) or 0)
except Exception:
hours = 0
if hours <= 0:
_session_budget_s = 0.0
else:
_session_budget_s = random.uniform(hours * 0.65, hours * 1.35) * 3600
Logger.info(f"[Stealth] Session budget: {_session_budget_s / 3600:.1f}h")
return _session_budget_s
# --------------------------------------------------------------------------
# Daily runtime cap
#
# session_budget_h is per PROCESS. A restart re-rolls it, so a bot that is
# restarted (by the user, or by the restart-on-crash path) can run all day and
# never trip it — which is exactly the signal a daily cap is meant to remove.
# This tracker persists to disk and is keyed on the calendar date.
#
# The rolled target is stored WITH the date. Re-rolling it on every process
# start would let a restart hand the bot a fresh, possibly larger budget; once
# a day's target is chosen it is fixed until the date changes.
# --------------------------------------------------------------------------
_DAILY_STATE_PATH = _os.path.join("log", ".daily_runtime.json")
_daily_last_tick: float | None = None
def _today() -> str:
import datetime
return datetime.date.today().isoformat()
def _daily_read() -> dict:
import json
try:
with open(_DAILY_STATE_PATH, "r", encoding="utf-8") as f:
state = json.load(f)
if state.get("date") == _today():
return state
except Exception:
pass
return {"date": _today(), "seconds": 0.0, "budget_s": None}
def _daily_write(state: dict) -> None:
import json
try:
_os.makedirs(_os.path.dirname(_DAILY_STATE_PATH), exist_ok=True)
with open(_DAILY_STATE_PATH, "w", encoding="utf-8") as f:
json.dump(state, f)
except Exception as e:
Logger.warning(f"[Stealth] could not persist daily runtime: {e}")
def daily_budget_seconds() -> float:
"""Today's runtime allowance in seconds. 0 = unlimited.
Rolled once per calendar day and persisted, so restarts cannot extend it.
"""
try:
hours = float(Config().stealth.get("daily_budget_h", 0) or 0)
except Exception:
hours = 0
if hours <= 0:
return 0.0
state = _daily_read()
if not state.get("budget_s"):
try:
jitter = float(Config().stealth.get("daily_budget_jitter", 0.12) or 0)
except Exception:
jitter = 0.12
# Jitter DOWNWARD only. daily_budget_h is a ceiling, not an average: a
# two-sided roll on 8h could hand out 9h, which is not what "cap it at 8
# hours" means. Varying 7-8h still breaks the identical-length-day signal
# this exists for, without ever exceeding the configured limit.
state["budget_s"] = random.uniform(hours * (1 - jitter), hours) * 3600
_daily_write(state)
Logger.info(f"[Stealth] Daily budget for {state['date']}: {state['budget_s'] / 3600:.1f}h")
return float(state["budget_s"])
def daily_runtime_tick() -> float:
"""Add elapsed time since the last tick to today's total; return the total.
Call periodically (end of each game). Time before the first tick in a
process is not counted a crash therefore under-counts rather than
over-counts, which is the safe direction for a cap.
"""
global _daily_last_tick
now = time.time()
if _daily_last_tick is None:
_daily_last_tick = now
return float(_daily_read().get("seconds", 0.0))
state = _daily_read()
# A date rollover resets seconds AND clears the budget so a new one is rolled.
state["seconds"] = float(state.get("seconds", 0.0)) + (now - _daily_last_tick)
_daily_last_tick = now
_daily_write(state)
return state["seconds"]
def daily_budget_exceeded() -> tuple[bool, float, float]:
"""(exceeded, seconds_used_today, budget_seconds). budget 0 = unlimited."""
budget = daily_budget_seconds()
used = daily_runtime_tick()
if not budget:
return False, used, 0.0
return used >= budget, used, budget
def idle_drift():
"""Nudge the cursor a little, the way a resting hand does.
BOT THREAD ONLY never call from health_manager/death_manager (see the
threading rules in CLAUDE.md). Between actions the cursor otherwise sits
exactly where the last click left it, indefinitely.
"""
try:
if not Config().stealth.get("idle_drift_enabled", True):
return
from input_layer import mouse
x, y = mouse.get_position()
rx = x + int(random.gauss(0, 18))
ry = y + int(random.gauss(0, 14))
rx = max(20, min(1260, rx))
ry = max(20, min(700, ry))
mouse.move(rx, ry, randomize=6, delay_factor=[0.6, 1.1])
except Exception:
pass
def chicken_threshold(base: float) -> float:
"""Per-game chicken threshold. Only ever RAISED (safer), never lowered.
A fixed 0.40 every single game is a precise tell. The asymmetry is
deliberate: a higher threshold bails earlier, so the worst case of this
randomisation is a slightly cautious run never a death.
"""
try:
spread = float(Config().stealth.get("chicken_variance", 0.08))
except Exception:
spread = 0.08
if spread <= 0:
return base
# Capped at base+spread. An uncapped Gaussian tail reached 0.55 on a 0.40
# base — chickening at 55% HP throws away healthy games, which is its own
# kind of cost. The band is meant to be a few points wide, not open-ended.
bump = min(spread, abs(random.gauss(0, spread / 2)))
return round(base + bump, 4)
def should_browse_town() -> bool:
"""Chance of an unproductive town action — opening the inventory and
closing it, the way a player checks something and moves on."""
try:
chance = float(Config().stealth.get("town_browse_chance", 0.0))
except Exception:
return False
if chance > 0 and random.random() < chance:
_tl("town_browse", "ok", f"idle inventory check (chance {chance})")
return True
return False
def should_skip_pickup() -> bool:
"""Occasionally walk past something the filter wanted.
The bot otherwise picks up exactly what the filter says, instantly, every
time. Kept small this costs real loot.
"""
try:
chance = float(Config().stealth.get("pickup_skip_chance", 0.0))
except Exception:
return False
if chance > 0 and random.random() < chance:
_tl("pickup_skip", "ok", f"walked past a wanted item (chance {chance})")
return True
return False
+1 -1
View File
@@ -1 +1 @@
__version__ = '0.8.4'
__version__ = '0.10.0'

Some files were not shown because too many files have changed in this diff Show More