Compare commits

..
11 Commits
Author SHA1 Message Date
alex bc5ff03970 Fix GITHUB_API_URL
CI / Install & Test (push) Waiting to run
2026-09-05 08:45:13 +00:00
alex 545fd501d5 Disable npm/pip cache (act_runner cache bug)
CI / Install & Test (push) Waiting to run
2026-09-05 08:38:57 +00:00
alex edb59dd5a4 Fix CI for ubuntu-latest
CI / Install & Test (push) Failing after 6m25s
2026-09-05 06:57:34 +00:00
3ed47ed822 fix: raise maintenance budget for cross-act routes; document Trav findings (#40)
CI / Install & Test (Windows) (push) Waiting to run
* fix: raise maintenance budget for cross-act routes; document Trav findings

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

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

Also documents, in HANDOVER.md:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Design points that matter:

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

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

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

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

---------

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-29 00:16:52 +02:00
alexpolo1andClaude Opus 5 1c19b8bdc8 fix(pather): scan for the threshold BEFORE the anti-stuck move
Review catch. The previous commit checked the counter after
find_abs_node_pos, which is still too late.

The anti-stuck block sits BEFORE the scan in the loop body:

    790  if _heading_rejects >= MAX:  abort        <- top-of-loop check
    799  if not did_force_move and now - last_move > 3.1:
    808      char.move(...)                        <- the wall-driving guess
    826  node_pos_abs = self.find_abs_node_pos(...)  <- 3rd rejection recorded
    833  if _heading_rejects >= MAX:  abort        <- too late

With two rejections banked, the moment 3.1s elapses the anti-stuck block
force-moves along last_direction — driving a wall-wedged character further in —
before the third rejection has been recorded. The exact guess this guard exists
to prevent stayed reachable on the threshold iteration.

The scan and the abort decision now both run ahead of the anti-stuck block, so
the counter is current when that decision is made.

The ordering test could not catch this: it searched for "taking a random guess"
only in the source AFTER find_abs_node_pos, while the guess block sits before
that call, so the comparison was against nothing. It now locates the anti-stuck
block explicitly and asserts BOTH the scan and the abort precede it.

Verified by falsification: restoring the scan-after-guess order fails with
"the node scan must run BEFORE the anti-stuck force-move".

That is now four times in this codebase where a check was verified by where it
sat in the source rather than by whether it ran at the deciding moment.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 22:21:06 +02:00
alexpolo1andClaude Opus 5 f43824f11d fix(pather): the heading-rejection abort never actually fired
Across 79 hell games: 4 random guesses, 0 aborts — including this sequence
inside a SINGLE traverse:

    Traverse from a5_town_start to a5_nihlathak_portal
    rejecting low-confidence A5_TOWN_1 (66.4%) for node 3
    rejecting low-confidence A5_TOWN_1 (65.9%) for node 3
    rejecting low-confidence A5_TOWN_1 (64.1%) for node 3
    taking a random guess towards (-218, 23)
    Wanted to select A5_RED_PORTAL, but could not find it

Three rejections is the threshold, so it should have aborted. The check existed,
was correctly indented inside the while loop, and sat before the anti-stuck
block — the placement I verified with a test when I added it. But the loop does
not reliably come back round to the top of the body after a rejection, so the
check was never evaluated at the moment the counter crossed.

That is why the earlier fix looked right and changed nothing: the ordering test
asserted where the check SAT in the source, not that it ever RAN.

Now checked immediately after find_abs_node_pos, in the same iteration the
counter trips, which removes the dependence on control flow entirely. The
original top-of-loop check is left in place as a second chance.

The character ended up outside the Harrogath battlements again, and the game
was lost to a 82s approach — the exact failure the abort was written to
prevent, still happening because the abort was inert.

Tests now assert the counter TRIPS at the threshold, not merely that the code is
ordered correctly.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 22:03:44 +02:00
Alex b5922b6b19 Merge pull request #38 from alexpolo1/fix/game-menu-guard
fix: the in-game ESC menu — one bug behind three separate incidents
2026-08-28 19:30:08 +02:00
alexpolo1andClaude Opus 5 a139fb3230 fix(view): pause the panel check during save_and_exit — the menu guard raced the shutdown
Follow-up to the GameMenu guard, caught by its own instrumentation: 75 menu
escapes across 15 games, ~5 per game, all clustered around game end.

save_and_exit deliberately opens the in-game ESC menu, but callers only pause
the panel check AFTER it returns:

    18:58:48.142  game | end | ok
    18:58:48.334  In-game menu open - closing it (1/6)      <- the guard
    18:58:48.487  Clicking SAVE_AND_EXIT_NO_HIGHLIGHT       <- the bot
    18:58:48.736  In-game menu open - closing it (2/6)      <- the guard again
    18:58:49.098  Health Manager is now paused              <- too late

Games still completed, so this was noise rather than breakage — but it is a
race, and the guard was pressing esc while the shutdown clicked the menu.

save_and_exit now pauses the panel check for the whole sequence and restores it
in a finally, so it cannot leak the paused state if save/exit raises.

The guard itself is working: 15 games, 0 failures, 0 portal failures, and the
loot-filter/Chronicle/Options incidents have not recurred.

MANA> instrumentation has also settled #23 — see the issue.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 19:26:45 +02:00
alexpolo1andClaude Opus 5 914a107a72 fix(health): detect and close the in-game ESC menu — one bug behind three incidents
The bot was found sitting in OPTIONS -> VIDEO with a "settings have changed,
apply or discard?" modal. Discarding revealed the cause: the in-game ESC menu
carries these buttons at SCREEN CENTRE.

    OPTIONS / SAVE AND EXIT / RETURN TO GAME / LOOT FILTER / CHRONICLE

A stray esc opens the menu and the bot's next movement click lands on one of
them. The HUD mask deliberately leaves screen centre clickable, so nothing
stops it.

That single mechanism explains three incidents previously treated as separate:

  - the LOOT FILTER being toggled (blamed on vigor=f4, which was a real but
    different bug)
  - CHRONICLE blanking every template match for a whole run, costing a 66s
    click_red_portal failure
  - the video OPTIONS being opened and a setting changed, which could have
    altered resolution and broken every template in the project

The menu has NO close button, so the CenterPanel guard (CLOSE_PANEL_2) cannot
see it. Bug 31 warned about precisely this: "just send esc is WORSE: with
nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not
match".

SAVE_AND_EXIT_NO_HIGHLIGHT scores 1.000 on the menu frame and does not match a
normal town frame, so detection is unambiguous. Escaped without counting toward
a chicken, bounded like the waypoint and centred-panel cases.

Also adds MANA> threshold-crossing logging for #23. That issue measured "1 mana
potion per game, never 2" over 70 games but was undecidable, because mana is
only logged when a potion is DRUNK — a second dip that failed to trigger looks
identical to mana never dipping twice. Every crossing is now logged with the
gate state, edge-triggered so it fires once per crossing rather than per poll.

NOT fixed here: whatever sends the stray esc. This is the safety net; the source
is still unknown.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 18:53:15 +02:00
17 changed files with 1217 additions and 885 deletions
+8 -12
View File
@@ -13,8 +13,8 @@ concurrency:
jobs:
install-and-test:
name: Install & Test (Windows)
runs-on: windows-latest
name: Install & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -29,10 +29,11 @@ jobs:
run: |
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
run: |
sudo apt-get update
sudo apt-get install -y tesseract-ocr
- name: Python version
run: python -c "import sys; print(sys.version)"
@@ -124,7 +125,7 @@ jobs:
sys.path.insert(0, 'src')
import pytesseract
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
import cv2
import numpy as np
@@ -148,7 +149,7 @@ jobs:
- name: Verify OCR (botty ocr module)
env:
PYTHONPATH: ./src
PYTESSERACT_TESSERACT_CMD: C:\Program Files\Tesseract-OCR\tesseract.exe
PYTESSERACT_TESSERACT_CMD: /usr/bin/tesseract
run: |
python -c "
import sys, os
@@ -201,13 +202,8 @@ jobs:
- 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
retention-days: 7
+323
View File
@@ -0,0 +1,323 @@
# Handover — 2026-08-29
Everything you need to run this yourself. Written after a long debugging session;
`CLAUDE.md` has the deep detail, this is the operating manual.
---
## Running the bot
```bash
run_botty.bat # starts the process (idle)
python scripts/hermes_bot_control.py start # begins playing
python scripts/hermes_bot_control.py status # running=X paused=Y
python scripts/hermes_bot_control.py stop # exits the process
```
**`start` is idempotent** (fixed 2026-08-28) — pressing it twice is safe. `pause`
and `toggle` are the toggle. Before the fix, a repeated `start` paused the bot
and `status` still said `running=True`; that cost ~5 hours once.
**Always verify with `status`, not the "OK: command sent" reply.** And check the
log actually moves — `=== BOT START ===` is the proof it began a game.
### Restarting after a code or config change
A running bot does **not** pick up edits. Python loads modules at process start.
```bash
python scripts/hermes_bot_control.py stop
# wait until nothing is listening:
netstat -ano | grep 18899
run_botty.bat
python scripts/hermes_bot_control.py start
```
If `stop` times out, retry it — the socket occasionally needs two attempts. Only
force-kill as a last resort: killing mid-game leaves D2R in a state the bot
cannot re-enter, and you then have to save+exit to the main menu by hand.
**Only ever run one instance.** Two both bind the control socket and fight over
start/pause, and the logs become nonsense. Check with `tasklist | grep -i python`.
---
## Is it stuck, or just idling?
The bot sits at the D2R **character-select menu** during a normal break. The
stuck case looks identical. Do not judge by the screen.
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
```
| | Normal break | Stuck |
|---|---|---|
| `status` | `running=True paused=True` | the same |
| `select_char` errors | none | present |
| `Restarting bot` | none | every ~20s |
| Log | quiet | new process repeatedly |
**The tell is the log filling with restart lines, not the menu.**
### Break lengths are longer than they look
`maybe_afk_break` calls `wait(m, m*1.5)` and `wait()` applies its own jitter (up
to 1.44x). They compound:
| planned | actual |
|---|---|
| 3.9m | 7.1m |
| 11.9m | 19.5m |
| 20:56 | 25.5m |
So **multiply any break setting by 1.5 x 1.44** before deciding it is safe. A
~25 minute idle is what left D2R unable to re-enter once. `afk_break_max_m` is
capped at 7 for this reason (=> ~15m worst case).
---
## Health check
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt > /tmp/c.log
echo "games $(grep -c 'game | start' /tmp/c.log) | failed $(grep -c 'game | end .*fail' /tmp/c.log) | deaths $(grep -c 'You have died' /tmp/c.log) | crashes $(grep -c 'Uncaught exception' /tmp/c.log)"
```
Useful greps:
| what | grep |
|---|---|
| Step-by-step timeline | `grep "TL>" log/log.txt` |
| Failure records | `grep "FAIL>" log/log.txt` |
| Stealth manifest at startup | `grep "STEALTH>" log/log.txt` |
| Mana threshold crossings | `grep "MANA>" log/log.txt` |
| Level / exp | `ls -t log/stats/mini_stats_*.json \| head -1` |
**`FAIL>` gives the whole story of a failed game** — reason, location, the three
slowest steps, and a breadcrumb trail with `!` marking failures. Read the trail,
not just the reason: the step that blew up is often not the one that caused it.
---
## Temporary settings to revert
| file | setting | now | should be |
|---|---|---|---|
| `config/params.ini` | `session_budget_h` | **20** | 8 |
| `config/params.ini` | `difficulty` | hell | your call |
`session_budget_h = 20` was raised for a levelling push. It rolls to 13-27h,
which largely disables the stop-for-the-day behaviour that the session-rhythm
work exists to provide. **Put it back to 8 once you have the levels.** It is
uncommitted, so `git checkout config/params.ini` reverts it.
---
## Outstanding
**PR #39 is merged and live** (`ec6599f`). Confirmed working 2026-08-29: the
pather abort fired for the first time (`aborting traverse` 1, `taking a random
guess` 8, over 21 games) after being 0-for-152 while it looked correct.
**Travincal (hell) status — 2026-08-29:** 21 games, 2 failed (9.5%), 0 deaths.
Council dies; 10 of 13 runs produced loot. Both failures were `open_wp`, but
with *different* waypoints — `A1_WP` once, `A5_WP` once — because the character's
act varies between games. The A5 case follows a Larzuk repair (stale believed
location, Bug 16 family). Waypoint template matching is now on the critical path
for every Trav game, where Pindle never used it at all.
**FoH is the wrong build for Travincal.** Blessed Hammer does magic damage and
the council are not magic immune; FoH's holy-bolt half only damages *undead*, so
against the living council half its output does nothing. That is the 2-3k vs
~10k gap — not a tuning issue. `atk_len_trav` was raised 6.0 -> 10.0 in the
profile as a stopgap (40s min / 120s max across the four attack sequences).
**Done 2026-08-29** — respecced and verified live: `type=hammerdin`,
`concentration=f6`, `redemption=f3`. Note **Blessed Hammer is NOT on an F-key**:
`_cast_hammers` puts the aura on the right slot and spams left-click, so Hammer
lives on left-click permanently — pressing a hammer hotkey would replace the aura.
`concentration` and `redemption` had to move into `[paladin]`; they were under
`[fohdin]`, which a hammerdin never reads, so both would have been silently
unbound. `atk_len_trav` is now 3.0 (the fight is a fixed clock, so lower = less
exposure, not less damage).
**Trav costs two cross-act waypoint trips per game.** The run leaves the
character in Act 3; maintenance relocates it to Act 4 (stash at `a4_tyrael_stash`,
repair at `a4_halbu`) and the next run then needs the A3 waypoint again. That
travel is *not attributed to any timed step*, so a `FAIL>` trail can show ~38s of
work inside a 250s maintenance window — do not read the trail as the whole cost
here. This blew the 240s budget 5 times; `max_maintenance_time_s` raised to 420.
It also explains the `open_wp` failures: Trav exercises waypoint templates on
every game, where Pindle walked to an in-act portal and never touched them.
Not yet investigated: `a3.py` reports `can_buy_pots`/`can_heal`/`can_stash` all
True, so the trip to Act 4 is *not* a missing A3 capability — something in the
maintenance chain relocates the character. Keeping town business in Act 3 would
remove both waypoint trips and is the real fix if Trav becomes the main route.
(Cosmetic: `a3.py` defines `can_identify` twice, identically — harmless.)
**The `open_wp` failures are NOT a template problem — proven 2026-08-29.**
Scored `a5_wp.png` against all three real failure frames:
* full-frame, no ROI: 0.96-0.98 at (924, 594) — but that is the **belt/mana-orb
HUD false positive** the `a5.py` comment already documents, not the stone. The
`cut_skill_bar` ROI exists precisely to exclude it. Do not "confirm" this
template by scoring without the ROI; it produces a confident wrong answer.
* inside the real `cut_skill_bar` ROI (0,0,1284,653): **0.436-0.480** against a
0.55 threshold, at scattered positions (1,401) / (712,519) / (32,520). Wandering
match positions = noise. `a5_wp_2` (masked, 4-channel) scores 0.26-0.29.
The waypoint is genuinely **not on screen**: the character never reaches it.
Recapturing the template would fix nothing. The cause is stale believed location
after town business happens in a different act (Bug 16 family), consistent with
the `taking a random guess` lines in the same failures.
**Therefore: keeping Trav town business in Act 3 is the real fix**, not a
performance tweak. It removes the A5 waypoint dependency, the two cross-act
trips, and the maintenance-timeout pressure in one change. 7 of 8 waypoint
misses were `A5_WP`; `A1_WP` was a single one-off.
**A3 town landmark coverage is the root cause of the Trav failures (2026-08-29).**
Measured against the two `info_npc_menu_timeout` frames where the bot was stuck:
| frame | a3_town landmarks >=0.68 | >=0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
With zero landmarks over threshold the pather cannot localise at all — hence
6 `taking a random guess` across 2 traverses to `a3_ormus`. The character then
ends up somewhere arbitrary, which produces all three symptoms from one cause:
Ormus not in his ROI, the waypoint not on screen, and maintenance burning its
budget.
Ormus himself is fine: best match INSIDE his ROI (444,13,372,318) is 0.342/0.334
— noise — while the global best (0.479/0.508) sits *outside* it. Do NOT lower
the body threshold; 0.34 is background level and dropping the bar there invites
the Bug 4 false-positive clicks. The NPC is not there to be found.
**CORRECTION to the earlier entry in this file:** "keeping Trav town business in
Act 3 is the real fix" was wrong. That was based on `a3.py` reporting
`can_buy_pots`/`can_heal`/`can_stash` as True — a capability check, not evidence
about pathing. A3 is in fact the worst-supported town in the project; moving more
work into it makes things worse. The fix is A3 landmark coverage.
New pather node templates need the character's absolute position at capture time,
so they cannot be made from saved frames — this needs a live capture pass in A3.
**BELT WAS HALF EMPTY — found 2026-08-30 after the session's only death.**
```ini
belt_hp_columns=1 # 4 healing potions
belt_mp_columns=1 # 4 mana potions
belt_rejuv_columns=2 # 8 slots for potions VENDORS DO NOT SELL
```
Rejuvenation potions cannot be bought in D2 — they only drop. So the bot asked
for `rejuv=8` on every restock, never got them, and ran Travincal on **4 healing
potions** with half the belt permanently empty. 50 `Failed to drink rejuv` events
in 200 games. The death: drank at 47%, chickened at 33.8%, died in the gap with
nothing left to drink. Many of the 37 chickens were likely "belt empty", not burst
damage.
Now `belt_hp_columns=2 / mp=1 / rejuv=1` (8 healing, 4 mana, 4 slots for dropped
rejuvs — `convert_rejuv=1` cubes them to Full). **Verify after restart that the
HP columns actually fill.**
**Repair NPC — settled by measurement (2026-08-30):**
| NPC | result |
|---|---|
| `a5_larzuk` | 6 fail / ~250 — **2.4%** ← use this |
| `a4_halbu` | 6 fail / ~50 — **12%** |
| `a1_charsi` | 0 for 3 — pathing never reaches her |
Bug 12's original reasoning holds: Halbu detection is unreliable even after Bug
30's threshold fix. Charsi failed for a different reason — `Traverse from
a1_wp_north to a1_charsi` fails, so `open_npc_menu` never runs and her "she does
not move" advantage is never tested. A1's wp->charsi route is broken like A3.
**A `repair_npc` code fix went in:** the cross-act destination used to be
hardcoded to A5, so the setting was silently ignored on every route not already
standing in A5. It is now honoured (`town_manager.repair()`).
**Damage profile at hell Travincal** (confirmed from screenshots): council cast
**Hydra (fire), Lightning/Charged Bolt, and Frost Nova (cold)**. Lightning is both
the most common and the weakest resist (~48% with Mara) — **Thundergod's Vigor**
(+10 max lightning res, 20% absorb, +20 vit) is the targeted upgrade, ahead of
Verdungo's. Fire is well covered by Dwarf Star's absorb.
**Do NOT raise `atk_len_trav` to reduce the "running around".** Hydras are
stationary fire turrets, so `kill_council`'s repositioning is actively dodging
sustained damage. 3.0 is correct.
**Merc blocks gold pickups.** `Mizan says: I can't use that` = a pickit click
landing on the merc. ~4 gold piles lost per 18 games. The bot retries twice then
moves on; not worth fixing.
**Lightning charm rules added** to `config/bnip/Den gode.bnip` (gitignored,
backup at `.bak`), 558 -> 561 expressions. The existing resist rules sum all four
resists (`>= 12` for smalls), and a max single-res lightning small charm is 11 —
so pure lightning charms were picked up and then **vendored**. Two older rules
existed but each required a second stat (`[Maxhp] >= 10`, `[Fhr] >= 3`).
**Current bind map (verified live 2026-08-29) — do NOT rebind F7:**
| key | skill | | key | skill |
|---|---|---|---|---|
| F1 | Battle Command (CTA) | | F5 | Teleport |
| F2 | Holy Shield | | F6 | **Concentration** |
| F3 | Redemption | | F7 | **Battle Orders (CTA)** |
| F4 | Town Portal (not a skill) | | F8 | free (Vigor if wanted) |
Blessed Hammer is on **left-click**, not an F-key. An earlier version of this file
said to bind Concentration to F7 — that would overwrite Battle Orders and cost a
large part of the life pool.
**Two things only you can do:**
1. **Resistances** — lightning 23% / poison 12% are the survivability ceiling.
Herald of Zakarum + Mara's took health chickens from 3-in-8 to **0-in-11**, so
this is largely addressed; the remaining holes are gear, not config.
2. **75% FCR** — currently 60% (HotO 40 + Trang's 20). The 48→75 breakpoint means
everything between is wasted; Arachnid Mesh in place of Goldwrap closes it.
**If FCR changes, update `casting_frames`**: 75% = 11 frames, 60% = 12, and
below 48% = 13. The bot derives its cast wait from that number, so a wrong
value cuts every buff short.
**Known-stale:** the `CONVICTION` preflight template scores 45.9% while the bind
is provably correct, so every startup logs a false alarm. Cosmetic.
---
## Checking your binds after any gear change
```bash
python tools/testbed.py spellbook --assets
```
Hovers the whole bind grid, reads each skill from its tooltip and each hotkey
from the icon corner, and prints which config keys disagree with the game. Exits
1 on a mismatch. This exists because an Enigma put Teleport on F5 and displaced
Conviction, and `conviction=f5` would have teleported the character mid-fight.
---
## The one habit worth keeping
Most of what went wrong here was **configured behaviour that never executed**,
and nothing reported it. AFK breaks were 0-for-225 at a configured 5%. The
manifest said `wired` throughout.
When something should be happening and you are not sure it is, **count it**:
```bash
grep -c "<the thing>" log/log.txt
```
A zero where you expected a number is the most informative result in this
project. It found the AFK break, the loot-filter clicks, the Chronicle panel,
and the pather abort — twice.
+70 -777
View File
@@ -1,18 +1,80 @@
{
"generated_at": "2026-06-17T07:19:33.572415+00:00",
"mode": "offline",
"generated_at": "2026-08-31T06:30:30.597153+00:00",
"mode": "offline-improved",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-12",
"days": 60,
"topics_scanned": 853,
"ladder_start_date": "2026-05-20",
"days": 21,
"topics_scanned": 20,
"skipped": {
"blocked": 2,
"no_topic": 0,
"no_date": 848,
"no_item": 4
},
"filters": {
"min_price_fg": 5.0,
"trim_fraction": 0.2
},
"estimates": {
"day_1": {},
"day_2": {},
"day_3": {},
"day_4": {},
"day_5": {},
"day_6": {},
"day_7": {},
"day_6": {
"Cham Rune": {
"median_fg": 7.5,
"avg_fg": 10.0,
"trimmed_mean_fg": 7.5,
"trimmed_median_fg": 7.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 4,
"raw_samples": 6
}
},
"day_7": {
"Aldur's Advance": {
"median_fg": 20.0,
"avg_fg": 21.7,
"trimmed_mean_fg": 20.0,
"trimmed_median_fg": 20.0,
"min_fg": 20.0,
"max_fg": 25.0,
"samples": 3,
"raw_samples": 6
},
"Ist Rune": {
"median_fg": 70.0,
"avg_fg": 94.0,
"trimmed_mean_fg": 66.7,
"trimmed_median_fg": 70.0,
"min_fg": 20.0,
"max_fg": 250.0,
"samples": 5,
"raw_samples": 16
},
"Gul Rune": {
"median_fg": 65.0,
"avg_fg": 65.0,
"trimmed_mean_fg": 65.0,
"trimmed_median_fg": 65.0,
"min_fg": 20.0,
"max_fg": 110.0,
"samples": 2,
"raw_samples": 4
},
"Unid Anni": {
"median_fg": 575.0,
"avg_fg": 575.0,
"trimmed_mean_fg": 575.0,
"trimmed_median_fg": 575.0,
"min_fg": 550.0,
"max_fg": 600.0,
"samples": 2,
"raw_samples": 2
}
},
"day_8": {},
"day_9": {},
"day_10": {},
@@ -26,775 +88,6 @@
"day_18": {},
"day_19": {},
"day_20": {},
"day_21": {},
"day_22": {},
"day_23": {},
"day_24": {},
"day_25": {},
"day_26": {},
"day_27": {},
"day_28": {},
"day_29": {},
"day_30": {},
"day_31": {
"CTA": {
"median_fg": 100.0,
"avg_fg": 358.0,
"min_fg": 1.0,
"max_fg": 1199.0,
"samples": 5
},
"Cham Rune": {
"median_fg": 30.0,
"avg_fg": 121.0,
"min_fg": 5.0,
"max_fg": 450.0,
"samples": 5
},
"Lo Rune": {
"median_fg": 25.0,
"avg_fg": 38.8,
"min_fg": 5.0,
"max_fg": 100.0,
"samples": 4
},
"Um Rune": {
"median_fg": 75.0,
"avg_fg": 99.6,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 10
},
"Lem Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Sorc Torch": {
"median_fg": 100.0,
"avg_fg": 100.0,
"min_fg": 100.0,
"max_fg": 100.0,
"samples": 1
},
"Aldur's Advance": {
"median_fg": 27.5,
"avg_fg": 43.8,
"min_fg": 20.0,
"max_fg": 100.0,
"samples": 4
},
"Stealth RW": {
"median_fg": 55.0,
"avg_fg": 55.0,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 2
},
"Ist Rune": {
"median_fg": 95.0,
"avg_fg": 112.2,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 12
},
"Mal Rune": {
"median_fg": 100.0,
"avg_fg": 112.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 13
},
"Pul Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Unid Anni": {
"median_fg": 550.0,
"avg_fg": 416.7,
"min_fg": 100.0,
"max_fg": 600.0,
"samples": 3
},
"Dual Leech Ring": {
"median_fg": 85.0,
"avg_fg": 85.0,
"min_fg": 70.0,
"max_fg": 100.0,
"samples": 2
},
"Gul Rune": {
"median_fg": 25.0,
"avg_fg": 63.1,
"min_fg": 5.0,
"max_fg": 200.0,
"samples": 8
},
"Key": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Insight": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Ohm Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vampire Gaze": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"White": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Shako": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Black": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vex Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Skin": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Spirit": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Bone": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Jah Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Moser": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ber Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Arach": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"5/5 Facet": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"Mara": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
}
},
"day_32": {},
"day_33": {},
"day_34": {},
"day_35": {},
"day_36": {},
"day_37": {
"Thresher": {
"median_fg": 32.5,
"avg_fg": 133.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 210
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 71.5,
"min_fg": 0.0,
"max_fg": 1300.0,
"samples": 318
},
"Spirit": {
"median_fg": 25.0,
"avg_fg": 78.3,
"min_fg": 0.0,
"max_fg": 1300.0,
"samples": 237
},
"Wind": {
"median_fg": 30.0,
"avg_fg": 175.0,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 60
},
"Arach": {
"median_fg": 40.0,
"avg_fg": 184.2,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 210
},
"Sorc Torch": {
"median_fg": 50.0,
"avg_fg": 166.2,
"min_fg": 10.0,
"max_fg": 2500.0,
"samples": 64
},
"Griffon": {
"median_fg": 60.0,
"avg_fg": 254.7,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 111
},
"Guardian's Light": {
"median_fg": 50.0,
"avg_fg": 342.6,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 236
},
"Skin": {
"median_fg": 30.0,
"avg_fg": 206.8,
"min_fg": 0.0,
"max_fg": 3000.0,
"samples": 242
},
"Viper Gorge": {
"median_fg": 30.0,
"avg_fg": 75.9,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 231
},
"Lo Rune": {
"median_fg": 30.0,
"avg_fg": 113.6,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 105
},
"Enigma": {
"median_fg": 20.0,
"avg_fg": 182.4,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 49
},
"Bone": {
"median_fg": 40.0,
"avg_fg": 244.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 264
},
"Um Rune": {
"median_fg": 45.0,
"avg_fg": 187.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 191
},
"Lem Rune": {
"median_fg": 20.0,
"avg_fg": 48.7,
"min_fg": 0.0,
"max_fg": 750.0,
"samples": 126
},
"Crescent": {
"median_fg": 10.0,
"avg_fg": 63.0,
"min_fg": 5.0,
"max_fg": 600.0,
"samples": 70
},
"Ist Rune": {
"median_fg": 45.0,
"avg_fg": 74.9,
"min_fg": 3.0,
"max_fg": 750.0,
"samples": 173
},
"Highlord": {
"median_fg": 25.0,
"avg_fg": 113.0,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 70
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 70.0,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 161
},
"Mal Rune": {
"median_fg": 30.0,
"avg_fg": 67.0,
"min_fg": 3.0,
"max_fg": 750.0,
"samples": 166
},
"Insight": {
"median_fg": 40.0,
"avg_fg": 189.9,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 155
},
"Pul Rune": {
"median_fg": 30.0,
"avg_fg": 69.6,
"min_fg": 5.0,
"max_fg": 750.0,
"samples": 113
},
"CTA": {
"median_fg": 50.0,
"avg_fg": 251.5,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 171
},
"Blessed": {
"median_fg": 30.0,
"avg_fg": 180.3,
"min_fg": 7.0,
"max_fg": 1200.0,
"samples": 36
},
"Ohm Rune": {
"median_fg": 25.0,
"avg_fg": 39.8,
"min_fg": 3.0,
"max_fg": 400.0,
"samples": 119
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 47.0,
"min_fg": 1.0,
"max_fg": 450.0,
"samples": 58
},
"Vampire Gaze": {
"median_fg": 10.0,
"avg_fg": 110.6,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 80
},
"Giant": {
"median_fg": 20.0,
"avg_fg": 96.1,
"min_fg": 0.0,
"max_fg": 600.0,
"samples": 70
},
"Ber Rune": {
"median_fg": 60.0,
"avg_fg": 253.1,
"min_fg": 5.0,
"max_fg": 1200.0,
"samples": 58
},
"Key": {
"median_fg": 20.0,
"avg_fg": 83.1,
"min_fg": 1.0,
"max_fg": 1200.0,
"samples": 114
},
"Shako": {
"median_fg": 30.0,
"avg_fg": 113.6,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 208
},
"Pcomb SK": {
"median_fg": 160.0,
"avg_fg": 685.0,
"min_fg": 5.0,
"max_fg": 3000.0,
"samples": 48
},
"Sacred": {
"median_fg": 35.0,
"avg_fg": 166.1,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 244
},
"Light Jewel": {
"median_fg": 80.0,
"avg_fg": 234.0,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 39
},
"Fist": {
"median_fg": 20.0,
"avg_fg": 51.7,
"min_fg": 15.0,
"max_fg": 120.0,
"samples": 9
},
"Vex Rune": {
"median_fg": 30.0,
"avg_fg": 97.2,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 132
},
"Unid Anni": {
"median_fg": 35.0,
"avg_fg": 45.0,
"min_fg": 3.0,
"max_fg": 150.0,
"samples": 96
},
"Light SK": {
"median_fg": 40.0,
"avg_fg": 383.2,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 41
},
"Mara": {
"median_fg": 50.0,
"avg_fg": 146.1,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 132
},
"Cold SK": {
"median_fg": 50.0,
"avg_fg": 162.9,
"min_fg": 7.0,
"max_fg": 1200.0,
"samples": 45
},
"Black": {
"median_fg": 30.0,
"avg_fg": 106.7,
"min_fg": 0.0,
"max_fg": 2400.0,
"samples": 199
},
"Jah Rune": {
"median_fg": 60.0,
"avg_fg": 267.5,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 87
},
"Troll": {
"median_fg": 30.0,
"avg_fg": 32.1,
"min_fg": 1.0,
"max_fg": 75.0,
"samples": 66
},
"Ward": {
"median_fg": 10.0,
"avg_fg": 21.8,
"min_fg": 1.0,
"max_fg": 50.0,
"samples": 45
},
"White": {
"median_fg": 20.0,
"avg_fg": 37.6,
"min_fg": 3.0,
"max_fg": 300.0,
"samples": 126
},
"Stealth RW": {
"median_fg": 32.5,
"avg_fg": 31.7,
"min_fg": 5.0,
"max_fg": 50.0,
"samples": 24
},
"Doom": {
"median_fg": 50.0,
"avg_fg": 285.3,
"min_fg": 45.0,
"max_fg": 1500.0,
"samples": 30
},
"Gul Rune": {
"median_fg": 30.0,
"avg_fg": 50.4,
"min_fg": 0.0,
"max_fg": 280.0,
"samples": 136
},
"Death's Fathom": {
"median_fg": 110.0,
"avg_fg": 96.7,
"min_fg": 60.0,
"max_fg": 120.0,
"samples": 12
},
"Unid Torch": {
"median_fg": 60.0,
"avg_fg": 49.5,
"min_fg": 5.0,
"max_fg": 120.0,
"samples": 39
},
"Sanctuary": {
"median_fg": 85.0,
"avg_fg": 82.5,
"min_fg": 5.0,
"max_fg": 170.0,
"samples": 24
},
"Grief": {
"median_fg": 50.0,
"avg_fg": 170.2,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 56
},
"Night": {
"median_fg": 250.0,
"avg_fg": 209.0,
"min_fg": 20.0,
"max_fg": 700.0,
"samples": 15
},
"5/5 Facet": {
"median_fg": 50.0,
"avg_fg": 313.3,
"min_fg": 10.0,
"max_fg": 1300.0,
"samples": 15
},
"Treachery": {
"median_fg": 20.0,
"avg_fg": 157.0,
"min_fg": 5.0,
"max_fg": 1300.0,
"samples": 30
},
"Pala Torch": {
"median_fg": 50.0,
"avg_fg": 215.6,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 43
},
"Bulwark": {
"median_fg": 50.0,
"avg_fg": 440.0,
"min_fg": 50.0,
"max_fg": 1300.0,
"samples": 10
},
"Fortitude": {
"median_fg": 70.0,
"avg_fg": 915.0,
"min_fg": 20.0,
"max_fg": 3500.0,
"samples": 16
},
"Cham Rune": {
"median_fg": 20.0,
"avg_fg": 22.5,
"min_fg": 5.0,
"max_fg": 60.0,
"samples": 53
},
"War Traveler": {
"median_fg": 20.0,
"avg_fg": 33.0,
"min_fg": 5.0,
"max_fg": 80.0,
"samples": 33
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 29.3,
"min_fg": 5.0,
"max_fg": 90.0,
"samples": 63
},
"Moser": {
"median_fg": 12.5,
"avg_fg": 12.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 16
},
"Hellfire": {
"median_fg": 60.0,
"avg_fg": 53.3,
"min_fg": 20.0,
"max_fg": 80.0,
"samples": 12
},
"Conviction": {
"median_fg": 20.0,
"avg_fg": 13.7,
"min_fg": 1.0,
"max_fg": 20.0,
"samples": 6
},
"Terror Key": {
"median_fg": 5.0,
"avg_fg": 6.7,
"min_fg": 5.0,
"max_fg": 10.0,
"samples": 12
},
"Hate Key": {
"median_fg": 5.0,
"avg_fg": 6.7,
"min_fg": 5.0,
"max_fg": 10.0,
"samples": 12
},
"Dual Leech Ring": {
"median_fg": 50.0,
"avg_fg": 198.8,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 16
},
"Cyclone": {
"median_fg": 300.0,
"avg_fg": 306.7,
"min_fg": 20.0,
"max_fg": 600.0,
"samples": 12
},
"Fury": {
"median_fg": 30.0,
"avg_fg": 76.9,
"min_fg": 5.0,
"max_fg": 250.0,
"samples": 32
}
},
"day_38": {},
"day_39": {},
"day_40": {},
"day_41": {},
"day_42": {},
"day_43": {},
"day_44": {},
"day_45": {},
"day_46": {},
"day_47": {},
"day_48": {},
"day_49": {},
"day_50": {},
"day_51": {},
"day_52": {},
"day_53": {},
"day_54": {},
"day_55": {},
"day_56": {},
"day_57": {},
"day_58": {},
"day_59": {},
"day_60": {}
"day_21": {}
}
}
+42 -27
View File
@@ -87,7 +87,7 @@ max_game_length_s=900
; max_maintenance_time_s: if the town maintenance loop (heal/buy/stash/repair) takes
; longer than this many seconds, save-and-exit and rejoin a fresh game.
; Prevents the bot staying stuck in A5 town forever when NPCs or pathing fail.
max_maintenance_time_s=240
max_maintenance_time_s=420
; auto_downgrade_threshold: if combined chickens+deaths exceed this number within 1 hour,
; bot automatically lowers difficulty by one tier (hell->nightmare->normal) and restarts.
; Set to 0 to disable. (NOTE: feature is parsed but not yet active in bot logic)
@@ -231,6 +231,21 @@ skill_hesitation_max_ms = 300
; session_budget_h=5 would average 5h but could stop after 3.25h. 8 gives a
; 5.2-10.8h window, guaranteeing the 5 hours while keeping day-to-day variation.
session_budget_h = 8
; daily_budget_h: HARD cap on total runtime per CALENDAR DAY (0 = unlimited).
; session_budget_h above is per PROCESS — a restart re-rolls it, so a bot that
; gets restarted (by you, or by the restart-on-crash path) can run all day and
; never trip it. This cap is persisted to log/.daily_runtime.json keyed on the
; date, so restarts cannot extend the day. The target is rolled ONCE per day
; (+/- daily_budget_jitter) and then FIXED, so restarting cannot re-roll a
; larger allowance. This is a CEILING, not an average: the jitter only ever
; subtracts, so daily_budget_h = 8 with 0.12 jitter runs 7.0-8.0h and NEVER
; more than 8. session_budget_h above can still stop a single process sooner.
daily_budget_h = 8
daily_budget_jitter = 0.12
; daily_budget_close_game: 1 = also close D2R when the daily cap trips (default).
; The cap fires in on_end_game, AFTER save-and-exit, so nothing is in progress and
; nothing is lost. A bot parked at character select for 16h is itself a pattern;
; a real player quits the game. Set 0 to leave D2R running.
; Small cursor movement during long idles. Between actions the cursor otherwise
; sits exactly where the last click left it.
idle_drift_enabled = 1
@@ -276,7 +291,7 @@ skill_mistake_chance = 0
; run_mephisto (Act 3 Durance of Hate)
; run_baal (Act 5 Throne of Destruction)
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
order=run_pindle, run_diablo
order=run_baal_xp, run_trav
[char]
; ==========================
@@ -289,9 +304,9 @@ type=hammerdin
; belt_rows: in-game belt size (2/3/4)
belt_rows=4
; casting_frames: your char cast breakpoint (affects action timing)
casting_frames=8
casting_frames=11
; cta_casting_frames: cast breakpoint on CTA swap (if used)
cta_casting_frames=8
cta_casting_frames=11
; attack_frames: base attack animation timing for non-cast attacks
attack_frames=15
; cta_available: 1 if Call to Arms swap exists, else 0
@@ -316,7 +331,7 @@ safer_routines=1
; 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
num_loot_columns=5
; game hotkeys:
; NOTE: each key must match your in-game binding exactly
@@ -332,13 +347,13 @@ show_items=alt
; stand_still cannot be the default "shift" as it would interfere with merc healing
stand_still=capslock
; teleport: leave empty if you can't use
teleport=b
town_portal=6
teleport=f4
town_portal=f8
; call to arms settings:
; weapon_switch/battle_orders/battle_command only used when cta_available=1
weapon_switch=w
battle_orders=7
battle_command=8
battle_orders=f6
battle_command=f5
; ==========================
; ==== Optional configs ====
@@ -383,7 +398,7 @@ atk_len_nihlathak=4.0
atk_len_pindle=8.0
atk_len_shenk=4.0
atk_len_diablo=3.0
atk_len_trav=4.0
atk_len_trav=5.0
; Boss run attack lengths (per-character defaults in kill_* methods)
; Adjust these if your build is faster/slower against these bosses
@@ -404,9 +419,9 @@ cs_town_visits=0
kill_cs_trash=1
; Belt settings
belt_hp_columns=1
belt_hp_columns=2
belt_mp_columns=1
belt_rejuv_columns=2
belt_rejuv_columns=1
; Potion/chicken settings
take_health_potion=0.60
@@ -426,11 +441,11 @@ fill_shared_stash_first=0
; to gamble, add any/all of the following: circlet, ring, coronet, talon, amulet
gamble_items=
; open_chests: 1 = open clickable chests along path when possible
open_chests=1
open_chests=0
; pre_buff_every_run: 1 = always recast buffs at run start
pre_buff_every_run=1
; runs_per_repair: visit repair vendor every X runs (blank/0 disables)
runs_per_repair=5
runs_per_repair=50
; repair_npc: preferred repair vendor strategy.
; - a5_larzuk (recommended: stays in A5, no cross-act trip; falls back to Halbu)
; - a4_halbu (requires WP trip to A4 every repair — act desync risk if it fails)
@@ -447,9 +462,9 @@ repair_npc=a5_larzuk
; the town visit past max_maintenance_time_s and killed the game.
resurrect_npc=a4_tyrael
; runs_per_stash: stash/sell every X runs (blank/0 disables)
runs_per_stash=4
runs_per_stash=1
; sell_junk: 1 = vendor non-keep items automatically
sell_junk=1
sell_junk=0
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name.
; DISABLED 2026-08-27. This is redundant: the EQUIPPED shield is already protected
; positionally by mouse._is_clicking_safe(), which cancels any click landing in
@@ -474,7 +489,7 @@ protect_shields_from_sell=0
; 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
protect_charms_from_sell=0
; pick_rares_for_gold: 1 = pick all yellow (rare) ground items; non-keep rares will be sold
; Requires sell_junk=1 to convert extra pickups into gold.
pick_rares_for_gold=0
@@ -490,9 +505,9 @@ stash_destination=0,1,2,3
; 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=
transmute=flawless
;how often we want to run transmute routine(e.g. every 100 games)
transmute_every_x_game=60
transmute_every_x_game=800
; number of stash tabs — drives tab click geometry in inventory/common.tab_properties().
; MUST match the tab bar on screen. This client shows 5: PERSONAL SHARED GEMS MATERIALS RUNES.
; It was 6, which made tab_properties compute centres of 63/127/192/256/320/384 while the
@@ -500,7 +515,7 @@ transmute_every_x_game=60
stash_tabs=5
; potion transmute settings
; convert_rejuv: 1 = convert regular Rejuv Potions to Full Rejuv via cube (3 -> 1)
convert_rejuv=1
convert_rejuv=0
; min_rejuv_to_convert: minimum regular rejuv potions in inventory before starting conversion
min_rejuv_to_convert=6
@@ -550,10 +565,10 @@ hydra=f1
; ==== Builds: Paladin ====
; =========================
[paladin]
cleansing=f9
holy_shield=f2
redemption=f3
vigor=f4
cleansing=
holy_shield=f4
redemption=f2
vigor=f3
[fohdin]
; foh must be left skill, hotkey required
@@ -564,9 +579,9 @@ foh=f6
holy_bolt=f7
[hammerdin]
blessed_hammer=f1
concentration=f8
conviction=f5
blessed_hammer=f9
concentration=f1
conviction=
; =========================
; ==== Builds: Warlock ====
+220
View File
@@ -0,0 +1,220 @@
# Post-mortem — run_trav on hell, FoH → hammerdin (29 Aug 2026)
**Outcome:** health chickens 3-in-8 → **0-in-11**. Battle 6468s → **4160s**.
Failure rate 12% → 9%. Deaths: 0 throughout. Character level 85.
Per-bug detail with code references lives in `CLAUDE.md`; operating instructions in
`HANDOVER.md`. This document is the narrative: what happened, in what order, and the
four times the obvious answer was wrong.
---
## 1. The build was wrong, and the timing data could not tell us
Travincal ran green on FoHdin — loot on 10 of 13 runs — but killed slowly. The user's
observation was blunt and correct: *"we only do 2-3k dmg with foh"*.
The reason is a game rule, not a bot bug. **Fist of the Heavens deals lightning damage
plus a holy bolt, and holy bolt only damages undead.** Travincal council members are
living. Half of FoH's output was landing on a target type immune to it. Against Pindle
(undead) FoH is excellent, which is exactly why nothing looked wrong until the route
changed.
After the respec, battle time barely moved — 64.0s and 65.5s versus FoH's 5068s. That
looked like the respec had failed. It had not:
```python
def _cast_hammers(self, time_in_s: float, aura: str = "concentration"):
start = time.time()
while (time.time() - start) < time_in_s: # fixed clock, no kill detection
```
`hammerdin.kill_council` casts for `3 × atk_len + 1.6` seconds **regardless of how fast
things die**. FoHdin's timer was mob-detection-driven and was hitting its ceiling;
hammerdin's is a hard-coded duration. Two different mechanisms landing on a similar
number.
> Comparing a fixed-duration fight against a detection-driven one tells you nothing about
> damage. The evidence that the respec worked was **loot on every run** (a RAL rune, 8
> Large Charms, ~30k gold), not the clock. Lowering `atk_len_trav` — not raising it — is
> the lever on a fixed-duration attack.
---
## 2. Scoring a template without the ROI the code uses gives a confident wrong answer
Three games died on `Approach failed for run_trav [step: open_wp]`. Following the
project's own advice, `a5_wp.png` was scored offline against the three real failure
frames:
```
a5_wp 0.980 at (924, 594)
a5_wp 0.962 at (924, 594)
a5_wp 0.971 at (924, 594)
```
0.960.98, identical position, every frame. Read naively this says: *the template is
perfect, so the search must be broken.* That conclusion was stated, and it was wrong.
`a5.py` already carries the answer in a comment: the recaptured `a5_wp.png` matches the
**belt/mana-orb HUD** at ~0.966 — higher than the real stone — which is why the search is
constrained to the `cut_skill_bar` ROI in the first place. Rescoring inside that ROI:
| frame | best `a5_wp` | position |
|---|---|---|
| g1 | 0.480 | (1, 401) |
| g20 | 0.440 | (712, 519) |
| g32 | 0.436 | (32, 520) |
0.4360.480 against a 0.55 threshold, at **wandering positions** — the signature of noise.
The waypoint was genuinely not on screen. The bot was telling the truth; the character had
never reached it.
> **Always score a template through the same ROI, colour filter and threshold the code
> uses.** A full-frame search finds whatever the ROI exists to exclude, and hands you a
> 0.98 that means the opposite of what it appears to mean. Recapturing this template would
> have fixed nothing and cost a day.
---
## 3. Ormus: three plausible causes, all wrong
The bot then stalled in Act 3 hunting Ormus, scoring `body 0.3580.393` against a 0.40
threshold — tantalisingly close, exactly the Akara pattern from Bug 3.
**Wrong answer #1 — "the templates are masked" (Bug 23).** All six Ormus body templates
are 4-channel, and the match positions wandered between frames — the Bug 23 signature
precisely. But a channel audit across every NPC showed the alpha is **fully opaque**, so
`alpha_to_mask` returns no mask and matching is normal. 4-channel is not the same as
masked.
**Wrong answer #2 — "lower the body threshold."** The global best match was 0.479/0.508,
above the 0.40 bar. But those matches sat *outside* Ormus's ROI `[444, 13, 372, 318]`, at
(531,415) and (375,445). Inside the ROI the best was **0.342 / 0.334** — background level.
Lowering the bar to catch that would have recreated the Bug 4 false-positive clicks.
**Wrong answer #3 — "keep Trav town business in Act 3."** This had been recommended
earlier in the same session, reasoned from `a3.py` reporting `can_buy_pots` / `can_heal` /
`can_stash` as `True`. That is a *capability* check and says nothing about pathing.
The actual cause, measured against the two frames where the bot was stuck:
| frame | `a3_town` landmarks ≥0.68 | ≥0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
**Zero landmarks over threshold.** The pather cannot localise at all, emits
`taking a random guess` (6 times across 2 traverses to `a3_ormus`), and the character ends
up somewhere arbitrary. One cause, three symptoms: Ormus not in his ROI, the waypoint not
on screen, and maintenance burning its budget.
A3 is the **worst-supported town in the project**. Moving more work into it would have made
things worse. The recommendation was reversed.
> Ormus was never the problem, the threshold was never the problem, and the mask was never
> the problem. The NPC was not there to be found. Before adjusting any detection
> parameter, establish that the subject is actually on screen.
---
## 4. Config that silently does nothing
Three separate settings were live-but-inert, each in a different way.
**Section scoping.** `concentration` and `redemption` were defined under `[fohdin]`.
`Config` builds `hammerdin` from `[hammerdin]` then applies `[paladin]` last:
```python
self.hammerdin.update(paladin_base_cfg) # [paladin] wins
```
`[fohdin]` is never read by a hammerdin. Both binds would have been **silently unbound**
after the respec — `redemption` invisibly, and `concentration` degrading every hammer to
aura-less damage that would have looked exactly like the FoH problem just respecced away
from.
**An impossible breakpoint.** `casting_frames=8`, from which the bot derives
`_cast_duration = frames * 0.04 + 0.01`. A paladin's fastest cast is **10 frames** at 125%
FCR; 8 is unreachable. At 60% FCR the real value is 12 frames, so the bot was waiting
0.33s for casts that take 0.49s — moving on mid-cast and cutting buffs short, including
the Battle Orders that supply a large share of a 1347-life pool.
**Right setting, wrong route.** `repair_npc=a5_larzuk` was set by Bug 12 specifically to
keep repair in-act for Pindle. On Trav the character is in A3/A4, so the same setting
*forces* the cross-act trip it was created to prevent: 35.8s at Larzuk versus 9.0s at
Halbu.
> A setting that was correct for one route can be actively harmful on another. Bug 12's
> reasoning (*stay in-act*) was right; its **value** was route-specific and nobody had
> revisited it.
---
## 5. Do not trust the key auto-detector
`stand_still` was configured `capslock`; the detector reported the game used `shift`. That
looked like a clear mismatch, and it was "fixed" to `shift`. The character immediately
began running instead of standing to cast.
After the user remapped the game to `shift`, the detector reported **`capslock`**.
| | detector says | actual in-game bind |
|---|---|---|
| before remap | `shift` | `capslock` |
| after remap | `capslock` | `shift` |
Wrong both times, and specifically reporting the value *not* in use — consistent with a
stale `.keyo` read (D2R writes that file on exit). This is why the code deliberately
prefers config over detection and merely logs the disagreement.
> `Keeping configured key binding for X` is **not** a warning that config is wrong. The
> only authority on a keybind is the user and the live game.
---
## 6. What actually fixed it
| change | effect |
|---|---|
| respec FoH → hammerdin | council no longer half-immune to the attack |
| Herald of Zakarum + Mara's Kaleidoscope | chickens 3-in-8 → **0-in-11** |
| `casting_frames` 8 → 12 | buffs stop being cut mid-cast |
| `concentration=f6`, `redemption=f3` moved to `[paladin]` | binds actually read |
| `atk_len_trav` 6.0 → 3.0 | less time standing inside the council |
| `max_maintenance_time_s` 240 → 420 | cross-act town trips stop killing games |
| `repair_npc``a4_halbu` | repair 35.8s → ~9s |
The user's own diagnosis — *"we need the block from zaka and mara so its a ressistance
problem"* — was the single highest-impact change of the night. Bot configuration cannot
compensate for 23% lightning resist and 1347 life while standing inside three hell council
members.
---
## 7. Still open
**A3 pather landmark coverage** is the sole remaining failure cause. It needs a live
capture pass: node templates require the character's **absolute position at capture time**,
so they cannot be produced from saved screenshots. `A5_TOWN_1` (6568% against a 0.68 bar)
is the same class, still outstanding.
**Gear:** 75% FCR is one Arachnid Mesh away (currently 60%, and 48→75 is the next
breakpoint — everything between is wasted). Lightning resist at 23% and poison at 12%
remain the survivability ceiling.
---
## The lessons, compressed
1. A fixed-duration attack loop cannot report damage. Measure kills by **loot**.
2. Score templates through the **ROI the code uses**, or you will confidently find the
false positive the ROI exists to exclude.
3. Before tuning a detection threshold, prove the **subject is on screen**.
4. `can_do_x() == True` is a capability, not evidence that pathing works.
5. Config sections are scoped: a bind under the old class is silently unbound after a
respec.
6. Cast-timing settings must track FCR. An unreachable breakpoint means every cast is
cut short.
7. A setting that is correct for one route can be harmful on another.
8. The key auto-detector reads a file the game writes **on exit**. It is not authoritative.
+94 -9
View File
@@ -1,6 +1,6 @@
from transitions import Machine
import time
from input_layer import keyboard
from input_layer import keyboard, mouse
import time
import os
import sys
@@ -19,7 +19,7 @@ from utils.restart import safe_exit
from game_stats import GameStats
from logger import Logger
from config import Config
from screen import grab, convert_monitor_to_screen, convert_screen_to_abs, convert_abs_to_monitor, convert_screen_to_monitor
from screen import grab, convert_monitor_to_screen, convert_screen_to_abs, convert_abs_to_monitor, convert_screen_to_monitor, find_and_set_window_position
import template_finder
from char import IChar
from item.pickit import PickIt
@@ -910,15 +910,22 @@ class Bot:
# A5 Malah is unreliable in the current patch (wandering NPC, stale body
# templates — fails most games). Buy at A4 Jamella instead when in A5.
buy_loc = self._curr_loc
if TownManager.get_act_from_location(buy_loc) == Location.A5_TOWN_START:
Logger.info("Buy consumables: in A5 — traveling to A4 Jamella (Malah unreliable)")
# A3 Ormus is the same story, measured 2026-08-30 over ~230 hell Trav
# games: ormus 39 hunt timeouts vs jamella 0. Each failed hunt is ~37s
# (12s body hunt + 25s grid sweep) and both retry layers re-run it, so a
# bad Ormus turned buy_consumables into 200-225s and blew the maintenance
# budget. One waypoint hop to a vendor that always detects is cheaper.
_buy_act = TownManager.get_act_from_location(buy_loc)
if _buy_act in (Location.A5_TOWN_START, Location.A3_TOWN_START):
_why = "Malah unreliable" if _buy_act == Location.A5_TOWN_START else "Ormus unreliable"
Logger.info(f"Buy consumables: in {_buy_act} — traveling to A4 Jamella ({_why})")
a4_loc = self._town_manager.go_to_act(4, buy_loc)
if a4_loc:
buy_loc = a4_loc
self._curr_loc = a4_loc
prev_buy_loc = a4_loc
else:
Logger.warning("Buy consumables: travel to A4 failed — trying A5 Malah anyway")
Logger.warning("Buy consumables: travel to A4 failed — trying the local vendor anyway")
self._curr_loc, result_items = self._town_manager.buy_consumables(buy_loc, items = items)
if self._curr_loc:
items = result_items
@@ -1255,6 +1262,37 @@ class Bot:
except Exception as e:
Logger.debug(f"session budget check skipped: {e}")
# Daily cap. Unlike the session budget above this is keyed on the calendar
# date and persisted to log/.daily_runtime.json, so restarting the process
# does NOT hand the bot a fresh allowance. A human does not play 24h.
try:
from utils.stealth import daily_budget_exceeded
over, used_s, budget_s = daily_budget_exceeded()
if over:
msg = (f"Daily budget reached ({hms(used_s)} of {hms(budget_s)}) — "
"stopping until tomorrow.")
Logger.info(msg)
self.tl("stlth", "daily_end", "ok", msg)
if self._messenger.enabled:
self._messenger.send_message(msg)
result = self.stop()
# Close the game too, not just the bot. This runs in on_end_game,
# AFTER save-and-exit has completed, so the character is sitting at
# the menu with nothing in progress — nothing is lost by closing here.
# Leaving D2R parked at character select for 16h is itself the pattern
# the daily cap exists to remove; a real player quits.
if Config().stealth.get("daily_budget_close_game", True):
try:
from utils.misc import close_down_d2
Logger.info("Daily budget: closing D2R.")
self.tl("stlth", "daily_end", "ok", "closing D2R")
close_down_d2()
except Exception as e:
Logger.warning(f"Daily budget: could not close D2R: {e}")
return result
except Exception as e:
Logger.debug(f"daily budget check skipped: {e}")
# Scheduled long break. The interval and duration are RE-ROLLED each
# time: a break at exactly 120 minutes every time is still a pattern,
# just a slower one than taking no break at all.
@@ -1700,6 +1738,12 @@ class Bot:
start_time = time.time()
last_log = 0.0
last_xp_check = 0.0
# Guard against a stuck InGame template: if the template misses twice in a
# row (e.g. a transient full-screen panel, a loading flicker, or a stale
# template) we do NOT immediately assume we were kicked. Instead we try to
# recover — re-anchor the window and re-check — before bailing out. A single
# missed frame must not end a 15-minute hide.
in_game_misses = 0
while True:
elapsed = time.time() - start_time
if elapsed > max_wait:
@@ -1708,10 +1752,51 @@ class Bot:
img = grab()
if not is_visible(ScreenObjects.InGame, img):
# Death screen, kicked to menu, or loading — bail out.
# The death manager thread handles the death screen itself.
Logger.warning("baal_xp: left in-game state (kicked/died) — leaving")
break
# Distinguish "died" from "kicked to menu / loading".
if is_visible(ScreenObjects.YouHaveDied, img):
# Died in the public game. The death manager thread is passive
# (it only watches for the death screen), so we handle it here:
# esc to dismiss the death screen, pick up the corpse, then
# resume hiding. If we can't get back in-game, bail out.
Logger.warning("baal_xp: died in public game — recovering")
self._save_error_screenshot("baal_xp", "died_in_public_game")
keyboard.send("esc")
wait(1.0, 1.5)
if is_visible(ScreenObjects.Corpse):
view.pickup_corpse()
wait_until_hidden(ScreenObjects.Corpse)
belt.fill_up_belt_from_inventory(Config().char["num_loot_columns"])
# Re-anchor the window (the death screen may have shifted it)
# and wait for the character to be back in-game.
find_and_set_window_position(force=True)
if game_browser.wait_for_in_game(timeout=30):
Logger.info("baal_xp: back in-game after death — resuming hide")
self._char.discover_capabilities()
in_game_misses = 0
continue
# Could not get back in-game — leave.
Logger.warning("baal_xp: could not recover after death — leaving")
break
# Not a death screen: kicked to menu, loading, or a transient
# state. Use the miss-counter so one bad frame doesn't end the hide.
in_game_misses += 1
if in_game_misses >= 2:
# Two consecutive misses — try to recover the window anchor once.
Logger.warning(f"baal_xp: InGame template missed {in_game_misses}x — re-anchoring")
find_and_set_window_position(force=True)
wait(1, 2)
img = grab()
if is_visible(ScreenObjects.InGame, img):
Logger.info("baal_xp: recovered in-game state — resuming hide")
in_game_misses = 0
continue
# Still not in-game after recovery — genuinely kicked/loading.
Logger.warning("baal_xp: left in-game state (kicked) — leaving")
break
# First miss: assume transient, re-check next iteration.
wait(1, 2)
continue
in_game_misses = 0
hp_pct = meters.get_health(img) * 100
if hp_pct < min_hp:
+8
View File
@@ -327,6 +327,14 @@ class Config:
"click_delay_enabled": bool(int(self._select_optional("stealth", "click_delay_enabled", "0"))),
# Session rhythm — the signals per-action jitter cannot reach.
"session_budget_h": float(self._select_optional("stealth", "session_budget_h", "0")),
# Hard per-CALENDAR-DAY cap. session_budget_h is per process, so a restart
# resets it and a bot restarted all day still runs all day. This one is
# persisted to disk and survives restarts. 0 = unlimited.
"daily_budget_h": float(self._select_optional("stealth", "daily_budget_h", "0")),
"daily_budget_jitter": float(self._select_optional("stealth", "daily_budget_jitter", "0.12")),
# Close D2R when the daily cap trips. A bot process idling at the character
# select screen for 16h is itself a signal; a real player quits the game.
"daily_budget_close_game": bool(int(self._select_optional("stealth", "daily_budget_close_game", "1"))),
"idle_drift_enabled": bool(int(self._select_optional("stealth", "idle_drift_enabled", "1"))),
"chicken_variance": float(self._select_optional("stealth", "chicken_variance", "0.08")),
"town_browse_chance": float(self._select_optional("stealth", "town_browse_chance", "0.06")),
+39
View File
@@ -38,6 +38,7 @@ class HealthManager:
self._count_panel_detects = 0
self._count_wp_panel_detects = 0
self._count_center_panel_detects = 0
self._mana_below_threshold = False
def stop_monitor(self):
self._do_monitor = False
@@ -178,6 +179,22 @@ class HealthManager:
self._last_health = time.time()
# check mana
last_drink = time.time() - self._last_mana
# Issue #23 instrumentation. "1 mana potion per game,
# never 2" was measured over 70 games, but the cause
# was undecidable: mana is only logged when a potion
# is DRUNK, so a second dip that failed to trigger
# looks identical to mana never dipping twice. Log
# every crossing of the threshold, whether or not it
# results in a drink.
_mana_low = mana_percentage <= Config().char["take_mana_potion"]
if _mana_low and not self._mana_below_threshold:
Logger.debug(
f"MANA> crossed below {Config().char['take_mana_potion']:.2f} "
f"at {mana_percentage*100:.1f}% "
f"(last drink {last_drink:.1f}s ago, gate {lp_mp_potion_delay}s) "
f"-> {'will drink' if last_drink > lp_mp_potion_delay else 'BLOCKED by gate'}"
)
self._mana_below_threshold = _mana_low
if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay:
wait(0.05, 0.1)
if belt.drink_potion("mana", stats=[health_percentage, mana_percentage]):
@@ -209,6 +226,28 @@ class HealthManager:
# A5_RED_PORTAL unfindable, 66s approach failure. It is not a
# threat, so escape it without counting toward a chicken,
# bounded the same way as the waypoint panel.
# The in-game ESC menu. A stray esc opens it, and its LOOT
# FILTER / CHRONICLE / OPTIONS buttons sit at screen centre
# where movement clicks land — which is how the loot filter
# got toggled, how Chronicle blocked every template match,
# and how the bot ended up in the video options with a
# "settings have changed" modal. It has no close button, so
# CenterPanel cannot detect it. esc toggles it shut.
if not self.get_panel_check_paused() and is_visible(ScreenObjects.GameMenu, img):
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"In-game menu open (its buttons sit under movement clicks) — closing it "
f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("In-game menu would not close — treating as a blocking panel")
if not self.get_panel_check_paused() and is_visible(ScreenObjects.CenterPanel, img):
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
+16 -2
View File
@@ -795,6 +795,22 @@ class Pather:
self._heading_rejects = 0
return False
# SCAN FIRST, then decide. This must sit ahead of the anti-stuck
# block below, not after it: with two rejections already banked,
# that block force-moves along last_direction the moment 3.1s
# elapses — driving a wall-wedged character further in — before
# find_abs_node_pos has recorded the third rejection. Putting the
# scan after it left the exact guess this guard exists to prevent
# reachable on the threshold iteration.
node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold, last_direction=node_last_dir)
if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS:
Logger.warning(
f"Pather: {self._heading_rejects} consecutive low-confidence rejections "
f"for node {node_idx} — aborting traverse instead of guessing"
)
self._heading_rejects = 0
return False
# Sometimes we get stuck at rocks and stuff, after a few seconds force a move into the last known direction
if not did_force_move and time.time() - last_move > 3.1:
if last_direction is not None:
@@ -822,8 +838,6 @@ class Pather:
break
teleport_count += 1
# Find any template and calc node position from it
node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold, last_direction=node_last_dir)
if node_pos_abs is not None:
dist = math.dist(node_pos_abs, (0, 0))
if dist < Config().ui_pos["reached_node_dist"]:
+21 -4
View File
@@ -565,12 +565,29 @@ class TownManager:
common.close()
return new_loc, items
return False, False
Logger.info("TownManager repair: going to A5 for repair")
new_loc = self.go_to_act(5, curr_loc)
# The current act cannot repair (e.g. A3 on a Travincal route), so we must
# travel. This used to be hardcoded to A5/Larzuk, which meant repair_npc was
# ignored on every route that does not already stand in A5 — the preference
# is only consulted in the A5 branch above. On run_trav that cost 22-37s per
# repair, and when Larzuk's menu failed to open the whole trip ran twice
# (~84s) and stranded the char in the wrong act, failing the following
# open_wp. Honour repair_npc here too.
_repair_dest = {
"a1_charsi": (1, Location.A1_TOWN_START, "Charsi"),
"charsi": (1, Location.A1_TOWN_START, "Charsi"),
"act1": (1, Location.A1_TOWN_START, "Charsi"),
"a4_halbu": (4, Location.A4_TOWN_START, "Halbu"),
"halbu": (4, Location.A4_TOWN_START, "Halbu"),
"act4": (4, Location.A4_TOWN_START, "Halbu"),
}
act_num, act_loc, npc_name = _repair_dest.get(
repair_preference, (5, Location.A5_TOWN_START, "Larzuk"))
Logger.info(f"TownManager repair: going to A{act_num} for repair ({npc_name})")
new_loc = self.go_to_act(act_num, curr_loc)
if not new_loc: return False, False
Logger.info("TownManager repair: opening Larzuk trade/repair menu")
Logger.info(f"TownManager repair: opening {npc_name} trade/repair menu")
set_panel_check_paused(True)
new_loc = self._acts[Location.A5_TOWN_START].open_trade_and_repair_menu(new_loc)
new_loc = self._acts[act_loc].open_trade_and_repair_menu(new_loc)
Logger.info(f"TownManager repair: open_trade_and_repair_menu returned {new_loc}")
if not new_loc:
set_panel_check_paused(False)
+79 -52
View File
@@ -111,69 +111,96 @@ def _open_join_tab() -> bool:
return True
def join_game(name_filter: str = "", max_wait_s: float = 60.0) -> bool:
def _join_attempt(name_filter: str, max_wait_s: float) -> bool:
"""One attempt to join a public game from the main menu.
:return: True if the join was initiated (loading screen or in-game detected).
"""
# Step 1: Make sure we're at the main menu with the Play button visible
start = time.time()
while True:
if is_visible(ScreenObjects.InGame):
Logger.warning("game_browser: already in a game")
return True
if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid:
break
if is_visible(ScreenObjects.MainMenu):
# Main menu visible but Play button not detected — wait for it to activate
wait(1, 2)
else:
Logger.error("game_browser: not at main menu")
return False
if time.time() - start > 30:
Logger.error("game_browser: Play button never appeared")
return False
# Step 2: Open the Join Game tab
if not _open_join_tab():
return False
# Step 3: Find and click a matching game
start = time.time()
while time.time() - start < max_wait_s:
img = grab()
games = _scan_game_list(img)
if games:
target = None
if name_filter:
for g in games:
if name_filter.lower() in g["name"].lower():
target = g
break
else:
target = games[0]
if target:
Logger.info(f"game_browser: joining '{target['name']}' (row {target['row']})")
_click_game_row(target["y_center"])
# Step 4: Wait for loading screen or in-game
return _wait_for_join()
Logger.debug(f"game_browser: {len(games)} games visible, none match filter '{name_filter}'")
else:
Logger.debug("game_browser: no games visible, waiting...")
wait(2, 4)
Logger.error(f"game_browser: no matching game found within {max_wait_s}s")
return False
def join_game(name_filter: str = "", max_wait_s: float = 60.0, attempts: int = 3) -> bool:
"""
Join a public game from the main menu.
Retries the whole browser flow up to `attempts` times: a single attempt can
fail for transient reasons (game full, kicked back to the menu, a missed
click, an empty list at that instant). The bot must never stop, so we keep
re-trying rather than giving up on the first failure.
:param name_filter: substring to match against game names (case-insensitive).
Empty string = join the first game in the list.
:param max_wait_s: how long to wait for a matching game to appear.
:param max_wait_s: how long each attempt waits for a matching game to appear.
:param attempts: how many times to retry the whole join flow before giving up.
:return: True if the join was initiated (loading screen or in-game detected).
"""
Logger.info(f"game_browser: joining game (filter='{name_filter or 'first'}')")
Logger.info(f"game_browser: joining game (filter='{name_filter or 'first'}', attempts={attempts})")
stop_detecting_window()
find_and_set_window_position(force=True)
try:
# Step 1: Make sure we're at the main menu with the Play button visible
start = time.time()
while True:
if is_visible(ScreenObjects.InGame):
Logger.warning("game_browser: already in a game")
return True
if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid:
break
if is_visible(ScreenObjects.MainMenu):
# Main menu visible but Play button not detected — wait for it to activate
wait(1, 2)
else:
Logger.error("game_browser: not at main menu")
return False
if time.time() - start > 30:
Logger.error("game_browser: Play button never appeared")
return False
# Step 2: Open the Join Game tab
if not _open_join_tab():
return False
# Step 3: Find and click a matching game
start = time.time()
while time.time() - start < max_wait_s:
img = grab()
games = _scan_game_list(img)
if games:
target = None
if name_filter:
for g in games:
if name_filter.lower() in g["name"].lower():
target = g
break
else:
target = games[0]
if target:
Logger.info(f"game_browser: joining '{target['name']}' (row {target['row']})")
_click_game_row(target["y_center"])
# Step 4: Wait for loading screen or in-game
return _wait_for_join()
Logger.debug(f"game_browser: {len(games)} games visible, none match filter '{name_filter}'")
else:
Logger.debug("game_browser: no games visible, waiting...")
wait(2, 4)
Logger.error(f"game_browser: no matching game found within {max_wait_s}s")
for attempt in range(1, attempts + 1):
try:
if _join_attempt(name_filter, max_wait_s):
return True
except Exception as e:
Logger.warning(f"game_browser: join attempt {attempt} raised {type(e).__name__}: {e}")
if attempt < attempts:
Logger.warning(f"game_browser: join attempt {attempt}/{attempts} failed — retrying")
# Give the client a moment to settle back at the main menu before
# re-anchoring the window and re-opening the browser.
wait(2, 4)
find_and_set_window_position(force=True)
Logger.error(f"game_browser: all {attempts} join attempts failed")
return False
finally:
start_detecting_window()
+15
View File
@@ -71,6 +71,21 @@ def save_and_exit() -> bool:
Performes save and exit action from within game
:return: Bool if action was successful
"""
# Pause the panel check for the WHOLE sequence. This function deliberately
# opens the in-game ESC menu, and the health manager now closes that menu on
# sight (its LOOT FILTER / CHRONICLE / OPTIONS buttons sit under movement
# clicks). Callers only pause AFTER save_and_exit returns, so without this
# the guard raced the shutdown — observed as 75 escapes across 15 games,
# interleaved with the save/exit clicks.
from health_manager import set_panel_check_paused
set_panel_check_paused(True)
try:
return _save_and_exit_inner()
finally:
set_panel_check_paused(False)
def _save_and_exit_inner() -> bool:
# if exit button isn't detected already, press escape
attempts = 1
success = False
+10
View File
@@ -225,6 +225,16 @@ class ScreenObjects:
threshold=0.8,
use_grayscale=True
)
GameMenu=ScreenObject(
# The in-game ESC menu. It has NO close button, so CenterPanel cannot
# see it, and it carries LOOT FILTER / CHRONICLE / OPTIONS buttons at
# screen centre — where the HUD mask deliberately allows clicks. A
# stray esc opens it and the next movement click lands on a button.
ref=["SAVE_AND_EXIT_NO_HIGHLIGHT", "SAVE_AND_EXIT_HIGHLIGHT"],
roi="reduce_to_center",
threshold=0.8,
use_grayscale=True
)
CenterPanel=ScreenObject(
ref=["CLOSE_PANEL_2", "CLOSE_PANEL"],
roi="center_panel_header",
+8 -2
View File
@@ -62,10 +62,16 @@ def get_build_skill_checks(config_instance: Config, char_type: str | None = None
if char_type in ("hammerdin", "fohdin"):
build_cfg = getattr(config_instance, char_type, {})
# blessed hammer lives on left-click; auras/teleport go to right
# hotkeys select onto the RIGHT slot; left holds the hammer permanently
# hotkeys select onto the RIGHT slot; left holds the hammer permanently.
# NOT required: having no hotkey is the CORRECT configuration. _cast_hammers
# deliberately keeps the hammer off the F-keys — pressing one would select it
# onto the right slot and REPLACE the damage aura. Marking this required made
# every startup log "blessed_hammer has no configured hotkey" and advise running
# set_binds_from_params.py, which would bind it and break the aura. If a hotkey
# IS configured it is still visually validated below.
checks.append(SkillCheck(char_type, "blessed_hammer",
_configured_hotkey(build_cfg, "blessed_hammer"),
"BLESSED_HAMMER", "right", True))
"BLESSED_HAMMER", "right", False))
checks.append(SkillCheck(char_type, "concentration",
_configured_hotkey(build_cfg, "concentration"),
"CONCENTRATION", "right", True))
+111
View File
@@ -322,6 +322,18 @@ def manifest() -> list:
True if budget_h else None,
f"this run {session_budget_seconds()/3600:.1f}h" if budget_h else "unlimited")
# Report the call site, not just the config — the lesson from chicken_variance.
daily_h = cfg.get("daily_budget_h", 0)
daily_wired = _count_calls("bot", "daily_budget_exceeded(") > 0
if daily_h:
_b = daily_budget_seconds() / 3600
_u = _daily_read().get("seconds", 0.0) / 3600
detail = f"today {_u:.1f}/{_b:.1f}h" if daily_wired else "no call site in bot.py"
else:
detail = "unlimited"
add("daily_budget", f"{daily_h}h" if daily_h else "0",
(daily_wired if daily_h else None), detail)
run_s, brk_s = break_schedule()
add("scheduled_break", f"{run_s/60:.0f}m/{brk_s/60:.0f}m" if run_s else "0",
run_s > 0, "re-rolled each break" if run_s else "disabled in [general]")
@@ -411,6 +423,105 @@ def session_budget_seconds() -> float:
return _session_budget_s
# --------------------------------------------------------------------------
# Daily runtime cap
#
# session_budget_h is per PROCESS. A restart re-rolls it, so a bot that is
# restarted (by the user, or by the restart-on-crash path) can run all day and
# never trip it — which is exactly the signal a daily cap is meant to remove.
# This tracker persists to disk and is keyed on the calendar date.
#
# The rolled target is stored WITH the date. Re-rolling it on every process
# start would let a restart hand the bot a fresh, possibly larger budget; once
# a day's target is chosen it is fixed until the date changes.
# --------------------------------------------------------------------------
_DAILY_STATE_PATH = _os.path.join("log", ".daily_runtime.json")
_daily_last_tick: float | None = None
def _today() -> str:
import datetime
return datetime.date.today().isoformat()
def _daily_read() -> dict:
import json
try:
with open(_DAILY_STATE_PATH, "r", encoding="utf-8") as f:
state = json.load(f)
if state.get("date") == _today():
return state
except Exception:
pass
return {"date": _today(), "seconds": 0.0, "budget_s": None}
def _daily_write(state: dict) -> None:
import json
try:
_os.makedirs(_os.path.dirname(_DAILY_STATE_PATH), exist_ok=True)
with open(_DAILY_STATE_PATH, "w", encoding="utf-8") as f:
json.dump(state, f)
except Exception as e:
Logger.warning(f"[Stealth] could not persist daily runtime: {e}")
def daily_budget_seconds() -> float:
"""Today's runtime allowance in seconds. 0 = unlimited.
Rolled once per calendar day and persisted, so restarts cannot extend it.
"""
try:
hours = float(Config().stealth.get("daily_budget_h", 0) or 0)
except Exception:
hours = 0
if hours <= 0:
return 0.0
state = _daily_read()
if not state.get("budget_s"):
try:
jitter = float(Config().stealth.get("daily_budget_jitter", 0.12) or 0)
except Exception:
jitter = 0.12
# Jitter DOWNWARD only. daily_budget_h is a ceiling, not an average: a
# two-sided roll on 8h could hand out 9h, which is not what "cap it at 8
# hours" means. Varying 7-8h still breaks the identical-length-day signal
# this exists for, without ever exceeding the configured limit.
state["budget_s"] = random.uniform(hours * (1 - jitter), hours) * 3600
_daily_write(state)
Logger.info(f"[Stealth] Daily budget for {state['date']}: {state['budget_s'] / 3600:.1f}h")
return float(state["budget_s"])
def daily_runtime_tick() -> float:
"""Add elapsed time since the last tick to today's total; return the total.
Call periodically (end of each game). Time before the first tick in a
process is not counted a crash therefore under-counts rather than
over-counts, which is the safe direction for a cap.
"""
global _daily_last_tick
now = time.time()
if _daily_last_tick is None:
_daily_last_tick = now
return float(_daily_read().get("seconds", 0.0))
state = _daily_read()
# A date rollover resets seconds AND clears the budget so a new one is rolled.
state["seconds"] = float(state.get("seconds", 0.0)) + (now - _daily_last_tick)
_daily_last_tick = now
_daily_write(state)
return state["seconds"]
def daily_budget_exceeded() -> tuple[bool, float, float]:
"""(exceeded, seconds_used_today, budget_seconds). budget 0 = unlimited."""
budget = daily_budget_seconds()
used = daily_runtime_tick()
if not budget:
return False, used, 0.0
return used >= budget, used, budget
def idle_drift():
"""Nudge the cursor a little, the way a resting hand does.
+66
View File
@@ -0,0 +1,66 @@
"""The in-game ESC menu must be detected and closed.
2026-08-28: the bot was found sitting in OPTIONS -> VIDEO with a "settings have
changed, apply or discard?" modal. Discarding revealed the cause — the in-game
ESC menu carries these buttons at SCREEN CENTRE:
OPTIONS / SAVE AND EXIT / RETURN TO GAME / LOOT FILTER / CHRONICLE
A stray esc opens the menu, and the bot's next movement click lands on one of
them. The HUD mask deliberately leaves screen centre clickable, so nothing
stops it.
That single mechanism explains three separate incidents: the loot filter being
toggled, CHRONICLE blanking every template match for a whole run, and the video
options being opened and changed.
It has NO close button, so CenterPanel (which matches CLOSE_PANEL_2) cannot see
it. Bug 31 warned about exactly this: "just send esc is WORSE: with nothing
open, esc opens the GAME MENU".
"""
import inspect
def test_game_menu_screenobject_exists():
from ui_manager import ScreenObjects
assert hasattr(ScreenObjects, "GameMenu")
def test_guard_closes_the_menu_before_chickening():
from health_manager import HealthManager
src = inspect.getsource(HealthManager)
assert "ScreenObjects.GameMenu" in src, "the guard never checks for the in-game menu"
menu_at = src.index("ScreenObjects.GameMenu")
chicken_at = src.index("Chickening to be safe")
assert menu_at < chicken_at, "menu escape must come before the chicken path"
def test_mana_threshold_crossings_are_logged():
"""Issue #23 cannot be settled without this.
Mana was only logged when a potion was DRUNK, so a second dip that failed to
trigger was indistinguishable from mana never dipping twice.
"""
from health_manager import HealthManager
src = inspect.getsource(HealthManager)
assert "MANA>" in src, "no threshold-crossing log; #23 stays undecidable"
assert "_mana_below_threshold" in src, "crossings are not edge-triggered"
def test_save_and_exit_pauses_the_panel_check():
"""The guard must not fight the shutdown it was built to protect.
save_and_exit deliberately opens the in-game ESC menu. The health manager
now closes that menu on sight, and callers only pause AFTER save_and_exit
returns so the guard raced the shutdown: 75 escapes across 15 games,
interleaved with the save/exit clicks.
"""
import inspect
from ui import view
src = inspect.getsource(view.save_and_exit)
assert "set_panel_check_paused(True)" in src, (
"save_and_exit opens the ESC menu without pausing the panel check, so "
"the GameMenu guard will close it mid-shutdown"
)
assert "finally" in src, "the pause must be released even if save/exit raises"
+87
View File
@@ -0,0 +1,87 @@
"""The heading-rejection abort must actually fire.
2026-08-28: across 79 hell games the pather logged 4 random guesses and **0
aborts**, including this sequence inside a single traverse:
Traverse from a5_town_start to a5_nihlathak_portal
rejecting low-confidence A5_TOWN_1 (66.4%) for node 3
rejecting low-confidence A5_TOWN_1 (65.9%) for node 3
rejecting low-confidence A5_TOWN_1 (64.1%) for node 3
taking a random guess towards (-218, 23)
Wanted to select A5_RED_PORTAL, but could not find it
Three rejections is the threshold, so the abort should have tripped. The check
existed, was correctly indented inside the loop, and sat before the anti-stuck
block but the loop does not reliably return to the top of the body after a
rejection, so it was never evaluated at the moment the counter crossed.
The fix is to check immediately after the find, in the same iteration the
counter trips, removing the dependence on control flow.
"""
import inspect
def test_counter_trips_exactly_at_the_threshold():
from pather import Pather
class Fake(Pather):
def __init__(self):
self._heading_rejects = 0
f = Fake()
fired = None
for it in range(1, 10):
f._heading_rejects += 1 # every find rejects
if f._heading_rejects >= f._MAX_HEADING_REJECTS:
fired = it
break
assert fired == Pather._MAX_HEADING_REJECTS, (
f"abort trips on iteration {fired}, expected {Pather._MAX_HEADING_REJECTS}"
)
def test_scan_and_abort_precede_the_anti_stuck_move():
"""The scan must happen BEFORE the anti-stuck force-move, not after it.
With two rejections already banked, the anti-stuck block force-moves along
last_direction as soon as 3.1s elapses -- driving a wall-wedged character
further in -- before find_abs_node_pos records the third rejection. An abort
placed only AFTER the find therefore still leaves the exact guess this guard
exists to prevent reachable on the threshold iteration.
An earlier version of this test searched only the source AFTER the find, so
it could not see the guess block at all (which sits before it). It passed
while the hole was open.
"""
from pather import Pather
src = inspect.getsource(Pather.traverse_nodes)
code = [l for l in src.splitlines() if not l.strip().startswith("#")]
def first(pred):
return next((i for i, l in enumerate(code) if pred(l)), None)
find_at = first(lambda l: "find_abs_node_pos(node_idx" in l)
stuck_at = first(lambda l: "did_force_move and time.time() - last_move" in l)
assert find_at is not None, "no node scan found"
assert stuck_at is not None, "no anti-stuck block found"
abort_after_find = next(
(i for i, l in enumerate(code) if i > find_at and "_MAX_HEADING_REJECTS" in l),
None,
)
assert abort_after_find is not None, "no abort check after the scan"
assert find_at < stuck_at, (
"the node scan must run BEFORE the anti-stuck force-move, or the "
"threshold rejection is recorded too late to stop the guess"
)
assert abort_after_find < stuck_at, (
"the abort decision must be made BEFORE the anti-stuck force-move"
)
def test_threshold_is_small_enough_to_beat_the_guess():
from pather import Pather
assert Pather._MAX_HEADING_REJECTS <= 3, (
"observed failures showed only 3 rejections before the guess"
)