Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc5ff03970 | ||
|
|
545fd501d5 | ||
|
|
edb59dd5a4 | ||
|
|
3ed47ed822 | ||
|
|
ec6599f6d3 | ||
|
|
b8a36e8231 | ||
|
|
1c19b8bdc8 | ||
|
|
f43824f11d | ||
|
|
b5922b6b19 | ||
|
|
a139fb3230 | ||
|
|
914a107a72 |
@@ -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
@@ -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
@@ -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
@@ -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 ====
|
||||
|
||||
@@ -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 64–68s → **41–60s**.
|
||||
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 50–68s. 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.96–0.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.436–0.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.358–0.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` (65–68% 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
@@ -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:
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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"]:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user