Commit Graph
1325 Commits
Author SHA1 Message Date
alexpolo1 a3abd15878 docs: add the baal_xp leech cycle to the state machine section
The state list was missing baal_xp (and cold_plains) even though the
route has been in bot.py since August. One short paragraph on how the
cycle works, since it deliberately bypasses the run_wrapper pattern.
2026-09-15 07:43:38 +02:00
alexpolo1 2333a437bd docs(baal_xp): note that the leech enable flag resolves to the params.ini default
The paladalla profile sets run_baal_xp=1 in [routes] but has no [baal_xp]
section, so the leech enable flag resolves to the params.ini default (1).
The 09-08 '5 cycles failed in a row' breaker was the join failing after the
fallback candidate was picked - the cycle then never re-armed and the bot
fell through to end_game. The rearm loop (98f9b73) handles that; this just
documents where the flag actually comes from so the next debug session does
not chase a phantom profile override.
2026-09-15 04:22:14 +02:00
alexpolo1 aa601e67c1 fix(baal_xp): skip the 30s town-spawn wait that strands the leech cycle
The leech-only path in on_init fell through to the generic marker scan,
which triggered on_start_from_town and its 30s wait_for_town_spawn().
When no town marker was visible (character standing away from spawn,
or a stale template) the wait burned the full 30s, the controller's
cooperative_shutdown (8s, no force-kill) gave up on the bot thread,
D2R restarted, and the next on_init found the character already in a
game and left it again - a fresh game burned per cycle.

The leech does its own setup inside the cycle (corpse pickup, /nopickup,
pre-buff, hide spot), so the full on_start_from_town routine is dead
weight for it. Add a skip_to_baal_xp transition (initialization ->
baal_xp) that mirrors skip_to_level, and have on_init's baal-only
branch trigger it directly after a short (8s) spawn scan with act
detection as fallback.

Also shorten _recover_to_own_game's wait_for_town_spawn to the same
8s budget so a failed recovery doesn't stall the cycle the same way.
2026-09-14 19:41:50 +02:00
alexpolo1 98f9b7302e fix(baal_xp): restore the 09-08 join hardening lost in the 09-09 branch switch
The stash taken before switching to main (stash@{1}) carried a re-verified
_try_join - row click retried up to 3x with a re-OCR of the row when the list
scrolled, JOIN GAME press retried up to 3x when the lobby swallowed it, and a
join_game() default of 900s - but the switch was stashed, never re-applied, and
the branch tip kept the single-click version from 09-02. A single missed click
(or a list that scrolled between scan and click) then failed the whole join,
which is exactly the 'no matching game found' the cycle kept hitting.

Also add the finally: start_detecting_window() the 09-08 version had dropped:
join_game() stops the window-detection thread at the top, and every return
path (success, failure, exception) has to re-arm it or screen tracking stays
dead for the rest of the cycle.
2026-09-14 18:35:28 +02:00
alexpolo1 3a12c163ad fix(baal_xp): give the rearm test a real GameStats so the give-up path runs
_baal_xp_rearm's 5-strike breaker calls _game_stats.reset_consecutive_fails();
the fixture's object.__new__(Bot) has no _game_stats, so the breaker test
crashed with AttributeError instead of asserting the flag stays down.
2026-09-08 14:02:04 +02:00
alexpolo1 c40f2efad2 fix(baal_xp): keep the leech alive when its own cycles keep failing
- leech cycle failures no longer feed the session consecutive-fail tally
  (five bad joins disabled the route for the whole session and ended the
  game); _baal_xp_rearm keeps its own local breaker and resets the
  game-level counter so the controller restarts D2R and the leech retries
- on_start_from_town: a leech that re-enters its own game on a corpse
  (died in the public game, or save-and-exit raced the death screen)
  gets pre_buff + /nopickup here - without it the char stays dead and the
  next cycle's save-and-exit has no living game to exit from
2026-09-06 08:18:47 +02:00
alexpolo1 165e4385d4 WIP: epitaxy pre-switch from baalxp 2026-09-05 09:42:53 +02:00
alexpolo1 d0d2a54999 fix(baal_xp): reset the game-length clock when the leech lands back in town
The leech cycle leaves the bot's own game for a public one and comes back.
The controller's max_game_length breaker counts real game time, so the
clock can already be over budget the moment the cycle ends - and it would
kill the game before the next cycle starts. Reset it in on_end_run for
baal_xp.

Also: on char-select failure in the join phases, go home before
end_run (the character is stranded on the character screen otherwise),
and grade the cycle by whether recovery to the own game succeeded -
a cycle that left early (low HP, timer) is a success, only a broken
cycle (join/leave/recovery failure) is a failure.
2026-09-05 08:17:30 +02:00
alexpolo1andClaude Opus 5 0faa31f3d8 fix(skills): let a permanently-slotted skill pass preflight without a hotkey
validate_build_skill_icons rejected any required check with a blank hotkey before
the icon was ever looked at, so moving Blessed Hammer to its real home (left slot,
no key) traded one false failure for another. SkillCheck gains a `permanent` flag
for skills that sit on a slot with no hotkey selecting them; the guard skips those
and the icon check inspects the slot as-is.

Preflight now passes clean: blessed_hammer 100% (left), concentration 86.5%,
redemption 92.0%, holy_shield 93.0%, teleport 92.4%.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 12:26:03 +02:00
alexpolo1andClaude Opus 5 4c915f17ac fix(skills): correct the hammerdin binds and check Blessed Hammer where it lives
Probed the live client by pressing F1-F8 and reading the right skill slot; D2R
renders the key label into the icon, so the crops are self-proving. Actual binds:
f2=Holy Shield, f3=Redemption, f4=a charged item skill, f5=TELEPORT,
f6=Concentration, f8=an aura with no template (Conviction). f1 and f7 are UNBOUND.

Against that, [hammerdin] was wrong three ways:
- conviction=f5 pointed at TELEPORT, so every attack-aura cast would have
  teleported the character — the exact hazard the [fohdin] profile section already
  warns about. Conviction also does nothing for magic-damage hammers (Bug 17), so
  it is now unbound rather than remapped.
- concentration=f8 pointed at Conviction; Concentration is on f6. The build was
  running with no Concentration at all — less hammer damage and no party aura for
  the merc, which plausibly fed the Travincal chickens.
- blessed_hammer=f1 pointed at nothing. The hammer lives permanently on LEFT-click
  and no hotkey moves it to the right slot, so the preflight's right-slot check
  could never pass; it scored 44.8% all session while the hammer sat correctly on
  the left the whole time. The check now inspects the left slot and presses no key,
  matching the comment that was already above it.

The stored blessed_hammer.png was a right-slot capture with "F1" baked into the
image, which is why it scored 34.4% against the unlabelled left slot. Recaptured
from the live left slot.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 12:23:41 +02:00
alexpolo1andClaude Opus 5 6688f2cb67 fix(health): an empty rejuv column is not a chicken condition
take_rejuv_potion_health is 0.45 but chicken is 0.40, so a failed rejuv drink
ended the game at 41-45% HP — above the configured chicken line, and with health
potions still in the belt; in every observed case one had been drunk successfully
1-3 seconds earlier. Rejuvs cannot be bought, only dropped, so the column runs dry
routinely (rejuv=4, a fully empty column, recurs in the Consumables needs line).
This chickened 3 of the first 15 games of the 11:39 session — a 20% failure rate,
and the dominant failure mode once the town bugs were fixed.

Now a missing rejuv falls back to a health potion, bypassing lp_hp_potion_delay
(that cooldown avoids wasting potions; it should not withhold emergency healing),
and chickens only when the belt is genuinely dry. The real chicken threshold check
directly below is untouched and still fires at <=40% HP.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 12:01:06 +02:00
alexpolo1andClaude Opus 5 c62856c618 fix(baal_xp): shorten the re-entry spawn scan, and close its docstring again
_baal_xp_reenter_own_game waited the full 30s default in wait_for_town_spawn when
no town marker was visible. The controller's cooperative_shutdown (8s, no force
kill) then gave up on the bot thread and restarted D2R, so on_init found the
character already in a game and left it again — a fresh game burned every cycle.
The spawn scan is the slow part, so it now runs with an 8s budget and falls back
to detect_current_act, which is what on_start_from_town does anyway.

The working-tree version of this change had commented out the docstring body but
deleted its closing triple-quote, so the docstring swallowed the whole function
and src/bot.py would not parse — the bot could not start at all. Restored.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 11:38:32 +02:00
alexpolo1 e3397936d6 fix(baal_xp): self-heal the leech loop when no route is armed
on_maintenance: when run_baal_xp is the only route and nothing is armed,
re-arm the leech and go again instead of falling through to end_game.
The old path handed off to the controller, which restarted D2R into a
brand new game that the next on_init immediately left again - burning a
game and a cycle for nothing. The _BAAL_XP_MAX_CONSECUTIVE_FAILS breaker
in _baal_xp_rearm still hands off to end_game after enough real failures.

on_end_run: a pure leech ends its cycle on the character screen, not in
a town - _verify_town_location() would burn template searches for a town
that is not there. Re-enter the own game and go straight to maintenance.

params.ini: document that run_baal_xp must stay out of order= (the
profile layer re-arms it between cycles).
2026-09-04 09:51:03 +02:00
alexpolo1andClaude Opus 5 89a4d86e2d fix(town): stop burning the NPC search on body+pose guesses; give A3 usable town markers
Measured over the 65-game session of 2026-09-04 (62 successes, 3 failures, all
three "Maintenance timeout ~300s before [gamble]"):

1. open_npc_menu's body+pose path is a guess, not a confirmation. For Ormus it
   opened the dialogue 5/73 times (7%); the name-tag path hit 37/47 (79%). Body
   score cannot separate them — failures averaged 0.46, the 5 successes were
   0.40-0.46, and one false positive scored 0.79 — so there is nothing to
   threshold on. Each guess also paid a retry click plus two 2.5s waits. 68 dead
   clicks is ~8 minutes of town time per session and is what drove the timeouts.
   Guesses are now budgeted (2 per call) and no longer retried, so the search
   reaches the grid sweep, which does find the NPC, with budget left to do it.

2. TOWN_MARKERS carried A3_TOWN_0 and A3_TOWN_1 for Act 3. Against 8 Kurast Docks
   screenshots they score 0.21 and 0.29 — the worst two of all 20 a3_town
   templates, against a 0.68 match threshold. Act detection in A3 therefore could
   not succeed: 35 "no town marker found" and 24 "_verify_town_location: act
   detection failed" in one session, which is the Bug 9 desync trigger. Added
   A3_TOWN_14 (0.95) and A3_TOWN_20 (0.74); both are act-unique, scoring <=0.35
   against A4 and A5 frames, so they cannot misfire.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 09:46:11 +02:00
alexpolo1andClaude Opus 5 914d4a911e fix(inventory): stash protected items instead of stranding them in the inventory
A charm pickit rejects gets keep=False, but protect_charms_from_sell blocks both
the sell and the drop, and the stash branch only transfers keep==True. The item
could therefore leave the inventory by no route at all: the same 8 charms were
re-hovered, re-OCR'd and re-rejected every game (~12s of town time each cycle),
and every new junk charm picked off the ground joined the set permanently.

_without_protected now flips a blocked item to keep=True so the stash step takes
it, mirroring the recovery inspect_inventory already does per item at the vendor.
bot.py recomputes keep_items after buy_consumables, since it is evaluated before
that call and would otherwise stay stale and skip the stash entirely.

The protection intent is preserved: a misread charm is still never sold or
dropped, it just goes to the stash rather than clogging the inventory.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 09:17:47 +02:00
alexpolo1andClaude Opus 5 bda4c68c13 docs: record the A3 cross-act repair trip as Bug 24
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 07:18:41 +02:00
alexpolo1andClaude Opus 5 458887d11a fix(repair): add in_act repair mode so a repair never triggers a cross-act trip
The Travincal route leaves the character in A3 Kurast Docks, which has no repair
vendor, so TownManager.repair() fell through to "going to A5 for repair" and took
a waypoint round trip every runs_per_repair games. Larzuk detection then failed,
the A4 Halbu fallback took a second trip, and the character was left stranded in
the wrong act — which is what turned a repair into 5 minutes of maintenance and
open_wp failures in the following games.

repair_npc=in_act (also current_act/none/off) now means: repair only if the
current act has a vendor, otherwise skip and stay put. The paladalla profile
(untracked) is set to it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 07:05:24 +02:00
alexpolo1andClaude Opus 5 a066d511ca fix(a5): restore real A5 waypoint template and bound the WP scan to the game world
assets/templates/a5_wp.png (commit 14f5876) was a crop of the blue mana globe,
not the Harrogath waypoint. It matched the HUD at 0.98-0.99 on every frame, so
A5.open_wp() teleported the character to the bottom-right of the screen and
clicked there; WaypointLabel never appeared and each anchor retry pushed the
character further out until it was stranded on top of the town wall. From there
Larzuk, Qual-Kehk and every town marker were off-screen, which cascaded into
repair/resurrect failures and 5 consecutive run_trav approach failures that
self-disabled all routes.

- restore the pre-14f5876 template (201x120, the real stone arch); it scores
  ~0.30 on the stranded-on-the-wall screenshots instead of 0.99
- scan only Config().ui_roi["town_world"], the already-defined ROI that excludes
  the HUD band and the party/chat column, so a bad template can never again move
  the character into the globes
- add an roi parameter to IChar.select_by_template() to support the above

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 06:37:33 +02:00
alexpolo1andClaude Opus 5 53ddbc0f60 fix(baal_xp): keep the leech cycling when a profile [routes] section exists
Two config-layer gaps stopped the leech from ever completing a cycle:

- _do_runs built 'run_baal_xp' from routes.get('run_baal_xp'), which raised
  KeyError when the key was absent (a profile-level [routes] section that
  lists only its own runs, e.g. paladalla's run_pindle/run_trav/run_diablo,
  does not define it). The exception escaped the Bot constructor and took
  the whole bot down at startup. Now .get(key, False).

- _baal_xp_rearm only re-set the _do_runs flag. on_maintenance re-reads
  Config().routes for its 'any run left?' check, so the leech fell through
  to end_game after every cycle and the controller created a brand new game
  purely to leave it again. Re-arm Config().routes too. The matching
  run_baal_xp=1 entry (deliberately NOT in order= - the leech is
  self-contained and must not sit between real runs) goes in the
  gitignored profile ini.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-03 21:49:55 +02:00
alexpolo1andClaude Opus 5 21c62f2cc9 fix(baal_xp): join at 1+ players and survive a stale LOBBY template
Two failures observed 2026-09-02 that stopped the leech cycle completing.

min_players default 3 -> 1. A public Baal game spawns at 1-2 players and fills
within seconds, so a 3-player floor skipped every real game: all listed games
read 1-2p and were passed over, the fallback candidate's join never completed,
and the cycle failed until the controller's circuit breaker restarted D2R. The
band was always meant to be a preference rather than a gate — the in-game checks
(portal wait, party size, XP idle) are what decide whether a game is worth
staying in, so the floor was doing nothing except rejecting valid games.

LOBBY button detection hardened. Its template is the one that goes stale: after
the mod font was removed it scored 0.70 against a 0.8 threshold, so every
save-and-exit failed with "not at character select" while the screen was plainly
showing it. Now:

- retry the detect three times before giving up
- fall back to a coordinate click on the known LOBBY position when the template
  never resolves, gated on at_character_select() so we only click blind when we
  have independent confirmation of the screen
- retry open_lobby once on a transient miss

The "no character selected" path is preserved: LOBBY and PLAY are inert until a
character is highlighted, so that still reports the real cause instead of
burning the timeout on a misleading "lobby did not open".

Also refreshes config/fg_daily_estimates.json (generated data, updated by the
bot's own pricing pass).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 10:12:00 +02:00
alexpolo1 d1ee54f372 WIP: epitaxy pre-switch from baalxp 2026-08-26 10:52:37 +02:00
alexpolo1 82ed9bc661 fix(pindle): no-TP walk-back via red portal, fix walk() indent
- bot.py: _pindle_return_to_town now uses loading-screen success func
  (was missing, so portal click never confirmed); early-return on fail
- bot.py: import loading from ui
- i_char.py: remove stray comment line breaking walk() indentation
- params.ini: order=run_pindle (was run_baal_xp)
2026-08-26 07:56:21 +02:00
alexpolo1 f347a55094 test(baal_xp): align empty-band test with the preference-not-gate fallback 2026-08-26 05:50:36 +02:00
alexpolo1 4ef500f370 fix(baal_xp): close the leech loop after controller restarts
- on_end_run: a pure leech ends its cycle on the character screen, so
  re-enter the own game there (select char -> start_game -> town spawn)
  before maintenance; previously the next cycle's save-and-exit had no
  game to exit from and the bot spun on the character screen.
- on_init: when baal_xp is the only route and D2R was just (re)started,
  create the leech's own game up front for the same reason.
- _run_wrapper: walk a no-TP char home after a battle exception
  (e.g. a no-TP paladin stranded inside Pindle) so maintenance starts
  in town instead of the instance.
- config: paladin base-layer keys no longer clobber profile/custom
  build sections (e.g. a no-CTA profile blanking call_to_arms);
  override_capabilities read from the merged parser.
2026-08-26 01:40:06 +02:00
alexpolo1 323c6976ba fix(baal_xp): join fallback when no game in player band, re-enable route
- game_browser: player band is now a preference; when no listed game
  matches min_players..max_players, join the best remaining candidate
  instead of refreshing until join_timeout_s and failing the cycle.
  The in-game checks (portal wait, party size, XP idle) decide whether
  to stay. This was the hard blocker: every cycle failed with
  'skipped N game(s) outside 3-7 players' until the 5-fail breaker.
- params.ini: min_players 3 -> 1 (band is a preference now)
- re-enable run_baal_xp in [routes] order (params.ini + paladalla profile)
2026-08-25 23:17:23 +02:00
alexpolo1andClaude Opus 5 5276c33ba8 feat(baal_xp): leech public Baal runs — label-gated portals, chat signals, no own game
Makes the baal_xp route actually earn XP unattended. Verified live at ~9.4M
XP/hour (paladalla 25 -> 32 over the session).

Portal selection is the core of it. A Baal town has several portals up at once
— the host's to the Throne plus other players' portals to wherever they were
standing — so taking the best template match picked an arbitrary one; the bot
sat in FRIGID HIGHLANDS (ARTEMIS) earning nothing. Every portal on screen is
now enumerated, hovered and its label read, and only a THRONE match is entered.
The labels sit on a bright animated ring and OCR badly, so matching is fuzzy:
measured over 21 real labels, the Throne portal scores 1.00 and every other
portal in the same town <= 0.50, which is what makes 0.7 safe. That tolerance
matters — a live run entered on 'THIR(NE ()F I)F GTTRIICT T()N', which exact
matching would have skipped.

Ending a run is driven by what the game actually tells us. D2R prints
"<name> left our world" per departure; OCR mangles it but MINIONS survives
(3/3 matched, 0/7 false positives at 0.8). Counting is baselined on arrival —
the chat log persists from before we join, and counting it wholesale abandoned
a fresh game after 28s over departures we never witnessed. Flat XP remains the
primary end-of-run signal; a gradual 8->5->4 drain never trips a departure
threshold, but idle XP catches it immediately.

Dropped the own-game round trip. The cycle used to click PLAY, create a game,
save-and-exit it, join a public one, then create another game just to leave it
again — two creations and two exits per cycle, each a way to fail. A leech only
needs the character screen. Cycles now re-arm without ending the game, with a
5-failure cutout so the controller's own circuit breaker still gets its say.

Town maintenance is skipped for this route: a leech buys, repairs and stashes
nothing, and the vendor/waypoint pathing stranded the character against the
town wall, which then hid the town markers and broke act detection for the rest
of the game.

Bugs fixed along the way:
- bot.py never imported mouse, so the hide step died with NameError on first use
- main_menu.start_game() dereferenced _wait_for_play_btn() without a None check
- game_browser passed a label into select_screen_object_match's delay_factor,
  crashing in random.uniform
- server-error dialogs were left up, so the next join candidate failed with
  "JOIN GAME button not visible" and aborted the whole join
- at_character_select() keyed on one template; when LOBBY_BTN went stale every
  save-and-exit failed while the screen was plainly showing
- difficulty was read by OCR and became "nrit al" after a font change, aborting
  every join before the game list; it is a template match now
- the chat-triggered portal hunt inherited the full 8-minute budget inside the
  hide loop, suspending the HP and timeout checks
- a portal that reads fine but will not open is given up on after 3 attempts

Lobby templates were re-cut after removing stale mods (which carried a font
replacement); all verify at 1.000.

test/baal_xp_test.py covers the decision logic against the real OCR strings and
captures each bug was diagnosed from — 27 tests, no game required.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 21:45:17 +02:00
alexpolo1 ed053092b9 fix(baal_xp): template-based clicks for LOBBY button, fix select_screen_object_match signature, recovery hardening 2026-08-25 15:35:56 +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
v0.9.1
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