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.
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.
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.
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.
_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.
- 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
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.
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]>
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]>
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]>
_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]>
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).
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]>
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]>
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]>
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]>
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]>
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]>
- 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.
- 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)
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]>
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.
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)
- 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.
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]>
- 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
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]>
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]>
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]>
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]>
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]>
- 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.
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]>
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]>
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]>
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]>
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.
- 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
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
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.