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]>