Compare commits

..
416 Commits
Author SHA1 Message Date
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
alexpolo1andClaude Sonnet 4.6 47e9344fd5 test: add regression tests for NPC interaction and TownManager
Adds 41 tests covering the highest-risk production paths:

test/npc/test_npc_manager.py (14 tests):
  - _action_btns_visible: missing NPC, white found, nothing found
  - open_npc_menu: fast path (Bug 6), name-tag in ROI (Bug 3),
    ROI gate blocks outside-ROI click (Bug 7), pose-distance gate (Bug 4),
    body+pose confirmed triggers click, timeout returns False
  - press_npc_btn: white/blue/grayscale fallback chain, red=cannot-afford, nothing found

test/town/test_town_manager.py (27 tests):
  - get_act_from_location: all five acts + sub-locations, bad input
  - identify: True never returned (Bug 1), A5 fallback (Bug 5),
    A5-also-fails returns new_loc, _cain_failed_acts skip
  - open_wp: budget increment, exhaustion fast-path (Bug 9), reset, success
  - buy_consumables: unknown loc, trade-menu failure (Bug 10), success, no-need skip
  - stash: current-act delegation, A5 travel when act can't stash

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 10:21:51 +02:00
alexpolo1andClaude Sonnet 4.6 0176f66a1c fix: add pyinstaller to requirements.txt so build job can find it
environment-win11.yml installs only from requirements.txt, which was missing
pyinstaller. build.py constructs the full path to pyinstaller.exe so the
install is sufficient; no PATH change needed.
Verified locally: 99 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 09:38:31 +02:00
alexpolo1andClaude Sonnet 4.6 38c2bbdea6 fix: add coverage and pytest deps to requirements.txt for CI
environment-win11.yml pulls only requirements.txt (not environment.yml), so
coverage, pytest, pytest-env, pytest-mock, and pytest-pythonpath were missing
from the CI conda env. Pinned to versions matching the local botty env.
Verified locally: 99 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 09:25:25 +02:00
alexpolo1andClaude Sonnet 4.6 682580e44c fix: use python -m coverage in CI (bare 'coverage' not on PATH in conda env)
conda activate does not add Scripts/ to PowerShell PATH in the GitHub Actions
runner. Switching to 'python -m coverage' works regardless of PATH state.
Verified locally: 99 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 09:21:08 +02:00
alexpolo1andClaude Sonnet 4.6 34cc75c9e4 fix: downgrade async-timeout to 5.0.1 (5.1.0 does not exist on PyPI)
CI was failing at conda env setup: pip could not find async-timeout==5.1.0.
Latest available version is 5.0.1.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 09:15:08 +02:00
alexpolo1andClaude Sonnet 4.6 e62272b743 fix: use -1.0 budget in time_budget test to survive Windows clock resolution
time.time() on Windows has ~15 ms resolution; a 0.0-second budget produced a
deadline equal to the current tick, so the anchor-loop check never fired and
traverse_calls reached 5 instead of 1. Using -1.0 puts the deadline one second
in the past — guaranteed expired on any hardware.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 09:11:27 +02:00
alexpolo1 e797445cb9 fix: remove stale tesserocr_source submodule (not needed by install.bat) 2026-08-01 08:51:31 +02:00
alexpolo1 5d5b03ca76 production: prepare for clean install test
- requirements.txt: guard keyboard/mouse as Windows-only (sys_platform=win32)
- input_layer/__init__.py: add Docker bridge_input support (gated by os.name)
- main.py: add Docker mode detection, graceful fallback for non-Windows
- pather.py: add A5_NIHLATHAK_PORTAL path nodes for Pindle/Nihlathak TP-back recovery
- run/pindle.py: add temple entry verification, retry logic for red portal clicks
- transmute/transmute.py: ensure GEMS convert panel is empty before loading next batch
- .gitignore: ignore Docker/dev-only files (Dockerfile, docker-compose, botty_next, bridge_*)
2026-08-01 08:49:49 +02:00
FiskenPoul f5ea29489e Fix runaway gem transmute loop: verify stack presence before every click
A "999" gem count (OCR couldn't read the digit, code assumes "convert
until depleted") relied on the loop noticing an empty stack and
breaking — but that check only ran when _gems_stack_monitor_for
returned None. For any registered gem type it always returns a static
screen coordinate, so the depletion check was dead code: the loop
just kept blindly clicking the same fixed position forever.

Observed in the wild: stuck on Topaz Flawless for 30+ minutes and 177
iterations (of a fake "999" target, ~2.7h worst case) before being
manually force-exited, repeatedly clicking fixed convert-panel/GEMS
coordinates with nothing real there — the likely cause of it also
grabbing and re-placing unrelated stash items during that time.

Now always does a live template search before clicking, breaking
immediately once the stack is genuinely gone, on every gem type.
Verified end-to-end with a mocked run: a fake depleted "999" stack now
stops instantly instead of looping, and a real gem right after it
still converts correctly.
2026-07-13 18:44:11 +02:00
FiskenPoul abb06066d5 CTA pre-buff: poll for Battle Command instead of racing a fixed wait
Every single game logged "Failed to find Battle Command, swapping
weapons again" — 1182 times in the last log alone, always on the
first attempt, always resolved by the very next loop iteration's
identical check with no extra wait in between. The skill icon just
takes a bit longer than the fixed 0.6-0.8s wait to render on this
system; the check was racing it every time.

Poll for up to 1.2s instead of a single check after a fixed wait.
Catches the skill as soon as it's actually visible rather than always
failing once first, and removes the latent risk of the fallback path
incorrectly swapping back to the main weapon if timing ever degraded
further.
2026-07-13 13:35:48 +02:00
FiskenPoul 05df847933 Make protect_charms_from_sell actually configurable
It already read Config().char.get("protect_charms_from_sell", True) in
personal.py's drop/sell guard, but the key was never added to the char
config dict builder in config.py, so setting it in an ini file did
nothing — charms were unconditionally undroppable regardless of the
pickit verdict. Wired it up the same way protect_shields_from_sell
already works. Defaults to 1 (protected, unchanged behavior) so this
is opt-in only.
2026-07-12 18:29:58 +02:00
FiskenPoul 0eca544d2e stash_all_items: try other stash tabs before giving up on a transfer failure
When a tab showed a free slot but the specific placement click kept
failing, the code deliberately gave up rather than advance tabs (to
avoid falsely triggering stash_full()'s taskkill on a transient
glitch). In practice this meant the bot got stuck retrying the same
tab forever every game, leaving loot in inventory even when every
other stash tab was completely empty.

Now it tries the next tab (up to all 6) on repeated transfer failure,
same as it does for a genuinely full tab — but never calls
stash_full() from this path, only from the original "confirmed no
empty slot anywhere" detection. Verified with a mocked simulation:
cycles through failing tabs to a working one, and degrades gracefully
(leaves items in inventory, no crash, no false stash_full) if every
tab fails.
2026-07-12 12:56:46 +02:00
FiskenPoul 737636b644 Make pickup-drought health check window configurable (pickup_drought_window)
Was hardcoded to 10 games; a strict pickit on a fast boss-only rush
route can legitimately go 10 games without a keep-worthy drop, making
the log warning noisy. Defaults to 10 (unchanged), override per-user
via profile.ini.
2026-07-11 22:39:38 +02:00
alexpolo1andClaude Opus 4.8 17bcb95c0a fix: make the Diablo (Chaos Sanctuary) run complete end-to-end
The run_diablo route was failing every game. Diagnosed and fixed live —
a full run now clears all three seals (Vizier, De Seis, Infector) and loots.

Pentagram navigation (was the #1 abort: "battle_failed", char stranded in
CS trash, pentagram never detected):
- _loop_pentagram now falls back to active node-602 navigation when the blind
  fixed-path teleport loop fails to surface the pentagram. Node 602 searches the
  PENT templates directly and teleports toward them with the pather's auto-
  recovery sweep — the same robust approach _cs_pentagram already uses. Applied
  in both diablo.py and vizier.py.
- Combined with the lowered _PENT_THRESHOLD (0.50), the pentagram now resolves:
  live reads were 57-96% where the old 0.83 threshold rejected them.

Seal layout check (next abort after the pentagram fix, at the Vizier seal):
- Added per-seal score logging (LC primary/confirm). This revealed the real
  cause is character-positioning variance, NOT template drift: the true layout
  reads 84-88% and the other 42-57% (clean separation) when well-positioned, but
  from a bad camera angle BOTH read ~55-66% and the check is ambiguous.
- So the fix is more re-approach attempts (max_attempts 2 -> 3), not lower
  thresholds — lowering a disambiguation threshold risks picking the WRONG seal
  from a bad-position read.
- Normalized seal-A threshold_confirmation 0.85 -> 0.80 (every other seal is
  0.80; safe given the 84-88% vs 42-57% separation).

Crash fix (game_recovery.py): go_to_hero_selection had been dedented to module
level while its body kept method indentation, so it fell out of the GameRecovery
class. Every post-chicken/death/failed-game recovery threw
AttributeError: 'GameRecovery' object has no attribute 'go_to_hero_selection'
and killed the run_bot thread. Re-indented into the class. Verified live: the
bot now recovers from failed games and auto-starts the next one.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 15:58:13 +02:00
alexpolo1 59b851b107 fix: stuck in town recovery improvements
- replace print() with Logger.debug() in misc.py to fix colorama OSError crash on restart
- try/except RuntimeError in template_finder ThreadPoolExecutor so interpreter shutdown falls back to sequential matching
- add last-resort direct WP scan in A5 open_wp after anchors fail
- extend go_to_hero_selection timeout 30s->45s, add ESC fallback after 15s if blocked by UI panel
- remove startup warning spam in screen.py
2026-06-24 10:47:34 +02:00
alexpolo1andClaude Opus 4.8 0e90b36e14 fix: bound A5 open_wp so a missed waypoint never strands the bot in town
The A5 waypoint stone matches reliably (65-91% when on screen). The real
failure mode is a stale curr_loc that lands the char off the stone, so
select_by_template("A5_WP") never matches. The old escalation (NPC anchors +
a 6-step directed sweep) then looped for 5+ minutes — the "stuck in town"
behavior seen in log/log.txt 2026-06-24 (08:28:48 -> 08:30 force-exit).

- Add a 45s hard wall-clock budget to open_wp; bail between anchors once past.
- Drop the directed sweep entirely: it never recovered in practice and was the
  main multi-minute time sink. A failure now returns fast so the caller falls
  back (buy at Malah / skip to stash) instead of stranding the bot.
- Also folds in the in-progress A5 repair-menu timing fix (wait_until_visible
  instead of a too-short 0.2-0.3s peek).
- Add test/town/a5_open_wp_test.py covering fast-fail, quick-mode, and budget.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 08:39:20 +02:00
alexpolo1 71109e9532 Merge fix stash branch into main 2026-06-21 19:04:54 +02:00
alexpolo1 6012934e66 Add chipped gem conversion runner 2026-06-21 16:06:58 +02:00
alexpolo1 48d8445c05 Bootstrap visual test harness and fix GEMS transmute flow 2026-06-21 15:04:12 +02:00
alexpolo1andClaude Sonnet 4.6 44794fba67 logs: hard size cap on log.txt + kill install-log progress-bar balloon
A single bot session produced a 22 GB log. The file logger used daily-only
rotation (TimedRotatingFileHandler when='midnight') with NO size cap, so a
long/spammy session grew log.txt unbounded within a day. Its archiver also
looked for .1/.2 backups that the timed handler never produced.

- logger.py: switch to size-based RotatingFileHandler — log.txt rotates at
  50 MB (override via BOTTY_LOG_MAX_MB), keeps 5 zipped backups, and prunes
  log/archive/ to 30 zips. Hard cap on both the live file and total disk.
  The .1/.2 naming now matches what the handler emits, so archiving works.
- install.bat: pip --progress-bar off. The progress bar redraws via \r;
  redirected to a file (run_install_capture.bat) those redraws became
  millions of lines — the other way an install log balloons to GBs.
- params.ini: document the log.txt cap + BOTTY_LOG_MAX_MB.

Verified: with a tiny cap, log.txt stayed under the limit while rotated
files zipped to archive; full suite 80 passed / 2 skipped.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 18:56:25 +02:00
alexpolo1andClaude Sonnet 4.6 aa78bab638 ci: harden coverage omit so config-3.py phantom never trips xml
Botty - CI / test (push) Waiting to run
Botty - CI / build (push) Blocked by required conditions
The bare "config-3.py" omit never matched the phantom's absolute path
(D:\a\...\config-3.py), so coverage xml only survived via --ignore-errors
and still logged the alarming "No source for code" line. Use a glob
(*config-*.py) that matches the phantom at any path while keeping
src/config.py measured (verified via coverage GlobMatcher).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 11:30:16 +02:00
alexpolo1andClaude Sonnet 4.6 3d11947bf2 ocr: bundle Tesseract into release for click-and-run OCR
Make the standalone exe work with OCR out of the box — no separate
Tesseract install, no tesserocr DLL hell. Verified end-to-end: built the
exe, ran it frozen with the system Tesseract blinded, confirmed it
resolves the bundled binary and reads text ("CHAM RUNE").

- ocr.py: resolve an _APP_BASE (exe dir when frozen, else cwd) and prefer
  a bundled <exe_dir>/tesseract/tesseract.exe over PATH / Program Files.
  Resolve assets/tessdata to an absolute path so OCR no longer depends on
  the current working dir. Applies to both the tesserocr and pytesseract
  paths.
- build.py: copy a portable Tesseract (exe + DLLs) from TESSERACT_DIR
  (default C:\Program Files\Tesseract-OCR) into <release>/tesseract/. Our
  trained models in assets/tessdata are used via --tessdata-dir, so their
  tessdata is skipped. Warns (non-fatal) if Tesseract isn't present.
- ci.yml: choco install tesseract before the build so the bundle is
  reproducible on the runner; verify it landed in the release dir.
- test/conftest.py: apply the SSL cert-store workaround so pytest can be
  collected on Windows boxes with a corrupted cert store (no-op on CI).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 10:49:25 +02:00
alexpolo1andClaude Sonnet 4.6 f31c30f388 security: untrack cached d2jsp scrapes, parametrize cookie helper
- git rm --cached data/d2jsp_pages (102 files, already gitignored) — saved
  authenticated HTML embedded the live d2jsp msec session token
- apply_manual_cookies.py: read member_id/msec from env vars instead of
  hardcoding the real session token

History purge of these blobs follows in the same cleanup.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 10:33:50 +02:00
alexpolo1andClaude Sonnet 4.6 b9f6635f8f security: stop tracking cookies.txt, ignore secrets, drop personal paths
Pre-public cleanup:
- Remove cookies.txt from tracking (held live d2jsp session cookies
  member_id + msec) and delete the local copy
- .gitignore: cookies.txt, cookies_temp*, *.cookies, config/custom.ini
- fg_scrape_pipeline.sh: replace hardcoded /c/Users/alex/Downloads path
  and /c/Python313/python with a script-relative cd and $PYTHON from PATH

NOTE: cookies.txt still exists in git history (commit e3d6605) — a
history purge + cookie rotation is still required before going public.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 10:28:47 +02:00
alexpolo1andClaude Sonnet 4.6 73c4601e2b ci: tag-triggered build-and-release in one run
Switch from the upload-to-an-existing-release model (create release in
UI/CLI first, release:published triggers CI) to a tag-driven flow:

  git tag v0.8.5 && git push --tags
  -> CI builds + smoke-tests both exes
  -> softprops/action-gh-release creates the release and attaches the zip

Build fails => no release is ever created (no more empty/half-published
releases). Removes the release: trigger so the workflow can't double-fire
when the action publishes the release. Keeps permissions: contents: write.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 10:25:17 +02:00
alexpolo1andClaude Sonnet 4.6 5647f323a7 ci: grant contents:write so release upload step can attach the zip
The build job's "Upload to Release" step failed with HTTP 403
"Resource not accessible by integration" because the default
GITHUB_TOKEN is read-only. Add top-level permissions: contents: write.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 10:12:44 +02:00
alexpolo1andClaude Sonnet 4.6 3db44fe4e6 build: bundle conda native DLLs + fix shopper SSL crash
Verified by building locally and running both exes to their menus.

build.py: prepend the conda env's Library\bin, Library\lib and DLLs
dirs to PATH before invoking PyInstaller. PyInstaller resolves binary
deps via PATH (not --paths, which only affects Python imports), so
without this the frozen exe crashed at startup with
"DLL load failed while importing _ctypes" (missing ffi-8.dll, plus
liblzma/libbz2). Now ffi-8/lzma/bz2/leptonica/tesseract52 all bundle.

src/shopper.py: mirror main.py's startup header — add the
ssl.load_default_certs monkey-patch (corrupted Windows cert store made
aiohttp crash at import with ASN1 NOT_ENOUGH_DATA) and drop Library\bin
from os.add_dll_directory (it ships mismatched OpenSSL DLLs that break
_ssl). shopper.exe previously crashed on any machine with a bad cert
store; CI's clean runner masked it.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 09:47:41 +02:00
alexpolo1andClaude Sonnet 4.6 57073946ae ci: split test job into named steps, fix coverage xml phantom-file error
- Split monolithic 'Validate Botty' into 4 named steps so failures are
  visible per-step without log diving: Python version / Syntax check /
  Tests / Coverage report
- Add scripts/ to compileall so new scripts are syntax-checked too
- Add -v to pytest for per-test pass/fail in CI output
- Fix coverage xml exiting 1 on conda phantom config-3.py:
  - Move ignore_errors to [report] section (was wrongly in [xml])
  - Add --ignore-errors flag on coverage xml command (belt+suspenders)
  - Omit config-3.py and site-packages paths from [run] tracking

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 09:34:11 +02:00
Alex 5c687773b1 Click-and-go install: self-bootstrapping install.bat + tesserocr DLL fix (#2)
Merges fix/stash-full-guard-and-testbed into main. See PR #2 for full description.
2026-06-20 09:27:12 +02:00
alexpolo1andClaude Sonnet 4.6 c1997b438e build: use --conda_path arg instead of hardcoded user path
Fixes CI build job which passes --conda_path C:\Miniconda but
botty_env was hardcoded to C:\Users\alex\.conda\envs\botty.
Also replace os.system mkdir with os.makedirs and add error
detection so PyInstaller failures are not silently swallowed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 00:59:20 +02:00
alexpolo1andClaude Sonnet 4.6 aa2f52f702 coverage: scope to src/, ignore missing sources in xml report
'coverage xml' was failing with 'No source for code: config-3.py' because
coverage was tracing conda internals (config-3.py from Miniconda's base env).
Add source=src to restrict coverage collection to our source tree, and
ignore_errors=True in [xml] as a safety net for any remaining phantom paths.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 00:49:18 +02:00
alexpolo1andClaude Sonnet 4.6 0fbe480f38 Fix two pre-existing CI test failures
- bnip/utils.py: standardise error code prefix NIP_0x23 -> BNIP_0x23 to match
  all other error codes in the bnip module (all use BNIP_ prefix)
- test_transpile: update syntax_test9 expected code 0x11 -> 0x23; parsing of
  '[idname] > ring' now hits the unique/set lookup (BNIP_0x23) before the
  logical-operator token check (BNIP_0x11) fires
- test_skill_preflight: test_unknown_build_has_no_checks used 'hammerdin' as
  the unknown build, but hammerdin now has skill checks defined; switch to
  'nonexistent_build' which has no checks

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 00:44:25 +02:00
alexpolo1andClaude Sonnet 4.6 de2943fe7a Add run_install_capture.bat — capture install output to install_log.txt
Wrapper for install.bat that redirects all output to install_log.txt so the
install can be run non-interactively and the log inspected after the fact.
Useful for debugging installs in CI or remote sessions.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 00:31:08 +02:00
alexpolo1andClaude Sonnet 4.6 41c230ad9c install.bat: fix discord import check for Windows SSL cert store bug
discord.py imports aiohttp which calls ssl.create_default_context() at import
time. A malformed cert in the Windows cert store raises ASN1 NOT_ENOUGH_DATA.
The bot patches this at runtime via _patch_ssl(); the install check now applies
the same workaround (null-patch load_default_certs) so discord: OK on all machines.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 00:19:24 +02:00
alexpolo1andClaude Sonnet 4.6 a4e3ff0e2f install.bat: fix Win11 detection, stale errorlevel in blocks, remove pause
- Echo with (no ABSOLUTE flag) had unescaped ) closing the if block early,
  causing BOTH Win11 and Win10 branches to run and wrong requirements used.
  Fix: escape the parens with ^( and ^).
- %errorlevel% inside ( ) blocks is expanded at parse time, not after the
  command that sets it. Affects winget install result check, tesseract winget
  check, pytesseract check, and dependency verify loop.
  Fix: use !errorlevel! (delayed expansion) in those four spots.
- Remove final pause so the CMD window closes automatically on success.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-20 00:14:17 +02:00
alexpolo1andClaude Sonnet 4.6 6625c1e1d5 install.bat: use winget as primary Miniforge install method
winget (available on Win10 1709+ and all Win11) handles registration,
PATH, and package integrity checks automatically — cleaner than the
NSIS /S fallback. Fall back to curl+NSIS if winget is absent or fails.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 23:54:51 +02:00
alexpolo1andClaude Sonnet 4.6 fc8ef97a7a install.bat: add %LOCALAPPDATA%\miniforge3 to conda search paths
Miniforge3's JustMe default install location changed from
%USERPROFILE%\miniforge3 to %LOCALAPPDATA%\miniforge3 in recent versions
(per robotology-superbuild docs and winget package metadata). Both the
initial scan and the post-bootstrap rescan now check %LOCALAPPDATA% first.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 23:51:41 +02:00
alexpolo1andClaude Sonnet 4.6 1a58006188 install.bat: fix Miniforge bootstrap — curl download + start /wait for installer
Two bugs found by studying oobabooga/text-generation-webui (reference impl):

1. PowerShell Invoke-WebRequest without $ProgressPreference='SilentlyContinue'
   renders an ASCII progress bar that drops download speed from ~50 MB/s to
   ~1 MB/s. Switch primary download to curl (built into Win10/11, fast, clean
   progress bar). PowerShell is kept as a fallback with the flag set.

2. Running the NSIS installer directly ("installer.exe /S") can return before
   the install finishes. The correct pattern is "start /wait "" installer.exe"
   which blocks until the installer process truly exits. Also adds /NoShortcuts
   and /NoRegistry flags (standard for embedded/self-contained installs).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 23:46:24 +02:00
alexpolo1andClaude Sonnet 4.6 e18b81200f install.bat: fix Miniforge silent install — drop /D= to avoid spaces-in-path failure
NSIS /D= does not support paths with spaces. %USERPROFILE% often contains
spaces (e.g. C:\Users\John Smith). Omitting /D= lets Miniforge install to
its own default location (%USERPROFILE%\miniforge3) which it handles safely.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 22:50:16 +02:00
alexpolo1andClaude Sonnet 4.6 03da8783b6 install.bat: auto-download and install Miniforge3 if conda not found
Users no longer need to pre-install conda. If the first scan finds no
conda.exe, install.bat downloads Miniforge3-Windows-x86_64.exe via
PowerShell (available on all Win10/Win11), installs it silently to
%USERPROFILE%\miniforge3 with no PATH changes, then re-scans and
continues. On failure it prints a manual fallback URL. Users with an
existing conda install are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 22:44:31 +02:00
alexpolo1andClaude Sonnet 4.6 26ecb826ec fix tesserocr: bundle MSVC tesseract52.dll + conda-forge .pyd instead of broken DLL name aliases
The old install.bat copied tesseract41.dll as tesseract.dll (wrong name — wheel
needs tesseract51.dll) and the bundled .pyd used MSVC C++ ABI that the MinGW conda
DLLs can't satisfy. Both tesserocr imports now work:

  tesseract52.dll        — MSVC tesseract 5.2.0 from conda-forge (compiled vs2019)
  tesserocr.cp310-...pyd — conda-forge 2.5.2 build linked against tesseract52.dll

The MSVC leptonica-1.78.0.dll comes from the existing conda tesseract=4.x install.
ocr.py already registers Library/bin via os.add_dll_directory so both backends
(tesserocr fast path, pytesseract reliable fallback) load correctly at bot startup.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 21:56:48 +02:00
alexpolo1andClaude Sonnet 4.6 3ed837e1ee testbed: add count to help docstring
count scenario was registered in the scenarios dict but missing from
the module docstring — running testbed.py with no args hid it.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 21:34:59 +02:00
alexpolo1andClaude Sonnet 4.6 175f8e342b install.bat: dual-backend OCR that works on Win10 and Win11 out of the box
Architecture: two backends, first one that works wins at runtime (ocr.py).
  1. tesserocr  — C extension via conda tesseract 4.x DLLs + bundled wheel (fast)
  2. pytesseract — subprocess via winget tesseract.exe (always works if exe exists)

Install changes:
- tesserocr setup: best-effort, never fatal. Wheel install failure or DLL mismatch
  just falls through to pytesseract. Removes the hard-exit on missing wheel.
- pytesseract setup: winget install with a manual download link fallback for Win10
  users where winget is unavailable. pytesseract is now in requirements.txt so it
  is always importable.
- OCR verification: tests BOTH backends after setup, reports which is active.
  Warns clearly if neither works rather than silently leaving OCR broken.
  Sets ALL_OK=0 so the installer ends with a visible failure state.
- Dependency loop: removes tesserocr from the import check — OCR is now verified
  end-to-end by the backend test above, not by a bare import.
- Removes the broken pytesseract source-patch block (syntax error, never ran).
- TESS_PATH hoisted to :env_found so it is available for the OCR check.

requirements.txt: add pytesseract (pure Python, no native deps).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 21:29:03 +02:00
alexpolo1andClaude Sonnet 4.6 c566519cd8 Revert misguided install.bat OCR commits
Reverts 63d0a33 and e712076. Both commits got the OCR architecture backwards:
- Made winget "optional" but DEPENDENCIES.md shows pytesseract is the working
  backend on the actual machine (tesserocr is DLL-broken there)
- Added a tesserocr smoke test that would always fail on this machine
- Added pytesseract to verification loop but it's not in requirements.txt

Starting fresh with a correct understanding of the OCR stack.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 21:27:17 +02:00
alexpolo1andClaude Sonnet 4.6 e712076f08 install.bat: make OCR setup work on Win10 and Win11
tesserocr (conda DLLs + bundled wheel) is the primary OCR backend and works
identically on both platforms. pytesseract is an optional fallback that needs
a working tesseract.exe, which winget can install on Win11 but not reliably
on Win10.

Changes:
- Winget step: reframed as optional/best-effort; failure is informational only,
  not a warning that "OCR will not work" (tesserocr handles all OCR)
- Remove pytesseract from the verification loop — it is not in requirements.txt
  and would always fail on Win10 without winget; adding it in the previous commit
  was wrong
- Remove pytesseract-specific smoke test block
- Add tesserocr end-to-end smoke test after the verification loop: opens a
  PyTessBaseAPI against the real tessdata model so DLL resolution + tessdata
  access are both exercised; sets ALL_OK=0 on failure so the installer reports
  the right thing

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 21:10:33 +02:00
alexpolo1andClaude Sonnet 4.6 63d0a334cb install.bat: fix OCR setup — verify tesseract exe, drop broken patch, add pytesseract check
Three fixes:
1. After winget tesseract install, check C:\Program Files\Tesseract-OCR\tesseract.exe
   actually exists and print a download link if not — previously silent failure.
2. Remove the broken pytesseract source-patch block (Python syntax error on the
   os.path.join call — it has never run). ocr.py already handles cmd discovery at
   import time via PYTESSERACT_TESSERACT_CMD (Bug 14 fix), so patching is moot.
   Replace with a pytesseract.get_tesseract_version() smoke test that exercises the
   full exe chain and fails clearly if tesseract is missing.
3. Add pytesseract to the dependency verification loop alongside tesserocr.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 21:02:36 +02:00
alexpolo1andClaude Sonnet 4.6 0facc84661 Add scripts/convert_chipped.py — inventory chipped-gem right-click converter
Standalone script: scans loot columns, tooltips every occupied slot to
identify chipped gems, then right-clicks each to convert in-place.
Uses the same screen/input_layer stack as the main bot (no D2R restart needed).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 20:40:25 +02:00
alexpolo1andClaude Sonnet 4.6 c3a2edb23c FG market: fix URL regex, expand to 60-day window, refresh estimates
fg_market_scraper.py: broaden user.php href regex to allow a path prefix
before user.php (some topic pages use relative paths like ../user.php).

fg_scrape_pipeline.sh: --days 21 -> --days 60 for a fuller price history.

improve_fg_estimates.py: hoist min_valid_price constant out of the per-file
loop (was a constant being re-assigned every iteration).

fg_daily_estimates.json: refreshed from 853 topics over 60 days (was 21).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 20:40:20 +02:00
alexpolo1andClaude Sonnet 4.6 a4498d2244 testbed: add count scenario, gem filter args, fix stash auto-open, dead return
- New 'count' scenario: read gem counts from GEMS tab via OCR, print plan
- gems_all: accept tier and gem-type filters + --max cap (e.g. 'gems_all flawless diamond')
- gems_all: fast-path ESC + stash-chest click before launching full TownManager
- gems_all: leave stash open after run so re-invocations skip navigation
- stash: stash chest auto-walk if stash not open
- Remove duplicate 'return 0' at end of _gems_all that made dead code

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 20:40:12 +02:00
alexpolo1andClaude Sonnet 4.6 0facc38613 Fix OCR digits whitelist syntax; broaden A5 stash-open detection
ocr.py: use -c tessedit_char_whitelist=0123456789 (tesseract 4+ style)
instead of the legacy blacklist+whitelist pair that was silently ignored.

a5.py: include left_inventory_ready() in the stash_is_open_func poll so
the open_stash success check fires even when GoldBtn templates miss at
current D2R rendering settings.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 20:40:06 +02:00
alexpolo1andClaude Sonnet 4.6 4bb460db2a Gem transmute v3: OCR counting, GEMS-tab cube flow, cube safety helpers
New production flow for convert_all_gems_to_perfect:
- Count gem stacks via OCR badge on each GEMS-tab slot (Hermes as fallback)
- Open cube from PERSONAL stash per-transmute, return to GEMS tab while cube stays open
- ctrl+shift+right-click x3 loads gems directly into cube from GEMS tab
- ctrl+shift+left-click moves result from cube back to GEMS tab
- Pre-flight: clear any leftover items in cube before planning
- Stash reopen guard at every iteration in case ESC closed the stash
- _ensure_cube_available / _locate_cube / _ensure_cube_in_stash safety chain
- _empty_cube_to_gems_tab: blindly ctrl+shift+left-click all 12 slots to clear

Also adds tier_filter, gem_filter, max_transmutes params to convert_all_gems_to_perfect
and wires the new flow into run_transmutes when stash_tabs > 4.

Includes 8 pytest unit tests covering OCR plan, gem/tier filters,
cube-location logic and _open_available_cube_for_gems_tab edge cases.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-19 20:40:00 +02:00
alexpolo1andClaude Sonnet 4.6 23f9488853 testbed gems_all: fall back to A5 when act detection fails
Avoids blocking on town-marker detection failure when character is
in A5 but detect_current_act() can't find templates (e.g. stash was
just closed, unusual camera angle). Assumes A5_TOWN_START so open_stash
navigates to Harrogath stash without triggering Cain.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 07:58:23 +02:00
alexpolo1andClaude Sonnet 4.6 e79cf0470d Fix gem transmute: open cube from inventory + ctrl+shift from GEMS tab into cube
- convert_all_gems_to_perfect: revert to _open_cube_from_inventory() — cube lives
  in character inventory, not stash PERSONAL tab
- After opening cube, switch to GEMS tab; ctrl+shift+click sends gems directly from
  GEMS tab into the open cube without an inventory roundtrip
- testbed _gems_all: add left_inventory_ready() fallback so stash-open detection
  works with new D2R UI (GoldBtnStash template doesn't match new UI)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 07:55:46 +02:00
alexpolo1andClaude Sonnet 4.6 fffc0a4b56 Gem transmute: use cube from stash (PERSONAL tab) + ctrl+shift to send gems to cube
- _open_cube_from_stash(): switch to PERSONAL tab, right-click cube from stash
- _ctrl_shift_click_monitor(): ctrl+shift+click sends item from active stash tab
  directly into the open cube (no inventory roundtrip)
- convert_all_gems_to_perfect: open cube once per gem type from stash, switch to
  GEMS tab, ctrl+shift+click gem stack 3x -> into cube, transmute, ctrl+click
  result -> back to GEMS tab; close cube when type is done
- Keeps _open_cube_from_inventory as fallback for other callers

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 07:45:47 +02:00
alexpolo1andClaude Sonnet 4.6 0e9c18e3e7 Fix gem transmute: stacked-slot clicks, search_all for inventory, Hermes key mapping
- _gems_tab_pull_3: click same stack position 3x (stacked slots = 1 match, not 3)
- convert_all_gems_to_perfect execute loop: same fix (was checking len(matches)<3)
- Replace inspect_inventory_area with search_all on right_inventory ROI in both
  old _run_gem_transmutes_new_ui and new convert_all_gems_to_perfect - slot-by-slot
  scan at 0.91 threshold missed gems that search_all finds at 0.80
- _plan_transmutes: handle both Hermes short keys (ruby_flawless) and template
  fallback long keys (inventory_ruby_flawless) via dual-key lookup
- _count_gems_by_template: return 999 instead of slot count - stacked slots show
  1 match per type regardless of how many gems are in the stack; execution loop
  stops naturally when the stack empties

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 07:31:14 +02:00
alexpolo1andClaude Sonnet 4.6 87c8dc5e16 convert_all_gems_to_perfect: use search_all for new GEMS tab UI
The new D2R stash has named tabs (PERSONAL/SHARED/GEMS/MATERIALS/RUNES)
where gems are stacked (one slot per type/tier) instead of individual
inventory grid positions. The old slot-by-slot inspect_area approach
found 0 matches because gem icons don't align to the 38x38 grid.

Fix: _gems_tab_pull_3() uses template_finder.search_all() to find gem
positions anywhere in the left panel, then ctrl+clicks the 3 best hits.
Cube must now be in the character's right inventory (not personal stash).

Verified: 101 flawless->perfect transmutes in one run. Batch cap 100->500.

Also adds _switch_to_gems_tab() and _open_cube_from_inventory() helpers.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 06:40:49 +02:00
alexpolo1andClaude Sonnet 4.6 b84bdc8f4b Add convert_all_gems_to_perfect + fix hermes encoding
transmute.py: convert_all_gems_to_perfect() chains all four gem tiers
(chipped->flawed->standard->flawless->perfect) by calling the existing
_run_gem_transmutes_lod for each tier in order. Result gems from each
pass land back in stash and feed the next pass.

testbed.py: gems_all scenario triggers convert_all_gems_to_perfect with
stash open; also adds missing return 0 to _gems().

hermes.py: fix UnicodeEncodeError on cp1252 terminals when the LLM
response contains non-ASCII characters (arrows, bullets, etc.).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 06:04:03 +02:00
alexpolo1andClaude Sonnet 4.6 8ca96241bf Hermes: disable Qwen3 thinking mode, add repetition_penalty
thinking mode caused the model to loop on uncertain items (e.g. weapon
slot identification). disable_thinking=False + repetition_penalty=1.15
gives clean, non-repetitive responses in ~5s.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-17 05:34:12 +02:00
alexpolo1andClaude Sonnet 4.6 f42637a5d8 Add Hermes: vision-LLM agent for live D2R game analysis
tools/hermes.py grabs the current D2R window (same grab() path the bot
uses), sends it to the local Qwen vision model at 192.168.1.98:8010,
and runs an interactive REPL. The system prompt gives Hermes full context
on coordinate systems, template matching, input layer, and bot patterns
so it can reason about what's on screen and suggest feature code.

  <botty-env-python> tools/hermes.py [initial prompt]
  <botty-env-python> tools/testbed.py hermes [initial prompt]

Also wired into testbed.py as the 'hermes' scenario.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-16 20:44:51 +02:00
alexpolo1andClaude Sonnet 4.6 cbc797c14a Stash scanner: fix multi-slot dedup, add periodic bot hook + trade CSV
- scripts/stash_inventory.py: fix BaseItem["dimensions"] being a list
  not a dict — use dims[0] for height so the claimed-slot dedup works
  and multi-slot items (Large/Grand Charms, 2h weapons) are no longer
  double-counted
- scripts/make_stash_csv.py: new — convert stash_inventory.json to a
  deduplicated stash_list.csv (name, page, stats) for trade reference
- stash_list.csv: current stash export (71 unique items)
- config/params.ini: add stash_scan_interval (default 0/off)
- src/config.py: parse stash_scan_interval from [general]
- src/bot.py: after stash+transmute, trigger scan every N runs when
  stash_scan_interval > 0
- src/inventory/personal.py: reset transfer_failures per stash tab so
  each tab gets its own 2-failure budget before moving to the next
- tools/testbed.py: PASS/FAIL fix for `stash all` — equipped-area cols
  (≥4) correctly left in inventory is a PASS not a FAIL (Bug 19 guard)
- CLAUDE.md: document stash scanner scripts in File Quick Reference

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-16 20:40:05 +02:00
alexpolo1andClaude Opus 4.8 3522dedcee Stash: guard against transfer failure being mistaken for full stash
stash_all_items() treated any keep item remaining after transfer_items()
as "this tab is full", paged through every stash tab, and on the last page
called stash_full() -> taskkill D2R + a false Discord "stash full" alert.
A transfer can fail for non-fullness reasons (equipped-area click guard,
transient UI), so this could kill the game on a stash that has free slots.

Fix: before declaring a tab full, check EmptyStashSlot. If a slot is free
but the transfer still failed, count it as a transfer failure (cap 2) and
bail, leaving items in inventory -- never advance tabs / call stash_full()
on a non-full page. Also fix a leftover >3 page bound (-> >5) in the same loop.

Add a `stash` scenario to tools/testbed.py (self-bootstraps: main menu ->
create game -> walk -> open stash -> run stashing; `all` widens the scan to
all 10 columns to exercise the keep-item branch) and extract the conda-env
SSL cert workaround into a shared _patch_ssl() helper. Document as Bug 19.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 04:05:01 +02:00
alexpolo1 6a8f1ed5d9 add fiskenersej character profile + pickit profile cycler (F10) 2026-06-12 12:08:21 +02:00
alexpolo1andClaude Fable 5 ebe2e354dc Log per-run loot summary at every run end for value judgment
- pickit: run-scoped loot accumulator (item names of every successful
  pickup), consumed once per run.
- bot._run_wrapper: at run end (success, battle-fail, and approach-fail
  paths) logs "Loot from run_diablo: JAH RUNE, 2x FLAWLESS SAPPHIRE" (or
  "nothing picked up") at INFO level.
- game_stats: run_finished events now carry a counted loot dict, so
  events_*.jsonl can be mined for per-boss drop value over time.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 10:35:43 +02:00
alexpolo1andClaude Fable 5 868d117530 Merc healing: rejuv at 45% (was 25%), health pot at 70% (was 60%)
Session evidence (2026-06-11/12): 2 merc deaths vs Hell Diablo with exactly
1 heal fired and 0 rejuvs. The pipeline works (read 30% -> fed a potion)
but the tuning loses: Hell bosses chunk a merc 30-50% per hit, so the
25% rejuv band was skipped straight past between polls, leaving only the
slow 10.24s-cooldown health pot branch. Rejuv (instant, 4s cooldown) now
covers the real danger band under 45%; pots top up from 70%.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 10:31:19 +02:00
alexpolo1andClaude Fable 5 9244570e22 Pickup priority + dive mode: secure Jah-class drops above all else
The most valuable drop must never be lost to pickup order or a chicken:

- pickit: ground items now sort high-value-first (HIGH_VALUE_KEYWORDS:
  Ist+ runes, Tyrael's, DWeb, Griffon's, SoJ, facets, HC, etc.), nearest
  of them first, junk after - if looting gets interrupted, the Jah is
  already in the bag.
- dive mode (health_manager.set_loot_priority): while a high-value drop
  is being picked up, the chicken threshold is halved (hard floor 20%
  HP), the two-juv panic and merc chicken are suppressed, and potions
  keep flowing - the bot spends its belt to secure the item instead of
  save+exiting away from it. Auto-expires after 15s so a stuck flag can
  never disable safety permanently; engaged/cleared around each
  high-value pickup attempt.

Unit-tested (Jah-first ordering incl. over nearer gold/potions, flag
expiry) and validated in a live profile-enabled Pindle run.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 10:27:39 +02:00
alexpolo1andClaude Fable 5 b06c77d2e4 Profile system: per-user char profiles + shared pickit profiles
Per-user character profiles (gitignored, survive git pulls):
- config/profiles/<name>/profile.ini overrides any params.ini key
  (runs, difficulty, char type, keybinds...). Priority:
  custom.ini > profile.ini > params.ini. Injected into _select_val and
  all 22 build-section merges.
- Active profile selected via config/active_profile.txt (gitignored);
  Config.get/set_active_profile + list_profiles/list_pickit_profiles.

Shared pickit profiles (git-tracked team content):
- config/pickit_profiles/<set>/*.bnip - one folder per season phase,
  built once, shared via git. Selected per user with
  [general] pickit_profile=<set> in their profile.
- Pickit dir priority: config/profiles/<me>/pickit/ (personal)
  > config/pickit_profiles/<set>/ > config/bnip/ > default.bnip.

Menu integration (main.py):
- startup banner shows active profile + pickit set + available lists
- "end" hotkey cycles character profiles (applies on restart)

Verified: fistman profile active end-to-end (general/char/build-section
overrides + shared pickit set resolution + 474 expressions loaded).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 10:16:12 +02:00
alexpolo1andClaude Fable 5 0834831220 Run skill bind preflight once per session at first town maintenance
Mirrors the D2R settings check: on the first on_maintenance of a bot
session, validate_build_skill_icons presses each configured skill hotkey
and verifies the icon on the right slot, warning loudly (with a pointer to
tools/set_binds_from_params.py) when a bind does not match params.ini.
Non-blocking; ~5s once per session.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 09:14:52 +02:00
alexpolo1andClaude Fable 5 40e4dd8a4f Skill hotkeys select the RIGHT slot - fix cast flow, prove auto-binding live
Live finding (fresh game, 2026-06-12): D2R skill hotkeys select onto the
RIGHT skill slot, not the left as the old comments assumed. The left slot
permanently holds Blessed Hammer. Consequence: _cast_hammers pressing the
hammer hotkey after activating an aura was REPLACING the aura on the right
slot every cast cycle - the true root cause of fights running without
Concentration. _cast_hammers no longer touches the hammer hotkey: select
aura (lands on right, stays active), hold stand-still, spam left-click.
Verified live: full Diablo kill at 09:04, ~55s from last seal to kill.

Auto skill binding proven end-to-end (tools/set_binds_from_params.py):
blessed_hammer/concentration/redemption/vigor/holy_shield/teleport all
bound via the in-game picker and visually verified (6/7 OK; conviction
correctly reported missing - not skilled on this char).

- capture tool + preflight verify now watch the RIGHT slot
- fresh skill slot templates + clean PICKER_* cell templates captured at
  current settings (blessed_hammer, concentration, redemption, vigor,
  holy_shield, teleport)
- removed bogus conviction.png (had captured vigor's icon)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 09:13:52 +02:00
alexpolo1andClaude Fable 5 1b4773b42e Never sell or drop charms (and other protected items)
transfer_items now filters protected items out of BOTH the sell and drop
paths (previously only shields, only on sell): anything whose name contains
"charm" is blocked by default (charms live in the inventory permanently -
a pickit misread must not vendor them), shields stay protected, and
never_sell_keywords in [char] params can extend the list. Blocked items
stay untouched in the inventory.

Motivated by a real grand-charm-sold-by-mistake incident.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 08:55:16 +02:00
alexpolo1andClaude Fable 5 77b35df26b Two-layer keybind verification/setting driven by params.ini
Layer 1 (Controls page / .keyo): tools/set_controls_keyo.py parses the
character keyo (same binary format as utils/key_detector) and verifies every
skill hotkey in params.ini is bound to a skill slot; --fix writes missing keys
into free skill slots with a backup (D2R must be closed). Picks the configured
character file. Verified: fistman controls match params.

Layer 2 (skill assignment / picker): enabled the existing skill_hotkey_setter
machinery for the hammerdin build:
- skill_preflight: hammerdin build rules (blessed_hammer left/required,
  concentration right/required, redemption/vigor/conviction/holy_shield/
  teleport right/optional) + PALADIN_TEMPLATE_ALIASES, merged TEMPLATE_ALIASES.
- skill_hotkey_setter: paladin picker template aliases with slot-icon fallback.
- tools/set_binds_from_params.py: end-to-end runner - opens the in-game picker,
  binds each skill to its params hotkey, verifies via slot icon, reports
  match/mismatch per skill.
- tools/capture_skill_hotkeys.py now saves crops straight into
  assets/templates/ui/skills/ so captures immediately become live templates
  for both the preflight and the picker search.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 08:30:43 +02:00
alexpolo1andClaude Fable 5 63248a9437 Hammerdin: hold Concentration through trash clears; add deps doc + keybind tool
- hammerdin.py _cast_hammers: skip aura/hammer re-selection when the slot
  already holds the wanted skill (tracked via _active_skill, invalidated by
  pre_move/_weapon_switch/cast_buffs and all raw redemption/vigor hotkey
  sends). Visible effect: aura stays on right-click and hammers fly from
  plain left-click spam instead of constant F1/F3/F8 churn.
- 35 mid-clear attack casts switched from Redemption to Concentration
  (damage aura while fighting); Redemption still pulses between packs and
  after kills for corpse cleanup.
- DEPENDENCIES.md: full verified working-state snapshot (env paths, package
  versions, tesseract wiring, D2R settings, DPI specifics, safety nets).
- tools/capture_skill_hotkeys.py: presses each params.ini skill hotkey
  in-game and saves labeled left-slot icon crops - verifies binds match the
  character and builds skill-icon templates for future aura checks.
- CLAUDE.md: remove stale main.exe mention (no exe build exists; bat runs
  current source directly).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 00:18:23 +02:00
alexpolo1andClaude Fable 5 2862591492 Fix launcher exit, OCR backend, A5 WP loops, Diablo aura, stash paging
Session fixes (2026-06-11), all live-tested over multiple farm games:

- input_layer/hotkey.py: no-arg keyboard.wait() returned on ANY keypress,
  silently killing the process right after F11 (all bot threads are
  daemons). Now blocks forever like the original keyboard lib. Poll loop
  is edge-triggered (no ~20ms refire while a hotkey is held) and callback
  exceptions print instead of being swallowed. (Bug 13)
- d2r_image/ocr.py: pytesseract never read PYTESSERACT_TESSERACT_CMD;
  tesseract_cmd is now wired from env var / PATH / winget default, fixing
  exit 0xC0000135 on every OCR call. (Bug 14)
- bnip/utils.py + config/default.bnip: undefined NipSyntaxError ->
  BNipSyntaxError, and Shaefershammer -> Schaefershammer typo; 474 pickit
  expressions load (was 473 + parser error). (Bug 15)
- town_manager/a5/bot: A5 WP death-loop containment - per-game WP failure
  budget (2 strikes), quick=True direct-path-only retries, sweep trimmed
  10->6 steps with 4s select timeouts, A5 select thresholds lowered
  (WP 0.62, stash 0.60/0.45; safe - every select is success_func-gated).
  Worst case dropped from 10+ min wandering to ~4.5 min contained fail
  with fresh-game recovery. (Bug 16)
- bot.py: vendor trip gating - the failure-prone A5->A4 Jamella round
  trip now only runs when consumables are needed or 3+ sell items pend.
- town_manager.py: Cain identify skips acts whose Cain timed out this
  session (straight to working A5 fallback).
- char/i_char.py: Battle Command buff check waits 0.6-0.8s after the
  hotkey (icon fade-in) to reduce double weapon-swaps.
- char/paladin/hammerdin.py: kill_diablo fights with Concentration
  instead of Conviction (useless for magic-damage hammers) and drops
  mid-fight Redemption downtime - faster kills, merc survives. (Bug 17)
- inventory: stash supports all 6 pages (personal + 5 shared, D2R 2.7+);
  gold deposits navigate via OCR-verified select_stash_page instead of
  raw 4-tab clicks; rotation %6, shared-first starts at page 5. (Bug 18)

Docs: CLAUDE.md Bugs 13-18; .hermes/plans/dia_run_test_state.md has the
full test log (two complete Diablo kills verified end-to-end today).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-11 22:05:53 +02:00
alexpolo1 ddb7008419 fix: reinstall OpenSSL, rename broken conda tesseract, use winget tesseract 5.5.0\n- Reinstalled OpenSSL (was corrupted in conda cache)\n- Renamed conda tesseract.exe to tesseract_broken.exe (access violation)\n- install.bat: winget tesseract-ocr.tesseract + rename conda binary\n- run_botty.bat: PYTESSERACT_TESSERACT_CMD points to winget version 2026-06-11 15:33:04 +02:00
alexpolo1 43c77deede fix: use winget tesseract 5.5.0, rename broken conda tesseract\n- winget install tesseract-ocr.tesseract\n- PYTESSERACT_TESSERACT_CMD points to winget version\n- conda tesseract renamed to tesseract_broken.exe 2026-06-11 15:17:27 +02:00
alexpolo1 b77347542d fix: patch pytesseract to handle tesseract --version crashes 2026-06-11 15:04:13 +02:00
alexpolo1 1b7721779c fix: remove tesserocr/tesseract from env yml; install.bat handles it\n- conda-forge tesserocr requires Python 3.11+, we use 3.10\n- conda tesseract crashes with access violation on this system\n- install.bat: tesseract=4.* + DLL renames + bundled wheel\n- run_botty.bat: SSL_CERT_DIR= + full PATH 2026-06-11 14:53:33 +02:00
alexpolo1 6822a7e495 fix: install.bat copies liblept.dll from conda leptonica 2026-06-11 14:51:23 +02:00
alexpolo1 66a71b4ad3 fix: add Scripts + DLLs to PATH in run_botty.bat for pytesseract/tesserocr 2026-06-11 14:34:50 +02:00
alexpolo1 b8479c2300 fix: broaden pytesseract import exception catch 2026-06-11 14:34:29 +02:00
alexpolo1 2bfcf0ff02 fix: patch ssl.load_default_certs to handle corrupted Windows cert store
OpenSSL 3.x + corrupted Windows cert store causes aiohttp to crash
at import time. Monkey-patch ssl.SSLContext.load_default_certs to
fall back to certifi when Windows store fails.
2026-06-11 13:53:54 +02:00
alexpolo1 c80e002ab9 fix: SSL cert store corruption + install.bat fixes\n- run_botty.bat: set SSL_CERT_DIR= to bypass corrupted Windows cert store\n- install.bat: use tesseract 4.x, bundled wheel, DLL copy, PATH fix 2026-06-11 13:45:32 +02:00
alexpolo1 1fe95aae08 fix: install.bat - use tesseract 4.x + bundled wheel, add DLL copy + PATH fix 2026-06-11 13:43:11 +02:00
alexandClaude Fable 5 f1c5b6cdf4 feat: pather auto-recovery sweep when a node is lost
traverse_nodes previously gave up after one random-guess move when a node
template wasn't on screen, failing every travel path where the char was
mis-positioned (the A5-after-Pindle stranding was the worst case). Now,
on timeout during travel pathing (timeout > 3.1s, so boss approaches keep
their fast fail), it walks an expanding 12-step sweep, re-scanning for the
node each step, and only fails once the sweep is exhausted. Makes ALL
node pathing self-healing, not just A5.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 20:51:20 +02:00
alexandClaude Fable 5 19011c1a12 fix: robust A5 open_wp — re-anchor via NPC landmarks + directed sweep
The A5 waypoint was unopenable after a Pindle return: the char lands in
the NE near Anya, where the town_start->WP node templates are off-screen,
so the pather random-guessed and timed out. Pathing to NPC spots
(Qual-Kehk/Malah) DOES work from there, and each has a defined path to
the WP. New open_wp: direct -> re-anchor via Qual-Kehk/town_start/Malah
-> directed sweep that re-attempts node pathing + full-screen WP scan
each step. This is the linchpin: it unblocks the A5->A4 vendor redirect,
maintenance, stash, and the Diablo run's travel out of A5.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 20:49:59 +02:00
alexandClaude Fable 5 78bc195bd9 fix: vendor failure non-fatal so maintenance always reaches stash
CRITICAL loot-loss bug: buy_consumables failure called end_game and
returned BEFORE the stash step, so Pindle runes/items piled in inventory
and were never banked (zero stash events across a whole session). Buying
pots is optional (belt refills from drops); stashing loot is the point.
Now warn + re-anchor + fall through to stash. Also raise
max_maintenance_time_s 120->240 so the A5 vendor thrash completes and
reaches stash instead of timing out first (transitional — disappears once
a Diablo run shifts spawns to A4).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 20:28:23 +02:00
alexandClaude Fable 5 2784b50675 config: fixed route order [pindle, diablo] so games end in A4 town
A5-spawn games burn on the Malah vendor step (stale patch templates);
A4-spawn games sail through Jamella. The Diablo run ends with a TP to
A4 town, so running it LAST makes every next game spawn in A4.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 20:09:48 +02:00
alexandClaude Fable 5 4e7c517764 fix: grid sweep ESC could open the game menu and blind the rest of the hunt
The blind ESC after a failed sweep click opens the ESC game menu when no
dialogue was actually open (seen covering the screen during the Tyrael
hunt). Detect SaveAndExit after the ESC and close the menu again.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:55:18 +02:00
alexandClaude Fable 5 3093e04517 fix: force boss loot past the mobs-alive pickit guard
Diablo died in 22s but picked_up_items was false: his death animation /
lingering effects register as visible targets, so the mobs-alive guard
skipped his drops. pick_up_items(force=True) at the Diablo loot site.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:48:44 +02:00
alexandClaude Fable 5 b1bec0cecc fix: grid sweep waits 5s for walk-to-NPC before declaring click failed
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:36:18 +02:00
alexandClaude Fable 5 54e63197c2 fix: spawn fallback uses act detection then A5, not blind A1 default
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:26:47 +02:00
alexandClaude Fable 5 3372b91ed7 fix: CS entrance loop budget 10s -> 20s (most common Diablo-run abort)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:21:07 +02:00
alexandClaude Fable 5 9d0ae9322a fix: grid sweep ESC after wrong-NPC click, wide second pass, 25s budget
The sweep for Tyrael clicked Deckard Cain (stands in the same zone) and
left his TALK dialogue open, blinding the rest of the sweep. Also sweep
the full search area as a second pass since NPCs can stand outside the
stored ROI, with a total time budget.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 18:49:13 +02:00
alexandClaude Fable 5 06c73e7cbf fix: fuzzy char-name OCR match; single-instance guard in detached launcher
- OCR reads FISTMAN as "fabiman" — SequenceMatcher >= 0.6 on the first
  word of the row handles the mangling
- start_bot_detached.bat kills existing main.py instances first: F11/F12
  are global hotkeys, so duplicate bots receive every press and fight
  each other (one starts while the other pauses/exits)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 18:28:12 +02:00
alexandClaude Fable 5 f410ea21f1 feat: grid hover sweep fallback when NPC body templates fail
Body templates went stale with the June 2026 patch (Malah, Cain,
Tyrael, Larzuk, Qual-Kehk all undetectable), but name tags on hover
score 0.9+ reliably. When the body-template hunt times out, sweep a
coarse hover grid over the NPC ROI and click wherever the name tag
appears.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 18:00:44 +02:00
alexandClaude Fable 5 d9a6e7ce40 feat: per-NPC body/pose thresholds (Malah 0.35/200); skip pickit while mobs alive
User-tuned detection thresholds and a safety guard against teleporting
into live packs during loot phases.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 17:58:31 +02:00
alexandClaude Fable 5 c92b7730ee fix: close open stash/inventory panels before NPC hunts and WP searches
A misclick on the stash chest (Cain stands beside it) opens the stash
UI which covers most of the screen and blinds every template search:
NPC hunts, A5_WP selection, act detection. The old guard pressed the
inventory key, which leaves a stash panel open — ESC closes both.
Panels are now closed at hunt start, per hover-loop iteration, and
before WP traversal.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 14:37:34 +02:00
alexandClaude Fable 5 a7b12bc8a3 fix: buy consumables at A4 Jamella when in A5 (Malah unreliable in current patch)
Malah wanders and her body templates predate the patch; the hunt fails
most games and the failed-hunt position strands the char. Jamella is
static and detects at 0.99+. Travel cost ~30s vs a lost game.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 14:24:49 +02:00
alexandClaude Fable 5 0b8d67b4e4 config: max_game_length_s 600 -> 900
A full CS clear takes ~10 min; the 600s watchdog force-quit a game that
had all three seals open and was waiting for Diablo to spawn.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 14:15:46 +02:00
alexandClaude Fable 5 3479a74503 fix: A5 open_wp walk-and-scan last resort for stranded positions
After a failed NPC hunt the char can be in a corner where no path nodes
anchor; both node traverses fail and the game dies. Walk a search
pattern across the small town scanning the whole screen for the WP
template directly each step.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 14:00:48 +02:00
alexandClaude Fable 5 5727a81cb8 fix: A2-Y boss seal 36B variant template; sanitize seal screenshot filename
- Seal 36 has two visual variants; dia_a2y4_36b_closed.png existed in
  assets but was never searched, so the B variant could never be clicked
  (8 sealdance tries then aborted the run)
- info_failed_seal_ screenshot name contained ": " (illegal on Windows)
  producing 0-byte files

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:53:17 +02:00
alexandClaude Fable 5 95bd5a76ec fix: post-loot seal calibration hops are non-fatal
After Vizier/De Seis are dead, a failed recalibration traverse aborted
the whole Diablo run (threw away two boss kills at 13:43). The seal
stage re-anchors at the pentagram via template loop immediately after,
so just skip the extra loot pass and continue.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:45:51 +02:00
alexandClaude Fable 5 a05a13be67 fix: on_init save+exit recovery when stranded mid-town with no visible marker
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:34:43 +02:00
alexandClaude Fable 5 a06e46df09 fix: track physical act across failed cross-act NPC trips; prefer A4 Jamella retry
- town_manager.identify: return the reached A5 location when Cain fails
  after successful travel (returning False made bot.py fall back to the
  pre-travel act and run wrong-act pathing all maintenance long)
- town_manager.resurrect: leave last_known_loc breadcrumb when the A4
  revive fails after travel; bot.py consumes it to re-anchor
- bot.py: alternate vendor retry prefers A4 Jamella (static, reliable)
  over A1; Malah wanders and is the main A5 vendor failure

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:31:32 +02:00
alexandClaude Fable 5 8a58b44866 fix: substring match in OCR char-name row scan (rows include level/class text)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:26:22 +02:00
alexandClaude Fable 5 d99e3461fe fix: seal layout-check retry via pentagram; never skip hammers on unbound aura
- diablo.py _layoutcheck: ambiguous/lost layout check now loops back to
  the pentagram and retries the approach once before aborting the run;
  template checks bumped from single-frame 0.1s to 0.5s
- hammerdin.py _cast_hammers: an unbound aura (e.g. conviction in
  kill_diablo on a non-Infinity build) silently cast NOTHING, so Diablo
  was never attacked with the damage aura. Fall back to concentration
  and always cast hammers.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:24:50 +02:00
alexandClaude Fable 5 a695252614 fix: select configured char_name before saving char template
First encounter at char select blindly saved whichever character was
highlighted, locking in a wrong character for the whole session. Now,
when char_name is configured, select it via OCR first and warn if the
saved template name does not match.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:09:26 +02:00
alexandClaude Fable 5 7ef62161ca fix: spawn-act tracking, alternate-act vendor retry, broader CS entrance templates
- bot.py: track _spawn_act at game start as act-of-record when mid-town
  marker detection fails; _verify_town_location(assumed) fallback chain
  assumed_act -> spawn_act -> A5; pass previous location at every
  retry/fallback site instead of blind defaults
- bot.py: buy_consumables alternate-act retry ladder (A5 Malah unless
  already in A5, then A1 Akara) gated on confirmed go_to_act travel
- diablo.py/vizier.py: widen CS entrance template set (DIA_CS_ENTRANCE_*
  node-603 variants) and lower threshold 0.8 -> 0.75
- start_bot_detached.bat: detached launcher with per-launch console log

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 13:06:15 +02:00
alexandClaude Fable 5 6301490cc4 fix: act-state verification, NPC dialogue confirm wait, stats integrity
Root causes from the 2026-06-09 session (51 games, 45 failed):

- Act desync: add TownManager.detect_current_act() and
  Bot._verify_town_location(); every maintenance/end-run retry and
  fallback now verifies the physical act instead of hardcoding town
  starts. open_wp/go_to_act self-heal act mismatches. Never run A1
  pathing when travel to A1 failed.
- NPC dialogue: poll action buttons up to 2.5s after click instead of
  a single-frame check (premature retry click was closing the dialog).
- Stats integrity: log_end_game skips duplicate calls (phantom 0s
  "successful" games were resetting the consecutive-fail breaker);
  clear stale failure reason at game start; set chicken flag before
  bot.stop() so chickens are no longer labeled "Bot stopped".
- Repair: prefer in-act Larzuk over cross-act Halbu trip (Halbu
  detection failed 100% last session and desynced the act state).

Documented as Bugs 9-12 in CLAUDE.md.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 08:23:10 +02:00
alexandClaude Sonnet 4.6 83951db876 fix: resolve NPC name tag template mismatch causing open_npc_menu timeouts
NPC body templates find Akara/Malah correctly but the name tag templates
score ~0.28, below the 0.35 threshold, causing 20s spinning when the NPC
is detected but the hover confirmation never passes.

Three targeted changes in open_npc_menu():
- Use a 240x140px ROI directly above the hover cursor for name tag search
  instead of the full screen, containing false-positive risk
- Lower name tag threshold from 0.35 to 0.26 so Akara at ~0.28 now passes
- Check ScreenObjects.NPCDialogue after click as primary confirmation;
  this is UI-state based and immune to stale template images
- Reduce per-NPC search timeout from 20s to 8s for faster failure recovery

Also lower max_maintenance_time_s from 120 to 60 to cap total stuck time.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-08 19:09:11 +02:00
alexandClaude Sonnet 4.6 793d7ed967 feat: resilient town loop, step tracking, per-run disable, maintenance timeout
Reliability
- All run approach() methods now set approach_fail_step before every return False;
  bot.py _run_wrapper reads it and includes [step: X] in Discord/log failure reasons
- on_maintenance() sets _maintenance_step before each town step (heal, identify,
  buy_consumables, stash_items, repair, resurrect_merc, gamble) for the same coverage
- on_init() logs startup line: char=X | difficulty=Y | routes=[...] each game

Per-run disable (bot runs as long as possible)
- GameStats tracks per-run failure counts across Bot instances (previously reset each game)
- After disable_run_after_failures consecutive failures a run is disabled for the session;
  game-level consecutive-fail counter resets so the bot continues on remaining routes
- game_controller no longer quits on max_consecutive_fails if active routes remain;
  only exits when all routes are disabled

Maintenance timeout (params.ini: max_maintenance_time_s=120)
- Hard 120 s wall on the entire town maintenance loop; checked between each major step
  and before every retry (buy_consumables, stash, repair, resurrect_merc, gamble)
- On timeout: error screenshot + Discord, then trigger end_game → save-and-exit → rejoin

Bug fixes
- Win11 mouse overshoot: mouse_move() uses SetCursorPos + zero-delta MOUSEEVENTF_MOVE
- _curr_loc = True propagation: TownManager.identify() now returns the act Location enum
- DAMAGED KeyError in pickit: added ItemQualityKeyword.Damaged.value to NTIP_ALIAS_QUALITY_MAP
- A4 WP interaction range: force-move character to WP stone before select_by_template
- NPC click blocked by equipped-area guard: open_npc_menu() closes inventory if open

Docs
- Added CLAUDE.md: AI working guide with step tables, bug history, debugging tips

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-08 18:44:45 +02:00
alex 5fbac05e4e Stabilize Diablo verification and potion handling 2026-06-07 20:17:04 +02:00
alex 594d633787 Fix D2R window sizing and input targeting 2026-06-07 19:00:15 +02:00
alex 9f45539210 feat: capture missing screen verification evidence 2026-06-07 18:42:43 +02:00
alex 83539ed9c3 fix: require stable screen verification 2026-06-07 18:37:58 +02:00
alex f1d6c6bd1c ci: align validation environment with Windows 11 2026-06-07 18:31:10 +02:00
alex 86e3cccbc9 feat: detect Windows profile for install and input 2026-06-07 18:26:16 +02:00
alex d0ecf3afdc ci: run full Botty validation 2026-06-07 18:19:24 +02:00
alex 8ccdca7946 fix: restore validation suite 2026-06-07 18:17:44 +02:00
alex 7bd9d01023 docs: note Diablo template logging helper 2026-06-07 17:53:37 +02:00
alex 45fa775a2e fix: verify Diablo waypoint recovery 2026-06-07 17:52:55 +02:00
alexandClaude Sonnet 4.6 b781dd56b1 fix: save-and-exit on looping NPC click failure instead of continuing
open_npc_menu timeout reduced 35s → 20s and now saves a screenshot when
it gives up. buy_consumables and stash failures after their retry now
trigger end_game (save & exit) instead of silently continuing with a bad
location, preventing the bot from running the next game in a broken state.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 16:17:54 +02:00
alexandClaude Sonnet 4.6 fda747a1bf feat: restart bot process instead of killing D2R when stuck
When restart_d2r_when_stuck is enabled, spawn a fresh Python process
(same main.py) and exit immediately rather than killing and relaunching
D2R. The new bot detects D2R is already running and skips launching it,
preserving the game session. D2R is only killed on deliberate exits
(safe_exit) and the initial auto_login launch.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 16:13:18 +02:00
alexandClaude Sonnet 4.6 5254b7ee75 fix: identify() returned boolean True instead of Location, breaking post-Cain town routing
After using Cain to identify items, self._curr_loc was set to True (boolean)
instead of the actual location. Every subsequent get_act_from_location(True)
returned None, causing buy_consumables and stash to bail immediately and
fall back to A1 even when the character was physically in A5.

Now returns curr_loc when identification succeeds in the current act, or
new_loc (the A5 WP location) when the A5 Cain fallback is used — so the
bot correctly routes to Malah and the A5 stash instead of navigating back
to Akara.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 16:13:11 +02:00
alexandClaude Opus 4.8 f705091991 Send error message + screenshot to Discord on run failures
Adds Messenger.send_error across the Discord (red embed with screenshot attached)
and generic (text-only) APIs. Bot._save_error_screenshot now also pushes the
failure to the configured messenger after saving the screenshot to disk, so each
approach/battle/exception failure is reviewable in Discord with the visual.

Gated by new config discord_log_errors ([general], default 1) and the
[discord_events] error toggle. Both optional/backward-compatible. Verified wiring
and the suppression path via a stubbed messenger.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 15:55:41 +02:00
alexandClaude Opus 4.8 8cf152cfb4 Stop bot cleanly when all routes are disabled
When every run has been auto-disabled there are no routes left to run, so save a
session report and shut down via safe_exit() instead of calling restart_or_exit
(which would needlessly restart D2R into empty games when restart_d2r_when_stuck
is enabled).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 15:52:22 +02:00
alexandClaude Opus 4.8 af43051b22 Auto-disable runs after repeated failures + save error screenshots
Recovery: each run now has its own consecutive-failure counter. After
disable_run_after_failures (default 5) consecutive failures, that single run is
disabled for the rest of the session and the bot keeps doing the other runs
instead of stopping. A success resets the counter. If every run is disabled the
bot stops for investigation. Disable is in-memory only (restart re-enables).

Diagnostics: on every run failure (approach, battle, or exception) the bot saves
a timestamped screenshot to log/screenshots/error/ named with the run, reason,
and game/run counters so logs and visuals can be cross-referenced. Gated by the
new error_screenshots config (falls back to info_screenshots). The error/ dir is
routed through log rotation.

Adds config keys error_screenshots and disable_run_after_failures (both optional,
backward compatible) and docs/recovery_and_error_logging.md. Verified bot startup,
config parsing, Bot construction, the disable/reset logic, and screenshot writing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 15:49:44 +02:00
alexandClaude Sonnet 4.6 a611765e42 Add comprehensive failure logging to all town acts and run approach methods
Every silent return False in a1-a5 town act files (resurrect, heal, identify,
open_wp, open_trade_menu, open_stash, open_trade_and_repair_menu, gamble) now
emits Logger.error with the specific step that failed (traverse, NPC menu open,
button press, panel visibility). Also adds Logger.error to all run approach()
methods (arcane, shenk_eld, trav, andariel, countess, mephisto, baal, pindle,
nihlathak) for open_wp, use_wp, go_to_act, and traverse failures. Fixes a4.py
open_trade_menu to use LeftPanel check instead of unreliable GoldBtnVendor.
town_manager fallback go_to_act calls (resurrect, identify, open_stash, gamble,
stash, heal) now log when they fail.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 15:44:24 +02:00
alexandClaude Sonnet 4.6 21e63fc423 fix: detect and log WP failures in approach/go_to_act
Problems:
1. use_wp() fired immediately after open_wp() — WP panel animation
   still playing, WaypointTabs template not yet visible → silent fail
2. vizier.py + diablo.py ignored use_wp() return value → approach()
   returned a truthy Location even when the WP was never used, causing
   battle() to run from the wrong map location
3. go_to_act() also ignored use_wp() return value

Fixes:
- waypoint.use_wp(): retry WaypointTabs detection up to 4x (1.6s total)
  before giving up; log each retry + the target WP and act being switched
- vizier/diablo approach(): check use_wp() return, return False on failure
- town_manager.go_to_act(): check use_wp() return, log + return False

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 15:35:11 +02:00
alexandClaude Sonnet 4.6 02d113ea0c fix: replace GoldBtnVendor check with LeftPanel in open_trade_menu
GoldBtnVendor (small gold coin template in a tight ROI) was failing even
when the vendor window was fully open. Replace with wait_until_visible(
LeftPanel, timeout=3.0) which checks for the panel close-X in the header
— the same reliable check used by wait_for_left_inventory/left_inventory_ready.

Fixes: A5 (Malah) and A1 (Akara) buy_consumables failing every run.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 10:42:27 +02:00
alexandClaude Sonnet 4.6 17c97a61b1 chore: gitignore runtime price data files
Add auto-generated price files to .gitignore and remove them from
tracking. These are updated at runtime by the price tracker and
should not be versioned.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 10:38:24 +02:00
alexandClaude Sonnet 4.6 307b021352 fix: 1282px window coords, NPC detection robustness, TP window re-detect
- screen.py: default monitor_roi width 1280 -> 1282 (actual window size)
- diablo.py: re-detect window position before TP in CS run (2 places)
- npc_manager.py: adjust Akara ROI/poses for 1282px; lower NPC name tag
  threshold 0.5->0.35 for more reliable hover detection; add NPCDialogue
  visibility wait + per-threshold debug logging in press_npc_btn
- a1.py: add wait(0.5, 0.8) after trade button before GoldBtnVendor check;
  guard open_trade_and_repair_menu on NPC menu result; add Logger.info
  throughout open_trade_menu, heal, open_trade_and_repair_menu
- a5.py: same wait + logging pattern for Malah open_trade_menu

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 10:37:35 +02:00
alexandClaude Sonnet 4.6 3410a82895 feat: log Windows version, mouse mode, and OCR backend at startup
Adds _log_platform_info() called from startup_checks(). On each bot
start the log now shows:
  - Windows 10/11 + build number (Win11 = build >= 22000)
  - Mouse input mode: relative (Win11) or absolute (Win10)
  - OCR backend: tesserocr (primary) or pytesseract fallback or ERROR

Win11 (build 26200) confirmed working: mouse in relative mode,
tesserocr 5.2.0 with hover/ground botty models loading correctly.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-06 22:14:38 +02:00
alexandClaude Sonnet 4.6 35fa4ba442 merge: town_manager.py — combine debug logging + set_panel_check_paused
Resolved 7 conflicts by keeping both local (Logger.info diagnostics in
buy_consumables and repair) and remote (set_panel_check_paused health-check
suppression during vendor/repair panels). All paths now log + pause correctly.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-06 22:07:24 +02:00
alexandClaude Sonnet 4.6 e729dc0c0e fix: 1282px coords, TP window re-detect, town_manager debug logging
- game.ini: adjust ROIs/positions for 1282x720 window (gold_btn_stash,
  left_inventory, panel_header, npc_dialogue, inventory_tabs, skill_bar)
- i_char.py: re-detect window position before casting TP to fix template drift
- town_manager.py: add Logger.info/error throughout buy_consumables and repair
  for easier diagnosis of vendor interaction failures

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-06 22:06:00 +02:00
FiskenPoul 7e39119f33 fix: robust cooperative shutdown and stability improvements for Windows 10
- Implementation of centralized cooperative shutdown mechanism (C10) to replace dangerous thread killing.
- Consolidation of all timing signatures through jittered wait() for anti-cheat stealth (C1).
- Enhanced HealthManager reactivity with smart mana potion fallback and premature panel-closing protection.
- Refactored waypoint stealth logic to simulate human-like mis-aim instead of area-breaking misclicks.
- Implementation of stateless 'garbage item' filtering in PickIt to eliminate ghost item loops and OCR artifacts.
- Optimization of inventory management to automatically stash protected items that cannot be sold.
- Suppression of individual game failure notifications on Discord; alerts now trigger on 5+ consecutive fails.
2026-06-06 19:34:06 +02:00
FiskenPoul d69f066da5 fix: robust cooperative shutdown and stability improvements for Windows 10
- Implementation of centralized cooperative shutdown mechanism (C10) to replace dangerous thread killing.
- Consolidation of all timing signatures through jittered wait() for anti-cheat stealth (C1).
- Enhanced HealthManager reactivity with smart mana potion fallback and premature panel-closing protection.
- Refactored waypoint stealth logic to simulate human-like mis-aim instead of area-breaking misclicks.
- Implementation of stateless 'garbage item' filtering in PickIt to eliminate ghost item loops and OCR artifacts.
- Optimization of inventory management to automatically stash protected items that cannot be sold.
- Suppression of individual game failure notifications on Discord; alerts now trigger on 5+ consecutive fails.
2026-06-06 19:32:18 +02:00
alexandClaude Sonnet 4.6 88bd369bd0 merge: resolve params.ini conflict — keep transmute_every_x_game=2000
Alex's value (2000) preserved over FiskenPoul's (200) for transmute cadence.
stash_destination=0,1,2,3 accepted from remote (matches fill_shared_stash_first=0).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-06 18:01:32 +02:00
alexandClaude Sonnet 4.6 dc48a05d61 restore: params.ini to pre-merge (7dcbb52) + common.py/pickit.py fixes
- params.ini: restore Alex's settings (auto_login, webhooks, hotkeys,
  run order, casting_frames, potion thresholds, override_capabilities, etc.)
- common.py: increase wait_for_left_inventory timeout 5s → 10s for stability
- pickit.py: re-detect window position before pickup to fix template drift

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-06 18:00:29 +02:00
FiskenPoul 6f4d6880e4 fix: remove duplicate auto_downgrade_threshold in params.ini 2026-06-06 11:29:17 +02:00
FiskenPoul 97f4e55643 fix: comprehensive stability, stealth, and logic improvements
- Stability: Replaced unsafe kill_thread() with cooperative_shutdown() in GraphicDebugger.
- Stealth: Integrated jittered wait() and human-like typing variation in win_input.py.
- Robustness: Switched to absolute paths for template loading and log rotation anchoring.
- Bugfix: Resolved AttributeError: 'ScreenObjects' has no attribute 'MercPanelText'.
- Logic: Implemented thread-safe state in DeathManager and re-indexed PickedUpResult enum.
- Pickup: Added failure blacklisting for unreachable items to prevent infinite loops.
- Stash: Fixed gold stashing to correctly respect fill_shared_stash_first=0 (personal first).
- Config: Updated settings validation to allow common D2R variations (Gamma, VSync, etc.).
2026-06-06 11:24:20 +02:00
alexandClaude Sonnet 4.6 a91e128a1e docs: restore params.ini documentation lost in merge conflict resolution
The FiskenPoul merge (95f520e) resolved conflicts by dropping three comment
blocks that documented Hammerdin-specific tuning guidance:

- Hammerdin difficulty guide near the 'difficulty' setting (Normal/NM/Hell
  gear targets, chicken-rate advice)
- Hammerdin attack-length reference table near atk_len_* values (per-boss
  HP ranges, Conviction interaction, CS seal boss breakdown)
- auto_downgrade_threshold parameter and its description (the feature is
  parsed by config.py but not yet active; restored with a note)

No logic changes — comments and one dormant config key only.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-06 09:09:03 +02:00
alex 01a0351530 fix: apply 4 priority reliability fixes from codex analysis
- nihlathak: verify waypoint load with template check, add grayscale fallback for layout detection, guard traverse_nodes_fixed return
- npc_manager: press_npc_btn now returns bool with threshold retry (0.85->0.78) + grayscale fallback
- town/a1,a4,a5: check press_npc_btn return and verify GoldBtnVendor before proceeding
- char/i_char: expand stash waypoint close to A1_TOWN_0, add lower-threshold retry for stash in a1/a5
- char/i_char: add _weapon_switch helper that resets _active_skill cache, fix CTA switch-back validation, fix switch_sucess typo
2026-06-06 01:28:41 +02:00
alex 5ac00d16d0 Add Win10/Win11 auto-detection for mouse input mode
- win_input.py: detect OS build via platform.win32_ver(), use
  MOUSEEVENTF_ABSOLUTE only on Win10 (build < 22000)
- install.bat: detect and display Windows version on install
2026-06-05 22:51:54 +02:00
FiskenPoul 4db549cd85 Track tesserocr_source dependency 2026-06-05 22:36:10 +02:00
FiskenPoul 95f520e968 Resolve merge conflicts, keeping local Win10 specific changes 2026-06-05 21:47:11 +02:00
alex 7dcbb5284b fix: improve potion management by filling belt from inventory and adjusting consumable handling 2026-06-05 21:39:15 +02:00
alexandClaude Sonnet 4.6 dc61367a6e fix: guard fill_up_belt_from_inventory against None img from failed open_inventory
personal.open_inventory() returns None when the inventory cannot be opened
(e.g. after a failed vendor interaction leaves the UI in a bad state). The
previous code passed that None straight into common.get_slot_pos_and_img()
which tried to index it, crashing the bot thread with:
  TypeError: 'NoneType' object is not subscriptable

Also wraps the shift-click loop in try/finally so keyboard.release("shift")
is guaranteed even if an exception fires mid-loop (prevents stuck Shift key).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-05 20:47:11 +02:00
alexandClaude Sonnet 4.6 6c8da72bf4 fix: weapon swap verification, health pot selling, town healing, pickup drought alert
- i_char.py: rewrite _pre_buff_cta() to verify each weapon switch via BC
  skill-bar template; detects wrong slot on game start (leftover from
  interrupted buff cycle), retries failed switches once, logs clearly
  when stuck on CTA slot to prevent dying with wrong weapon in combat

- personal.py: protect needed consumables from sell/drop in inspect_items;
  check get_needs() before marking a pot for discard — if the belt needs
  that pot type, skip it so fill_up_belt_from_inventory can restock later

- bot.py: add fill_up_belt_from_inventory + update_pot_needs after
  buy_consumables so inventory pots reach the belt even when out of gold;
  add town-heal loop at start of on_maintenance to drink health/rejuv pots
  until HP >= 95% before the next run (health manager is paused in town)

- game_stats.py: add rolling 10-game pickup health check; warns in log and
  sends Discord alert when zero item pickups occur across 10 games while
  chickens or merc deaths are present

- params.ini: fix show_belt=n -> show_belt=k (belt key was wrong, causing
  1.5s wasted recovery attempt every first game in a session)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-05 16:36:07 +02:00
alex 77f2092572 Refactor bot behavior on TP failure, enhance item processing logic, and improve game stats logging
- Updated bot behavior to end the game when no TP charges are left, instead of attempting to walk back to town.
- Refined item processing logic in `processing_helpers.py` to use identity checks for item comparisons and improved item removal methods.
- Enhanced game stats logging to include failure reasons and send Discord notifications for failed games.
- Improved health management logic to trigger chicken behavior based on health percentage after rapid rejuvination.
- Added error handling for item pickup failures in `pickit.py` to blacklist items that cannot be picked up.
- Introduced walking fallback nodes for Countess, Andariel, Mephisto, and Baal runs in `pather.py`.
- Implemented path data validation in run scripts for Countess, Andariel, Mephisto, and Baal to ensure paths are correctly defined.
- Enhanced item identification and stash processes in `diablo.py` with retry logic for failures.
- Updated town management to handle identification failures gracefully and log appropriate warnings.
- Improved character selection logic to provide clearer logging messages.
- Modified game restart logic to launch the game via PowerShell for better session handling.
- Added documentation for recording paths for the Countess run and created a PowerShell script for scheduled task management.
2026-06-05 14:58:50 +02:00
alex 6d42216fd1 fix: add missing mouse.right-click after _select_skill for CTA buffs 2026-06-04 20:43:20 +02:00
alex d1502a008a fix: guard on_maintenance against _curr_loc=False crash 2026-06-04 20:41:19 +02:00
alex b94147a9c9 fix: CTA use _select_skill with right-click (matches working forks aeon0/redjon) 2026-06-04 20:34:12 +02:00
alex 75a35821e4 fix: CTA pre-buff is just w, 7+RClick, 8+RClick, w (no extra buffs - Concentration is attack rotation) 2026-06-04 20:31:03 +02:00
alex d4ccca8496 fix: CTA buff sequence: w, 7+RClick, 8+RClick, buffs, w (no visual verification) 2026-06-04 20:29:52 +02:00
alex 26f5faf977 fix: CTA buffs move mouse to center before right-click to target self 2026-06-04 20:25:05 +02:00
alex 3f46ccbe23 fix: CTA buffs use left-click to cast on self; simplify swap-back to 2 attempts
- BC and BO cast with left-click (targets self) instead of right-click
- Swap-back loop simplified to 2 attempts with match score logging
- Added full test route order: trav, pindle, eldritch_shenk, nihlathak, arcane, diablo, vizier
2026-06-04 20:21:54 +02:00
alex 08a79b73c1 fix: CTA pre-buff no longer depends on broken BC/BO skill templates
Removed the visual skill check (is_right_skill_selected BC/BO) that always
failed because the BC.png/BO.png templates do not match D2R icons. New flow:
switch weapon, cast BC, cast BO, buff, switch back. Only verifies swap-back
by matching the original skill icon via template matching.
2026-06-04 19:47:24 +02:00
alex 22b7ac5b49 fix: set failure reasons before log_end_game to eliminate 'Unknown'
- game_controller: set reason on max_game_length, chicken, force_stop
- bot.py on_end_game: set default reason when failed=True has no reason yet
- bot.py save_and_exit failure: set reason before stop()
- bot.py restart_or_exit: capture error message as failure reason
2026-06-04 19:39:15 +02:00
alex 3d7163df16 fix: prevent _curr_loc=False crash in town manager retries; add travincal route
- All retry paths (stash, buy_consumables, heal, repair) now use known-good
  location constants instead of corrupted self._curr_loc (False)
- Guard gamble->stash chain against False location
- Guard wait_for_tp against False location
- Add run_trav to route order (travincal then pindle)
2026-06-04 19:30:23 +02:00
alex 4c88c7204f docs: add plan for broken runs - missing coords and templates 2026-06-03 23:28:38 +02:00
alex 4b96155a37 Hammerdin Diablo/Pindle fixes
- Fix merc icon detection: expanded ROI from 40x40 to 60x60 (matchTemplate requires search area > template)
- Add MercPanelText screen object using mercenary_screen.png for reliable merc alive check
- Bot merc check now uses MercPanelText instead of generic LeftPanel
- Diablo: wait for boss to spawn before attacking (up to 15s), use conviction aura
- Diablo/Pindle attack lengths increased (diablo 3->10s, pindle 3->8s)
- Aura key switch delay increased from 50-100ms to 200-300ms for reliable activation
- Add conviction hotkey (F5) to hammerdin config
- Move Pindle CTA pre-buff from town to after entering red portal
- More aggressive healing thresholds (health potion at 98%, rejuv at 85%)
- Max game length increased from 160s to 600s for full Diablo runs
2026-06-02 19:41:42 +02:00
alex 2baf51c717 Fix merc detection and portal clicking
- Crop A5_RED_PORTAL template to remove empty space (center now aligns with portal)
- Fix merc_icon ROI from 10,9,50,50 to 15,14,40,40 (matches 40x40 merc templates)
- Lower MercIcon threshold from 0.9 to 0.85, add MERC_A2_2 variant
- Add merc panel confirmation with 'O' key before resurrect (prevents false resurrect when icon detection fails)
- Override capabilities to can_teleport_natively
2026-06-01 22:20:49 +02:00
alex 2cd683ac7b Add not enough gold screenshot logging and new MISSING_GOLD template
- src/inventory/vendor.py: Log screenshots when NotEnoughGold detected
  during gambling, purchasing, and repairing
- src/ui_manager.py: Update NotEnoughGold to use new MISSING_GOLD template
  from assets/npc/missing_gold/ (full dialog match instead of color-based)
- assets/npc/missing_gold/missing_gold.png: New template for the popup
2026-06-01 11:21:53 +02:00
alex 30bf163ee3 Fix move_d2r_window to position client area, not outer window
- src/utils/misc.py: move_d2r_window now calculates outer window position
  to place client area at target coords (was incorrectly setting outer
  window to target, shifting client area unpredictably)
- src/bot.py: Call move_d2r_window(5,98) at game start to restore original
  working client area position and prevent 13px offset drift
2026-06-01 11:07:53 +02:00
alex cb3d49fa22 Fix window offset drift causing pathing and template matching failures
- src/screen.py: Add force param to find_and_set_window_position/set_window_position
  to bypass early-return guard and skip wait when called programmatically
- src/run/pindle.py: Force window re-detection before each traverse_nodes and
  select_by_template call to handle offset drift between runs
- src/pather.py: Add fallback full-image search with lower threshold (0.55) when
  cropped ROI search fails in find_abs_node_pos
- src/bot.py: Move D2R window to stable position (0,0) at game start via
  move_d2r_window; skip merc resurrect after first failure (no gold)
- src/game_stats.py: Add _merc_resurrect_failed flag (reset each game)
- src/utils/misc.py: Add move_d2r_window() function
- config/params.ini: Updated teleport=b, show_belt=k, override_capabilities,
  restore_settings_from_backup_key=insert, graphic_debugger_key=delete
2026-06-01 11:01:00 +02:00
alex 5244a50ea3 feat: potion transmute system - convert Rejuv to Full Rejuv via cube 2026-06-01 09:54:26 +02:00
alex f59ffd5d1f fix: raise stash page limit to 20 for ROTW support, fix merc_icon ROI, add red resurrect button detection 2026-06-01 09:27:42 +02:00
alex 8eafd91f0a fix: health_manager merc healing by actual HP + clean up stray TODO comment
- health_manager.py: read actual merc health for heal/chicken/rejuv decisions
  instead of timer-only logic; pass merc_health to belt.drink_potion stats
- processing.py: remove stale TODO comment (traceback import is used)
2026-05-31 13:43:26 +02:00
alex a83dc15b62 fix: heal merc via keyboard timer instead of fragile icon detection 2026-05-31 13:30:39 +02:00
alex 78bb963654 fix: correct merc_icon and merc_health ROI to top-left corner 2026-05-31 13:29:29 +02:00
alex 7d1f03beb7 chore: commit all changes 2026-05-31 12:51:21 +02:00
alex 7d6314b575 Save all config, data, and tools state 2026-05-30 20:21:15 +02:00
alex 21c33a3dc9 Add D2R Keys (Terror, Destruction, Hate, 3x3 Set) to daily price tracker 2026-05-30 19:55:10 +02:00
alex b9f3253e24 fix(ci): restore test assets from git history, remove dead download step
- Restore test/assets/ from commit 5127417^ (was moved to bottytools/botty-test-assets which is now 404)
- Remove 'Download test assets' step from CI workflow (assets are now in-repo)
- Make download_test_assets.py a no-op for backward compatibility
- Fix import: from pipes import Template -> from template_finder import Template
- Remove test/assets/ from .gitignore
2026-05-30 10:03:11 +02:00
alex fefcfa013f fix(ci): correct import from template_finder instead of non-existent pipes 2026-05-30 09:54:30 +02:00
alex 4b339228fe fix(ci): merge coverage into pytest step to fix Coverage failure 2026-05-30 09:41:50 +02:00
alex 43f973ebc2 bump version to 0.8.4 2026-05-30 09:34:41 +02:00
alex 16a4d121c0 feat: log rotation with Discord notifications + failure reason tracking
- Add utils/log_rotation.py: safe_imwrite() auto-rotates screenshot dirs
  when they exceed configured file count or size limits
- Add [log_rotation] config section in params.ini (pickit/info/items
  max_files, max_mb, discord_notify_rotation)
- Replace 40 cv2.imwrite() calls with safe_imwrite() across 13 files
- Add failure reason tracking: bot.py catches run exceptions, stores
  reason in game_stats, Discord message includes the error
- Add Discord notification when log rotation deletes old files
- Prevents disk-full crashes that stopped botty on May 30
2026-05-30 09:32:24 +02:00
alex be874f3275 Ignore debug_forum.html and fg_daily_estimates.json 2026-05-28 20:43:03 +02:00
alex 719b0b1dea Clean up .gitignore: add data/, screenshots/, tools/__pycache__, remove duplicates 2026-05-28 19:27:57 +02:00
alex c228fa9661 Add log/ and *.log to .gitignore
- Ignore log/, log/archive/, log/runs/, log/stats/, *.log
- Remove duplicate *.log and log/ entries that were lower in the file
2026-05-28 19:12:45 +02:00
alex 8853620786 Archive rotated logs to log/archive/ as zips
- Custom ArchiveRotatingFileHandler zips old log.txt.N files
  into log/archive/log_YYYYMMDD_HHMMSS.zip
- Keeps log/ directory clean — no loose .txt.1 files
2026-05-28 19:12:18 +02:00
alex e4c201bf2d Add rotating session reports and log rotation
- on_exit() now generates a session report (txt + json) in log/runs/
- Log file rotates daily, keeps 7 days (TimedRotatingFileHandler)
- Added log/runs directory creation in main.py
2026-05-28 19:07:42 +02:00
alex 9491169549 Add gold tracking to stats and fix game resolution 2026-05-28 16:52:41 +02:00
alex 3f50a6529c Update FG database with 1229 sellers 2026-05-27 20:00:00 +02:00
alex ed65691257 Add post links to sellers: extract post anchors, store direct links to seller posts 2026-05-27 19:57:51 +02:00
alex 14b4835702 Update FG data: 1500 topics, 1229 sellers with usernames 2026-05-27 16:44:34 +02:00
alex ecf91683b7 Add query_fg_prices.py: CLI tool to query FG price database 2026-05-27 16:04:11 +02:00
alex 29e6cc3c99 Add query_fg_prices.py: CLI tool to query FG price database 2026-05-27 15:56:22 +02:00
alex 9c00ca5de7 Fix DB update script: correct prev_scrape query 2026-05-27 15:16:24 +02:00
alex dbee47e152 Add database update script for FG price tracker 2026-05-27 15:15:46 +02:00
alex 512acb02b9 Fix username extraction: correct regex for user.php links and split indexing 2026-05-27 15:12:30 +02:00
alex 69dc33706a Add SQLite database for FG price tracking with sellers table 2026-05-27 13:46:32 +02:00
alex 93771198f1 Add username extraction: per-seller cheapest prices in output and DB 2026-05-27 13:42:36 +02:00
alex 5cfa91fe64 Major scraper overhaul: multi-page pagination, 80+ item patterns, multi-item detection 2026-05-27 11:23:32 +02:00
alex 8574eedc16 Update FG daily estimates: 1500 topics, 24 item types 2026-05-27 09:46:21 +02:00
alex bc649ed863 Fix FG report: normalize item names, estimate FG from item_kept events 2026-05-27 09:38:14 +02:00
alex ea4a753e1c Update FG daily estimates: 500 topics, 20 item types with prices 2026-05-27 08:57:42 +02:00
alex 635ccb0dea Ignore fg_reports output directory 2026-05-27 08:51:20 +02:00
alex fd49d9700d Add FG report outputs 2026-05-27 08:51:02 +02:00
alex ef29136e16 Add FG daily earnings report tool (8h interval) 2026-05-27 08:50:57 +02:00
alex 51319fd11f Fix d2jsp scraper: Cloudflare bypass, relative URL handling, URL dedup, Python 3.10 compat 2026-05-27 01:13:10 +02:00
alex e3d6605595 Update FG price tracker, game stats, and add d2jsp collection tooling 2026-05-27 01:08:45 +02:00
alex 19f4b8a553 Harden offline FG parser and add d2jsp collection tooling 2026-05-26 13:04:28 +02:00
alex 3888cc9666 Fix status XP/level math, env refs, and potion tier fallback 2026-05-26 11:36:58 +02:00
alex 055cd4fdf1 Expand cows route plan with screenshot and coordinate SOP 2026-05-26 09:53:13 +02:00
alex 377b2803e3 Improve run stability, repair flow, and stats tracking 2026-05-26 09:43:53 +02:00
alex 7620bd4ef7 Document each route in params.ini routes section 2026-05-26 09:34:24 +02:00
alex 9b5ba463c2 Improve routes documentation and add Hammerdin keyrun example 2026-05-26 09:33:25 +02:00
alex 64349a988d Add Hermes (Qwen) Botty takeover development/testing guide 2026-05-26 08:49:06 +02:00
alex cdc13d1c6c Add configurable Discord event toggles in params.ini 2026-05-26 08:42:31 +02:00
alex a3ac31463e Fix Discord embed send by omitting file arg when no attachment 2026-05-26 08:28:49 +02:00
alex e54bb64d03 Add phased Linux port plan documentation 2026-05-26 08:19:07 +02:00
alex 179eac8b80 Document .env workflow and recent stability/safety fixes 2026-05-25 20:14:55 +02:00
alex b7a7280964 Fix XP status math edge-cases causing 'Failed to log exp' 2026-05-25 19:49:15 +02:00
alex 909aa88977 Make XP OCR parsing robust to casing and common OCR typos 2026-05-25 19:46:51 +02:00
alex 254c167cbe Reduce XP logging warning spam and keep run stable 2026-05-25 19:45:27 +02:00
alex b3fd60c26d Add sell safety for shields and log sold item names 2026-05-25 19:43:46 +02:00
alex 55def980cd Make repair flow best-effort and harden Discord send fallback 2026-05-25 19:23:09 +02:00
alex 265326a471 Stop tracking personal .env file 2026-05-25 17:17:58 +02:00
alex af6ab38939 Add untracked .env overrides with documented template 2026-05-25 17:15:58 +02:00
alex 5ee0061936 Add Discord embed unit tests for safe send behavior 2026-05-25 17:13:15 +02:00
alex 0bd8e7e7bc Add A5->A4 repair fallback and harden Discord embed send 2026-05-25 17:03:16 +02:00
alex 25c5810778 Handle missing OCR deps without crashing XP logging 2026-05-25 15:18:45 +02:00
alex f20ac02720 Add tesserocr wheel fallback to Windows installer 2026-05-25 15:16:09 +02:00
alex c96fcdf545 Improve dependency checker with concrete remediation steps 2026-05-25 15:13:37 +02:00
alex b77d62144a Add requirements.txt package validation to dependency checker 2026-05-25 15:11:58 +02:00
alex e573a28e8a Add Windows dependency health-check script and launcher 2026-05-25 15:10:23 +02:00
alex d79f472c33 Add robust Larzuk repair interaction fallback with logging 2026-05-25 15:07:17 +02:00
alex 3bf1c3d539 Fix town marker check in start_game by passing current frame 2026-05-25 15:04:45 +02:00
alex a691d4bdfc Harden game creation, tome parsing, and A5 repair menu flow 2026-05-25 15:03:19 +02:00
alex b84b745dbd Accept 'page up' alias for hotkey mapping 2026-05-25 14:56:11 +02:00
alex a70302f44d Change default auto settings hotkey to page up 2026-05-25 14:52:29 +02:00
alex 0b1af05841 Avoid unsafe force-kill for bot/controller shutdown 2026-05-25 14:46:11 +02:00
alex d96cf544bf Fix rare-for-gold override when pickit cache is used 2026-05-25 14:41:36 +02:00
alex fc3ac30431 Persist stats snapshot continuously for crash resilience 2026-05-25 14:36:11 +02:00
alex a39acf510d Fix pytest setup and handle missing tesserocr in test env 2026-05-25 14:35:13 +02:00
alex c9ffcf1e4f Add Discord status updates every N runs with config option 2026-05-25 14:30:33 +02:00
alex 0ccf8ae5ea Add structured run/item logging and per-area item stats 2026-05-25 14:28:52 +02:00
alex d4e1758d97 Make pick_gold override BNIP gold threshold 2026-05-25 14:25:37 +02:00
alex 40d46bb592 Add simple pick_gold config toggle for ground gold 2026-05-25 14:22:35 +02:00
alex c5519ff290 Add config toggle to pick and sell rares for gold 2026-05-25 14:18:57 +02:00
alex dd45386579 Improve config docs and harden discord+difficulty parsing 2026-05-25 14:10:54 +02:00
alex 54f0e3ebcc Update docs and params defaults 2026-05-25 13:52:39 +02:00
alex 7eb961476e Fix Discord webhook init and add local config defaults/ignores 2026-05-25 13:51:50 +02:00
alex d44acf8bf2 Add CI launch smoke test for built executables 2026-05-25 13:47:09 +02:00
alex f597a0108c Fix cooldown pacing for Blizzard and FoH casts 2026-05-25 11:14:52 +02:00
alexandClaude Sonnet 4.6 af06d8aff4 Fix show_belt key, post-attack wait, and pickit variable scope
- config/params.ini: show_belt k->n matches the actual D2R keybind so
  the bot can now correctly read and manage belt potion inventory.
  pickit_screenshots=1 enables per-run loot scan evidence.
- blizz_sorc.py: kill_pindle() now waits 1.5-2.0s (was ~0.33s) after
  the attack loop before teleporting to the loot area.  Blizzard has an
  ~1.8s fall duration, so the old wait left enemies alive when the sorc
  arrived.  Also adds _cast_static() at the start to reduce pack HP
  immediately.
- pickit.py: rename loop var i->ground_item (fixes potential NameError
  when items list is empty) and improve tele-fail warning message.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 20:46:00 +02:00
alexandClaude Sonnet 4.6 9ee102a0f1 Fix charged teleport not firing in traverse_nodes_fixed
The TELE_ACTIVE template only matches native teleport's icon. Charged
teleport (from a staff/item) shows a different icon with a blue charge
indicator, so is_right_skill_selected(["TELE_ACTIVE"]) returns False
and char.move() falls back to walking.

Walking a path calibrated for teleport drops the sorc in the wrong
game-world position, so blizzard casts land south of Pindle ("below
targets") and the sorc is exposed to the full minion pack.

Fix: for can_teleport_with_charges chars, call select_tp() once then
pass force_tp=True to each char.move() hop. force_tp bypasses the
template check and re-arms the teleport hotkey before each right-click,
so charges are actually consumed per hop. If charges deplete mid-path
char.move() right-clicks with no effect and the remaining hops degrade
to standing still rather than walking into monsters.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 20:09:11 +02:00
alexandClaude Sonnet 4.6 5e320f0eef Fix charges-sorc walking into Pindle's minion pack (death)
pindle.py: route can_teleport_with_charges chars through
traverse_nodes_fixed for the Pindle approach instead of traverse_nodes.
traverse_nodes (node-based) falls back to walking for charges chars
because pre_move does nothing for them — so the sorc was slow-walking
through the entire minion pack before the fight, arriving at near-zero HP.

pather.py: traverse_nodes_fixed now calls char.select_tp() before
pre_move when using charge-based teleport, so char.move() sees
teleport on the right-skill slot and uses charges for each step.
When charges deplete mid-path it falls back to walking gracefully.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 19:54:15 +02:00
alexandClaude Sonnet 4.6 2634619b18 Fix window-detection race condition and None monitor range crash
- misc.py: catch psutil.NoSuchProcess in WindowSpec.match() — a process
  can exit between EnumWindows enumerating it and us calling .name(),
  which was crashing the window-detection thread and leaving
  monitor_x_range/monitor_y_range as None
- screen.py: guard convert_screen_to_monitor against None ranges so
  template matching doesn't throw TypeError before the D2R window is
  first located; logs a warning and returns unclamped coords instead
- main.py: switch BeautifulTable to STYLE_DEFAULT (ASCII only) to avoid
  UnicodeEncodeError on cp1252 terminals; add try/except fallback

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 19:42:40 +02:00
alexandClaude Sonnet 4.6 dc224bec5c Fix CTA spam, traverse_nodes_fixed crash, and add skill preflight/setter
- config: cta_available=0 (user has no CTA weapon; was causing 5 failed
  weapon-swap attempts per run)
- pather: traverse_nodes_fixed now allows can_teleport_with_charges chars
  through instead of raising ValueError; when charges deplete mid-path
  char.move() falls back to walking gracefully
- utils: add skill_preflight.py (visual hotkey verification, all blizz_sorc
  skills use side=right) and skill_hotkey_setter.py (automated picker binding
  with step-by-step logging)
- test: add test_skill_preflight.py (3 passing tests)
- assets: add sorc skill icon templates for preflight matching
- tools: add capture_sorc_skill_icons.py and set_sorc_skill_hotkeys.py
- key_detector: extend VK map with numpad/F-key/symbol codes; add
  validate_key_bindings() and parse_key_file(); fix char_name lookup

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 19:26:00 +02:00
alex a77e87e138 Improve Botty launch and runtime recovery 2026-05-24 18:14:23 +02:00
alex 080b7d1679 Set Botty to run Pindle with blizz sorc 2026-05-24 17:53:29 +02:00
alexandClaude Sonnet 4.6 d4fec70950 feat: add character selector (blizz sorc / fohdin / hammerdin) to F6 UI
The run-selector window now also shows a character section with radio
buttons for the three supported builds. Selecting a char updates
Config().char["type"] in-memory alongside the run selection; both take
effect on the next game.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 17:52:45 +02:00
alexandClaude Sonnet 4.6 d3dbf2bf9c fix: disable auto-login by default, require explicit auto_login=1
Auto-launch of D2R and bnet credential injection are now gated behind
an explicit `auto_login=0` flag in params.ini. Setting it to 1 restores
the previous behaviour. Credentials stored in bnet_name/bnet_pass are
never appended to launch options while auto_login=0.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 17:45:23 +02:00
alexandClaude Sonnet 4.6 887a97c93b feat: add F6 hotkey to select boss/farm runs at runtime
Adds a tkinter run-selector window accessible from the botty console via
the new select_runs_key hotkey (default F6). Users can check/uncheck any
of the 11 supported boss/farm runs; the selection updates Config in-memory
and takes effect on the next game without restarting botty.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 17:42:10 +02:00
alexandClaude Sonnet 4.6 d6c8d8d5f7 fix: replace custom tesserocr wheel with conda-forge package
The custom tesserocr-2.5.2 wheel was compiled on a specific machine
against DLL versions that do not match a fresh conda-forge installation.
This caused persistent ImportError: DLL load failed regardless of PATH
or LoadLibraryExW approach.

conda-forge's tesserocr package is compiled against the exact same
conda-forge tesseract/leptonica binaries, so all DLL dependencies
are automatically satisfied within the conda environment — no manual
DLL path manipulation needed.

Changes:
- environment.yml: add tesserocr + tesseract as conda-forge packages,
  remove leptonica pin (no longer needed), remove custom wheel from pip
- install.bat: replace wheel force-reinstall with pip uninstall cleanup
- src/*.py: simplify DLL fix to os.add_dll_directory only

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:49:00 +02:00
alexandClaude Sonnet 4.6 e1c18faf26 fix: use conda run instead of direct python for DLL loading
Every approach to manually replicating conda's DLL environment from
Python code or batch PATH manipulation has failed. conda run activates
the environment exactly like "conda activate botty" — setting PATH,
running activate.d scripts, and properly resolving all transitive DLL
dependencies for tesseract51.dll.

run_botty.bat now derives conda.exe from the botty python.exe path
(two levels up: envs/botty -> envs -> miniforge3 -> Scripts/conda.exe)
and uses "conda run -n botty --no-capture-output python src/main.py".

install.bat smoke test now uses "%CONDA_EXE% run -n botty python -c ..."
which already has CONDA_EXE set from the install step.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:37:14 +02:00
alexandClaude Sonnet 4.6 bdd6247342 fix: use LoadLibraryExW(0x1000) to preload tesseract51.dll
os.add_dll_directory alone is not enough — LOAD_LIBRARY_SEARCH_USER_DIRS
does not propagate to transitive deps of deps when loaded automatically
by the OS (e.g. tesseract51.dll's deps like mingw runtimes, leptonica).

LoadLibraryExW with LOAD_LIBRARY_SEARCH_DEFAULT_DIRS (0x1000) explicitly
propagates user DLL dir search to the entire transitive dep chain, so
leptonica, libgcc, libstdc++, zlib etc. are all found in Library\bin
and Library\mingw-w64\bin without conda activate.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:30:12 +02:00
alexandClaude Sonnet 4.6 77ad97022e fix: remove ctypes.WinDLL preload — use only os.add_dll_directory
ctypes.WinDLL uses LoadLibraryW which does NOT search user DLL dirs
registered via os.add_dll_directory/AddDllDirectory. It was throwing
FileNotFoundError and blocking the import before tesserocr was ever tried.

Python 3.8+ loads .pyd files with LOAD_LIBRARY_SEARCH_USER_DIRS which
DOES search user-registered dirs for the pyd and all its transitive DLL
dependencies. os.add_dll_directory(Library\bin) alone is sufficient.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:19:18 +02:00
alexandClaude Sonnet 4.6 d9383cc164 feat: add update.bat for git pull workflow
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:12:31 +02:00
alexandClaude Sonnet 4.6 a77dafaf08 fix: set conda DLL PATH at batch level before Python starts
os.environ['PATH'] set from inside Python does not affect the Windows
DLL loader used by ctypes.WinDLL — the loader reads the process PATH
at load time, not from Python's env dict. Set PATH in the .bat files
before python.exe is launched so tesseract51.dll's transitive deps
(leptonica, zlib, libpng, etc.) are findable by the loader.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:10:00 +02:00
alexandClaude Sonnet 4.6 18c08a4c65 fix: prepend conda Library\bin to PATH before ctypes.WinDLL call
When ctypes.WinDLL loads tesseract51.dll by absolute path, Windows
resolves that DLL's own transitive deps using the standard search order:
app-dir → System32 → Windows → cwd → PATH. Library\bin is in none of
those (conda activate was not run), so leptonica, zlib, libpng, etc.
are invisible and the load fails even though the DLLs are all present.

Fix: prepend all conda DLL dirs to os.environ['PATH'] before the
ctypes.WinDLL call so the standard DLL search finds them. os.add_dll_directory
is still called for Python's LOAD_LIBRARY_SEARCH_USER_DIRS path.
Together the three steps guarantee the import works without conda activate:
  1. os.add_dll_directory  - for .pyd loading
  2. os.environ PATH       - for ctypes transitive dep resolution
  3. ctypes.WinDLL(abs)    - pre-cache tesseract so .pyd reuses it

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 14:03:00 +02:00
alexandClaude Sonnet 4.6 f8380af3bd fix: pre-load tesseract51.dll by absolute path to fix transitive DLL deps
os.add_dll_directory alone is not enough on Windows. When Python loads
the tesserocr .pyd via LOAD_LIBRARY_SEARCH_USER_DIRS, Windows finds
tesseract51.dll in the added directory but then resolves tesseract's own
transitive deps (leptonica, zlib, libpng etc.) using only the standard
system search path -- not the user DLL dirs. Those libs live in
Library\bin, not System32, so they're invisible and the load fails even
though every DLL is present.

Fix: call ctypes.WinDLL(absolute_path_to_tesseract51.dll) before the
tesserocr import. LoadLibraryW with a full path anchors tesseract51.dll
to Library\bin, so Windows searches that directory for its transitive
deps. The already-loaded DLL is then returned from cache when the .pyd
requests it, making the import succeed.

Applied to ocr.py (test entry point), main.py, and shopper.py.
Also updated install.bat smoke test and diagnostic to use the same fix.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 13:59:21 +02:00
alexandClaude Sonnet 4.6 ee940b7e3d fix: pin leptonica=1.78.0 to match tesserocr wheel DLL dependency
The custom tesserocr wheel links against leptonica-1.78.0.dll at
compile time. Unpinned leptonica on conda-forge resolves to 1.82+
which installs leptonica-1.82.0.dll — a different filename — so
Windows DLL loader cannot find it regardless of os.add_dll_directory.

Also force-reinstall the wheel in install.bat to guarantee the
correct binary is used (not a stale cached version), and add a
diagnostic that prints which DLLs are actually present when the
smoke test fails so the root cause is visible instead of a vague
warning.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 13:51:54 +02:00
alexandClaude Sonnet 4.6 91403e49d3 feat: make zip-download install flow work for first-time users
- README: add step-by-step Installation section (Miniforge → download
  ZIP → install.bat → config → run_botty.bat) so a non-technical user
  can follow it without reading development.md
- config/params.ini: reset personal fields (name, char_name,
  saved_games_folder) to generic defaults so the downloaded zip
  works out of the box for anyone
- install.bat: apply os.add_dll_directory before the tesserocr smoke
  test so it stops emitting a false warning on every install

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 09:39:12 +02:00
alexandClaude Sonnet 4.6 8ee31d9691 fix: use sys.prefix for tesserocr DLL search in all entry points
dirname(dirname(sys.executable)) walks two levels up from the exe on
Windows, landing in the envs/ parent rather than the env root. Replace
with sys.prefix which is always the correct conda env root, and cover
Library/{bin,mingw-w64/bin,usr/bin} to handle all conda-forge layouts.
Also remove unused _dll_fix.py which had the same bug.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-24 09:34:59 +02:00
alex 40a207be9a feat: harden install.bat + add CI checks for launcher scripts
install.bat:
- Add conda self-test (conda --version) before env create
- Verify botty python.exe exists after env creation
- Smoke-test key imports (cv2, tesserocr, discord, etc.)

CI (.github/workflows/ci.yml):
- Add test_setup_bat_files.py to test matrix

test/test_setup_bat_files.py (7 tests):
- All expected .bat files exist
- No hardcoded usernames (alex, alexpolo, ultimate) in run scripts
- No absolute home paths in run scripts (must use %~dp0 / %USERNAME%)
- All run_*.bat source find_python.bat (no duplicated conda detection)
- find_python.bat checks >= 6 conda locations
- install.bat has conda self-test and python verification
2026-05-23 23:15:24 +02:00
alex dec3269a0c fix: make launcher bat files work from any working directory
- Add find_python.bat shared helper to auto-detect conda env (6 locations)
- All bat scripts now resolve paths relative to script location (%~dp0)
- Fixes crash when launching from Start Menu / pinned shortcut (cwd=System32)
- Fixes hardcoded username path in run_asset_extractor.bat
2026-05-23 23:10:16 +02:00
alex 27573bf362 add blizzpw.txt to .gitignore 2026-05-23 00:06:10 +02:00
alexandClaude Sonnet 4.6 5451357b86 fix tesserocr DLL load failure on CI Windows runners
Python 3.8+ no longer searches conda's Library\bin for DLLs automatically.
Add os.add_dll_directory(sys.prefix/Library/bin) before importing tesserocr
so leptonica and tesseract DLLs are found on any conda-based Windows env.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-23 00:04:40 +02:00
alexandClaude Sonnet 4.6 94f3e6bca8 fix install.bat: cd to script dir so environment.yml is found
Without cd /d "%~dp0", conda resolves environment.yml relative to
wherever the user launched the bat from (e.g. C:\Windows\system32).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:54:33 +02:00
alexandClaude Sonnet 4.6 6c261c3cbb fix fuzzy matching bug and CI coverage collection
find_best_match was using extractOne (which maximises its scorer) with
Levenshtein distance (lower = better), so it returned the worst match
instead of the best. Switch to extract+min to correctly minimise distance.
Fixes 6 failing tests in test_text_correction.py.

CI: run pytest under `coverage run` so coverage.xml has data to report;
drop deprecated use-only-tar-bz2 flag from setup-miniconda steps.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:53:15 +02:00
alexandClaude Sonnet 4.6 6ace0a7970 include install.bat, src, env files in release zip
Adds install.bat, run_botty.bat, run.bat, environment.yml, src/, and
dependencies/ to the build output so users can download zip → run
install.bat → run_botty.bat without needing a pre-built conda env.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:43:36 +02:00
alexandClaude Sonnet 4.6 ed4aaf77f8 add install.bat: automates conda env setup from environment.yml
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:40:26 +02:00
alex 416b6714dd fix CI: add PYTHONPATH=./src to all steps 2026-05-22 23:31:27 +02:00
alex ab29fa0a46 fix release bundle: main.exe naming, full zip with config+assets, add release trigger 2026-05-22 23:28:21 +02:00
alex 77d84680c6 fix CI: remove broken pytest-pythonpath, add test→build pipeline, fix test asset guards 2026-05-22 23:24:41 +02:00
alex d0e6eb7d21 update params.ini, bot.py, win_input.py 2026-05-22 21:11:59 +02:00
alex 75f64351ee add stealth system, key detection, click recorder, FOHdin, new routes, and tooling updates 2026-05-21 23:34:20 +02:00
alex 8dc677d2e6 document stealth, key detection, click recorder, and FOHdin in README 2026-05-20 20:06:21 +02:00
alex 8d1a2f20c8 merge main into mine 2026-05-20 20:02:52 +02:00
alex e9d88f9763 feat: auto-launch D2R on botty startup
- main.py checks if D2R.exe is running, launches with auto-login if not
- Set char_name=Burr in config for OCR character selection
2026-05-20 09:20:37 +02:00
alex 776a09da5d feat: auto-login + OCR character selection
- Added bnet_name, bnet_pass, char_name to [general] config
- Config._build_launch_options() appends -bnetname/-bnetpass when set
- character_select.py: OCR fallback when template matching fails
2026-05-20 09:16:48 +02:00
alex b3052e50b4 feat: auto-login + OCR character selection
- Added bnet_name, bnet_pass, char_name config options
- Launch options auto-append -bnetname/-bnetpass when credentials set
- character_select.py: OCR fallback when template matching fails
- Scans character list row-by-row, matches by name, clicks and scrolls
2026-05-20 08:55:04 +02:00
alex 8575bc4446 fix: npc_auto_label global declaration order 2026-05-20 07:44:39 +02:00
alex 3f6363aec6 feat: enhanced stealth layer - click delay, endpoint wobble, behavior model
- custom_mouse.click(): added 50-800ms arrival-to-click delay (human hesitation)
- custom_mouse.stealth_move(): added endpoint wobble (2-5px micro-adjustment)
- stealth.py: new functions for keyboard timing, skill hesitation, wrong waypoint, skill mistakes
- params.ini: 8 new tunable stealth config options with documented defaults

All 46 files using mouse module automatically benefit — stealth is centralized.
2026-05-20 07:22:24 +02:00
alex bab6e24438 feat: parallel template search, async mouse moves, NPC auto-label
- template_finder.search(): parallel matching via ThreadPoolExecutor (4 workers)
- utils/custom_mouse.py: async_move() for non-blocking mouse movement
- utils/npc_auto_label.py: detect_visible_npcs() scans 16 NPCs in parallel
2026-05-19 22:47:30 +02:00
alex 9bbc753a16 feat: complete key auto-detection with binary .key parser
- Parse D2R binary .key format (10-byte entries, action=1=skill, action=0=non-skill)
- Detect skill slot bindings and validate against params.ini
- Scan Saved Games for any .key/.keyo file (handles battle tag names)
- Config loads cleanly, warns on skill/non-skill mismatches
2026-05-19 18:32:18 +02:00
alex df501d3ea9 docs: improvements reference + d2r-key-detection skill
- Added references/improvements.md tracking all implemented fixes
- Created d2r-key-detection skill for future sessions
2026-05-19 16:50:10 +02:00
alex 5ea1c17902 fix: target false positives, pickit timing, hardcore death loop
- target_detect: add aspect ratio filtering to reject health bars and immune text
- pickit: add 0.2-0.3s wait after pickup before moving on (fix #939)
- game_controller: detect hardcore mode and stop instead of infinite death loop
- config: add 'hardcore' flag (default 0)

Closes #959, #939, #942
2026-05-19 16:49:24 +02:00
alex 0af8a54116 feat: auto-detect D2R key bindings from .key/.keyo file
- Read character .key file from Saved Games / D2R install dir / APPDATA
- Auto-fill empty hotkeys in [char] section (inventory, belt, potions, etc.)
- Match skill slots to configured skills for validation
- Normalize key variants (left alt ~ alt, left shift ~ shift)
- params.ini values always take priority over detected bindings
- Warn on genuine mismatches between config and .key file
2026-05-19 16:46:24 +02:00
319 changed files with 32686 additions and 3222 deletions
+13
View File
@@ -1,8 +1,21 @@
# .coveragerc to control coverage.py
[run]
branch = True
source = src
# Exclude conda/xonsh internal shims that leak into coverage data. The phantom
# is an absolute path like D:\a\...\config-3.py, so a bare "config-3.py" omit
# never matches — use a glob that matches any path ending in config-<n>.py.
omit =
*config-*.py
*/site-packages/*
*/conda-meta/*
[xml]
output = coverage.xml
[report]
# Ignore missing source files (conda shims reference files not in tree)
ignore_errors = True
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
-1
View File
@@ -1 +0,0 @@
PYTHONPATH=./src
+25
View File
@@ -0,0 +1,25 @@
# Botty personal environment overrides.
# Copy this file to ".env" in the repo root and edit your values there.
# ".env" is ignored by git and should never be committed.
#
# Format: BOTTY_<CONFIG_KEY>=<value>
# These override values from config/params.ini and config/custom.ini at runtime.
#
# Common personal settings:
# Bot display/account name used in logs/Discord usernames.
# BOTTY_NAME=zapzap
# Main Discord webhook (status/death/chicken/general messages).
# BOTTY_CUSTOM_MESSAGE_HOOK=https://discord.com/api/webhooks/...
# Optional dedicated loot webhook for item drops.
# BOTTY_CUSTOM_LOOT_MESSAGE_HOOK=https://discord.com/api/webhooks/...
# Optional auto-login credentials (keep private).
# BOTTY_BNET_NAME=your-battlenet-email-or-name
# BOTTY_BNET_PASS=your-battlenet-password
# BOTTY_CHAR_NAME=your-character-name
# Optional path override if you reference it in params.ini:
# BOTTY_SAVED_GAMES_FOLDER=C:\Users\you\Saved Games\Diablo II Resurrected
+201 -79
View File
@@ -1,91 +1,213 @@
name: Botty - CI
name: CI
on:
pull_request:
push:
branches: [main, mine]
release:
types: [published]
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:
install-and-test:
name: Install & Test (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v4
- 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.yml
use-only-tar-bz2: true
python-version: "3.10"
cache: "pip"
- name: Download test assets
shell: powershell
- name: Install dependencies
run: |
C:\Miniconda\condabin\conda.bat activate botty
python -c "import utils.download_test_assets"
- name: Pytest
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat activate botty
python -c "import sys; print(sys.version)"
python -m pytest test/smoke_test.py test/nip/ test/game_stats_test.py test/closest_non_hud_test.py test/transmute/ -v -s
- name: Coverage
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat activate botty
coverage xml
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.yml
use-only-tar-bz2: true
- name: Build exe
shell: powershell
env:
BOTTY_NO_RENAME: '1'
run: |
C:\Miniconda\condabin\conda.bat activate botty
python build.py --conda_path C:\Miniconda
- 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
uses: actions/upload-artifact@v4
with:
name: botty-build
path: botty_v*/
retention-days: 7
- name: Upload to Release
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
python -m pip install --upgrade pip
pip install -r requirements-win11.txt
shell: bash
- name: Install Tesseract OCR
run: choco install tesseract --no-progress -y
- name: Python version
run: python -c "import sys; print(sys.version)"
- name: Syntax check
env:
PYTHONPATH: ./src
run: python -m compileall -q src tools test scripts
- name: Verify core imports
env:
PYTHONPATH: ./src
run: |
BOTTY_DIR=$(ls -d botty_v* | head -1)
gh release upload "${{ github.event.release.tag_name }}" "${BOTTY_DIR}.zip" --clobber
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: 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 = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
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: C:\Program Files\Tesseract-OCR\tesseract.exe
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: |
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
- name: Tests with coverage
env:
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
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
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
# Non-blocking: this is a convenience artifact, not a gate. When the account's
# artifact storage quota is full the upload fails with
# "Failed to CreateArtifact: Artifact storage quota has been hit" and marked the
# whole run red even though every test step passed. A quota problem is not a
# broken build.
continue-on-error: true
with:
name: coverage-report
path: coverage.xml
retention-days: 7
+92 -3
View File
@@ -15,13 +15,75 @@ botty_v*/
custom.ini
config/custom.ini
config/custom.*.ini
config/*.local.ini
.vscode
.vs/
test/assets/
# Logs and run reports
log/
log/archive/
log/runs/
log/stats/
*.log
# Scraped data (cached web pages, URLs)
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
config/fg_prices.db
config/daily_prices.json
config/daily_prices_history.json
config/traderie_prices.json
# Screenshots (debug/test captures)
screenshots/
# Tools dev cache
tools/__pycache__/
# Build artifacts
botty_v*/
botty_v*.zip
.venv
.env
.env.*
!.env.example
*.bak
.coverage
htmlcov/
# Secrets / account-specific — never commit
cookies.txt
cookies_temp*
*.cookies
config/custom.ini
coverage.xml
utils/live-view/
config/bnip/*
@@ -33,5 +95,32 @@ loot_screenshots/
pickit_screenshots/
*info_log_parsed.txt
*info_*.png
*.log
log/
blizzpw.txt
# Local user/workspace files
.claude/
GEMINI.md
test_config_load.py
# Debug directory
botty_debug/
# per-user character profiles (survive git pulls)
config/profiles/
config/active_profile.txt
# Docker / dev-only files (not production)
.dockerignore
Dockerfile
docker-compose.yml
docker-*.bat
DOCKER.md
botty_next/
src/bridge_server.py
src/input_layer/bridge_input.py
.hermes/
fixtures/
test/run/
# Installer output (generated by run_install_capture.bat)
install_log.txt
-87
View File
@@ -1,87 +0,0 @@
# Botty Improvements Implementation Plan
## Status Legend
- [ ] Not started
- [~] In progress
- [x] Done
- [-] Cancelled / low priority
---
## Phase 1: Key Auto-Detection (Issue #940)
Read D2R .key file and auto-fill hotkeys.
- [x] Create src/utils/key_detector.py module
- [x] VK code mapping (partial — needs review for accuracy)
- [x] Parse .key file (text format: VK action_type param)
- [x] Auto-fill empty [char] hotkeys from detected bindings
- [x] Auto-fill build-specific skill hotkeys (fohdin, hammerdin, etc.)
- [x] Wire into config.py load_data()
- [ ] REVIEW: Verify VK_MAP accuracy (D2R uses its own VK offset scheme)
- [ ] REVIEW: Skill slot-to-config matching is heuristic — may misassign
- [ ] TEST: Verify against actual D2R .key file on user's machine
## Phase 2: Target Detection False Positives (Issues #959/#964)
Health bars and "immune to X" text mistaken for targets.
- [ ] Analyze current get_visible_targets() in target_detect.py
- [ ] Add shape/size filtering: health bars are thin horizontal strips, immune text is small
- [ ] Add aspect ratio check: real targets (poison/freeze auras) are roughly circular/elliptical
- [ ] Add minimum bounding box height constraint (filter out thin text)
- [ ] Optionally: add color temperature check (immune text is yellow/gold, not blue/green)
- [ ] Test with screenshots of edge cases
## Phase 3: Pickit Timing Fix (Issue #939)
Items skipped because bot teleports away before grabbing.
- [ ] Review pickit.py _yoink_item() for timing issues
- [ ] Add configurable pickup_delay parameter (current: fixed timing)
- [ ] Add retry logic: if item still visible after pickup attempt, re-try
- [ ] Add "slow mode" for large/heavy items (framed/magic items may animate longer)
- [ ] Ensure bot doesn't teleport until pickup animation completes
- [ ] Test: verify no "Attempt to pick xyz" warnings followed by teleport
## Phase 4: Parallel Template Search (Issue #848)
Speed up template_finder.search() with threading.
- [ ] Add ThreadPoolExecutor-based search_all_parallel()
- [ ] Keep existing search() for single-template (no overhead)
- [ ] Only parallelize when searching >3 templates simultaneously
- [ ] Benchmark: measure speedup on typical 1280x720 grab
## Phase 5: Async Mouse Moves (Issue #955)
Non-blocking mouse movement.
- [ ] Add async_move() to utils/custom_mouse.py
- [ ] Run movement in background thread
- [ ] Add is_moving() / wait_for_move() synchronization
- [ ] Integrate into game_controller.py for smoother action chains
## Phase 6: Hardcore Chicken Loop Fix (Issue #942)
Prevent infinite death loops on Hardcore characters.
- [ ] Review death_manager.py chicken logic
- [ ] Add max_chicken_count config parameter (default: 3)
- [ ] If max chicken count exceeded on HC, exit gracefully instead of re-entering
- [ ] Add defensive chicken config option (chicken to TP instead of full chicken)
- [ ] Test: verify HC character exits cleanly after N deaths
## Phase 7: Auto-Label NPCs (Issue #950)
Learn vendor identities automatically during gameplay.
- [ ] During town states, detect NPC name plates via OCR
- [ ] Cross-reference detected names with known NPC list
- [ ] Auto-capture NPC templates when confidence is high
- [ ] Store learned templates in assets/npc/
- [ ] This is a long-term feature — lower priority
---
## Priority Order (implement in this order)
1. **Phase 1** - Key auto-detection (already partially done, needs review + test)
2. **Phase 3** - Pickit timing (high impact on loot collection)
3. **Phase 2** - Target detection (high impact on kill reliability)
4. **Phase 6** - Hardcore chicken fix (safety critical)
5. **Phase 4** - Parallel template search (performance)
6. **Phase 5** - Async mouse moves (quality of life)
7. **Phase 7** - Auto-label NPCs (long-term feature)
-561
View File
@@ -1,561 +0,0 @@
# Quest Framework + Den of Evil Plan
## Goal
Build a quest automation framework in botty that can interact with D2R NPCs, handle dialogue,
track quest progress, and run Den of Evil as the first quest -- all usable by a low-level FoHdin.
---
## Architecture
The quest framework is a new subsystem that plugs into the existing botty state machine.
It follows the same patterns as existing runs (approach -> battle -> return to town) but adds
NPC dialogue interaction and quest state persistence.
### New files
```
src/quest/
__init__.py # Exports
quest_manager.py # Quest state machine + persistence (JSON)
quest_dialogue.py # OCR-based NPC dialogue interaction
quest_items.py # Quest item detection/pickup
quest_combat.py # Lightweight combat wrapper (killing trash)
a1/
__init__.py
q_den_of_evil.py # Den of Evil run
```
### Modified files
```
src/npc_manager.py # Add TOWN_MAIDEN NPC constant + templates
src/pather.py # Add A1_ROARING_CANYON + DoE entrance locations
src/bot.py # Add quest state, transitions, handler
src/run/__init__.py # Export DenOfEvil
src/town/a1.py # (optional) Add can_do_den_of_evil method
config/params.ini # Add run_doe to [routes]
config/bnip/ # Add town_maiden.png template
```
---
## Phase 1: Foundation
### 1.1 `src/quest/quest_manager.py`
Purpose: Track which quests are done, persist between sessions, dispatch to quest modules.
```python
class QuestManager:
"""Manages quest state: tracks done/available quests per act, persists to JSON."""
# Quest definitions per act
QUESTS = {
"a1": ["den_of_evil"],
"a2": [], # future: radament, horadric_staff, etc.
...
}
def __init__(self):
self._state_file = "config/quest_state.json"
self._state = self._load()
def is_done(self, quest_name: str) -> bool:
return self._state.get(quest_name, False)
def mark_done(self, quest_name: str):
self._state[quest_name] = True
self._save()
def mark_all_done(self, act: str):
for q in self.QUESTS.get(act, []):
self._state[q] = True
self._save()
def next_pending(self, act: str) -> str | None:
for q in self.QUESTS.get(act, []):
if not self.is_done(q):
return q
return None
def all_done(self, act: str) -> bool:
return all(self._state.get(q, False) for q in self.QUESTS.get(act, []))
def _load(self) -> dict:
if os.path.exists(self._state_file):
with open(self._state_file) as f:
return json.load(f)
return {}
def _save(self):
with open(self._state_file, "w") as f:
json.dump(self._state, f, indent=2)
```
JSON format (config/quest_state.json):
```json
{
"den_of_evil": true,
"search_for_smith": true,
...
}
```
### 1.2 `src/quest/quest_dialogue.py`
Purpose: Talk to NPCs, read dialogue options via OCR, click the right branch.
This is the core of quest automation -- it makes the bot "converse" with NPCs.
```python
class QuestDialogue:
"""OCR-based NPC dialogue interaction for quest conversations."""
# ROI at 1280x720
DIALOGUE_TEXT_ROI = (200, 470, 680, 100) # NPC speech text
DIALOGUE_OPTIONS_ROI = (200, 560, 680, 140) # Player response buttons
DIALOGUE_CLOSE_Y = 670 # Close button area
@staticmethod
def open_dialogue(npc_name: str) -> bool:
"""Walk to NPC and open their dialogue menu."""
from npc_manager import Npc, open_npc_menu
return open_npc_menu(getattr(Npc, npc_name.upper()))
@staticmethod
def read_dialogue() -> dict:
"""OCR the current dialogue box. Returns:
{
'npc_text': str, # What the NPC said
'options': [str, ...], # Response options (may be empty if no choice)
'has_continue': bool # True if just need to click continue
}
"""
img = grab()
npc_text = ocr_roi(img, self.DIALOGUE_TEXT_ROI)
options_text = ocr_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Parse options: split by line, filter out empty, return list
options = [line.strip() for line in options_text.split('\n') if line.strip()]
has_continue = len(options) == 0 or "continue" in options_text.lower()
return {
'npc_text': npc_text.strip(),
'options': options,
'has_continue': has_continue
}
@staticmethod
def click_option(option_text: str) -> bool:
"""Find and click a specific dialogue option by matching text via OCR.
Searches the options ROI for a template match of the option text."""
img = grab()
options_img = cut_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Use template_finder or OCR to locate which button matches
# Then click at that position
...
@staticmethod
def continue_dialogue() -> bool:
"""Click the close/continue button to advance dialogue."""
# Click in the close button area
x, y, w, h = self.DIALOGUE_CLOSE_Y
mouse.click at center of close area
...
@staticmethod
def follow_conversation(expected_options: list[str]) -> bool:
"""Follow a multi-step conversation:
- Read NPC text
- If options present, click the expected one
- If no options, click continue
- Repeat until dialogue closes or unexpected text appears
"""
max_steps = 20 # Safety limit
for i in range(max_steps):
dialogue = self.read_dialogue()
if not dialogue['has_continue'] and dialogue['options']:
# We have a choice - click the expected option
for opt in expected_options:
if opt.lower() in ' '.join(dialogue['options']).lower():
if not self.click_option(opt):
return False
break
else:
Logger.warning(f"Unexpected dialogue options: {dialogue['options']}")
return False
else:
# Just continue
if not self.continue_dialogue():
return False
wait(1.0, 1.5)
# Check if dialogue box is still visible
if not is_visible(ScreenObjects.NPCDialogue):
return True # Done
return False # Hit max steps
```
Key design: `follow_conversation()` takes a list of expected response text. It will
match against whatever options the NPC presents and click the right one. This handles
multi-branch dialogues without hardcoding step-by-step clicks.
### 1.3 `src/quest/quest_combat.py`
Purpose: Lightweight combat for clearing trash during quests. Reuses existing char methods.
```python
class QuestCombat:
"""Combat helpers for quest areas -- reuses existing character combat logic."""
@staticmethod
def clear_area(pather: Pather, char: IChar, path_nodes: list[int],
timeout: float = 60) -> bool:
"""Walk a path while killing monsters until timeout or all nodes cleared.
This is the core of DoE: walk down, kill, walk back."""
return pather.traverse_nodes(path_nodes, char, timeout=timeout, do_combat=True)
@staticmethod
def wait_for_clear(char: IChar, timeout: float = 15) -> bool:
"""Wait until no monsters are visible (area is clear)."""
start = time.time()
while time.time() - start < timeout:
targets = get_visible_targets()
if not targets or len(targets) == 0:
return True
# Attack if enemies present
char.attack()
wait(0.5)
return False
```
### 1.4 `src/quest/quest_items.py`
Purpose: Detect and pick up quest items (gold glow detection).
```python
class QuestItems:
"""Quest item detection and management."""
@staticmethod
def detect_quest_items(img: np.ndarray) -> list[tuple[float, float]]:
"""Detect gold-glowing items on screen (quest items).
Returns list of (x, y) positions in monitor coords."""
quest_item_mask, _ = color_filter(img, Config().colors.get("gold_glow", [
(180, 140, 0), (255, 220, 80)
]))
# Find contours, return centers
...
@staticmethod
def pick_up_quest_items(char: IChar, img: np.ndarray = None) -> bool:
"""Find and pick up any quest items currently visible."""
if img is None:
img = grab()
items = self.detect_quest_items(img)
for pos in items:
char.pick_up_item(pos, item_name="Quest Item")
wait(0.5)
return len(items) > 0
```
---
## Phase 2: NPC & Location additions
### 2.1 Add Town_Maiden to `src/npc_manager.py`
```python
# In class Npc:
TOWN_MAIDEN = "town_maiden" # Act 1, Roaring Canyon
# In _build_npcs():
Npc.TOWN_MAIDEN: {
"head": "town_maiden.png", # Need to capture template
"actions": {} # No trade/identify - just dialogue
}
```
The Town Maiden sits in Roaring Canyon (eastern part of Act 1 town). She has a simple
dialogue: you talk to her to "unlock" the Den of Evil entrance, then you talk to her
again after clearing it to get the XP reward and reset it for another run.
### 2.2 Add locations to `src/pather.py`
```python
class Location:
# ... existing locations ...
# Act 1 Roaring Canyon / Den of Evil
A1_ROARING_CANYON = "a1_roaring_canyon" # Town area where Maiden is
A1_DEN_OF_EVIL_ENTRANCE = "a1_doe_entrance" # Stairs down to DoE
A1_DEN_LEVEL_1 = "a1_doe_level_1"
A1_DEN_LEVEL_2 = "a1_doe_level_2"
A1_DEN_LEVEL_3 = "a1_doe_level_3"
A1_DEN_LEVEL_4 = "a1_doe_level_4"
# (DoE has 3-5 levels depending on game version - need to confirm)
```
Path nodes will need to be added for the Roaring Canyon area and each DoE level.
These are captured via quest_debug.py by walking the path and recording waypoints.
---
## Phase 3: Den of Evil run module
### 3.1 `src/quest/a1/q_den_of_evil.py`
```python
class DenOfEvil:
"""Den of Evil run - Act 1 repeatable quest for XP.
Flow:
1. Ensure character is in Act 1
2. Walk to Roaring Canyon (Town Maiden)
3. Talk to Town Maiden (unlock entrance if needed)
4. Enter Den of Evil
5. Pre-buff (FoH + Conviction for FoHdin)
6. Walk through each level, killing trash
7. Exit back to Roaring Canyon
8. Talk to Town Maiden again for reward
9. Return to town center
"""
name = "run_doe"
# Path nodes per level (to be filled in via quest_debug.py)
LEVEL_PATHS = {
1: [], # Entrance to level 1 stairs
2: [], # Level 1 to level 2
3: [], # Level 2 to level 3
4: [], # Level 3 to level 4 (or final area)
}
def __init__(self, pather, town_manager, char, pickit, runs):
self._pather = pather
self._town_manager = town_manager
self._char = char
self._pickit = pickit
self._runs = runs
self._quest_manager = QuestManager()
self._dialogue = QuestDialogue()
def approach(self, curr_loc: Location, do_buff: bool) -> Location | bool:
"""Get to Roaring Canyon and talk to Town Maiden."""
Logger.info("Run Den of Evil")
# Ensure we're in Act 1
if TownManager.get_act_from_location(curr_loc) != Location.A1_TOWN_START:
curr_loc = self._town_manager.go_to_act(1, curr_loc)
if not curr_loc:
return False
# Walk to Roaring Canyon (Town Maiden area)
if not self._pather.traverse_nodes(
(curr_loc, Location.A1_ROARING_CANYON), self._char, force_move=True
):
return False
# Talk to Town Maiden to unlock/open the Den
if not self._dialogue.open_dialogue("town_maiden"):
return False
# Follow the conversation (expect "Oh no, not again" or similar)
if not self._dialogue.follow_conversation(["Tell me more", "I'll help you"]):
return False
# Enter the Den
if not self._pather.traverse_nodes(
(Location.A1_ROARING_CANYON, Location.A1_DEN_OF_EVIL_ENTRANCE),
self._char, force_move=True
):
return False
return Location.A1_DEN_OF_EVIL_ENTRANCE
def battle(self, do_pre_buff: bool) -> bool | tuple[Location, bool]:
"""Fight through the Den of Evil."""
# Pre-buff
if do_pre_buff:
if not self._char.pre_buff():
return False
# Clear each level
for level in sorted(self.LEVEL_PATHS.keys()):
Logger.info(f"Clearing Den of Evil level {level}")
if not self._pather.traverse_nodes(
self.LEVEL_PATHS[level], self._char, timeout=120, do_combat=True
):
Logger.error(f"Failed to clear DoE level {level}")
return False
# Pick up any quest items / loot
self._pickit.pick_up_items(self._char)
QuestItems.pick_up_quest_items(self._char)
# Walk back to Roaring Canyon
if not self._pather.traverse_nodes(
(Location.A1_DEN_OF_EVIL_ENTRANCE, Location.A1_ROARING_CANYON),
self._char, force_move=True
):
return False
# Talk to Town Maiden for reward
if not self._dialogue.open_dialogue("town_maiden"):
return False
if not self._dialogue.follow_conversation(["Yes", "Thank you"]):
Logger.warning("Failed to collect DoE reward from Town Maiden")
# Mark as done (for non-repeatable quests) or just return success
# Note: DoE is repeatable once per real-day, so we DON'T mark permanently done
# self._quest_manager.mark_done("den_of_evil") # Only if non-repeatable
return (Location.A1_ROARING_CANYON, True)
```
---
## Phase 4: Bot integration
### 4.1 `src/bot.py` changes
```python
# Add import
from quest.a1.q_den_of_evil import DenOfEvil
# In __init__:
self._do_runs["run_doe"] = Config().routes.get("run_doe")
self._doe = DenOfEvil(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)
# In _states list:
# (No new state needed - DoE uses the existing pattern: town -> doe -> end_run -> town)
# In _transitions list (add):
{ 'trigger': 'run_doe', 'source': 'town', 'dest': 'doe', 'before': "on_run_doe" },
# Add 'doe' to end_run source list:
{ 'trigger': 'end_run', 'source': [..., 'doe'], 'dest': 'town', 'before': "on_end_run" },
# Add end_game source:
{ 'trigger': 'end_game', 'source': [..., 'doe'], 'dest': 'initialization', 'before': "on_end_game" },
# Add handler method:
def on_run_doe(self):
res = False
self._do_runs["run_doe"] = False
self._game_stats.update_location("DoE")
self._curr_loc = self._doe.approach(self._curr_loc, not self._pre_buffed)
if self._curr_loc:
set_pause_state(False)
res = self._doe.battle(not self._pre_buffed)
self._ending_run_helper(res)
```
### 4.2 `src/run/__init__.py` changes
```python
# No change needed if DoE lives in src/quest/ (not src/run/)
# But if we want consistency, add:
from quest.a1.q_den_of_evil import DenOfEvil
```
### 4.3 `config/params.ini` changes
```ini
[routes]
; ... existing runs ...
; run_doe (Act 1 Den of Evil - repeatable daily XP)
order=run_doe
```
### 4.4 `config/params.ini` FoHdin config
For a lvl 1 Paladin running DoE, the params.ini needs:
```ini
[char]
type=fohdin
...
[fohdin]
; FoHdin-specific config for low-level DoE runs
teleport=
; No teleport at lvl 1-9, so pathing is on foot
```
---
## Phase 5: Testing workflow
### What needs user input (I cannot see D2R):
1. **Capture Town_Maiden template:**
- Go to Roaring Canyon in Act 1
- Stand near the Town Maiden
- Run `quest_debug.py`, press F4 (NPC detection)
- Paste output so I can save the template
2. **Capture DoE path nodes:**
- Enter the Den of Evil
- Run `quest_debug.py`, press F1 at each waypoint
- Walk from entrance through each level
- Paste outputs so I can build the path arrays
3. **Capture dialogue:**
- Talk to Town Maiden (both before and after clearing)
- Run `quest_debug.py`, press F2 (dialogue OCR)
- Paste output so I can code the conversation flow
4. **Test run:**
- After I write the code, you run botty with `run_doe` in the route order
- Report what happens / paste terminal output
- I iterate based on results
### Lvl 1 Paladin specifics:
- **FoHdin requires FOH skill lvl 6 for Feign of Life passive** -- this needs 3 skill points
in FoH, meaning character level 9 minimum (or level 4 with a +1 skill weapon)
- Before reaching lvl 9, the bot can still run DoE but will be much more fragile
- Recommended: manually level Paladin to ~lvl 4-5 (short runs in Area 1 or 2) before
letting the bot solo DoE with FoH
- The bot pathing should handle the walk-through at low speed with heavy FoH spam
---
## Implementation order
1. Write `quest_manager.py` (simple JSON state tracker)
2. Write `quest_dialogue.py` (OCR-based NPC interaction)
3. Write `quest_items.py` + `quest_combat.py` (lightweight helpers)
4. Add Town_Maiden NPC to npc_manager.py
5. Write `q_den_of_evil.py` (skeleton with placeholder paths)
6. Integrate into bot.py (state, transitions, handler)
7. Update params.ini
8. **USER TESTS** -- captures templates, paths, dialogue
9. I fill in the actual path nodes and dialogue based on your captures
10. Full test run and iterate
---
## File tree after implementation
```
my-botty/
├── config/
│ ├── params.ini # Modified: +run_doe in routes
│ ├── quest_state.json # New: auto-created by QuestManager
│ └── bnip/
│ └── town_maiden.png # New: captured template
├── src/
│ ├── quest/ # New directory
│ │ ├── __init__.py
│ │ ├── quest_manager.py
│ │ ├── quest_dialogue.py
│ │ ├── quest_items.py
│ │ ├── quest_combat.py
│ │ └── a1/
│ │ ├── __init__.py
│ │ └── q_den_of_evil.py
│ ├── npc_manager.py # Modified: +TOWN_MAIDEN
│ ├── pather.py # Modified: +A1_ROARING_CANYON, +A1_DEN_* locations
│ ├── bot.py # Modified: +doe state, transitions, handler
│ └── run/__init__.py # Modified: +DenOfEvil export
```
+124 -10
View File
@@ -34,14 +34,14 @@ main.py (main thread)
### Input Layer (`src/input_layer/`)
Native Windows API input replacement (no kernel drivers):
Native Windows API input replacement designed to evade kernel-level anti-cheat (Warden). It bypasses common Python libraries like `pyautogui` or `pynput` which can be easily detected.
| File | Purpose |
|---|---|
| `win_input.py` | ctypes wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos` |
| `mouse_impl.py` | Humanized mouse with Bezier curves, Gaussian distortion, endpoint wobble |
| `hotkey.py` | Polling-based hotkey manager (replaces `keyboard.add_hotkey`) |
| `__init__.py` | Drop-in API: `from input_layer import keyboard, mouse` |
| `win_input.py` | Low-level `ctypes` wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos`. Uses standard Windows user-mode APIs. |
| `mouse_impl.py` | Humanized mouse controller. Features include: Bezier curve trajectories, Gaussian noise (hand tremor), endpoint wobble, and randomized arrival-to-click delays. |
| `hotkey.py` | Polling-based hotkey manager that avoids global hooks. All polling intervals include micro-jitter. |
| `__init__.py` | Drop-in API that shims standard input calls with stealth timing and variable duration automatically. |
All key presses include stealth micro-pauses and variable press duration automatically.
@@ -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 |
@@ -162,12 +183,15 @@ Singleton that merges config files in priority order:
Supports variable substitution via `[variables]` sections.
### Stealth (`src/utils/stealth.py`)
### Stealth System (`src/utils/stealth.py`)
Three-tier stealth system:
- **Tier 1 (Input)**: Micro-pauses, click variance, key press duration, endpoint wobble
- **Tier 2 (Behavior)**: Wrong waypoint chance, skill mistake chance, skill hesitation
- **Tier 3 (Session)**: AFK breaks, run skipping, personality seed per character
A multi-tiered approach to mimicking human behavior and evading detection:
- **Tier 1: Input Stealth**: Automatic micro-pauses (20-120ms), variable key press durations (20-200ms), and non-linear mouse paths via `input_layer`.
- **Tier 2: Behavioral Stealth**: Probabilistic "mistakes" such as clicking the wrong waypoint (2.5% chance) or skill hesitation (80-300ms) before casting.
- **Tier 3: Session Stealth**: Randomized run durations (+/-15%), AFK breaks (2-12 mins), and shuffling of farming routes between rotations.
All timing across the bot is routed through `utils.misc.wait()`, which applies Gaussian jitter to every sleep call.
### Utilities (`src/utils/`)
@@ -181,6 +205,17 @@ Three-tier stealth system:
| `node_recorder.py` | Record new path nodes |
| `stealth.py` | Stealth behavior randomization |
### Auxiliary Tools
Standalone tools located in the root directory for project maintenance and development:
- `asset_manager.py`: Unified interface for auditing, searching, and optimizing template assets.
- `asset_extractor.py`: Screenshot capture and AI-assisted entity cropping workflow.
- `build.py`: PyInstaller wrapper for building production executables.
- `desktop_snap.py`: Lightweight tool for capturing full desktop screenshots.
- `quest_debug.py`: Debugging interface for the questing system.
- `screenshot_tool.py`: Simple utility for taking D2R client area screenshots.
## Data Flow
```
@@ -219,6 +254,16 @@ D2R Game Window
At startup, `key_detector.py` reads the character's `.key`/`.keyo` file from `Saved Games/Diablo II Resurrected/` to auto-detect skill bindings and non-skill keys (inventory, show items, etc.). This eliminates manual key configuration.
Slot-to-key mapping (`CHAR_BINDING_SLOTS`):
| Slot | Config key |
|---|---|
| 41 | `show_belt` |
| 36 | `stand_still` |
| 44 | `weapon_switch` |
| 43 | `force_move` |
If params.ini and the `.keyo` file disagree, params.ini wins and a `"Keeping configured key binding"` line is logged. No disagreement = no log line.
## Coordinate Systems
| System | Origin | Used By |
@@ -229,3 +274,72 @@ At startup, `key_detector.py` reads the character's `.key`/`.keyo` file from `Sa
| Relative | Template match position | Inventory grid, NPC interaction |
Conversion functions in `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()`, etc.
## State Machine
`bot.py` uses the `transitions` library. States and transitions are defined at `Bot.__init__` time.
**States**: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
**Key transitions**:
| Trigger | Source | Dest | Notes |
|---|---|---|---|
| `init` | initialization | initialization | Screen detection, routes to create_game or start_from_town |
| `select_character` | initialization | hero_selection | |
| `start_from_town` | initialization/hero_selection | town | |
| `maintenance` | town | town | Heal, buy pots, stash, repair, resurrect merc |
| `run_pindle` | town | pindle | |
| `run_arcane` | town | arcane | |
| `end_run` | any run state | town | TP back; calls `on_end_run` which TPs to town |
| `end_game` | town/any run state | initialization | Save & exit; use when no TP scrolls or unrecoverable |
`end_run` requires working TP scrolls. If charges = 0, trigger `end_game` instead — otherwise the bot loops trying to TP back indefinitely.
## HealthManager — Internal Timing
The background health monitor (`src/health_manager.py`) polls at:
```
interval = max(0, (3/25 - fn_elapsed) * jitter(0.81.2))
```
That's approximately every 3 game frames (96144ms at 25 FPS). The jitter prevents perfectly regular polling patterns from being detectable.
**Rejuv logic**:
1. Minimum 0.60s between rejuv drinks (hit recovery guard).
2. Drinks rejuv if `health ≤ take_rejuv_potion_health` OR `mana ≤ take_rejuv_potion_mana`.
3. "Double rejuv" chicken fires only if `last_drink < 8s` **AND** `health ≤ take_rejuv_potion_health`.
- The HP check is critical: mana-triggered rejuvs can legitimately fire back-to-back at full HP (Hammerdin spending mana fast). Without the HP check, false chickens occur at 99.9% HP.
## PickIt — Item Identity
`GroundItem` has two identity fields:
| Field | Formula | Purpose |
|---|---|---|
| `ID` | `slugify(Name + all as_dict() values including Amount)` | Pickit cache key; different gold amounts = different IDs |
| `UID` | `ID + screen center position` | Deduplication within a single items list; same pile at same coords = same UID |
The fail-detection in `_pick_up_item` uses `item.ID == prev.ID` for gold and `item.UID == prev.UID` for everything else. Two nearby gold piles with different amounts bypass the ID check since they produce different IDs.
`_yoink_item()` always returns `PickedUpResult.PickedUp` regardless of actual success. True pickup failures surface only through `_pick_up_item`'s same-ID/UID repeat detection. On confirmed failure, the item's `ID` is blacklisted in `_cached_pickit_items` so it isn't retried in the same session.
## Configuration Priority
Merge order (highest priority first):
```
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
```
`Config` is a singleton (`__new__` + `data_loaded` class variable). First instantiation loads all files; subsequent calls return the same instance. Key detection runs during `__init__` after `self.char` is populated.
## Known Architectural Issues
See `IMPROVEMENTS.md` for the full list. Highest-priority unresolved items:
- **C10**: `kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API). Can corrupt locks/GIL. Replace with `threading.Event` cooperative shutdown.
- **H12/H14**: Health/death managers use module-level globals for state. HealthManager now has a `_state_lock`; death manager does not yet.
- **M14**: `PickedUpResult` enum has a gap (values 0,1,3,4,5 — missing 2).
- **H10/H11**: `pather.py` (750 lines) and `config.py` are oversized and should be split.
+1029
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
# Botty working-state dependencies (verified 2026-06-11)
Everything required for the bot to run as well as it did during the verified
full Diablo runs on 2026-06-11. If a future setup misbehaves, diff against this.
## Runtime stack
| Layer | Requirement | Verified value |
|---|---|---|
| Python env | conda env `botty` | `C:\ProgramData\miniforge3\envs\botty` (python 3.10.14) — NOTE: `C:\Users\alex\miniforge3\envs\botty` is a broken leftover (no python.exe); `find_python.bat` skips it correctly |
| OCR binary | Tesseract 5.5.0 (winget) | `C:\Program Files\Tesseract-OCR\tesseract.exe` — wired in `src/d2r_image/ocr.py` (env var `PYTESSERACT_TESSERACT_CMD` → PATH → this default). Conda tesseract must stay UNINSTALLED (access violations) |
| OCR backend | pytesseract fallback | tesserocr wheel is DLL-broken (needs Tesseract 4.x libs) — pytesseract is the working path; startup logs `OCR backend: pytesseract (fallback)` |
| Launcher | `run_botty.bat` | sets conda-like PATH, UTF-8, TESSDATA. No exe build exists — bot always runs current source |
## Key python packages (installed, working)
```
opencv-python==4.5.5.64 numpy==1.26.4 pytesseract==0.3.13
mss==7.0.1 beautifultable==1.1.0 colorama==0.4.6
discord.py==2.7.1 aiohttp==3.14.1 certifi==2026.5.20
pillow==12.2.0 rapidfuzz==2.15.1 pywin32==312
psutil==7.2.2 cryptography==48.0.1
```
## D2R requirements (template matching breaks without these)
- Settings must match `assets/d2r_settings.json` — startup warns loudly if not.
Verified in-game 2026-06-11: 1280x720 windowed, resolution scale 100, DLSS OFF,
AA OFF, AO OFF, texture HIGH, character/environment/transparency/shadow LOW.
(DLSS ON was the root cause of the CS template failures earlier that day.)
- Window: bot enforces client area at (5, 98) size 1280x720 (`enforce_d2r_window`).
- Keybinds file: `C:\Users\alex\Saved Games\Diablo II Resurrected\Fistman*.keyo`
(auto-parsed at startup). Skill hotkeys in `config/params.ini` must match the
in-game skill assignments: blessed_hammer=f1, holy_shield=f2, redemption=f3,
vigor=f4, conviction=f5, concentration=f8, teleport=b, BO=7, BC=8.
Use `tools/capture_skill_hotkeys.py` to verify/capture them from the live game.
- Char: hammerdin "fistman", Hell, CTA swap on weapon slot 2.
## Host specifics
- Windows 11 build 26200, display 1920x1200 physical at 125% scaling (1536x960
logical). D2R renders 1:1 physical. Any DPI-unaware automation (PowerShell
SetCursorPos/mouse_event) lands 1.25x off — use the bot's input_layer from the
conda env with `SetProcessDPIAware()` instead.
- Mouse mode: relative (Win11) with SetCursorPos retry fallback (`win_input.py`).
- `max_consecutive_fails=5`, `max_game_length_s=900`, maintenance timeout ~280s,
per-game WP failure budget = 2 (town_manager) are the safety nets that keep a
bad game cheap.
+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 |
+9 -19
View File
@@ -24,20 +24,9 @@ Character: FOH Paladin | Priority: Anti-cheat stealth > everything else
## CRITICAL - DO FIRST
### C1. Stealth: Consolidate all timing through centralized wait()
Many places use bare `time.sleep()` instead of `utils.misc.wait()` (which has Gaussian jitter).
Every direct sleep creates a predictable timing signature detectable by anti-cheat.
Files with bare `time.sleep()`:
- health_manager.py line 14
- chest.py
- pather.py
- game_recovery.py
- npc_manager.py
- bot.py
Fix: Replace all bare `time.sleep(n)` with `wait(n, n*1.2)` for human-like jitter.
- [x] **C1. Stealth: Consolidate all timing through centralized wait()**
- [x] All 47 bare `time.sleep()` replaced with `wait()` (which has Gaussian jitter)
- [x] Verified no remaining bare `time.sleep()` in `src/` (except inside `utils.misc.wait`)
### C2. Stealth: Add variable typing rhythm
@@ -83,11 +72,12 @@ Fix: Add per-segment timing variation in `HumanCurve` execution loop.
### C9. ~~Bug: Fix FoHdin missing PickIt (bot.py line 72)~~ ~~(DONE)~~
### C10. ~~Bug: Replace thread killing with cooperative shutdown~~
~~`utils.misc.kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API).~~
~~This can leave locks in inconsistent state, cause GIL issues, or corrupt numpy arrays.~~
~~Fix: Replace with threading.Event flags for cooperative shutdown.~~
(Still needs doing - this is the most dangerous remaining bug.)
- [x] **C10. Bug: Replace thread killing with cooperative shutdown**
- [x] `utils.misc.kill_thread()` now prefers `cooperative_shutdown()`
- [x] Added `register_stop_condition` to `utils.misc`
- [x] Centralized `wait()` and `search_and_wait()` now check for shutdown signals
- [x] `Bot`, `HealthManager`, and `DeathManager` register their stop conditions
---
+76 -4
View File
@@ -4,13 +4,49 @@
Pixelbot for Diablo 2 Resurrected. This project is for informational and educational purposes only.
## Installation (first time)
**Step 1 — Install Miniforge** (only needed once, skip if you already have conda/Miniconda)
Download and run the installer from: https://github.com/conda-forge/miniforge/releases/latest
Pick the Windows x86_64 `.exe`. Keep defaults; tick "Add to PATH" if asked.
**Step 2 — Download Botty**
Click the green **Code** button on this GitHub page → **Download ZIP**. Extract the ZIP anywhere (e.g. `C:\botty`).
**Step 3 — Install dependencies**
Double-click **`install.bat`** inside the extracted folder. It will create the `botty` conda environment and install everything. This takes a few minutes the first time.
`install.bat` detects Windows 10 vs Windows 11 automatically:
- Windows 10 uses `environment-win10.yml`, `requirements-win10.txt`, and absolute mouse input.
- Windows 11 uses `environment-win11.yml`, `requirements-win11.txt`, and relative mouse input.
Botty also runs OS detection at startup and logs the selected requirements profile and mouse mode.
**Step 4 — Configure**
Open `config\params.ini` in Notepad and set at minimum:
- `[char] type=` — your build (`sorceress`, `hammerdin`, `paladin`, `trapsin`, …)
- `[routes] order=` — which bosses to farm (e.g. `run_pindle`)
- Hotkeys under your build's section to match your D2R keybinds
**Step 5 — Start**
Double-click **`run_botty.bat`**. Switch to D2R, go to the hero selection screen, then press **F11** to start. Press **F12** to stop.
> D2R must be in **English** and running at **720p** window mode.
Optional personal setup:
- Copy `.env.example` to `.env` in the repo root and set personal values there.
- `.env` is git-ignored so multiple testers can use different local values without git conflicts.
---
## Getting started & Prerequisites
- D2R needs to be in English Language,
- Botty currently works with 720p D2R window (will be adjusted automatically on auto settings)
### 1) Graphics and Gameplay Settings
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default f9). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default **ctrl+f9**). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
**Note**: Make sure that no other external programs adapt your graphics settings (HDR, Geforce Experience, etc.)
### 2) Supported builds
@@ -23,13 +59,20 @@ Open up D2R and wait till you are at the hero selection screen. Make sure the ch
### 4) Start Botty
Refer to [development.md](development.md) for setup instructions. Once the conda environment is created:
- **Quick start**: Double-click `run_botty.bat` (auto-detects your conda env)
- **Manual**: `conda activate botty` then `python src\main.py`
After starting, focus your D2R window and press the start key (default f11). You can always force stop botty with f12. Note: Botty will use the /nopickup command in the first game to avoid pickup up trash while traversing. This command will only allow item pickup when "show items" is active.
### Stability and safety updates
- XP OCR parsing now tolerates common OCR mistakes (`I/l/| -> 1`, `O/o -> 0`, mixed-case "experience").
- XP status math no longer throws on early-session edge cases; unavailable projections show as `n/a`.
- Routine repair is best-effort for native-teleport builds to reduce fail spirals when NPC detection is flaky.
- Repair fallback from A5 now attempts A4 from Larzuk location for better path reliability.
- Discord message sending now guards invalid embed payloads and has a plain-text fallback.
- Selling now logs item names (not just positions) and shields are protected by default.
## Development
Check out the [development.md](development.md) docu for infos on how to build from source and details of the project structure and code.
@@ -99,7 +142,10 @@ order=run_pindle, run_eldritch
| [routes] | Descriptions |
| ------------ | ------------------------------------------------------------------------ |
| order | List of runs botty should do. These will be run in the the order listed unless `randomize_runs` is set to 1. Possible runs: </br> run_trav, run_pindle, run_eldritch, run_eldritch_shenk, run_nihlathak (requires teleport), run_arcane (requires teleport), run_diablo (requires teleport, only hammardin)
| order | Comma-delimited run list. If `randomize_runs=0`, Botty executes left-to-right. If `randomize_runs=1`, enabled runs are shuffled each game. Possible runs: </br> run_trav, run_pindle, run_eldritch, run_eldritch_shenk, run_nihlathak (teleport strongly recommended), run_arcane (teleport strongly recommended), run_diablo (teleport recommended), run_vizier, run_andariel, run_countess, run_mephisto, run_baal |
Hammerdin keyrun example (stable-first):
`order=run_countess, run_arcane, run_nihlathak`
| [char] | Descriptions |
| ------------------ | -------------------------------------------------------------------------------------------------|
@@ -198,6 +244,32 @@ python tools/click_recorder.py playback # replay with human-like timing
Records mouse clicks with timestamps and replays them with configurable speed and repeat count.
### Asset Manager
A comprehensive tool for managing bot assets (templates). It helps with inventorying, auditing, searching, and optimizing template images.
```bash
python asset_manager.py inventory # List all templates
python asset_manager.py audit # Check for missing/low-quality templates
python asset_manager.py search <name> # Search for a specific template
python asset_manager.py quality # Analyze template quality (SNR, contrast)
python asset_manager.py batch resize 64x64 # Batch process templates
```
Features include similarity checking (to find duplicates), automatic cropping, and validation of template paths.
### Asset Extractor
A workflow tool for capturing and cropping new templates from D2R screenshots. Designed to work alongside an AI agent for rapid asset generation.
```bash
python asset_extractor.py
```
- **F1**: Capture D2R screen to `screenshots/debug/latest.png`.
- **F2**: Crop entities using an AI-generated `latest_annotations.json` file.
- **F3**: List all currently extracted assets.
### Builds
|| [sorceress] | Descriptions |
|| ------------- | ----------------------------------------------------------------------------- |
+2 -2
View File
@@ -8,14 +8,14 @@
"Screen Resolution (Windowed)": "1280x720",
"Resolution Scale": 100,
"Sharpening": 6,
"Game Resolution": 1,
"Game Resolution": 2,
"Light Quality": 2,
"Blended Shadows": 0,
"Perspective": 0,
"VSync": 1,
"Framerate Cap": 60,
"Framerate Target": 0,
"Window Mode": 0,
"Window Mode": 1,
"Graphic Presets": 4,
"Texture Quality": 4,
"Texture Anisotropy": 0,
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

-1
View File
@@ -1 +0,0 @@
3LONE*dkbcy^v0I8
-109
View File
@@ -1,109 +0,0 @@
import os
import shutil
from pathlib import Path
from src.version import __version__
import argparse
import getpass
import random
from cryptography.fernet import Fernet
import string
parser = argparse.ArgumentParser(description="Build Botty")
parser.add_argument(
"-v" , "--version",
type=str,
help="New release version e.g. 0.4.2",
default=""
)
parser.add_argument(
"-c", "--conda_path",
type=str,
help="Path to local conda e.g. C:\\Users\\USER\\miniconda3",
default=f"C:\\Users\\{getpass.getuser()}\\miniconda3")
parser.add_argument(
"-r", "--random_name",
action='store_true',
help="Will generate a random name for the botty exe")
parser.add_argument(
"-k", "--use_key",
action='store_true',
help="Will build with encryption key")
args = parser.parse_args()
# clean up
def clean_up():
# pyinstaller
if os.path.exists("build"):
shutil.rmtree("build")
if os.path.exists("main.spec"):
os.remove("main.spec")
if os.path.exists("health_manager.spec"):
os.remove("health_manager.spec")
if os.path.exists("shopper.spec"):
os.remove("shopper.spec")
if __name__ == "__main__":
new_version_code = None
if args.version != "":
print(f"Releasing new version: {args.version}")
os.system(f"git checkout -b new-release-v{args.version}")
botty_dir = f"botty_v{args.version}"
version_code = ""
with open('src/version.py', 'r') as f:
version_code = f.read()
version_code = version_code.split("=")
new_version_code = f"{version_code[0]}= '{args.version}'"
with open('src/version.py', 'w') as f:
f.write(new_version_code)
else:
botty_dir = f"botty_v{__version__}"
print(f"Building version: {__version__}")
clean_up()
if os.path.exists(botty_dir):
for path in Path(botty_dir).glob("**/*"):
if path.is_file():
os.remove(path)
elif path.is_dir():
shutil.rmtree(path)
shutil.rmtree(botty_dir)
for exe in ["main.py", "shopper.py"]:
key_cmd = " "
if args.use_key:
key = Fernet.generate_key().decode("utf-8")
key_cmd = " --key " + key
installer_cmd = f"pyinstaller --onefile --noconsole --distpath {botty_dir}{key_cmd} --exclude-module graphviz --exclude-module keyboard --exclude-module mouse --exclude-module pyclick --exclude-module mouseinfo --paths .\\src --paths {args.conda_path}\\envs\\botty\\Lib\\site-packages src\\{exe}"
os.system(installer_cmd)
os.system(f"cd {botty_dir} && mkdir config && cd ..")
with open(f"{botty_dir}/config/custom.ini", "w") as f:
f.write("; Add parameters you want to overwrite from param.ini here")
shutil.copy("config/game.ini", f"{botty_dir}/config/")
shutil.copy("config/params.ini", f"{botty_dir}/config/")
shutil.copy("config/shop.ini", f"{botty_dir}/config/")
shutil.copy("config/default.bnip", f"{botty_dir}/config/")
os.makedirs(f"{botty_dir}/config/bnip", exist_ok=True)
shutil.copy("README.md", f"{botty_dir}/")
shutil.copytree("assets", f"{botty_dir}/assets")
clean_up()
if args.random_name:
print("Generate random names")
new_name = ''.join(random.choices(string.ascii_letters, k=random.randint(6, 14)))
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
# Rename main.exe to avoid Warden flagging the obvious name
# In CI/production builds (env BOTTY_NO_RENAME=1) keep main.exe as-is
if not args.random_name and not os.environ.get("BOTTY_NO_RENAME"):
new_name = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
print(f"Renamed main.exe -> {new_name}.exe")
if new_version_code is not None:
os.system(f'git add .')
os.system(f'git commit -m "Bump version to v{args.version}"')
+15
View File
@@ -0,0 +1,15 @@
@echo off
setlocal
set "BOTTY_DIR=%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%BOTTY_DIR%tools\check_dependencies.ps1"
set "RC=%ERRORLEVEL%"
if not "%RC%"=="0" (
echo.
echo Dependency check reported issues.
)
pause
exit /b %RC%
+23
View File
@@ -0,0 +1,23 @@
; Personal overrides for your local machine/user.
; This file is tracked as an example.
; Copy to config/custom.ini and edit values there.
;
; Botty auto-loads config/custom.ini when present.
[general]
; name=botty
; custom_message_hook=
; custom_loot_message_hook=
; discord_status_runs=10
[char]
; type=blizz_sorc
; atk_len_pindle=3.0
; show_items=alt
[sorceress]
; teleport=t
[blizz_sorc]
; blizzard=f1
; ice_blast=f2
+571 -432
View File
File diff suppressed because it is too large Load Diff
+800
View File
@@ -0,0 +1,800 @@
{
"generated_at": "2026-06-17T07:19:33.572415+00:00",
"mode": "offline",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-12",
"days": 60,
"topics_scanned": 853,
"estimates": {
"day_1": {},
"day_2": {},
"day_3": {},
"day_4": {},
"day_5": {},
"day_6": {},
"day_7": {},
"day_8": {},
"day_9": {},
"day_10": {},
"day_11": {},
"day_12": {},
"day_13": {},
"day_14": {},
"day_15": {},
"day_16": {},
"day_17": {},
"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": {}
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"notes": "Estimated Day 1-14 ladder prices derived from Day 3 snapshot using decay multipliers. Use as planning guidance, not exact market truth.",
"source_day": 3,
"day_multipliers": {
"day_1": 1.45,
"day_2": 1.2,
"day_3": 1.0,
"day_4": 0.93,
"day_5": 0.88,
"day_6": 0.84,
"day_7": 0.8,
"day_8": 0.77,
"day_9": 0.74,
"day_10": 0.72,
"day_11": 0.7,
"day_12": 0.68,
"day_13": 0.66,
"day_14": 0.64
},
"base_day_3_fg": {
"Cham Rune": 800,
"Lo Rune": 900,
"Ohm Rune": 600,
"Vex Rune": 400,
"Gul Rune": 200,
"Ist Rune": 200,
"Mal Rune": 120,
"Um Rune": 90,
"Pul Rune": 50,
"Lem Rune": 70,
"Unid Anni": 400,
"Unid Torch": 1500,
"Unid Griffon": 2000,
"Unid Eth Andy": 2000,
"Shako": 400,
"Mara 30": 1200,
"Mara Mid": 750,
"BK 5": 600,
"War Traveler": 500,
"Death's Fathom": 800,
"5/5 Facet": 500,
"5@res SC": 300,
"20life SC": 100,
"7mf SC": 100,
"Pcomb SK": 200,
"Cold SK": 250,
"Java SK": 200,
"Light SK": 200
}
}
+20 -13
View File
@@ -18,7 +18,7 @@ rejuv_potion=140,50,40,160,255,255
skill_charges=70,30,25,150,163,255
health_globe_red=178,110,20,183,255,255
health_globe_green=47,90,20,54,255,255
mana_globe=117,120,20,121,255,255
mana_globe=110,50,15,125,255,255
blue_slot=102,194,18,138,230,54
green_slot=33,181,18,87,258,69
red_slot=161,204,28,197,240,64
@@ -51,8 +51,8 @@ potion1_y=695
potion_width=30
potion_height=30
potion_next=41
merc_health_top=14
merc_health_left=15
merc_health_top=675
merc_health_left=305
merc_health_width=40
; skills
skill_y=693
@@ -88,7 +88,7 @@ play_btn=426,616,320,71
difficulty_select=536,236,210,320
gold_btn=997,521,20,18
inventory_gold=980,510,150,40
gold_btn_stash=160,520,25,25
gold_btn_stash=158,518,30,30
vendor_gold_digits=186,509,97,16
stash_gold_digits=184,527,100,16
inventory_gold_digits=1017,523,100,16
@@ -97,16 +97,16 @@ health_globe=160,580,240,140
mana_globe=887,580,240,140
health_slice=309,610,7,101
mana_slice=961,610,7,101
cut_skill_bar=0,0,1280,653
cut_skill_bar=0,0,1284,653
reduce_to_center=120,60,1040,540
search_npcs=120,0,1040,620
merc_icon=0,0,100,100
search_npcs=120,0,1042,620
merc_icon=10,9,56,56
loading_left_black=0,0,350,720
death=444,198,397,71
tp_search=353,120,547,400
repair_btn=318,473,90,80
left_inventory=35,86,378,378
right_inventory=866,348,379,152
left_inventory=33,84,382,382
right_inventory=868,348,379,152
transmute_third_slot=242,342,38,38
skill_right=664,673,41,41
skill_right_expanded=655,375,385,255
@@ -128,12 +128,19 @@ cube_btn_roi=160,368,125,57
xp_bar_text=369,630,554,34
corpse=459,195,414,213
chat_icon=7,555,43,41
left_panel_header=0,0,450,54
right_panel_header=830,0,450,54
npc_dialogue=458,4,21,140
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
left_inventory_tabs=31,62,385,28
left_inventory_tabs=29,60,389,30
tab_indicator=31,71,385,14
stash_page_select_left=155,475,20,20
stash_page_select_right=270,475,20,20
View File
+443 -65
View File
@@ -1,58 +1,166 @@
; There is detailed documentation for each parameter in the README.md
[general]
difficulty=normal
name=bigfont
; Personal values can reference env keys from repo-root .env:
; Example syntax:
; custom_message_hook=${BOTTY_CUSTOM_MESSAGE_HOOK}
; custom_loot_message_hook=${ENV:BOTTY_CUSTOM_LOOT_MESSAGE_HOOK}
; bnet_name=${BOTTY_BNET_NAME}
; bnet_pass=${BOTTY_BNET_PASS}
; char_name=${BOTTY_CHAR_NAME}
; difficulty: game difficulty to create ("normal", "nightmare", "hell")
;
; Hammerdin difficulty guide (CTA build with Conviction aura):
; NORMAL - Very easy. 1-2h Hammer kills most packs. No CTA needed.
; NIGHTMARE - Recommended starting point. Bosses hit ~100-200 dmg.
; CTA + Conviction drops resists. Need decent armor (ED/HR).
; Gear targets: ~200 AR, ~15 ED, ~30% HR, ~50% FCR on hammer.
; HELL - Bosses hit 400-800+ dmg per swing. Conviction is mandatory.
; Gear targets: ~350+ AR, ~20+ ED, ~50%+ HR, ~60%+ FCR,
; ~150% IAS on weapon. Full Rejuv belt recommended.
; 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=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)
target_tz=1
saved_games_folder=C:\Users\alex\Saved Games\Diablo II Resurrected
; saved_games_folder: optional override path to D2R Saved Games folder (blank = auto)
saved_games_folder=
; level_max_steps: max pathing steps for leveling-style routines
level_max_steps=20
; Battle.net credentials (for auto-login at launch)
; Leave empty to log in manually
bnet_name=
bnet_pass=
; Set to 1 to enable auto-login and auto-launch of D2R on startup.
; Credentials below are ONLY used when auto_login=1.
auto_login=0
bnet_name=${BOTTY_BNET_NAME}
bnet_pass=${BOTTY_BNET_PASS}
; Character name to auto-select from the character selection screen
; If empty, bot relies on the saved character template from previous sessions
char_name=Burr
char_name=${BOTTY_CHAR_NAME}
; messaging
custom_loot_message_hook=
custom_message_hook=
; custom_loot_message_hook: optional separate webhook for loot notifications
custom_loot_message_hook=${BOTTY_CUSTOM_LOOT_MESSAGE_HOOK}
; custom_message_hook: main webhook for status/death/chicken messages
custom_message_hook=${BOTTY_CUSTOM_MESSAGE_HOOK}
; discord_log_chicken: 1 = send chicken/death style notifications
discord_log_chicken=1
; discord_log_errors: 1 = send a Discord message + error screenshot every time a
; run fails (approach/battle/exception). Set to 0 to keep error screenshots on
; disk only. Can also be toggled via [discord_events] error=0.
discord_log_errors=1
; discord_status_runs: send periodic status every X completed runs (blank/0 disables)
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
; easily go 10 games without a keep-worthy drop).
pickup_drought_window=10
; message_api_type: "" disables messaging, "discord" or "generic_api"
message_api_type=discord
; breaks
break_length_m=0
max_runtime_before_break_m=0
; break_length_m: scheduled break duration in minutes (0 = disabled)
break_length_m=15
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
max_runtime_before_break_m=120
; timers / fail handling
; d2r_path: Diablo II: Resurrected install path
d2r_path=C:\Program Files (x86)\Diablo II Resurrected
; max_consecutive_fails: stop bot after this many failed runs in a row
max_consecutive_fails=5
max_game_length_s=160
; max_game_length_s: emergency timeout per run/game
; 900: a full Chaos Sanctuary clear (3 seals + bosses + loot) takes ~10 min;
; 600 force-quit a game while literally waiting for Diablo to spawn (2026-06-10).
; Genuinely stuck games are caught much earlier by max_maintenance_time_s and
; approach step timeouts.
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
; 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)
auto_downgrade_threshold=0
; if you set this field to 1, botty will attempt to restart d2 after a crash or failure
restart_d2r_when_stuck=0
restart_d2r_when_stuck=1
; hardcore: 1 enables hardcore-safe assumptions in some routines
hardcore=0
; screenshots
; info_screenshots: save screenshots for info/chicken/death events
info_screenshots=1
; error_screenshots: save a screenshot to log/screenshots/error/ every time a run
; fails (approach failure, battle failure, or an exception) so logs and visuals can
; be reviewed side by side. Falls back to info_screenshots if unset.
error_screenshots=1
; loot_screenshots: save screenshots for picked loot
loot_screenshots=0
pickit_screenshots=0
; pickit_screenshots: save screenshots for pickit debugging
pickit_screenshots=1
; recovery
; disable_run_after_failures: after this many CONSECUTIVE failures of the same run
; (e.g. run_vizier), the bot disables just that run for the rest of the session and
; keeps doing the other runs instead of stopping. A single success resets the count.
; If every run gets disabled the bot stops for investigation. Set high to effectively
; disable this behaviour.
disable_run_after_failures=5
; stash_scan_interval: scan all stash tabs and export stash_list.csv every X runs (0 disables)
stash_scan_interval=0
[discord_events]
; Fine-grained Discord event toggles (1=send, 0=disable)
; status: periodic status + generic bot messages (breaks, shop notifications, etc.)
status=1
; item_keep: send kept item notifications (loot webhooks/embeds)
item_keep=1
; death: send death notifications
death=1
; chicken: send chicken (emergency leave) notifications
chicken=1
; stash_full: send stash full notifications
stash_full=1
; gold_full: send gold full notifications
gold_full=1
; error: send run-failure notifications (message + error screenshot)
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)
reshuffle_each_rotation = 1
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
; there) — the next game then spawns at A4 where Jamella/Cain/Tyrael work,
; avoiding the A5 Malah vendor entirely (stale templates in current patch).
reshuffle_each_rotation = 0
; Probability (0-100) of randomly skipping a run each game
; 0 = never skip, 20 = skip ~1 in 5 runs
@@ -62,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
@@ -73,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)
@@ -80,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)
@@ -95,38 +217,84 @@ 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
; 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]
; Add these possible routes to "order" as a comma delimited list to run them:
; run_trav
; run_pindle
; run_eldritch
; run_eldritch_shenk
; run_nihlathak
; run_arcane
; run_diablo
; run_vizier
; Controls which farm runs Botty performs each game.
; "order" is a comma-delimited list and runs left-to-right when randomize_runs=0.
; If randomize_runs=1 (in [general]), Botty shuffles enabled runs each game.
;
; Hammerdin keyrun recommendation (stable-first):
; order=run_countess, run_arcane, run_nihlathak
;
; Route quick notes:
; run_trav (Act 3 Travincal council farm; short/high-density run)
; run_pindle (Act 5 Nihlathak temple red portal boss farm)
; run_eldritch (Act 5 Frigid Highlands Eldritch-only run)
; run_eldritch_shenk (Act 5 Eldritch then Shenk in same game)
; run_nihlathak (Act 5 Halls of Vaught, teleport strongly recommended)
; run_arcane (Act 2 Arcane Sanctuary / Summoner, teleport strongly recommended)
; run_diablo (Act 4 Chaos Sanctuary, teleport recommended)
; run_vizier (Act 4 Chaos Vizier-only route; lighter/faster than full Diablo run)
; run_andariel (Act 1 Catacombs)
; run_countess (Act 1 Forgotten Tower)
; run_mephisto (Act 3 Durance of Hate)
; run_baal (Act 5 Throne of Destruction)
order=run_pindle
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
order=run_pindle, run_diablo
[char]
; ==========================
; ==== Mandatory Fields ====
; ==========================
; These configs have to be alligned with your d2r settings and char build
type=fohdin
; type: character build profile to use (must match one section below)
; examples: blizz_sorc, hammerdin, fohdin
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
; cta_casting_frames: cast breakpoint on CTA swap (if used)
cta_casting_frames=8
; attack_frames: base attack animation timing for non-cast attacks
attack_frames=15
; cta_available: 1 if Call to Arms swap exists, else 0
cta_available=1
;Do we want to cast non-cta buffs (ex energy shield) with cta
buff_with_cta=1
@@ -136,9 +304,22 @@ 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.
;
; 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=4
; game hotkeys:
; NOTE: each key must match your in-game binding exactly
force_move=e
inventory_screen=i
potion1=1
@@ -151,9 +332,10 @@ 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=5
teleport=b
town_portal=6
; 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
@@ -165,13 +347,43 @@ stash_gold=1
use_merc=1
; Attack length for barbarians should be as high as 8-10 and even 10-12 for trav/shenk
;
; Hammerdin attack lengths (seconds of hammer spam per boss):
; atk_len_trav = 4.0 (Council of 3 - 3 council members, easy)
; atk_len_pindle = 8.0 (Pindle — Hell: 13094-16070 HP, 75% fire, 100% poison)
; Pindle stats by difficulty:
; Normal: 1064-1588 HP, 75% fire, 70% poison
; Nightmare: 4773-5859 HP, 100% poison
; Hell: 13094-16070 HP, 75% fire, 50% cold, 33% light, 100% poison
; With Conviction (-33% res), Hell Pindle = 50% fire, 17% cold, 0% light, 67% poison
; CTA adds +100% dmg to hammer. Need ~60%+ FCR and good IAS to kill in 8s.
; atk_len_nihlathak = 4.0 (Nihlathak - single boss)
; atk_len_eldritch = 3.0 (Eldritch only - single boss)
; atk_len_shenk = 4.0 (Shenk only - single boss)
; atk_len_arc = 2.5 (Summoner in Arcane - single boss)
; atk_len_diablo = 10.0 (Diablo in CS - longest single boss)
; atk_len_countess = 3.0 (Countess - single boss, easy)
; atk_len_andariel = 4.0 (Andariel - single boss)
; atk_len_mephisto = 12.0 (Mephisto - single boss, high HP)
; atk_len_baal = 10.0 (Baal - single boss)
; atk_len_baal_waves = 30.0 (Baal's spawn waves before boss)
;
; Chaos Sanctuary (Diablo run) individual fights:
; atk_len_cs_trashmobs = 2.0 (Trash packs in CS)
; atk_len_diablo_vizier = 2.0 (Vizier of Chaos - seal boss A)
; atk_len_diablo_infector = 4.0 (Infector of Souls - seal boss C)
; atk_len_diablo_deseis = 5.0 (Lord De Seis - seal boss B, hardest seal)
;
; Increase these if your hammer kills slower (low IAS/FCR).
; Decrease if your hammer kills faster (high IAS/FCR, good gear).
; If you die during a fight, the attack length is too long for your defense.
atk_len_arc=2.5
atk_len_eldritch=3.0
atk_len_nihlathak=4.0
atk_len_pindle=9.0
atk_len_pindle=8.0
atk_len_shenk=4.0
atk_len_diablo=3.0
atk_len_trav=3.0
atk_len_trav=4.0
; Boss run attack lengths (per-character defaults in kill_* methods)
; Adjust these if your build is faster/slower against these bosses
@@ -197,42 +409,107 @@ belt_mp_columns=1
belt_rejuv_columns=2
; Potion/chicken settings
take_health_potion=0.90
take_mana_potion=0.80
take_rejuv_potion_health=0.75
take_rejuv_potion_mana=0.50
heal_merc=0.7
heal_rejuv_merc=0.25
chicken=0.75
merc_chicken=0.25
take_health_potion=0.60
take_mana_potion=0.40
take_rejuv_potion_health=0.45
take_rejuv_potion_mana=0.10
heal_merc=0.70
heal_rejuv_merc=0.45
chicken=0.40
merc_chicken=0.20
; Misc.
; helps reduce accidental pickups when enabled especially on walking characters
enable_no_pickup=1
enable_no_pickup=0
; fill_shared_stash_first: 1 = prefer shared stash tabs before personal stash
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
pre_buff_every_run=0
runs_per_repair=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
; 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
sell_junk=0
; sell_junk: 1 = vendor non-keep items automatically
sell_junk=1
; 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
; (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=1
; 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
; pick_gold: 1 = pick up ground gold piles, 0 = ignore all ground gold
pick_gold=1
[transmute]
;stash tabs by priority where to put transmuted gems
stash_destination=3,2,1,0
stash_destination=0,1,2,3
; Add these possible gems to "transmute" to transmute them:
; chipped, flawed, standard, flawless
transmute=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=
;how often we want to run transmute routine(e.g. every 100 games)
transmute_every_x_game=2000
transmute_every_x_game=60
; 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
; min_rejuv_to_convert: minimum regular rejuv potions in inventory before starting conversion
min_rejuv_to_convert=6
; ===========================
; ==== Builds: Sorceress ====
; ===========================
[sorceress]
energy_shield=f3
frozen_armor=f4
energy_shield=f4
frozen_armor=f7
static_field=f5
telekinesis=f6
thunder_storm=
@@ -249,14 +526,14 @@ frozen_orb=
; blizzard must be right skill, hotkey required
blizzard=f1
; ice_blast must be left skill (hotkey optional as it shouldnt change)
ice_blast=
ice_blast=f2
[blizzorb_sorc]
; frozen orb must be left skill and preselected (no hotkey required)
;All others must be right skill and hotkey required!
blizzard=f1
glacial_spike=f2
glacial_spike=f7
[nova_sorc]
; nova must be right skill, hotkey required
@@ -273,22 +550,23 @@ hydra=f1
; ==== Builds: Paladin ====
; =========================
[paladin]
cleansing=
cleansing=f9
holy_shield=f2
redemption=f3
vigor=f4
[fohdin]
; foh must be left skill, hotkey required
blessed_hammer=
concentration=
blessed_hammer=f1
concentration=f8
conviction=f5
foh=f6
holy_bolt=f7
[hammerdin]
blessed_hammer=f4
concentration=f5
blessed_hammer=f1
concentration=f8
conviction=f5
; =========================
; ==== Builds: Warlock ====
@@ -441,27 +719,127 @@ 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
restore_settings_from_backup_key=f7
settings_backup_key=f8
auto_settings_key=f9
graphic_debugger_key=f10
; select_runs_key: open run selector UI
select_runs_key=pagedown
; restore_settings_from_backup_key: restore backed-up D2R settings
restore_settings_from_backup_key=insert
; settings_backup_key: create backup of current D2R settings
settings_backup_key=pause
; auto_settings_key: auto-apply required D2R settings
auto_settings_key=pageup
; graphic_debugger_key: toggle on-screen debug layers
graphic_debugger_key=delete
; resume_key: start/pause bot loop
resume_key=f11
; exit_key: hard stop bot
exit_key=f12
; cycle_pickit_profile_key: cycle through pickit profiles in config/pickit_profiles/
cycle_pickit_profile_key=f10
; etc.
; graphic_debugger_layer_creator: 1 = enable interactive layer creator tooling
graphic_debugger_layer_creator=0
; hwnd_window_process: process regex used to locate D2R window handle
hwnd_window_process=D2R\.exe
; hwnd_window_title: optional title regex override for window lookup
hwnd_window_title=
; launch_options: will replace <name> with setting for [general] "name" above
launch_options=-mod <name> -txt
; logg_lvl: logging verbosity (debug/info/warning/error)
logg_lvl=debug
; message_body_template: payload template for generic_api mode
message_body_template={{"content": "{msg}"}}
; message_headers: optional JSON headers for generic_api mode
message_headers=
; ocr_during_pickit: 1 = run OCR while looting (slower, more diagnostics)
ocr_during_pickit=0
;use "can_teleport_natively" or "can_teleport_with_charges" if you want to force certain behavior in case autodetection isn't working properly
override_capabilities=
override_capabilities=can_teleport_natively
; pathing_delay_factor: movement/click delay multiplier (1 fast .. 10 slow)
pathing_delay_factor=2
; If you want to control Hyper-V window from host use 0,51 here
; window_client_area_offset: x,y pixel offset for captured game client area
window_client_area_offset=0,0
[log_rotation]
; Log rotation prevents screenshot directories from filling the disk.
; Each managed directory has a max file count and max total size.
; When limits are exceeded, oldest files are deleted automatically.
; Checked every 60 seconds (to avoid I/O overhead during runs).
;
; The text log (log/log.txt) is size-capped separately: it rotates at 50 MB,
; keeps 5 zipped backups in log/archive/ (max 30 archives). Override the per-file
; cap with the BOTTY_LOG_MAX_MB environment variable (e.g. set 100 for 100 MB).
; pickit directory (log/screenshots/pickit/):
; Can grow very fast — every item scan writes a PNG + JSON.
; pickit_max_files=500 ; max files before rotation kicks in
; pickit_max_mb=500 ; max total size in MB
pickit_max_files=300
pickit_max_mb=200
; info directory (log/screenshots/info/):
; Debug screenshots for deaths, chickens, errors, etc.
; info_max_files=200 ; max files before rotation kicks in
; info_max_mb=500 ; max total size in MB
info_max_files=100
info_max_mb=200
; items directory (log/screenshots/items/):
; Loot screenshots sent to Discord.
; items_max_files=100
; items_max_mb=100
items_max_files=50
items_max_mb=50
; discord_notify_rotation: 1 = send Discord message when log rotation deletes files
discord_notify_rotation=0
+26
View File
@@ -0,0 +1,26 @@
# Shared pickit profiles (git-tracked)
One folder per pickit set, e.g. for season phases:
```
config/pickit_profiles/
season_start/ *.bnip (leveling: keep bases, gems, chipped...)
mid_season/ *.bnip
endgame/ *.bnip (GG-only filter)
```
Drop `.bnip` (or `.nip`) files in a folder; a `.nipignore` works like in
`config/bnip`. These folders ARE committed — build them once, everyone
gets them via git pull.
## Selecting a set
Per user, in your gitignored `config/profiles/<you>/profile.ini`:
```ini
[general]
pickit_profile=season_start
```
Priority: `config/profiles/<you>/pickit/` (personal set, gitignored)
> `config/pickit_profiles/<pickit_profile>/` (shared, from this folder)
> `config/bnip/` > `config/default.bnip`.
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1,4 +1,5 @@
[claws]
; shop_trap_claws: 1 = enable claw-shopping routine for trap claws from Anya/Drognan flow
; Current scoring for trap claws
; 3 traps: +12
; 2/1 traps: +8
@@ -7,17 +8,22 @@
; x weapon block: +1
; x death sentrey: +4
shop_trap_claws=0
; trap_min_score: minimum combined score required to keep a trap claw
trap_min_score=13
; shop_melee_claws: 1 = enable shopping melee claws (venom/block focused)
; Current scoring for melee claws
; 2 assa: +10
; x venom: +6
; x weapon block: +2
shop_melee_claws=0
; melee_min_score: minimum combined score required to keep a melee claw
melee_min_score=13
[gloves]
; shop_3_skills_ias_gloves: 1 = shop +3 skill tree + IAS gloves
shop_3_skills_ias_gloves=1
; shop_2_skills_ias_gloves: 1 = shop +2 skill tree + IAS gloves
shop_2_skills_ias_gloves=0
;
@@ -29,6 +35,9 @@ shop_2_skills_ias_gloves=0
; apply_pather_adjustment - alternative option that applies an adjustment to movements.
; Should not need this. Try it if you have trouble when it is off.
[scepters]
; shop_hammerdin_scepters: 1 = enable Hammerdin scepter shopping route
shop_hammerdin_scepters=1
; speed_factor: movement compensation for FRW and path timing during shopping loop
speed_factor=0.25
apply_pather_adjustment=0
; apply_pather_adjustment: optional alternate node adjustment if default route misses NPC/shop spots
apply_pather_adjustment=0
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
Binary file not shown.
+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.
+74
View File
@@ -0,0 +1,74 @@
# BNIP Guide
This guide explains how to edit `config/default.bnip` safely and predictably.
## What BNIP Does
BNIP rules decide which items Botty keeps.
Each line is a filter expression evaluated against detected item data.
## Rule Shape
Typical rule format:
```text
[Name] == Ring && [Quality] == Rare # [Fcr] >= 10 && [Allres] >= 15
```
- Left side (`[Name]`, `[Type]`, `[Quality]`, etc.) narrows item identity.
- Right side after `#` checks stats/rolls.
## Enable or Disable Rules
- Enabled: line starts with `[...`
- Disabled: line starts with `//`
Example:
```text
//[Name] == Lemrune
[Name] == Pulrune
```
## Safe Editing Workflow
1. Copy an existing nearby rule.
2. Keep your new rule commented out initially (`//`).
3. Enable one new rule at a time.
4. Run a few games and verify behavior before adding more.
## Rule Ordering
BNIP files are easier to maintain when ordered from specific to broad.
- Put strict high-value rules first.
- Put broad catch-all rules later.
- Avoid duplicated broad rules in multiple sections.
## Common Fields You Will Use
- `[Name]`
- `[Type]`
- `[Quality]`
- `[Flag]` (for ethereal/sockets behavior)
- Stat aliases like `[Fcr]`, `[Allres]`, `[Enhanceddefense]`, `[Enhanceddamage]`
## Troubleshooting
If a desired item is not kept:
1. Confirm the rule is enabled (no `//`).
2. Relax one condition at a time.
3. Check for typos in stat aliases.
4. Ensure no local BNIP file in `config/bnip/` is overriding expectations.
If too much junk is kept:
1. Tighten broad rules.
2. Disable catch-all rules first.
3. Add stricter stat thresholds.
## Recommended Local Customization
Keep `config/default.bnip` as the team baseline.
Put personal experiments in separate local `.bnip` files under `config/bnip/` and test there first.
+142
View File
@@ -0,0 +1,142 @@
# Broken Runs - Setup Guide
## Current Status
All 4 boss runs (Countess, Andariel, Mephisto, Baal) have **code structure in place** but require **path recording** before they can run.
| Run | Code | Guards | Path Coords | Templates | Walking Fallback |
|---|---|---|---|---|---|
| Countess | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1000-1004 |
| Andariel | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1010-1012 |
| Mephisto | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1020-1021 |
| Baal | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1030-1031 |
## What's Done
- **Run classes**: `countess.py`, `andariel.py`, `mephisto.py`, `baal.py` - all wired in `bot.py`
- **Kill methods**: `kill_countess()`, `kill_andariel()`, `kill_mephisto()`, `kill_baal()`, `kill_baal_waves()` - implemented in Hammerdin/FoHdin/Warlock
- **Path guards**: All 4 runs check for `(0,0)` paths and refuse to run with a clear error message
- **Walking fallback**: `pather.py` has node definitions (1000-1031) and path routes for walking fallback
- **Template dirs**: `assets/templates/countess/`, `andariel/`, `mephisto/`, `baal/` created (empty)
## What's Needed to Enable Each Run
### Option A: Teleport (faster, recommended)
Record path coordinates in `config/game.ini` using `node_recorder.py`:
```bash
cd C:\Users\alex\Downloads\my-botty
python src/utils/node_recorder.py
```
Then follow the on-screen instructions:
- **F8**: Capture template ROI (top-left, then bottom-right) -> saves PNG
- **F9**: Record node position at cursor -> generates path coordinates
- **F10**: Update all nodes with visible templates
### Option B: Walking (slower, needs templates + nodes)
Same as above, but also requires creating template PNGs for each landmark.
---
## 1. COUNTESS (Act 1 Forgotten Tower)
**game.ini keys to record (5):**
| Key | What to Record |
|---|---|
| `a1_tower_level2_enter` | First click inside tower after entering from Black Marsh |
| `a1_tower_level3_enter` | Top of stairs from L2 to L3 |
| `a1_tower_level4_enter` | Top of stairs from L3 to L4 |
| `a1_tower_level5_enter` | Top of stairs from L4 to L5 |
| `a1_countess_safe_dist` | Position near Countess at safe hammer range |
**Templates for walking fallback (in `assets/templates/countess/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `countess_tower_l2.png` | COUNTESS_TOWER_L2 | Tower L2 entrance |
| `countess_tower_l3.png` | COUNTESS_TOWER_L3 | Stairs L2->L3 |
| `countess_tower_l4.png` | COUNTESS_TOWER_L4 | Stairs L3->L4 |
| `countess_tower_l5.png` | COUNTESS_TOWER_L5 | Stairs L4->L5 |
| `countess_boss.png` | COUNTESS_BOSS | Countess boss area |
**Nodes in pather.py:** 1000-1004 (placeholder coords `(0,0)` - update after recording)
---
## 2. ANDARIEL (Act 1 Catacombs)
**game.ini keys to record (3):**
| Key | What to Record |
|---|---|
| `a1_andy_level3_enter` | Catacombs L3 entrance (from L2) |
| `a1_andy_level4_enter` | Catacombs L4 entrance (from L3) |
| `a1_andy_safe_dist` | Position near Andariel's cage at safe hammer range |
**Templates for walking fallback (in `assets/templates/andariel/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `andy_l3_stairs.png` | ANDY_L3_STAIRS | Stairs from L2 to L3 |
| `andy_l4_stairs.png` | ANDY_L4_STAIRS | Stairs from L3 to L4 |
| `andy_cage.png` | ANDY_CAGE | Andariel cage area |
**Nodes in pather.py:** 1010-1012 (placeholder coords `(0,0)` - update after recording)
---
## 3. MEPHISTO (Act 3 Durance of Hate)
**game.ini keys to record (2):**
| Key | What to Record |
|---|---|
| `a3_meph_level3_enter` | Durance of Hate L3 entrance (from L2) |
| `a3_meph_safe_dist` | Position near Mephisto's cage at safe hammer range |
**Templates for walking fallback (in `assets/templates/mephisto/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `meph_l3_stairs.png` | MEPH_L3_STAIRS | Stairs from L2 to L3 |
| `meph_cage.png` | MEPH_CAGE | Mephisto cage area |
**Nodes in pather.py:** 1020-1021 (placeholder coords `(0,0)` - update after recording)
---
## 4. BAAL (Act 5 Throne of Destruction)
**game.ini keys to record (2):**
| Key | What to Record |
|---|---|
| `a5_baal_throne_entry` | Throne of Destruction entrance (from Worldstone Keep L2) |
| `a5_baal_safe_dist` | Position near Baal at safe hammer range (after wave clear) |
**Templates for walking fallback (in `assets/templates/baal/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `baal_throne_entry.png` | BAAL_THRONE_ENTRY | Entrance to Throne area |
| `baal_arena.png` | BAAL_ARENA | Baal arena / boss position |
**Nodes in pather.py:** 1030-1031 (placeholder coords `(0,0)` - update after recording)
---
## Quick Start: Record Paths for One Run
1. Launch D2R and navigate to the boss area
2. Run `python src/utils/node_recorder.py` from the botty directory
3. Enter the run name when prompted (e.g., `countess`)
4. Position your cursor at the target location in-game
5. Press F8 to capture a template, F9 to record a node
6. Press F10 to update all nodes with visible templates
7. Copy the generated path coordinates to `config/game.ini`
8. Copy the generated template PNGs to `assets/templates/<run>/`
9. Update `pather.py` node definitions with real coordinates from the recorder output
+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.).
+18
View File
@@ -0,0 +1,18 @@
# D2R Window and Input Troubleshooting
Botty expects Diablo II: Resurrected to expose a stable 1280x720 client area. If
the live D2R window reports a slightly different client size, template matching
can still succeed while mouse clicks land at the wrong monitor coordinate.
At startup and before creating a game, Botty now resizes and positions the D2R
client area to the configured `config/game.ini` dimensions. The expected size is
`1280x720`, matching `assets/d2r_settings.json` and the template assets.
If a UI click is detected but the cursor does not visibly move, the native input
layer first tries `SendInput` and then verifies the cursor position. When
`SendInput` misses the target coordinate, Botty falls back to `SetCursorPos` and
logs the fallback.
If movement or clicks still do not reach D2R, check that D2R and the bot process
are running at the same privilege level. Windows can block input from a
non-admin process into an elevated game window.
+41
View File
@@ -0,0 +1,41 @@
# Diablo Waypoint Recovery Notes
Date: 2026-06-07
## What changed
- Diablo is now first in the configured run order, followed by Pindle only.
- After using the Act 4 River of Flame waypoint, the Diablo approach now closes the waypoint panel, resets stale health-manager panel detections, and verifies that River of Flame templates are visible.
- If the first River of Flame waypoint attempt leaves the bot in town, the approach retries from the Act 4 town start before failing the run.
- River of Flame and Pentagram traversal now include template-based verification before continuing into Chaos Sanctuary logic.
- Diablo now has a reusable `_search_and_log()` wrapper for template searches that can report match confidence during route debugging.
- Health manager reset now clears `_count_panel_detects` so detections from a previous game do not immediately chicken the next game.
- `config/fg_daily_estimates.json` was regenerated with the improved offline estimator output, including skipped-topic counts and trimmed price statistics.
## Why
Same-act waypoint use can leave the waypoint panel open without a loading screen. That stale panel could combine with the CTA weapon-swap panel during pre-buffing and trigger the health manager panel-detection chicken path. The bot could also silently remain in town after a missed waypoint click and continue as if it had reached River of Flame.
The new checks make Diablo startup state-based: the bot confirms River of Flame and Pentagram markers before continuing. Failed waypoint transitions are retried once, then reported as approach failures instead of drifting into later pathing.
## Validation
Run a focused route order:
```ini
order=run_diablo, run_pindle
```
Expected log behavior:
- `_verify_in_rof: confirmed in River of Flame ...` after the Act 4 waypoint.
- `CS: Calibrated at PENTAGRAM` after Pentagram traversal.
- No immediate chicken caused by the waypoint panel plus CTA weapon-swap panel sequence.
If River of Flame or Pentagram verification fails repeatedly, refresh the affected templates:
- `DIABLO_ROF_WP_0`
- `DIABLO_ROF_WP_1`
- `DIABLO_ENTRANCE_50` through `DIABLO_ENTRANCE_55`
- `DIA_NEW_PENT_TP`
- `DIA_NEW_PENT_0` through `DIA_NEW_PENT_2`
+44
View File
@@ -0,0 +1,44 @@
# FG Market Scraper (Day 1-14)
This tool estimates FG prices per ladder day by scraping public trade topics from:
- https://forums.d2jsp.org/forum.php?f=271
## Script
- `tools/fg_market_scraper.py`
## What it does
1. Scans forum listing pages for topic links.
2. Fetches topic pages (rate-limited).
3. Detects known item/rune keywords.
4. Extracts `fg` prices from post text.
5. Buckets prices by ladder day index (Day 1..Day N).
6. Writes median estimates + sample counts.
## Usage
From repo root:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-23 --days 14
```
Output:
- `config/fg_daily_estimates.json`
## Tuning
- `--max-forum-pages`: listing pages to scan (default 40)
- `--max-topics`: hard cap on fetched topics (default 800)
- `--delay-s`: delay between requests (default 0.35s)
Example heavier run:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-23 --days 14 --max-forum-pages 120 --max-topics 2400 --delay-s 0.5
```
## Notes
- This is a heuristic estimator, not a full market engine.
- Accuracy depends on post format quality and keyword matches.
- Keep request rate polite to avoid stressing the forum.
- Some environments/IPs will receive HTTP `403` from d2jsp. In that case use:
- `config/fg_day_estimates.json` (Day 1-14 estimated multiplier model from Day 3 snapshot),
- and update `config/fg_prices.json` manually from your current market sample.
+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.
+87
View File
@@ -0,0 +1,87 @@
# GEMS-tab transmute flow (rewritten in commit 48d8445)
`src/transmute/transmute.py``Transmute.convert_all_gems()` and helpers.
## What changed and why
The old flow drove gem upgrades through the **Horadric Cube UI**: it required the cube to live in
the PERSONAL stash tab, opened the cube, ctrl+shift+right-clicked 3 gems into it, clicked the cube's
Transmute button, then pulled the result back out. This was fragile — it depended on cube placement,
cube-open detection (`ScreenObjects.CubeOpened` via `wait_until_visible`), and a pre-flight
"empty the cube" pass.
The rewrite uses D2R's **native GEMS-tab convert panel** instead of the cube. Gems are moved
directly between the GEMS stack grid and the convert panel, and conversion uses the GEMS tab's own
convert button. The cube is no longer required to be in the stash and, if present in the character
inventory, is left untouched.
## Fixed stack-coordinate grid
`GEMS_TAB_STACK_COORDS` is a new module-level dict mapping every gem template
(`INVENTORY_<FAMILY>_<TIER>`) to a fixed `(x, y)` **screen** coordinate at 1280×720. The grid is:
- **Columns by family** (x): diamond 85, emerald 132, ruby 180, topaz 227, amethyst 275,
sapphire 322, skull 370.
- **Rows by tier** (y): chipped 154, flawed 195, standard 234, flawless 273, perfect 311.
Because gem stacks always render at these fixed slots on the GEMS tab, the flow can click them by
coordinate instead of template-searching for them each time. `_gems_stack_monitor_for(template)`
converts the screen coord to monitor coords (returns `None` if the template isn't in the grid, so
the caller can fall back to a template search).
## Panel constants (1280×720)
| Constant | Value | Meaning |
|----------|-------|---------|
| `GEMS_TAB_X` | `223` | GEMS tab click X (was `200`) |
| `GEMS_CONVERT_BUTTON` | `(225, 500)` | native convert button in the GEMS panel |
| `GEMS_CONVERT_PANEL_ROI` | `(160, 296, 128, 160)` | ROI to search for the converted result |
| `GEMS_CONVERT_FIRST_SLOT` | `(181, 318)` | fallback slot to grab the result if the search misses |
## Per-transmute flow (new)
For each gem type/tier with enough gems to convert:
1. **Switch to GEMS tab.**
2. **Load 3 gems**`_ctrl_shift_left_click_monitor` the stack slot ×3 to move 3 gems into the
convert panel. If exactly 3 weren't loaded, abort that batch.
3. **Convert**`_click_gems_tab_convert_button()` clicks the panel's native convert button.
4. **Recover the result** — template-search `gems_out` within `GEMS_CONVERT_PANEL_ROI`; if found,
ctrl+shift+left-click it back onto the GEMS tab. If not found, fall back to ctrl+shift+left-click
on `GEMS_CONVERT_FIRST_SLOT` (instead of the old behavior of clearing the cube and bailing).
5. **Leave the inventory cube untouched** and return to the GEMS tab for the next iteration.
The old pre-flight "open cube → empty 12 cube slots → reopen stash" pass and the per-iteration
"close cube / reopen stash" steps are **removed**.
## Helper changes
- `_open_cube_from_stash()` / `_open_cube_from_gems_tab()` / `_open_available_cube_for_gems_tab()`
now operate from the **GEMS** tab rather than PERSONAL, and can open the cube from character
inventory (`_open_cube_from_inventory`) when the GEMS tab is active. These remain for older
inventory flows; the new gem flow doesn't need them.
- `_locate_cube()` can now return `'opened'` (cube UI already open on the GEMS tab) in addition to
`'stash'` / `'inventory'`.
- `_ensure_cube_available()` now treats `inventory` as usable ("it will stay there") instead of an
error — the cube no longer has to be moved to PERSONAL.
- `_ensure_cube_in_stash()` dropped the loot-column safety guard: it now ctrl+clicks any positive
`CubeInventory` match, so the cube can be moved from reserved inventory columns too.
- **Keyboard modifier handling** switched from the low-level
`win_input.key_down/key_up(_get_vk(...))` to `keyboard.send(..., do_release=False)` /
`keyboard.release(...)` wrapped in `try/finally`, so ctrl+shift are always released even if the
click raises. Applies to both `_ctrl_shift_click_monitor` (right) and
`_ctrl_shift_left_click_monitor` (left).
- `_empty_cube_to_gems_tab()` now uses `_ctrl_click_monitor` (plain ctrl) rather than ctrl+shift.
## Gem counting (`_count_gems_by_ocr`)
Counting no longer template-searches the left-inventory ROI for each gem. It now reads each fixed
slot from `GEMS_TAB_STACK_COORDS`: crop the slot icon, and **skip empty slots** by checking the
95th-percentile grayscale brightness (`< 45` ⇒ no gem present). Occupied slots still OCR the count
badge in the bottom-right quadrant. This is faster and immune to template-match drift, at the cost
of depending on the fixed 1280×720 grid.
## Resolution assumption
The whole flow is hard-coded to **1280×720**. All coordinates above are screen coordinates at that
resolution, converted to monitor coordinates at click time via `convert_screen_to_monitor`.
+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.
-258
View File
@@ -1,258 +0,0 @@
# Anti-Detection Framework for Botty-Go
## Overview
This document outlines the multi-layered anti-detection system built into botty-go.
Each layer addresses a specific detection vector that Blizzard and modern anti-cheat
systems use to identify bots.
---
## 1. Server-Side Behavior Analysis Countermeasures
### Detection: Session length, timing consistency, pathing patterns, repetition
### Countermeasures:
#### 1a. Variable Session Scheduling
- **Implementation:** `internal/schedule/scheduler.go`
- Randomized session start times using a circadian model
- Simulated human sleep patterns: 6-10 hour breaks between sessions
- Weekend/weekday behavior variance (humans play differently on weekends)
- Random session lengths: 20min to 6hours with exponential distribution
- Occasional "just 5 more minutes" overtime and "I'm tired" early stops
#### 1b. Stochastic Pathing
- **Implementation:** `internal/pather/stochastic.go`
- Add deliberate pathing imperfection: 5-15% deviation from optimal route
- Occasional wrong-way teleports followed by course correction
- Non-optimal waypoint selections (humants don't always take shortest path)
- Variable route ordering with cooldown-dependent choices
- 2-3% chance of "getting lost" and using wrong waypoint first
#### 1c. Skill Rotation Variance
- **Implementation:** `internal/char/behavior.go`
- Variable pre-buff timing (humans rush sometimes, sometimes take time)
- Occasional wrong skill selection followed by correction
- Potion usage with human-like hesitation (check multiple times before drinking)
- Merc healing variance: sometimes forget, sometimes over-heal
#### 1d. Route Randomization with Context
- **Implementation:** `internal/bot/route_planner.go`
- Dynamic route selection based on:
- Time since last run of each type
- Current TP scroll count (humans adapt)
- Gem/transmute urgency
- Occasional "feels like it" switches
- Never perfect round-robin; use weighted probability with drift
#### 1e. Farming Repetition Masking
- Never run the same route more than 8 times consecutively
- Insert "town breaks": stash visit, shrine check, repair, gamble
- 1-2% chance of "I'm bored, switching to different run" mid-session
- Vary kill strategies: sometimes rush, sometimes methodical
---
## 2. Warden / Client Integrity Countermeasures
### Detection: Loaded modules, injected DLLs, memory signatures, debuggers
### Countermeasures:
#### 2a. Pixel-Only Architecture (No Memory Access)
- **Implementation:** entire bot reads game state ONLY via screenshots
- NO memory reading, NO DLL injection, NO process hooking
- Same attack surface as a human with a camera pointed at the screen
- This is the #1 defense: if you only use screen capture + input simulation,
there's nothing to scan in process memory
#### 2b. Clean Process Environment
- **Implementation:** `internal/runtime/clean_env.go`
- Standard Go binary with no suspicious imports
- No debuggers, no memory readers, no process manipulation
- Run as a normal application, not injected
#### 2c. Overlay Avoidance
- Never draw on top of game window
- No window hooking or injection
- Screenshot from a separate thread, not an overlay
---
## 3. Input Pattern Analysis Countermeasures
### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections
### Countermeasures:
#### 3a. Human Motor Model
- **Implementation:** `internal/mouse/human_model.go`
- Full biomechanical mouse model based on Fitts' Law and human motion studies
- Real human mouse data characteristics:
- Multi-segment movement with micro-pauses (1-3 segments per motion)
- Acceleration curve: start slow, peak in middle, decelerate into target
- Endpoint micro-adjustments: 2-5 pixel wobble before click
- Inter-trial variability: each movement is unique even to same target
- Asymmetric error distribution: overshoot more right/down (human bias)
#### 3b. Click Timing Model
- **Implementation:** `internal/mouse/click_model.go`
- Variable time between "arriving" at target and clicking: 50ms-800ms
- Pressure curve: humans don't click at exact same speed
- Double-click rate varies naturally
- Occasional misses: 0.5-1% of clicks land slightly off (1-3px)
#### 3c. Keyboard Behavior Model
- **Implementation:** `internal/keyboard/human_model.go`
- Key press duration variance: not all keypresses are identical
- Typing rhythm for skill hotkeys: natural cadence with micro-pauses
- Occasional key repeat (holding too long = rapid fire)
- Realistic key-up/key-down timing ratios
#### 3d. Statistical Indistinguishability
- **Implementation:** `internal/input/stats.go`
- All input streams modeled from real human motion capture data
- Entropy analysis of output matches human baselines
- Auto-calibration: measure user's own input if they do manual play
- Periodically inject "manual-looking" variance spikes
---
## 4. Economy and Item-Flow Countermeasures
### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior
### Countermeasures:
#### 4a. Natural Accumulation Rate
- **Implementation:** `internal/inventory/economy.go`
- Vary farming intensity: some sessions heavy, some light
- Match accumulation to stated playtime (more sessions = more loot)
- Occasionally "waste" items on gambling/repairs like a real player
#### 4b. Realistic Trading Patterns
- No mass item funneling
- If trading, do it in human-sized batches with natural pauses
- Vary trade partners and timing
#### 4c. Rune Farming Variance
- Don't farm the same runes every session
- Match rune acquisition to character progression
- Occasionally skip rune picks when "full"
---
## 5. Ban Wave Defense
### Detection: Delayed batch bans
### Countermeasures:
#### 5a. Graceful Degradation
- **Implementation:** `internal/runtime/safe_mode.go`
- If one account gets banned, immediately reduce intensity across all
- Auto-pause farming for 48-72 hours (simulating "taking a break")
- Gradual return with reduced session lengths
- Change behavior patterns after any ban event
#### 5b. Account Diversity
- Each account has distinct "personality":
- Different session timing preferences
- Different route preferences
- Different response timing distributions
- Different play styles (rusher vs methodical)
---
## 6. Server Authority Countermeasures
### Detection: Server-side validation of movement, drops, combat, inventory
### Countermeasures:
#### 6a. Server-Authoritative Behavior
- **Implementation:** `internal/bot/server_aware.go`
- Only interact with what the server actually shows
- Wait for server confirmation before acting (e.g., confirm item picked up)
- Respect server-enforced movement limits (no speed hacks)
- Process drops in game-authorized order
#### 6b. No Client Manipulation
- Never try to spoof packets, modify client, or exploit desync
- Purely reactive: see screen -> decide -> act -> wait for response
---
## 7. Social/Reporting System Countermeasures
### Detection: Player reports + telemetry correlation
### Countermeasures:
#### 7a. Social Stealth
- **Implementation:** `internal/social/stealth.go`
- Play during off-peak hours less suspiciously
- Avoid solo-public routes that attract attention
- Occasionally join other players' games (with reduced automation)
- Inherit human-like chat behavior if configured
---
## 8. Hardware/Identity Correlation Countermeasures
### Detection: IP patterns, hardware fingerprints, VMs, account clusters
### Countermeasures:
#### 8a. Clean Deployment
- **Implementation:** `internal/deploy/clean.go`
- Run on real hardware, not VMs
- Use residential IP, not datacenter
- One account per hardware profile
- No VPN/proxy during play sessions
---
## Implementation Architecture
```
internal/
├── input/ # Human-like input generation
│ ├── mouse_model.go # Fitts' Law mouse movement
│ ├── click_model.go # Human click timing
│ ├── keyboard_model.go # Keyboard behavior
│ └── stats.go # Statistical verification
├── behavior/ # High-level human behavior simulation
│ ├── scheduler.go # Session scheduling
│ ├── route_planner.go # Dynamic route selection
│ ├── fatigue.go # Simulated fatigue/boredom
│ └── personality.go # Per-account personality
├── economy/ # Economic behavior masking
│ ├── accumulation.go # Natural loot accumulation
│ └── trading.go # Human-like trading patterns
├── safe_mode/ # Graceful degradation
│ ├── detection.go # Ban wave detection
│ └── cooldown.go # Auto-pause and return
└── deploy/ # Clean deployment helpers
└── check.go # Pre-flight integrity checks
```
## Key Design Principles
1. **Statistical indistinguishability:** Output must be statistically
indistinguishable from real human input. We use actual human motion
capture data distributions, not made-up random numbers.
2. **Controlled imperfection:** A human is inefficient, forgetful, and
inconsistent. The bot should be too — but in a way that matches
real human distributions.
3. **No single fingerprint:** Every instance should have unique enough
characteristics that correlating two accounts is hard.
4. **Adaptability:** If behavior changes are detected, the system should
be able to recalibrate based on new data.
5. **Defense in depth:** No single countermeasure is sufficient. The
combination across all layers is what provides real protection.
-33
View File
@@ -1,33 +0,0 @@
# Botty-Go
D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows).
Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite
in Go that maintains compatibility with the same config files, templates, and run
logic while adding native Linux support.
## Features
- Cross-platform: Linux (X11/Wayland) and Windows
- Same config format as original Botty (params.ini, game.ini, shop.ini)
- Template matching with OpenCV Go bindings
- Tesseract OCR for item identification
- Human-like mouse movement (Bezier curves)
- BNIP pickit language
- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.)
- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo)
## Building
```bash
# Linux
go build -o botty ./cmd/botty
# Windows (from Linux with cross-compile)
GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty
```
## Configuration
Copy `config/` from the original Botty project. Params, routes, and character
config work identically.
-19
View File
@@ -1,19 +0,0 @@
# Legacy: Go Rewrite Design Notes
These docs are archived from an abandoned `~/git/botty-go` directory (May 2026).
That project was a planned ground-up Go rewrite of `johannes-do/botty` for
cross-platform (Linux + Windows) support. Only design docs existed — no `.go`
source was ever written.
The Python `my-botty` project (this repo) is the active path. These docs are
kept here as **reference material**, primarily for Milestone 2 (anti-detection /
stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`.
## Files
- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers
server-side behavior analysis countermeasures (session scheduling, stochastic
pathing, skill rotation variance) and more. Directly applicable as the design
basis for the Python stealth layer.
- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project.
Context only — explains feature scope and what the rewrite was aiming for.
+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.
+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>
+112
View File
@@ -0,0 +1,112 @@
# Recovery & Error Logging
This document describes the failure-recovery and diagnostics features that let the
bot survive broken runs and makes every failure reviewable after the fact.
## 1. Comprehensive failure logging
Every town action (`src/town/a1.py``a5.py`) and every run `approach()` method
(`src/run/*.py`) now logs the **specific step that failed** via `Logger.error`
instead of silently returning `False`. Examples of what now gets logged:
- `A1 resurrect: open_npc_menu(KASHYA) failed — NPC not found or menu did not open`
- `A4 open_wp: waypoint template A4_WP/A4_WP_2 not found — WP may be obscured or template mismatch`
- `Vizier approach: use_wp('River of Flame') failed`
- `Mephisto approach: go_to_act(3) failed`
- `gamble: go_to_act(4) failed from A1_TOWN_START — cannot reach Jamella`
Town-manager fallbacks (`resurrect`, `identify`, `open_stash`, `gamble`, `stash`,
`heal`) also log when their `go_to_act` recovery hop fails, so you can tell whether
the problem was reaching the NPC, opening the menu, pressing the button, or the
panel never opening.
This means that "if Cain is missing, if the WP is not enabled, if some other game
factor is not in place" the reason ends up in the log.
## 2. Error screenshots
When a run fails, the bot saves a screenshot so logs and visuals can be compared.
- **Where:** `log/screenshots/error/`
- **Filename:**
`error_<run_name>_<reason>_g<game#>_r<run#>_<YYYYMMDD_HHMMSS>.png`
e.g. `error_run_vizier_approach_failed_g12_r47_20260607_143501.png`
- **When:** captured on three failure types inside `Bot._run_wrapper`:
- `approach_failed` — couldn't reach the boss
- `battle_failed` — boss fight didn't complete
- `exception_<ExceptionType>` — an uncaught exception during battle
- The `g<game#>_r<run#>` in the filename matches the `game`/`run` fields in the
structured event log (`log/stats/events_*.jsonl`) and the `Logger` output, so a
screenshot can be lined up with its log lines.
### Discord delivery
When a run fails the bot also sends the **error message and the screenshot** to
Discord (as a red embed with the screenshot attached). For the generic webhook API
a text-only message is sent (the screenshot still lands on disk). This is on by
default and can be turned off two ways:
- `[general] discord_log_errors=0` — master switch for error → Discord
- `[discord_events] error=0` — fine-grained event toggle (Discord API only)
### Config
`config/params.ini``[general]`:
```ini
; save a screenshot every time a run fails (falls back to info_screenshots if unset)
error_screenshots=1
; also send the error message + screenshot to Discord on each run failure
discord_log_errors=1
```
`config/params.ini``[discord_events]`:
```ini
; send run-failure notifications (message + error screenshot)
error=1
```
Set `error_screenshots=0` to turn off error screenshots entirely. Old screenshots
are size-rotated using the `info_*` limits in `[log_rotation]` (or optional
`error_max_files` / `error_max_mb` if you add them).
## 3. Auto-disable broken runs (recovery)
Previously, repeated failures of one run could stall the whole bot. Now each run
has its own **consecutive failure counter**:
- Every failed attempt of a run increments its counter; a single success resets it.
- When a run reaches the threshold of consecutive failures, it is **disabled for
the rest of the session**. The bot keeps doing the other runs instead of stopping.
- Disabling is logged and sent to Discord (if enabled):
`DISABLING run_vizier for this session: it failed 5 consecutive times. …`
- The disable is **session-only / in-memory** — it does not edit `params.ini`.
Restarting the bot re-enables the run. This is intentional so a transient problem
(e.g. a game hiccup) doesn't permanently change your config.
- If **all** runs end up disabled there are no routes left, so the bot saves a
session report and stops cleanly (kills the game and exits) rather than looping
over empty games or pointlessly restarting D2R.
### Config
`config/params.ini``[general]`:
```ini
; after this many CONSECUTIVE failures of the same run, disable just that run
; for the rest of the session and keep doing the others. A success resets the count.
disable_run_after_failures=5
```
Set this high (e.g. `9999`) to effectively disable the auto-disable behaviour.
## Implementation notes
- `src/bot.py`
- `__init__`: `_run_failure_counts`, `_disabled_runs`, `_max_run_failures`
- `_record_run_result(run_name, failed)`: counts failures, disables runs, resets on success
- `_save_error_screenshot(run_name, reason)`: writes the diagnostic screenshot
- `_run_wrapper(...)`: calls both on each failure path (approach / battle / exception)
- `src/config.py`: parses `error_screenshots` and `disable_run_after_failures`
(both via `_select_optional`, so existing configs keep working).
- `src/utils/log_rotation.py`: routes the `error/` directory through rotation.
+71
View File
@@ -0,0 +1,71 @@
# Run Countess Route Plan
## Goal
Record path data and templates for the Countess run (Act 1 Forgotten Tower) so it executes reliably with Hammerdin.
## Current State
- `src/run/countess.py` exists and is wired into bot.py
- `Location` constants defined in `pather.py`: A1_TOWER_LEVEL2_ENTER through A1_TOWER_LEVEL5_ENTER, A1_COUNTESS_SAFE_DIST
- `config/game.ini` has 5 path entries — all set to `0,0` (placeholders)
- Run is disabled in `params.ini` (`order=run_arcane, run_pindle`)
## Paths Needed (5 entries in game.ini)
| Config Key | Description | How to Record |
|---|---|---|
| `a1_tower_level2_enter` | First click point inside tower after entering from Black Marsh | Walk into tower, record first position on L2 |
| `a1_tower_level3_enter` | Click point at top of stairs to L3 | Walk up stairs, record position at L3 entrance |
| `a1_tower_level4_enter` | Click point at top of stairs to L4 | Walk up stairs, record position at L4 entrance |
| `a1_tower_level5_enter` | Click point at top of stairs to L5 | Walk up stairs, record position at L5 entrance |
| `a1_countess_safe_dist` | Click point near Countess (safe hammer range) | Walk to Countess, step back to hammer range |
## Recording Steps
### 1. Record path coordinates
1. Start botty, let it waypoint to Black Marsh
2. Enter the tower manually (walk through the door)
3. Run `static_run_recorder.py`:
```
conda activate botty
cd src/utils
python static_run_recorder.py
```
4. Right-click each target position in game order (L2 enter → L3 enter → L4 enter → L5 enter → Countess safe distance)
5. Copy the printed coordinate list
### 2. Update game.ini
Replace the `0,0` values with recorded coordinates. Format: `x,y` per line, or comma-separated for multi-point paths.
Example:
```ini
a1_tower_level2_enter=400,300
a1_tower_level3_enter=410,280
a1_tower_level4_enter=420,260
a1_tower_level5_enter=430,240
a1_countess_safe_dist=450,200
```
### 3. Test the run
1. Add `run_countess` back to `order=` in `params.ini`
2. Start botty and verify it navigates through all tower levels
3. Adjust coordinates if it misses stairs or clicks in wrong spots
## Hammerdin-Specific Notes
- `atk_len_countess=3.0` is already set in `params.ini`
- Hammerdin uses `kill_countess()` method — ensure conviction aura is active before engaging
- Safe distance should account for hammer swing range (~3-4 game units)
## Path Recorder Alternative
If `static_run_recorder.py` is inconvenient, use `node_recorder.py`:
```
cd src/utils
python node_recorder.py
```
Follow on-screen prompts to record nodes, then copy the generated path coordinates.
## Acceptance Criteria
- Botty enters Black Marsh via waypoint
- Navigates L2 → L3 → L4 → L5 without getting stuck
- Reaches Countess and engages
- Completes kill and returns to town
- No false template matches on tower stairs
+225
View File
@@ -0,0 +1,225 @@
# Run Cows Route Plan
## Goal
Add a stable `run_cows` route that can:
- create/open Secret Cow Level portal in Act 1,
- clear cows with safe movement and loot handling,
- exit cleanly and continue normal run rotation.
## Scope
This plan is for implementation and testing in this repo only.
No behavior changes to existing routes unless needed for shared helpers.
## Constraints
- Keep it compatible with current config style (`[routes]`, `[char]`, per-build sections).
- Prioritize stability over speed for first release.
- Avoid forcing hard dependencies on OCR for core route progression.
## Route Design
1. Town prep in Act 1:
- go to stash/cube workflow position.
- ensure cube available.
2. Cow portal creation:
- verify Wirt's Leg + Tome of Town Portal availability.
- transmute in Act 1 town.
- detect red portal reliably.
3. Entry and combat:
- enter portal.
- execute deterministic clear pattern (clockwise + center sweep).
- run character-specific attack loop with timeout guard.
4. Loot and exit:
- reuse existing pickit flow.
- return through TP or save/exit based on safety state.
## Code Changes (Planned)
1. Add new run module:
- `src/run/cows.py` (`name = "run_cows"`)
- methods: `approach()`, `battle()`
2. Wire route registration:
- include `run_cows` in route loader/dispatcher used by `bot.py`.
3. Town/cube helpers:
- add minimal helper(s) for cube transmute flow if missing.
4. Pathing/templates:
- add cow portal/town waypoint templates only as needed.
- add robust fallback checks for portal detect/select.
5. Config/docs:
- document `run_cows` in `config/params.ini` route comments.
- add usage notes to `README.md`.
## Config Additions (Planned)
In `[char]` or dedicated route section:
- `cows_clear_timeout_s` (default safe value, e.g. 90-150)
- `cows_use_tp_exit` (1/0)
- `cows_repair_before_run` (1/0, default 1)
Keep defaults conservative.
## Safety & Recovery
- If portal creation fails: log once, abort current cow attempt, continue next route.
- If portal click fails: retry from known anchor position once.
- If in-combat timeout reached: force exit sequence and mark run failed.
- If inventory full during cows: run pickit stop logic and exit route early.
## Character Compatibility Strategy
Phase 1 support:
- Blizz Sorc
- Hammerdin
- FoHdin
Phase 2 support:
- remaining builds with route-specific combat tuning.
## Telemetry / Stats
Track in `game_stats`:
- `run_cows` attempts
- success/fail count
- elapsed duration
- rune/valuable item outcomes (already covered by global item tracking)
## Test Plan
1. Unit-ish tests:
- route registration includes `run_cows`
- config parsing accepts route key without crash
2. Integration smoke (manual):
- portal creation success path
- missing Wirts Leg failure path
- missing tome failure path
- portal click retry path
3. Stability run:
- 25+ games mixed with existing routes
- verify no regression in Pindle/Trav/Diablo flows
## Assets / Inputs Needed
- Screenshot set for:
- Act 1 stash/cube interaction points
- successful cow portal in town
- entry location inside cows
- Optional:
- map movement recording for an efficient clear loop.
## Screenshot + Coordinates SOP (Detailed)
### Why this matters
Cow route reliability depends on visual matching and deterministic interaction points.
Bad captures (cursor overlap, wrong UI state, mismatched lighting) are the #1 reason for unstable routes.
### Environment lock before capture
1. Use the same D2R setup that bot runtime uses:
- same resolution,
- same UI scale,
- same graphics preset,
- same window mode.
2. Apply Botty auto settings before capture.
3. Keep panel state consistent:
- inventory closed unless the step explicitly needs it,
- item labels in known state,
- no obstructing chat/quest panels.
### Capture tools in this repo
- `screenshot_tool.py`
- `quest_screenshot_tool.py`
- `quest_debug.py`
- `desktop_snap.py`
- Graphic debugger hotkey from `config/params.ini` (`advanced_options.graphic_debugger_key`)
### Capture types required per target
For every actionable object, collect both:
1. Full-context screenshot (for debugging and reproducibility)
2. Tight template crop (for matching)
Template crop rules:
- include only the unique visual feature + small margin,
- avoid cursor overlap,
- avoid tooltip overlap unless tooltip itself is the detection target,
- avoid over-cropping edges that change with animation.
### Coordinate types to record
For each target interaction, record:
1. Screen/monitor click point used when interaction succeeds.
2. Whether template-center click worked.
3. Optional fallback click point if template-center is unreliable.
4. ROI used (if narrowed in code/tests).
5. Confidence range seen in quick manual validation.
### Mandatory cows capture checklist (in order)
1. Act 1 town anchor (post-spawn)
- full screenshot
- possible anchor templates
2. Stash interaction
- full screenshot near stash
- stash template crop(s)
- successful click coordinate(s)
3. Cube interaction/transmute context
- inventory+cube state screenshot
- transmute UI screenshot references
4. Cow portal in town (opened)
- full screenshot with portal visible
- portal template crops (2-3 variants)
- portal click success coordinates
5. Cow level entry area
- immediate post-load screenshot(s)
- safe first-move anchor capture
6. Exit path reference
- screenshot where exit action is normally triggered
- any portal/escape interaction target needed
### Variants to capture for robustness
Per critical template (stash, portal, entry anchor), capture:
- 3-5 samples with slight camera/position variation,
- at least one “busy background” sample,
- one sample with nearby NPC/player clutter if possible.
### Validation pass before route coding
For each template candidate:
1. Verify it matches across the sample set at intended threshold.
2. Verify it does not false-match to nearby unrelated objects.
3. Verify click-at-center triggers the intended interaction.
4. Record accepted threshold and failure notes.
Reject templates that:
- only match at very low confidence,
- require exact pixel-perfect camera alignment,
- frequently collide with non-target objects.
### File naming convention
Use deterministic names aligned with route semantics, e.g.:
- `A1_COW_PORTAL_0`
- `A1_COW_PORTAL_1`
- `A1_STASH_REF_0`
- `COW_ENTRY_ANCHOR_0`
Keep source full screenshots in a dated debug folder and final template crops in the templates location used by the project.
### Capture log format (what testers should write down)
For each target:
- `template_name`
- capture timestamp
- location/context
- successful click point(s)
- confidence range observed
- notes on failed attempts
### Inputs needed from testers (you + friend)
Provide:
1. ZIP/folder of full screenshots + template crops.
2. Short markdown/text capture log using format above.
3. Character/build used during capture (Blizz Sorc / FoH / Hammerdin).
4. Any observed instability notes (miss-clicks, wrong target matches, etc.).
### Acceptance criteria for capture package
Capture package is ready for implementation when:
- all mandatory checklist targets are present,
- each critical target has multi-sample variants,
- at least one tester validated successful interaction per target,
- naming is consistent and unambiguous.
## Rollout
1. Land Phase 1 behind route opt-in (`order=... ,run_cows`).
2. Keep disabled by default in examples until 10+ stable sessions.
3. Promote to documented standard route after stability threshold.
## Definition of Done
- `run_cows` can run repeatedly without manual intervention.
- Failure modes recover without crashing whole bot loop.
- Route is documented in `params.ini` and `README.md`.
- CI/lint/tests pass for changed files.

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