Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccef0d01ea | ||
|
|
8e92a88fec | ||
|
|
e2da98d474 | ||
|
|
34ad263ef6 | ||
|
|
d9f68bef33 | ||
|
|
ecb8418df8 | ||
|
|
e66896e499 | ||
|
|
70d7c7dcf2 | ||
|
|
bc5ff03970 | ||
|
|
545fd501d5 | ||
|
|
edb59dd5a4 | ||
|
|
3ed47ed822 | ||
|
|
ec6599f6d3 | ||
|
|
b8a36e8231 | ||
|
|
1c19b8bdc8 | ||
|
|
f43824f11d | ||
|
|
b5922b6b19 | ||
|
|
a139fb3230 | ||
|
|
914a107a72 | ||
|
|
9c06cd8474 | ||
|
|
e17b8c6c94 | ||
|
|
3597c01b17 | ||
|
|
ee39aa5dbf | ||
|
|
e6ba9ad830 | ||
|
|
48f46fba7f | ||
|
|
bc398c471f | ||
|
|
91e201c5f6 | ||
|
|
74c3050163 | ||
|
|
ce394c2cb6 | ||
|
|
0ce92b4e7c | ||
|
|
3942ad4cf5 | ||
|
|
913eeeff75 | ||
|
|
dfd64b8f4d | ||
|
|
a7e9f4e50f | ||
|
|
1f440cfed2 | ||
|
|
c8e63bc9fa | ||
|
|
fd889a4df7 | ||
|
|
2feb13f44a | ||
|
|
5b386391df | ||
|
|
963b19dbe5 | ||
|
|
93185cb93c | ||
|
|
2162046dac | ||
|
|
55075d2c23 | ||
|
|
83c211bd5f | ||
|
|
31dacab9e3 | ||
|
|
0a4575ea6a | ||
|
|
f0f50a9332 | ||
|
|
2966ba4f7b | ||
|
|
8aa150d6a3 | ||
|
|
371abafa35 | ||
|
|
00e047db98 | ||
|
|
4bdc5b90db | ||
|
|
5211564df4 | ||
|
|
e42211231e | ||
|
|
c3557678d5 | ||
|
|
da83f71bef | ||
|
|
3d153e67f5 | ||
|
|
f70e118f88 | ||
|
|
11186e2c7d | ||
|
|
6ac1105fc7 | ||
|
|
cad7d898a4 | ||
|
|
4de8f6192e | ||
|
|
7763afdcb8 | ||
|
|
c0e5241eb9 | ||
|
|
831e0d9319 | ||
|
|
489f779849 | ||
|
|
402c768fc8 | ||
|
|
2e7517f9b8 | ||
|
|
9325c9cc63 | ||
|
|
8da4c8eb9d | ||
|
|
f01610e53f | ||
|
|
e976749910 | ||
|
|
dfacb12393 | ||
|
|
2402c18e06 | ||
|
|
4a5459ab15 | ||
|
|
a213509a03 | ||
|
|
938554e622 | ||
|
|
b7ce002e4e | ||
|
|
fa72da56e3 | ||
|
|
abe0a54849 | ||
|
|
4db82cfcd6 | ||
|
|
97703acf85 | ||
|
|
5df9ff4732 | ||
|
|
98e12c0e00 | ||
|
|
e9b6c71607 | ||
|
|
c26f249268 | ||
|
|
a014fc538e | ||
|
|
09bfd330e6 | ||
|
|
ce930f2a76 | ||
|
|
c8a6657b0a | ||
|
|
1d8a6657e2 | ||
|
|
81c8d65375 | ||
|
|
28b7c210d8 | ||
|
|
aa48c29df8 | ||
|
|
f1b0f6439c | ||
|
|
cf4c557a26 | ||
|
|
7a7950e752 | ||
|
|
fc9a7bfae1 | ||
|
|
fd82f0ff05 | ||
|
|
394aca35f4 | ||
|
|
e091a3cca4 | ||
|
|
f69d5c01c4 | ||
|
|
14f5876524 | ||
|
|
9e4ef6721a | ||
|
|
c04c60ead7 |
@@ -13,26 +13,27 @@ 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
|
||||
- name: Checkout (skipped - workspace pre-populated)
|
||||
run: echo "Using pre-populated workspace"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
# cache disabled for act_runner
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-win11.txt
|
||||
shell: bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Install Tesseract OCR
|
||||
run: choco install tesseract --no-progress -y
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tesseract-ocr libgl1 libglib2.0-0 python3-tk
|
||||
|
||||
- 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,7 +202,8 @@ jobs:
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
retention-days: 7
|
||||
|
||||
@@ -30,8 +30,29 @@ data/d2jsp_pages/
|
||||
data/d2jsp_topic_urls.txt
|
||||
|
||||
# Debug files
|
||||
debug_*.png
|
||||
debug_forum.html
|
||||
|
||||
# Dev tools (moved to tools/)
|
||||
asset_extractor.py
|
||||
asset_manager.py
|
||||
build.py
|
||||
desktop_snap.py
|
||||
development.md
|
||||
quest_debug.py
|
||||
quest_plan.md
|
||||
quest_screenshot_tool.py
|
||||
references/improvements.md
|
||||
run_asset_extractor.bat
|
||||
run_chipped_gems.bat
|
||||
run_gems_all.bat
|
||||
run_quest_debug.bat
|
||||
screenshot_tool.py
|
||||
start_bot_detached.bat
|
||||
start_botty.ps1
|
||||
test_plan.md
|
||||
update.bat
|
||||
|
||||
# Generated/runtime price data (updated automatically by bot)
|
||||
config/fg_daily_estimates.json
|
||||
config/fg_prices.json
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# Botty - Open Issues & TODO Plan
|
||||
|
||||
Generated: 2026-06-01
|
||||
|
||||
---
|
||||
|
||||
## HIGH PRIORITY (affects botting reliability)
|
||||
|
||||
### 1. BNIP transpiler broken code (`src/bnip/transpile.py:369`)
|
||||
- **Status:** `# TODO FIX THIS SHIT` — `remove_quantity()` function is hacky
|
||||
- **Problem:** Expression splitting by `#` is fragile; can corrupt BNIP expressions with multiple `#` delimiters
|
||||
- **Impact:** Pickit rules may silently misparse quantity operators
|
||||
- **Fix:** Rewrite `remove_quantity()` to properly handle `#`-delimited expressions with edge cases
|
||||
- **File:** `src/bnip/transpile.py`
|
||||
|
||||
### 2. FOH cast delay missing (`src/char/paladin/fohdin.py:99`)
|
||||
- **Status:** `# TODO: add delay between FOH casts--doesn't properly cast each FOH in sequence`
|
||||
- **Problem:** FOH casts fire too fast; some casts don't land in sequence
|
||||
- **Impact:** Reduced DPS, wasted FOH rotations
|
||||
- **Fix:** Add `wait()` between FOH casts to ensure each cast completes before next
|
||||
- **File:** `src/char/paladin/fohdin.py`
|
||||
|
||||
### 3. Chest opening telekinesis workaround (`src/chest.py:51`)
|
||||
- **Status:** `# TODO: Act as picking up a potion to support telekinesis`
|
||||
- **Problem:** Chest open simulates potion pickup to work around telekinesis skill
|
||||
- **Impact:** Fragile interaction; may break with game updates
|
||||
- **Fix:** Implement proper chest interaction that accounts for telekinesis
|
||||
- **File:** `src/chest.py`
|
||||
|
||||
### 4. Inventory full handling (`src/item/pickit.py:236`)
|
||||
- **Status:** `#TODO Create logic to handle inventory full`
|
||||
- **Problem:** When inventory fills, pickit just stops — doesn't try to stash, sell, or prioritize
|
||||
- **Impact:** Bot stops picking up items mid-run; lost gold/runes
|
||||
- **Fix:** Add fallback logic: stop picking, trigger town run to stash/sell
|
||||
- **File:** `src/item/pickit.py`
|
||||
|
||||
### 5. Overburdened handling (`src/ui/view.py:109`)
|
||||
- **Status:** `#TODO: handle "Overburdened"`
|
||||
- **Problem:** `pickup_corpse()` doesn't detect "Overburdened" state after clicking
|
||||
- **Impact:** Bot may get stuck trying to pickup corpse when overweight
|
||||
- **Fix:** Add template detection for "Overburdened" UI and handle gracefully
|
||||
- **File:** `src/ui/view.py`
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM PRIORITY (code quality / edge cases)
|
||||
|
||||
### 6. BNIP parenthesis cross-section check (`src/bnip/transpile.py:199`)
|
||||
- **Status:** `# TODO Backtrace until the last opening to make sure it wasn't from the past section.`
|
||||
- **Problem:** Parenthesis validation doesn't catch `(` in one BNIP section and `)` in another
|
||||
- **Impact:** Silent BNIP syntax errors that pass validation
|
||||
- **Fix:** Implement backtrace to reject cross-section parentheses
|
||||
- **File:** `src/bnip/transpile.py`
|
||||
|
||||
### 7. BNIP lexer misplaced checks (`src/bnip/lexer.py:319`)
|
||||
- **Status:** `# TODO: The second checks seem a little misplaced`
|
||||
- **Problem:** `NTIPAliasClass` and `TokenType.CLASS:` checks should be in transpiler validation, not lexer
|
||||
- **Impact:** Code organization; potential missed validation
|
||||
- **Fix:** Move validation logic to `transpile.py` and emit warnings
|
||||
- **File:** `src/bnip/lexer.py`, `src/bnip/transpile.py`
|
||||
|
||||
### 8. BNIP actions error handling (`src/bnip/actions.py:209`)
|
||||
- **Status:** `# TODO look at these errors`
|
||||
- **Problem:** BNIP load errors are printed but not properly logged or categorized
|
||||
- **Impact:** Hard to diagnose BNIP parse failures
|
||||
- **Fix:** Replace `print()` with proper `Logger.error()` and structured error reporting
|
||||
- **File:** `src/bnip/actions.py`
|
||||
|
||||
### 9. Pickit return type (`src/item/pickit.py:165`)
|
||||
- **Status:** `TODO :return: return a list of the items that were picked up`
|
||||
- **Problem:** Docstring says it should return a list, but function returns `bool`
|
||||
- **Impact:** Inconsistent API; callers can't know what was actually picked
|
||||
- **Fix:** Return `list[Item]` of picked items instead of `bool`
|
||||
- **File:** `src/item/pickit.py`
|
||||
|
||||
### 10. Consumable auto-belt (`src/inventory/personal.py:350`)
|
||||
- **Status:** `# TODO: logic for trying to add potion to belt if there are needs`
|
||||
- **Problem:** Consumables found during inventory management aren't auto-added to belt
|
||||
- **Impact:** Bot doesn't restock belt potions from inventory during runs
|
||||
- **Fix:** Add logic to detect belt needs and move potions from inventory
|
||||
- **File:** `src/inventory/personal.py`
|
||||
|
||||
### 11. Merc blocking templates (`src/run/nihlathak.py:45`)
|
||||
- **Status:** `# TODO: We might need a second template for each option as merc might run into the template`
|
||||
- **Problem:** Merc can stand on template match location, causing detection failure
|
||||
- **Impact:** Nihlathak run fails to detect layout variant
|
||||
- **Fix:** Add backup templates with offset ROIs for each layout variant
|
||||
- **File:** `src/run/nihlathak.py`
|
||||
|
||||
---
|
||||
|
||||
## LOW PRIORITY (cleanup / refactoring)
|
||||
|
||||
### 12. Character select cleanup (`src/ui/character_select.py:109`)
|
||||
- **Status:** `# TODO: can cleanup logic here, can we utilize a generic ScreenObject or use custom locator?`
|
||||
- **Problem:** Character selection uses ad-hoc template search instead of reusable ScreenObject
|
||||
- **Fix:** Refactor to use `ScreenObjects` pattern
|
||||
- **File:** `src/ui/character_select.py`
|
||||
|
||||
### 13. Screen utility functions (`src/screen.py:104`)
|
||||
- **Status:** `# TODO: Move the below funcs to utils(?)`
|
||||
- **Problem:** `convert_monitor_to_screen()` and related functions live in `screen.py` but could be in utils
|
||||
- **Fix:** Move coordinate conversion functions to `src/utils/`
|
||||
- **File:** `src/screen.py`
|
||||
|
||||
### 14. Graphic debugger re-init (`src/utils/graphic_debugger.py:60`)
|
||||
- **Status:** `# TODO: these two layers variable needs to be reassigned because F10 will not re-init`
|
||||
- **Problem:** Debugger layers don't reinitialize properly on F10 toggle
|
||||
- **Fix:** Move layer state into controller class; reinit on stop/start
|
||||
- **File:** `src/utils/graphic_debugger.py`
|
||||
|
||||
### 15. mttkinter logging (`src/utils/mttkinter.py:62-63`)
|
||||
- **Status:** `# TODO: Replace custom logging functionality with standard logging.Logger`
|
||||
- **Problem:** Custom logging in tkinter utils instead of standard library
|
||||
- **Fix:** Replace with `logging.Logger`
|
||||
- **File:** `src/utils/mttkinter.py`
|
||||
|
||||
### 16. Pickit test note (`test/nip/keep_item_test_cases.py:978`)
|
||||
- **Status:** `# TODO: I had to change from [defense] >= 47 to [plusdefense] >= 47`
|
||||
- **Problem:** `[defense]` is a calculated property; `[plusdefense]` is the raw value. Note for future reference.
|
||||
- **Fix:** Document in BNIP docs that `[defense]` is calculated; `[plusdefense]` is the raw modifier
|
||||
- **File:** `test/nip/keep_item_test_cases.py`
|
||||
|
||||
### 17. New route scaffolding (`src/utils/new_route.py`)
|
||||
- **Status:** Multiple TODO placeholders (by design — it's a code generator template)
|
||||
- **Problem:** Template placeholders are intentional; not bugs
|
||||
- **Fix:** No action needed — these are scaffolding placeholders
|
||||
- **File:** `src/utils/new_route.py`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Priority | Count | Files |
|
||||
|----------|-------|-------|
|
||||
| HIGH | 5 | transpile.py, fohdin.py, chest.py, pickit.py, view.py |
|
||||
| MEDIUM | 6 | transpile.py, lexer.py, actions.py, pickit.py, personal.py, nihlathak.py |
|
||||
| LOW | 6 | character_select.py, screen.py, graphic_debugger.py, mttkinter.py, keep_item_test_cases.py, new_route.py |
|
||||
|
||||
**Total: 17 items across 13 files**
|
||||
@@ -1,87 +0,0 @@
|
||||
# Botty Improvements Implementation Plan
|
||||
|
||||
## Status Legend
|
||||
- [ ] Not started
|
||||
- [~] In progress
|
||||
- [x] Done
|
||||
- [-] Cancelled / low priority
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Key Auto-Detection (Issue #940)
|
||||
Read D2R .key file and auto-fill hotkeys.
|
||||
|
||||
- [x] Create src/utils/key_detector.py module
|
||||
- [x] VK code mapping (partial — needs review for accuracy)
|
||||
- [x] Parse .key file (text format: VK action_type param)
|
||||
- [x] Auto-fill empty [char] hotkeys from detected bindings
|
||||
- [x] Auto-fill build-specific skill hotkeys (fohdin, hammerdin, etc.)
|
||||
- [x] Wire into config.py load_data()
|
||||
- [ ] REVIEW: Verify VK_MAP accuracy (D2R uses its own VK offset scheme)
|
||||
- [ ] REVIEW: Skill slot-to-config matching is heuristic — may misassign
|
||||
- [ ] TEST: Verify against actual D2R .key file on user's machine
|
||||
|
||||
## Phase 2: Target Detection False Positives (Issues #959/#964)
|
||||
Health bars and "immune to X" text mistaken for targets.
|
||||
|
||||
- [ ] Analyze current get_visible_targets() in target_detect.py
|
||||
- [ ] Add shape/size filtering: health bars are thin horizontal strips, immune text is small
|
||||
- [ ] Add aspect ratio check: real targets (poison/freeze auras) are roughly circular/elliptical
|
||||
- [ ] Add minimum bounding box height constraint (filter out thin text)
|
||||
- [ ] Optionally: add color temperature check (immune text is yellow/gold, not blue/green)
|
||||
- [ ] Test with screenshots of edge cases
|
||||
|
||||
## Phase 3: Pickit Timing Fix (Issue #939)
|
||||
Items skipped because bot teleports away before grabbing.
|
||||
|
||||
- [ ] Review pickit.py _yoink_item() for timing issues
|
||||
- [ ] Add configurable pickup_delay parameter (current: fixed timing)
|
||||
- [ ] Add retry logic: if item still visible after pickup attempt, re-try
|
||||
- [ ] Add "slow mode" for large/heavy items (framed/magic items may animate longer)
|
||||
- [ ] Ensure bot doesn't teleport until pickup animation completes
|
||||
- [ ] Test: verify no "Attempt to pick xyz" warnings followed by teleport
|
||||
|
||||
## Phase 4: Parallel Template Search (Issue #848)
|
||||
Speed up template_finder.search() with threading.
|
||||
|
||||
- [ ] Add ThreadPoolExecutor-based search_all_parallel()
|
||||
- [ ] Keep existing search() for single-template (no overhead)
|
||||
- [ ] Only parallelize when searching >3 templates simultaneously
|
||||
- [ ] Benchmark: measure speedup on typical 1280x720 grab
|
||||
|
||||
## Phase 5: Async Mouse Moves (Issue #955)
|
||||
Non-blocking mouse movement.
|
||||
|
||||
- [ ] Add async_move() to utils/custom_mouse.py
|
||||
- [ ] Run movement in background thread
|
||||
- [ ] Add is_moving() / wait_for_move() synchronization
|
||||
- [ ] Integrate into game_controller.py for smoother action chains
|
||||
|
||||
## Phase 6: Hardcore Chicken Loop Fix (Issue #942)
|
||||
Prevent infinite death loops on Hardcore characters.
|
||||
|
||||
- [ ] Review death_manager.py chicken logic
|
||||
- [ ] Add max_chicken_count config parameter (default: 3)
|
||||
- [ ] If max chicken count exceeded on HC, exit gracefully instead of re-entering
|
||||
- [ ] Add defensive chicken config option (chicken to TP instead of full chicken)
|
||||
- [ ] Test: verify HC character exits cleanly after N deaths
|
||||
|
||||
## Phase 7: Auto-Label NPCs (Issue #950)
|
||||
Learn vendor identities automatically during gameplay.
|
||||
|
||||
- [ ] During town states, detect NPC name plates via OCR
|
||||
- [ ] Cross-reference detected names with known NPC list
|
||||
- [ ] Auto-capture NPC templates when confidence is high
|
||||
- [ ] Store learned templates in assets/npc/
|
||||
- [ ] This is a long-term feature — lower priority
|
||||
|
||||
---
|
||||
|
||||
## Priority Order (implement in this order)
|
||||
1. **Phase 1** - Key auto-detection (already partially done, needs review + test)
|
||||
2. **Phase 3** - Pickit timing (high impact on loot collection)
|
||||
3. **Phase 2** - Target detection (high impact on kill reliability)
|
||||
4. **Phase 6** - Hardcore chicken fix (safety critical)
|
||||
5. **Phase 4** - Parallel template search (performance)
|
||||
6. **Phase 5** - Async mouse moves (quality of life)
|
||||
7. **Phase 7** - Auto-label NPCs (long-term feature)
|
||||
@@ -1,114 +0,0 @@
|
||||
# Dia-run test #2 — session state & plan (2026-06-11 ~20:45)
|
||||
|
||||
## OUTCOME (21:29) — ALL FIXES VERIFIED ✅
|
||||
Full Diablo run completed with every fix live: game 1 (21:14 session) ran Pindle (45s, clean,
|
||||
no BC double-swap... BC retried once — template marginal, see below) then run_diablo SUCCEEDED
|
||||
(21:17:28→21:29:04): A5 WP found FIRST TRY at new 0.62 threshold (was 0-for-5 before),
|
||||
vendor trip skipped by gating, CS layout matched 91.5%, all seals, Diablo killed, game ended
|
||||
clean at 872s. Implemented beyond the original plan: quick-mode for open_wp (failure 1 → direct
|
||||
path only on next call; failure 2 → instant fail), sweep 10→6 steps, select timeout 4s, and
|
||||
A5 threshold drops (stash 0.60/0.45, WP 0.62 — safe because success_func gates every click).
|
||||
Remaining known marginals (non-blocking): BC/BO skill icon template (1 extra swap ~2s, 2/3 games),
|
||||
A5_TOWN_0/1 town markers (detect_current_act warns but soft-falls-back), npc body templates
|
||||
(Larzuk/Cain flaky, fallbacks work), numpy truthiness bug in missing-template debug screenshot
|
||||
helper, inventory-full pickit skips until next successful stash. Char left at char-select/lobby,
|
||||
D2R running, no bot processes.
|
||||
|
||||
## Goal
|
||||
User asked: "trigger a full dia run and monitor it for loops and mistakes". A full Diablo run
|
||||
(WP → ROF → CS → 3 seals → kill) must complete while logging all loops/mistakes, then deliver
|
||||
an analysis report.
|
||||
|
||||
## Current state
|
||||
- Bot was F12-stopped at 20:37 (was stuck in A5_WP search loop, game 3, char wandered to town wall).
|
||||
- D2R is OPEN, character "fistman" is IN the stuck game with the **Options→Video menu open**.
|
||||
- Next immediate steps: Esc out of options, click SAVE AND EXIT at **physical (650, 424)**,
|
||||
relaunch bot, F11, re-arm monitor, wait for a full dia run (stealth may randomly skip runs).
|
||||
- After run completes: F12 stop, kill leftover `cmd`/`pwsh` with `run_botty` in commandline,
|
||||
delete `log/_*.png` and `log/_run2_out.txt` scratch files, deliver findings report.
|
||||
|
||||
## How to drive (hard-won specifics)
|
||||
- Display is 1920x1200 physical, 125% scaling (1536x960 logical). **Use the botty env python**
|
||||
(`C:\ProgramData\miniforge3\envs\botty\python.exe`) with `ctypes.windll.user32.SetProcessDPIAware()`
|
||||
+ `src/input_layer/win_input.py` `mouse_move/mouse_click/mouse_wheel` for clicks (physical coords;
|
||||
cwd must be C:\Users\alex\my-botty with sys.path.insert(0,"src")).
|
||||
PowerShell `SetCursorPos`/`mouse_event` are DPI-virtualized → clicks land 1.25x off — do NOT use.
|
||||
- Click into D2R twice (first click only activates the window).
|
||||
- F11/F12 hotkeys work via `keybd_event` from anywhere (GetAsyncKeyState polling).
|
||||
- Screenshots: PIL `ImageGrab.grab(all_screens=True)` in the DPI-aware python = physical pixels.
|
||||
- Launch: `cmd /c C:\Users\alex\my-botty\run_botty.bat *> log\_run2_out.txt` (PowerShell bg task).
|
||||
- Watch `log/stats/events_*.jsonl` (newest) + `log/log.txt`.
|
||||
|
||||
## Findings so far for the final report (test #2, started 20:23)
|
||||
1. **A5_WP selection loop (CRITICAL, 3/3 occurrences after Pindle returns)**: every A5 WP open
|
||||
after a Pindle run fails first try ("Wanted to select A5_WP"); anchor retries (qual_kehk, malah)
|
||||
sometimes recover (~25s cost), but in game 3 (~20:30) ALL anchors + directed sweep failed,
|
||||
char wandered to the town wall off all pather nodes, looped 15+ times until manual intervention.
|
||||
Hypothesis: after Pindle TP return, pather position estimate is wrong; traverses compound the error.
|
||||
2. **NPC detection failures**: Cain (A4) timed out → fell back to A5 Cain (worked); Tyrael resurrect
|
||||
timed out once, retry worked. Town maintenance took ~3 min in game 1 due to these.
|
||||
3. **Battle Command prebuff retry fired 3/3 games** ("Failed to find Battle Command, swapping
|
||||
weapons again") — CTA buff icon detection systematically needs a second swap.
|
||||
4. **Mouse misses (relative mode)**: ~6 occurrences, 12-80px off, all self-corrected via SetCursorPos
|
||||
retry (win_input fallback working as designed).
|
||||
5. **Player chicken at Pindle** game 2 (HP 37.2%, 59s game) — survivability, not logic.
|
||||
6. **Stealth random skip** skipped Diablo in game 1 — by design but reduces dia throughput.
|
||||
7. **D2R settings now verified correct** (in-game screenshots 20:42): DLSS OFF, 1280x720 windowed,
|
||||
texture HIGH, details LOW, AA/AO off → matches assets/d2r_settings.json (startup warning gone).
|
||||
Previous session's CS template failures should be fixed; pentagram matched 95% last session.
|
||||
8. Earlier fixes this session (all verified live): hotkey.wait() no-arg blocking bug, edge-triggered
|
||||
hotkeys, OCR tesseract_cmd wiring, NipSyntaxError→BNipSyntaxError + Schaefershammer typo.
|
||||
|
||||
## PERMANENT FIX PLAN (user-approved direction: fix properly, prefer smarter designs)
|
||||
|
||||
### Fix 1 — A5_WP loop: fail-fast + fresh game (CRITICAL, the 10-min wander)
|
||||
`a5.py:open_wp` already has 3 escalation layers (direct path → 3 anchors → directed sweep,
|
||||
~3.5 min total). The death loop is the OUTER chain: `bot.on_maintenance` retry sites call
|
||||
`buy_consumables`/`go_to_act` again → `town_manager.open_wp` again → full 3.5-min escalation
|
||||
again, from an ever-worse position estimate. Each failed cycle compounds.
|
||||
**Smart fix:** position estimates can't be trusted after a failure, but a NEW GAME gives a
|
||||
guaranteed-known spawn in ~40s. Add a per-game WP-failure budget on the `Bot` instance:
|
||||
- `self._wp_fail_count` reset in `on_init`; `town_manager.open_wp` failure increments it
|
||||
(thread the signal via return or a callback).
|
||||
- In `on_maintenance`/`on_end_run`: if `_wp_fail_count >= 2` → `_save_error_screenshot` +
|
||||
`trigger_or_stop("end_game", failed=True)` immediately. No more wandering retries.
|
||||
- Also cap `a5.open_wp` layer 3 (sweep) to run only on the FIRST failure per game; subsequent
|
||||
calls in the same game go straight to fail (the sweep from an unknown spot is what walked the
|
||||
char onto the town wall).
|
||||
|
||||
### Fix 2 — same family: A5_RED_PORTAL first-click miss (Pindle approach, seen 20:47)
|
||||
Same position-estimate root cause, already has a "retry from town start" recovery that works.
|
||||
Include its failure in the same per-game budget rather than new mechanisms.
|
||||
|
||||
### Fix 3 — reduce A5→A4 Jamella trips (exposure reduction, smarter)
|
||||
`buy_consumables: in A5 — traveling to A4 Jamella (Malah unreliable)` runs every game even when
|
||||
only selling 1-2 junk items. Gate the trip: only travel to A4 if (pots needed below threshold)
|
||||
OR (tp/id tomes low) OR (inventory has >N sell items). Selling junk can wait; stash is in A5.
|
||||
Fewer WP trips = fewer chances to hit Fix-1 territory.
|
||||
|
||||
### Fix 4 — Battle Command prebuff double-swap (3/3 games)
|
||||
`Failed to find Battle Command, swapping weapons again` every game. The buff check runs too
|
||||
soon after weapon swap (buff icons fade in). In the prebuff code (char/hammerdin.py or
|
||||
i_char.pre_buff): add ~0.4-0.6s wait after CTA casts before checking the buff bar, and lower
|
||||
the BC icon threshold slightly (capture shows icons render fine). Saves a full swap cycle/game.
|
||||
|
||||
### Fix 5 — Cain ID: sticky act preference
|
||||
A4 Cain timed out (20s wasted) then A5 Cain worked. Cache `self._last_good_cain_act` on Bot;
|
||||
try that act first next game. One-line behavioral memory, halves ID time after first game.
|
||||
|
||||
### Fix 6 — leave as-is (verified fine)
|
||||
- Mouse misses: ~6/session, all self-corrected by SetCursorPos retry (stealth Bezier primary
|
||||
path is intentional). No change.
|
||||
- Stealth random run skip: by design.
|
||||
- Pindle chicken @ 37% HP: gear/survivability, not code. Mention to user only.
|
||||
|
||||
### Verification after implementing
|
||||
- Unit-light: run 3+ games (`run_pindle`+`run_diablo`), grep log for: no second consecutive
|
||||
`Wanted to select A5_WP` burst per game; `Battle Command` retry absent; Jamella trip skipped
|
||||
when nothing needed; failed-WP game ends < 90s instead of 900s timeout.
|
||||
|
||||
## Stats so far (test #2)
|
||||
- Game 1: Pindle OK (46s) + Diablo stealth-skipped. Maintenance ~3min (Cain/Tyrael/A5_WP issues).
|
||||
- Game 2: Pindle chicken @ HP 37% (59s, failed).
|
||||
- Game 3: Pindle OK (43s), then A5_WP loop before Diablo → manually stopped 20:37.
|
||||
- Diablo run not yet completed in test #2.
|
||||
@@ -1,561 +0,0 @@
|
||||
# Quest Framework + Den of Evil Plan
|
||||
|
||||
## Goal
|
||||
Build a quest automation framework in botty that can interact with D2R NPCs, handle dialogue,
|
||||
track quest progress, and run Den of Evil as the first quest -- all usable by a low-level FoHdin.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The quest framework is a new subsystem that plugs into the existing botty state machine.
|
||||
It follows the same patterns as existing runs (approach -> battle -> return to town) but adds
|
||||
NPC dialogue interaction and quest state persistence.
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
src/quest/
|
||||
__init__.py # Exports
|
||||
quest_manager.py # Quest state machine + persistence (JSON)
|
||||
quest_dialogue.py # OCR-based NPC dialogue interaction
|
||||
quest_items.py # Quest item detection/pickup
|
||||
quest_combat.py # Lightweight combat wrapper (killing trash)
|
||||
a1/
|
||||
__init__.py
|
||||
q_den_of_evil.py # Den of Evil run
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
```
|
||||
src/npc_manager.py # Add TOWN_MAIDEN NPC constant + templates
|
||||
src/pather.py # Add A1_ROARING_CANYON + DoE entrance locations
|
||||
src/bot.py # Add quest state, transitions, handler
|
||||
src/run/__init__.py # Export DenOfEvil
|
||||
src/town/a1.py # (optional) Add can_do_den_of_evil method
|
||||
config/params.ini # Add run_doe to [routes]
|
||||
config/bnip/ # Add town_maiden.png template
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation
|
||||
|
||||
### 1.1 `src/quest/quest_manager.py`
|
||||
|
||||
Purpose: Track which quests are done, persist between sessions, dispatch to quest modules.
|
||||
|
||||
```python
|
||||
class QuestManager:
|
||||
"""Manages quest state: tracks done/available quests per act, persists to JSON."""
|
||||
|
||||
# Quest definitions per act
|
||||
QUESTS = {
|
||||
"a1": ["den_of_evil"],
|
||||
"a2": [], # future: radament, horadric_staff, etc.
|
||||
...
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._state_file = "config/quest_state.json"
|
||||
self._state = self._load()
|
||||
|
||||
def is_done(self, quest_name: str) -> bool:
|
||||
return self._state.get(quest_name, False)
|
||||
|
||||
def mark_done(self, quest_name: str):
|
||||
self._state[quest_name] = True
|
||||
self._save()
|
||||
|
||||
def mark_all_done(self, act: str):
|
||||
for q in self.QUESTS.get(act, []):
|
||||
self._state[q] = True
|
||||
self._save()
|
||||
|
||||
def next_pending(self, act: str) -> str | None:
|
||||
for q in self.QUESTS.get(act, []):
|
||||
if not self.is_done(q):
|
||||
return q
|
||||
return None
|
||||
|
||||
def all_done(self, act: str) -> bool:
|
||||
return all(self._state.get(q, False) for q in self.QUESTS.get(act, []))
|
||||
|
||||
def _load(self) -> dict:
|
||||
if os.path.exists(self._state_file):
|
||||
with open(self._state_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
with open(self._state_file, "w") as f:
|
||||
json.dump(self._state, f, indent=2)
|
||||
```
|
||||
|
||||
JSON format (config/quest_state.json):
|
||||
```json
|
||||
{
|
||||
"den_of_evil": true,
|
||||
"search_for_smith": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 `src/quest/quest_dialogue.py`
|
||||
|
||||
Purpose: Talk to NPCs, read dialogue options via OCR, click the right branch.
|
||||
This is the core of quest automation -- it makes the bot "converse" with NPCs.
|
||||
|
||||
```python
|
||||
class QuestDialogue:
|
||||
"""OCR-based NPC dialogue interaction for quest conversations."""
|
||||
|
||||
# ROI at 1280x720
|
||||
DIALOGUE_TEXT_ROI = (200, 470, 680, 100) # NPC speech text
|
||||
DIALOGUE_OPTIONS_ROI = (200, 560, 680, 140) # Player response buttons
|
||||
DIALOGUE_CLOSE_Y = 670 # Close button area
|
||||
|
||||
@staticmethod
|
||||
def open_dialogue(npc_name: str) -> bool:
|
||||
"""Walk to NPC and open their dialogue menu."""
|
||||
from npc_manager import Npc, open_npc_menu
|
||||
return open_npc_menu(getattr(Npc, npc_name.upper()))
|
||||
|
||||
@staticmethod
|
||||
def read_dialogue() -> dict:
|
||||
"""OCR the current dialogue box. Returns:
|
||||
{
|
||||
'npc_text': str, # What the NPC said
|
||||
'options': [str, ...], # Response options (may be empty if no choice)
|
||||
'has_continue': bool # True if just need to click continue
|
||||
}
|
||||
"""
|
||||
img = grab()
|
||||
npc_text = ocr_roi(img, self.DIALOGUE_TEXT_ROI)
|
||||
options_text = ocr_roi(img, self.DIALOGUE_OPTIONS_ROI)
|
||||
# Parse options: split by line, filter out empty, return list
|
||||
options = [line.strip() for line in options_text.split('\n') if line.strip()]
|
||||
has_continue = len(options) == 0 or "continue" in options_text.lower()
|
||||
return {
|
||||
'npc_text': npc_text.strip(),
|
||||
'options': options,
|
||||
'has_continue': has_continue
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def click_option(option_text: str) -> bool:
|
||||
"""Find and click a specific dialogue option by matching text via OCR.
|
||||
Searches the options ROI for a template match of the option text."""
|
||||
img = grab()
|
||||
options_img = cut_roi(img, self.DIALOGUE_OPTIONS_ROI)
|
||||
# Use template_finder or OCR to locate which button matches
|
||||
# Then click at that position
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def continue_dialogue() -> bool:
|
||||
"""Click the close/continue button to advance dialogue."""
|
||||
# Click in the close button area
|
||||
x, y, w, h = self.DIALOGUE_CLOSE_Y
|
||||
mouse.click at center of close area
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def follow_conversation(expected_options: list[str]) -> bool:
|
||||
"""Follow a multi-step conversation:
|
||||
- Read NPC text
|
||||
- If options present, click the expected one
|
||||
- If no options, click continue
|
||||
- Repeat until dialogue closes or unexpected text appears
|
||||
"""
|
||||
max_steps = 20 # Safety limit
|
||||
for i in range(max_steps):
|
||||
dialogue = self.read_dialogue()
|
||||
if not dialogue['has_continue'] and dialogue['options']:
|
||||
# We have a choice - click the expected option
|
||||
for opt in expected_options:
|
||||
if opt.lower() in ' '.join(dialogue['options']).lower():
|
||||
if not self.click_option(opt):
|
||||
return False
|
||||
break
|
||||
else:
|
||||
Logger.warning(f"Unexpected dialogue options: {dialogue['options']}")
|
||||
return False
|
||||
else:
|
||||
# Just continue
|
||||
if not self.continue_dialogue():
|
||||
return False
|
||||
wait(1.0, 1.5)
|
||||
# Check if dialogue box is still visible
|
||||
if not is_visible(ScreenObjects.NPCDialogue):
|
||||
return True # Done
|
||||
return False # Hit max steps
|
||||
```
|
||||
|
||||
Key design: `follow_conversation()` takes a list of expected response text. It will
|
||||
match against whatever options the NPC presents and click the right one. This handles
|
||||
multi-branch dialogues without hardcoding step-by-step clicks.
|
||||
|
||||
### 1.3 `src/quest/quest_combat.py`
|
||||
|
||||
Purpose: Lightweight combat for clearing trash during quests. Reuses existing char methods.
|
||||
|
||||
```python
|
||||
class QuestCombat:
|
||||
"""Combat helpers for quest areas -- reuses existing character combat logic."""
|
||||
|
||||
@staticmethod
|
||||
def clear_area(pather: Pather, char: IChar, path_nodes: list[int],
|
||||
timeout: float = 60) -> bool:
|
||||
"""Walk a path while killing monsters until timeout or all nodes cleared.
|
||||
This is the core of DoE: walk down, kill, walk back."""
|
||||
return pather.traverse_nodes(path_nodes, char, timeout=timeout, do_combat=True)
|
||||
|
||||
@staticmethod
|
||||
def wait_for_clear(char: IChar, timeout: float = 15) -> bool:
|
||||
"""Wait until no monsters are visible (area is clear)."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
targets = get_visible_targets()
|
||||
if not targets or len(targets) == 0:
|
||||
return True
|
||||
# Attack if enemies present
|
||||
char.attack()
|
||||
wait(0.5)
|
||||
return False
|
||||
```
|
||||
|
||||
### 1.4 `src/quest/quest_items.py`
|
||||
|
||||
Purpose: Detect and pick up quest items (gold glow detection).
|
||||
|
||||
```python
|
||||
class QuestItems:
|
||||
"""Quest item detection and management."""
|
||||
|
||||
@staticmethod
|
||||
def detect_quest_items(img: np.ndarray) -> list[tuple[float, float]]:
|
||||
"""Detect gold-glowing items on screen (quest items).
|
||||
Returns list of (x, y) positions in monitor coords."""
|
||||
quest_item_mask, _ = color_filter(img, Config().colors.get("gold_glow", [
|
||||
(180, 140, 0), (255, 220, 80)
|
||||
]))
|
||||
# Find contours, return centers
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def pick_up_quest_items(char: IChar, img: np.ndarray = None) -> bool:
|
||||
"""Find and pick up any quest items currently visible."""
|
||||
if img is None:
|
||||
img = grab()
|
||||
items = self.detect_quest_items(img)
|
||||
for pos in items:
|
||||
char.pick_up_item(pos, item_name="Quest Item")
|
||||
wait(0.5)
|
||||
return len(items) > 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: NPC & Location additions
|
||||
|
||||
### 2.1 Add Town_Maiden to `src/npc_manager.py`
|
||||
|
||||
```python
|
||||
# In class Npc:
|
||||
TOWN_MAIDEN = "town_maiden" # Act 1, Roaring Canyon
|
||||
|
||||
# In _build_npcs():
|
||||
Npc.TOWN_MAIDEN: {
|
||||
"head": "town_maiden.png", # Need to capture template
|
||||
"actions": {} # No trade/identify - just dialogue
|
||||
}
|
||||
```
|
||||
|
||||
The Town Maiden sits in Roaring Canyon (eastern part of Act 1 town). She has a simple
|
||||
dialogue: you talk to her to "unlock" the Den of Evil entrance, then you talk to her
|
||||
again after clearing it to get the XP reward and reset it for another run.
|
||||
|
||||
### 2.2 Add locations to `src/pather.py`
|
||||
|
||||
```python
|
||||
class Location:
|
||||
# ... existing locations ...
|
||||
|
||||
# Act 1 Roaring Canyon / Den of Evil
|
||||
A1_ROARING_CANYON = "a1_roaring_canyon" # Town area where Maiden is
|
||||
A1_DEN_OF_EVIL_ENTRANCE = "a1_doe_entrance" # Stairs down to DoE
|
||||
A1_DEN_LEVEL_1 = "a1_doe_level_1"
|
||||
A1_DEN_LEVEL_2 = "a1_doe_level_2"
|
||||
A1_DEN_LEVEL_3 = "a1_doe_level_3"
|
||||
A1_DEN_LEVEL_4 = "a1_doe_level_4"
|
||||
# (DoE has 3-5 levels depending on game version - need to confirm)
|
||||
```
|
||||
|
||||
Path nodes will need to be added for the Roaring Canyon area and each DoE level.
|
||||
These are captured via quest_debug.py by walking the path and recording waypoints.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Den of Evil run module
|
||||
|
||||
### 3.1 `src/quest/a1/q_den_of_evil.py`
|
||||
|
||||
```python
|
||||
class DenOfEvil:
|
||||
"""Den of Evil run - Act 1 repeatable quest for XP.
|
||||
|
||||
Flow:
|
||||
1. Ensure character is in Act 1
|
||||
2. Walk to Roaring Canyon (Town Maiden)
|
||||
3. Talk to Town Maiden (unlock entrance if needed)
|
||||
4. Enter Den of Evil
|
||||
5. Pre-buff (FoH + Conviction for FoHdin)
|
||||
6. Walk through each level, killing trash
|
||||
7. Exit back to Roaring Canyon
|
||||
8. Talk to Town Maiden again for reward
|
||||
9. Return to town center
|
||||
"""
|
||||
|
||||
name = "run_doe"
|
||||
|
||||
# Path nodes per level (to be filled in via quest_debug.py)
|
||||
LEVEL_PATHS = {
|
||||
1: [], # Entrance to level 1 stairs
|
||||
2: [], # Level 1 to level 2
|
||||
3: [], # Level 2 to level 3
|
||||
4: [], # Level 3 to level 4 (or final area)
|
||||
}
|
||||
|
||||
def __init__(self, pather, town_manager, char, pickit, runs):
|
||||
self._pather = pather
|
||||
self._town_manager = town_manager
|
||||
self._char = char
|
||||
self._pickit = pickit
|
||||
self._runs = runs
|
||||
self._quest_manager = QuestManager()
|
||||
self._dialogue = QuestDialogue()
|
||||
|
||||
def approach(self, curr_loc: Location, do_buff: bool) -> Location | bool:
|
||||
"""Get to Roaring Canyon and talk to Town Maiden."""
|
||||
Logger.info("Run Den of Evil")
|
||||
|
||||
# Ensure we're in Act 1
|
||||
if TownManager.get_act_from_location(curr_loc) != Location.A1_TOWN_START:
|
||||
curr_loc = self._town_manager.go_to_act(1, curr_loc)
|
||||
if not curr_loc:
|
||||
return False
|
||||
|
||||
# Walk to Roaring Canyon (Town Maiden area)
|
||||
if not self._pather.traverse_nodes(
|
||||
(curr_loc, Location.A1_ROARING_CANYON), self._char, force_move=True
|
||||
):
|
||||
return False
|
||||
|
||||
# Talk to Town Maiden to unlock/open the Den
|
||||
if not self._dialogue.open_dialogue("town_maiden"):
|
||||
return False
|
||||
|
||||
# Follow the conversation (expect "Oh no, not again" or similar)
|
||||
if not self._dialogue.follow_conversation(["Tell me more", "I'll help you"]):
|
||||
return False
|
||||
|
||||
# Enter the Den
|
||||
if not self._pather.traverse_nodes(
|
||||
(Location.A1_ROARING_CANYON, Location.A1_DEN_OF_EVIL_ENTRANCE),
|
||||
self._char, force_move=True
|
||||
):
|
||||
return False
|
||||
|
||||
return Location.A1_DEN_OF_EVIL_ENTRANCE
|
||||
|
||||
def battle(self, do_pre_buff: bool) -> bool | tuple[Location, bool]:
|
||||
"""Fight through the Den of Evil."""
|
||||
# Pre-buff
|
||||
if do_pre_buff:
|
||||
if not self._char.pre_buff():
|
||||
return False
|
||||
|
||||
# Clear each level
|
||||
for level in sorted(self.LEVEL_PATHS.keys()):
|
||||
Logger.info(f"Clearing Den of Evil level {level}")
|
||||
if not self._pather.traverse_nodes(
|
||||
self.LEVEL_PATHS[level], self._char, timeout=120, do_combat=True
|
||||
):
|
||||
Logger.error(f"Failed to clear DoE level {level}")
|
||||
return False
|
||||
|
||||
# Pick up any quest items / loot
|
||||
self._pickit.pick_up_items(self._char)
|
||||
QuestItems.pick_up_quest_items(self._char)
|
||||
|
||||
# Walk back to Roaring Canyon
|
||||
if not self._pather.traverse_nodes(
|
||||
(Location.A1_DEN_OF_EVIL_ENTRANCE, Location.A1_ROARING_CANYON),
|
||||
self._char, force_move=True
|
||||
):
|
||||
return False
|
||||
|
||||
# Talk to Town Maiden for reward
|
||||
if not self._dialogue.open_dialogue("town_maiden"):
|
||||
return False
|
||||
if not self._dialogue.follow_conversation(["Yes", "Thank you"]):
|
||||
Logger.warning("Failed to collect DoE reward from Town Maiden")
|
||||
|
||||
# Mark as done (for non-repeatable quests) or just return success
|
||||
# Note: DoE is repeatable once per real-day, so we DON'T mark permanently done
|
||||
# self._quest_manager.mark_done("den_of_evil") # Only if non-repeatable
|
||||
|
||||
return (Location.A1_ROARING_CANYON, True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Bot integration
|
||||
|
||||
### 4.1 `src/bot.py` changes
|
||||
|
||||
```python
|
||||
# Add import
|
||||
from quest.a1.q_den_of_evil import DenOfEvil
|
||||
|
||||
# In __init__:
|
||||
self._do_runs["run_doe"] = Config().routes.get("run_doe")
|
||||
|
||||
self._doe = DenOfEvil(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)
|
||||
|
||||
# In _states list:
|
||||
# (No new state needed - DoE uses the existing pattern: town -> doe -> end_run -> town)
|
||||
|
||||
# In _transitions list (add):
|
||||
{ 'trigger': 'run_doe', 'source': 'town', 'dest': 'doe', 'before': "on_run_doe" },
|
||||
|
||||
# Add 'doe' to end_run source list:
|
||||
{ 'trigger': 'end_run', 'source': [..., 'doe'], 'dest': 'town', 'before': "on_end_run" },
|
||||
|
||||
# Add end_game source:
|
||||
{ 'trigger': 'end_game', 'source': [..., 'doe'], 'dest': 'initialization', 'before': "on_end_game" },
|
||||
|
||||
# Add handler method:
|
||||
def on_run_doe(self):
|
||||
res = False
|
||||
self._do_runs["run_doe"] = False
|
||||
self._game_stats.update_location("DoE")
|
||||
self._curr_loc = self._doe.approach(self._curr_loc, not self._pre_buffed)
|
||||
if self._curr_loc:
|
||||
set_pause_state(False)
|
||||
res = self._doe.battle(not self._pre_buffed)
|
||||
self._ending_run_helper(res)
|
||||
```
|
||||
|
||||
### 4.2 `src/run/__init__.py` changes
|
||||
|
||||
```python
|
||||
# No change needed if DoE lives in src/quest/ (not src/run/)
|
||||
# But if we want consistency, add:
|
||||
from quest.a1.q_den_of_evil import DenOfEvil
|
||||
```
|
||||
|
||||
### 4.3 `config/params.ini` changes
|
||||
|
||||
```ini
|
||||
[routes]
|
||||
; ... existing runs ...
|
||||
; run_doe (Act 1 Den of Evil - repeatable daily XP)
|
||||
order=run_doe
|
||||
```
|
||||
|
||||
### 4.4 `config/params.ini` FoHdin config
|
||||
|
||||
For a lvl 1 Paladin running DoE, the params.ini needs:
|
||||
|
||||
```ini
|
||||
[char]
|
||||
type=fohdin
|
||||
...
|
||||
|
||||
[fohdin]
|
||||
; FoHdin-specific config for low-level DoE runs
|
||||
teleport=
|
||||
; No teleport at lvl 1-9, so pathing is on foot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Testing workflow
|
||||
|
||||
### What needs user input (I cannot see D2R):
|
||||
|
||||
1. **Capture Town_Maiden template:**
|
||||
- Go to Roaring Canyon in Act 1
|
||||
- Stand near the Town Maiden
|
||||
- Run `quest_debug.py`, press F4 (NPC detection)
|
||||
- Paste output so I can save the template
|
||||
|
||||
2. **Capture DoE path nodes:**
|
||||
- Enter the Den of Evil
|
||||
- Run `quest_debug.py`, press F1 at each waypoint
|
||||
- Walk from entrance through each level
|
||||
- Paste outputs so I can build the path arrays
|
||||
|
||||
3. **Capture dialogue:**
|
||||
- Talk to Town Maiden (both before and after clearing)
|
||||
- Run `quest_debug.py`, press F2 (dialogue OCR)
|
||||
- Paste output so I can code the conversation flow
|
||||
|
||||
4. **Test run:**
|
||||
- After I write the code, you run botty with `run_doe` in the route order
|
||||
- Report what happens / paste terminal output
|
||||
- I iterate based on results
|
||||
|
||||
### Lvl 1 Paladin specifics:
|
||||
|
||||
- **FoHdin requires FOH skill lvl 6 for Feign of Life passive** -- this needs 3 skill points
|
||||
in FoH, meaning character level 9 minimum (or level 4 with a +1 skill weapon)
|
||||
- Before reaching lvl 9, the bot can still run DoE but will be much more fragile
|
||||
- Recommended: manually level Paladin to ~lvl 4-5 (short runs in Area 1 or 2) before
|
||||
letting the bot solo DoE with FoH
|
||||
- The bot pathing should handle the walk-through at low speed with heavy FoH spam
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Write `quest_manager.py` (simple JSON state tracker)
|
||||
2. Write `quest_dialogue.py` (OCR-based NPC interaction)
|
||||
3. Write `quest_items.py` + `quest_combat.py` (lightweight helpers)
|
||||
4. Add Town_Maiden NPC to npc_manager.py
|
||||
5. Write `q_den_of_evil.py` (skeleton with placeholder paths)
|
||||
6. Integrate into bot.py (state, transitions, handler)
|
||||
7. Update params.ini
|
||||
8. **USER TESTS** -- captures templates, paths, dialogue
|
||||
9. I fill in the actual path nodes and dialogue based on your captures
|
||||
10. Full test run and iterate
|
||||
|
||||
---
|
||||
|
||||
## File tree after implementation
|
||||
|
||||
```
|
||||
my-botty/
|
||||
├── config/
|
||||
│ ├── params.ini # Modified: +run_doe in routes
|
||||
│ ├── quest_state.json # New: auto-created by QuestManager
|
||||
│ └── bnip/
|
||||
│ └── town_maiden.png # New: captured template
|
||||
├── src/
|
||||
│ ├── quest/ # New directory
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── quest_manager.py
|
||||
│ │ ├── quest_dialogue.py
|
||||
│ │ ├── quest_items.py
|
||||
│ │ ├── quest_combat.py
|
||||
│ │ └── a1/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── q_den_of_evil.py
|
||||
│ ├── npc_manager.py # Modified: +TOWN_MAIDEN
|
||||
│ ├── pather.py # Modified: +A1_ROARING_CANYON, +A1_DEN_* locations
|
||||
│ ├── bot.py # Modified: +doe state, transitions, handler
|
||||
│ └── run/__init__.py # Modified: +DenOfEvil export
|
||||
```
|
||||
@@ -53,6 +53,27 @@ Handles D2R window detection and screenshot capture via MSS library. Converts be
|
||||
|
||||
Template matching via OpenCV (`cv2.matchTemplate`). Searches against pre-captured asset templates in `assets/templates/`. Returns match position and validity.
|
||||
|
||||
**Asset conventions — read before adding or recapturing any template:**
|
||||
|
||||
- Every `.png` under the `TEMPLATE_PATHS` roots is loaded recursively and keyed by its
|
||||
**uppercased filename** (`a5_red_portal.png` → `A5_RED_PORTAL`). Keys are one flat
|
||||
namespace across all roots, so filenames must be globally unique — and **never leave a
|
||||
backup or scratch `.png` anywhere under `assets/`**, or it silently becomes a live
|
||||
template.
|
||||
- **Keep templates fully opaque (3-channel, or 4-channel with no zero-alpha pixel).**
|
||||
`alpha_to_mask` only produces a mask when the image has 4 channels *and* contains a
|
||||
fully transparent pixel; that mask is then passed to
|
||||
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)`. **OpenCV only properly supports
|
||||
masks for `TM_SQDIFF` and `TM_CCORR_NORMED`** — masked `TM_CCOEFF_NORMED` returns
|
||||
unreliable scores and wandering match positions. A masked template will appear to
|
||||
"work" in isolation and then fail at random. See CLAUDE.md Bug 23.
|
||||
- Crop something **structurally stable and unoccluded**. Animated or partly hidden
|
||||
features (a swirling portal's interior, a ring occluded by scenery) make poor anchors.
|
||||
- **Validate on held-out frames**: build the crop from one capture, score it against
|
||||
*other* captures, and — critically — against frames where the subject is **absent**.
|
||||
A template that scores high on both is matching background, not the subject. Aim for a
|
||||
clear gap straddling the 0.68 default threshold.
|
||||
|
||||
### UI Detection (`src/ui/`)
|
||||
|
||||
| File | Detects |
|
||||
|
||||
@@ -8,11 +8,12 @@ This file is auto-read by Claude Code at session start. It covers what you need
|
||||
|
||||
`my-botty` is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only `SendInput`, `GetCursorPos`, `SetCursorPos` via `ctypes`.
|
||||
|
||||
**Active setup (as of 2026-06-08):**
|
||||
- Character: Hammerdin (`fistman`), Hell difficulty
|
||||
- Runs: `run_diablo`, `run_pindle` (see `config/params.ini`)
|
||||
- OS: Windows 11, D2R 1280×720 windowed
|
||||
**Active setup (as of 2026-08-26):**
|
||||
- Character: FoHdin (`paladalla`, lvl 32), Normal difficulty — profile `config/profiles/paladalla/profile.ini`
|
||||
- Runs: `run_pindle` (see `config/params.ini`)
|
||||
- OS: Windows 11 (desktop at 125% scaling), D2R 1280×720 windowed, forced to `(5, 98)` on init
|
||||
- Start bot: `run_botty.bat` (runs `python src/main.py` from the conda env — always current with source; no exe build exists/needed)
|
||||
- Control without the F11 hotkey: `python scripts/hermes_bot_control.py start|pause|stop|status` (TCP 127.0.0.1:18899)
|
||||
|
||||
**Always start a session by reading logs first:**
|
||||
```
|
||||
@@ -77,11 +78,24 @@ ERROR|WARNING|failed|Approach failed|starting from True|DAMAGED|SendInput.*misse
|
||||
| `SendInput.*missed target` | Win11 pointer acceleration amplifying relative mouse moves — fixed in `win_input.py` |
|
||||
| `Repair/vendor interaction failed` | Flaky NPC detection, best-effort — bot continues |
|
||||
| `Could not identify act from location` | `_curr_loc` is `None` or `True`, not a `Location` string |
|
||||
| `Wanted to select A5_RED_PORTAL, but could not find it` | Red portal template stale/masked — see Bug 23 |
|
||||
| `Loot from <run>: nothing picked up` on **every** run | Boss never actually fought — check XP delta, see Bug 24 |
|
||||
| `already in Pindle area before portal click` | Should no longer exist; if seen, the fake-success shortcut is back — Bug 24 |
|
||||
| `rejecting low-confidence .* implies reversal` | Working as intended — pather refused a phantom node match (Bug 25) |
|
||||
| `Found open panels .* Chickening to be safe` | If the char is at full HP, it walked onto the waypoint — Bug 26 |
|
||||
| `Got stuck exit pather` / char against town wall | Fabricated node position from the low-confidence fallback — Bug 25 |
|
||||
| `expected result .* not found in GEMS convert panel` repeating | Stash tab never switched — gems being handled in the wrong tab, Bug 27 |
|
||||
| `could not activate the .* stash tab` | Tab bar geometry drifted; re-measure tab centres against the live client — Bug 27 |
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs and Fixes (permanent reference)
|
||||
|
||||
> Narrative write-up of the 2026-08-26/27 run_pindle collapse (what happened, in what order,
|
||||
> and the four times the obvious answer was wrong): `docs/postmortem_pindle_2026-08-27.md`.
|
||||
> Bugs 23-30 below are the per-bug detail for that incident.
|
||||
|
||||
|
||||
### Bug 1: `_curr_loc = True` propagation
|
||||
**File:** `src/town/town_manager.py` — `identify()` method (~line 236)
|
||||
|
||||
@@ -403,6 +417,451 @@ pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_E
|
||||
print([e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT])
|
||||
```
|
||||
|
||||
### Bug 23: masked `a5_red_portal.png` → portal never clickable (2026-08-26)
|
||||
**File:** `assets/templates/a5_town/a5_red_portal.png`
|
||||
|
||||
`A5_RED_PORTAL` peaked at **0.50–0.60** — under the 0.68 `select_by_template` needs — and its
|
||||
best match was always a false positive on bottom-right scenery near `(934, 62x)`, never the
|
||||
portal. Every Pindle run died on `Wanted to select A5_RED_PORTAL, but could not find it`.
|
||||
|
||||
**Root cause:** it was the **only masked template in `a5_town/`** (4-channel, 60.9% opaque).
|
||||
Every working A5 template is fully opaque:
|
||||
```
|
||||
a5_red_portal.png 4 chan 60.9% opaque MASKED <- the only one that failed
|
||||
a5_town_1..12.png 3 chan 100.0% opaque no mask <- every one that works
|
||||
```
|
||||
The original capture had the "…thak's Te…" hover tooltip baked in, and alpha was used to hide
|
||||
it. `alpha_to_mask` therefore returned a mask, routing it to
|
||||
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)` — a combination **OpenCV does not
|
||||
properly support** (masks are only valid for `TM_SQDIFF` / `TM_CCORR_NORMED`). That produced
|
||||
both the depressed scores and the wandering match positions.
|
||||
|
||||
**Fix:** recaptured as a plain **3-channel opaque** crop of the portal's **upper arch** (the
|
||||
lower ring is occluded by branches on most approaches, which wrecks matching). Verified by
|
||||
held-out validation *and* against portal-absent frames:
|
||||
|
||||
| | score | position |
|
||||
|---|---|---|
|
||||
| portal present (5 frames + live) | **0.949 – 1.000** | correct every time |
|
||||
| portal absent (6 real failure frames) | **0.398 – 0.514** | `valid=False` |
|
||||
|
||||
**Do not "fix" a template by masking part of it.** Recapture it clean. See the asset
|
||||
conventions in `ARCHITECTURE.md` → Image Recognition.
|
||||
|
||||
### Bug 24: Pindle "already in Pindle area" fired in town → silent zero-XP fake success (2026-08-26)
|
||||
**File:** `src/run/pindle.py` — `approach()` pre-click shortcut
|
||||
|
||||
The worst bug of the set, because it **reported success**. `approach()` opened with a
|
||||
"are we already there?" shortcut calling `_verify_in_pindle_area()` before clicking the
|
||||
portal. Harrogath scenery near the portal scores **0.76–0.79 on `PINDLE_7`** — well above its
|
||||
0.62 bar — so the shortcut fired *in town*, returned `Location.A5_PINDLE_START` **without ever
|
||||
clicking the portal**, and the bot ran `kill_pindle()` in the middle of Harrogath.
|
||||
|
||||
Five consecutive games logged `confirmed temple entry (PINDLE_7 @ 75.7%)`, `End game`,
|
||||
`runs_failed_total: 0` — with **zero loot and zero XP**. The logs looked perfect.
|
||||
|
||||
**Fix:** the shortcut is deleted. Entry is proven only by the portal click's loading screen.
|
||||
Real temple entries score **85.8–88.7%**, cleanly above the town false positives.
|
||||
|
||||
**Two traps this bug teaches:**
|
||||
1. **`failed:false` in the event stream is the bot's opinion, not evidence.** Verify a boss
|
||||
run with **XP delta** (`log/stats/mini_stats_*.json` → `current_exp`) and **loot lines**.
|
||||
`Loot from run_pindle: nothing picked up` on *every* run is a red flag, not bad luck.
|
||||
2. **A "we might already be there" fast path is dangerous** when its test can false-positive
|
||||
at the origin. It converts a loud failure into a silent one.
|
||||
|
||||
**Do NOT try to fix this with a red-portal visibility check.** The Nihlathak portal is
|
||||
visible from **both sides** — the return portal renders inside the temple too — so
|
||||
"portal on screen ⇒ still in town" is false and blocks every genuine entry.
|
||||
|
||||
### Bug 25: pather 0.55 fallback fabricated node positions → char walked into the town wall (2026-08-26)
|
||||
**File:** `src/pather.py` — `find_abs_node_pos()`, `traverse_nodes()`
|
||||
|
||||
When the primary 0.68 ROI search missed, `find_abs_node_pos` fell back to a **0.55
|
||||
first-match** full-image search. That fallback invented node positions: `A5_TOWN_1` scored
|
||||
0.60–0.62 on unrelated scenery at `(532,110)`, `(857,522)` and `(1209,86)` across three
|
||||
consecutive failure frames. The pather steered toward each phantom and wedged the character
|
||||
against the Harrogath battlements. Note the landmarks themselves were **healthy** where
|
||||
actually visible (`A5_TOWN_6 @ 0.93`, `A5_TOWN_4 @ 0.85`) — only the fallback was lying.
|
||||
|
||||
**Fix (three parts):**
|
||||
1. Fallback threshold `0.55` → **`0.62`** (`Pather._FALLBACK_THRESHOLD`).
|
||||
2. Fallback forced to **`best_match=True`** — it was `best_match=False`, so an ambiguous
|
||||
frame was decided by **node-dict ordering** rather than by score.
|
||||
3. New `_heading_is_plausible()` gate: a **low-confidence** match implying a >90° reversal
|
||||
from the current heading is rejected (`Pather: rejecting low-confidence X — implies
|
||||
reversal`) and the recovery sweep runs instead. Confident matches are never gated.
|
||||
Heading is tracked **per node** (`node_last_dir`, reset each node) because a >90° turn
|
||||
*between* nodes is normal.
|
||||
|
||||
Fired for real on the very first live session: two `PINDLE_4` phantoms at 62.2%/62.9% were
|
||||
rejected, and that game completed (73 s instead of ~50 s) rather than being lost.
|
||||
|
||||
### Bug 26: waypoint panel counted as a chicken → healthy games thrown away (2026-08-26)
|
||||
**File:** `src/health_manager.py` — panel check in the monitor loop
|
||||
|
||||
A mis-steered pather walks the char **over the Harrogath waypoint stone**, which opens the WP
|
||||
panel. The panel check saw `LeftPanel`/`RightPanel`, counted twice, and killed the game:
|
||||
`Found open panels (inv/quest/stats) twice. Chickening to be safe.` — **3 of 6 games died this
|
||||
way at full health.**
|
||||
|
||||
**Fix:** if `ScreenObjects.WaypointLabel` is visible the panel is self-inflicted, not a
|
||||
threat: send `esc` **without** incrementing `_count_panel_detects`. Bounded by
|
||||
`_MAX_WP_PANEL_ESCAPES = 6` so a WP panel that genuinely will not close still falls through
|
||||
to the normal chicken path, and `_count_wp_panel_detects` resets as soon as a poll sees no
|
||||
panel at all.
|
||||
|
||||
### Bug 27: gem conversion ran in the PERSONAL tab — tab-switch click missed the tab bar (2026-08-26)
|
||||
**Files:** `src/transmute/transmute.py`, `config/params.ini`
|
||||
|
||||
`convert_all_gems` ran its entire routine in whichever stash tab happened to be open —
|
||||
PERSONAL — never the GEMS tab. It logged `[819/999] … expected result
|
||||
INVENTORY_TOPAZ_PERFECT not found in GEMS convert panel; trying first slot fallback` **on
|
||||
every one of 800+ iterations** and kept going, ctrl+shift+clicking blind into the personal
|
||||
stash grid.
|
||||
|
||||
**Root cause:** `GEMS_TAB_Y = 100`. Measured off the live client, the stash tab labels
|
||||
occupy the row **y = 63–78** (selected-tab underline at y=80) and the **stash grid starts at
|
||||
y≈87**. So every `_switch_to_gems_tab()` click at y=100 landed on a *stash slot*, not a tab.
|
||||
The X values were already correct — only Y was wrong, by ~30px.
|
||||
|
||||
Measured tab centres on this client (5 tabs: PERSONAL SHARED GEMS MATERIALS RUNES):
|
||||
|
||||
| PERSONAL | SHARED | GEMS | MATERIALS | RUNES |
|
||||
|---|---|---|---|---|
|
||||
| (68, 70) | (144, 70) | (220, 70) | (295, 70) | (370, 70) |
|
||||
|
||||
**Second, independent bug found alongside it:** `params.ini` had `stash_tabs=6` with only
|
||||
**5** tabs on screen. `inventory/common.tab_properties()` divides the tab bar by that number,
|
||||
so it computed centres of `63/127/192/256/320/384` against real centres of
|
||||
`68/144/220/295/370` — tabs 2, 3 and 4 clicked the **gaps between tabs**. Set to `5`.
|
||||
|
||||
**Fix:**
|
||||
1. `TAB_Y = 70`, plus named X constants for all five tabs.
|
||||
2. `stash_tabs=5`.
|
||||
3. **Tab switches are now verified, not fire-and-forget.** `_switch_to_tab()` clicks, confirms
|
||||
the tab actually became active, retries up to 3×, and returns False. `convert_all_gems`
|
||||
aborts on False instead of converting in the wrong tab.
|
||||
|
||||
**How to detect the active tab** — measure the **cell background**, not the glyphs:
|
||||
`np.percentile(gray_label_cell, 30) > 52` (active ≈ 67–68, inactive ≈ 38–39). A
|
||||
brightness-of-text test does **not** work: label brightness scales with label length, so an
|
||||
active `GEMS` peaks at 167 while an active `PERSONAL` hits 215 — any glyph threshold that
|
||||
catches PERSONAL misreads GEMS as inactive. This was a real wrong turn during the fix.
|
||||
|
||||
**The recurring lesson (same as Bug 24):** an unverified click that "should" have worked will
|
||||
fail silently forever. Any click that changes UI *mode* — a tab, a page, a panel — must be
|
||||
confirmed before the code acts as if it took effect.
|
||||
|
||||
### Bug 28: `detect_current_act` decided the act on a 1.7pp margin → A5 pathing inside Act 4 (2026-08-27)
|
||||
**File:** `src/town/town_manager.py` — `detect_current_act()`
|
||||
|
||||
The single biggest cause of run failures: **25% of games failed**, dominated by
|
||||
`Approach failed for run_pindle [step: click_red_portal]`. The character was running A5
|
||||
pathing while physically standing in **Act 4**.
|
||||
|
||||
**The chain:**
|
||||
1. `buy_consumables` travels A5 → A4 for Jamella (`in A5 — traveling to A4 Jamella (Malah
|
||||
unreliable)`) — 32 times in one session.
|
||||
2. Repair then runs in A4 too (`TownManager repair: starting from a4_town_start`, 32×).
|
||||
3. `Run Pindle` calls `go_to_act(5, a4_town_start)`, which asks `detect_current_act()` to
|
||||
verify. It answered **A5** while in Act 4, so `go_to_act` "corrected" the assumption and
|
||||
**skipped the travel entirely**.
|
||||
4. A5 node pathing ran in Act 4 → portal never found → failure. 11 failures traced directly
|
||||
to step 1 in one session.
|
||||
|
||||
**Root cause:** `detect_current_act` used `search_and_wait(TOWN_MARKERS, best_match=True)`
|
||||
and committed to whatever cleared 0.68 first. Measured on a real Act 4 failure frame:
|
||||
|
||||
```
|
||||
A5_TOWN_1 0.636 <- phantom, always at (1046, 40), the top-right corner
|
||||
A4_TOWN_5 0.619 <- the genuine marker for the act actually occupied
|
||||
```
|
||||
|
||||
A **1.7 percentage point** gap decided which act the bot believed it was in. Pure coin flip.
|
||||
|
||||
**Fix:** the winner must now beat the best marker **from any other act** by
|
||||
`_ACT_DETECT_MARGIN` (0.05) as well as clearing 0.68. Below that it logs
|
||||
`detect_current_act: ambiguous … refusing to guess the act` and returns None.
|
||||
|
||||
**Why returning None is the safe answer:** every caller treats None as "keep the assumed
|
||||
act". In `go_to_act` that means `curr_act` stays `a4_town_start`, which no longer equals the
|
||||
target, so it **actually travels** via waypoint. Refusing to answer produces correct
|
||||
behaviour; guessing wrong does not. Never make this function more willing to commit.
|
||||
|
||||
**Result:** failure rate 25% → 9% over the validation run. The first game of that run was the
|
||||
exact failing case (repair started from `a4_town_start`) and completed successfully.
|
||||
|
||||
**Residual, different cause:** the failures that remain are NPC-detection flakiness — e.g.
|
||||
`open_npc_menu: timed out finding qual_kehk` during merc resurrect burns ~40s and strands the
|
||||
char. Same family as Bugs 3/4/6/7, not act desync. See Bug 29.
|
||||
|
||||
### Bug 29: undetectable resurrect NPC burned ~80s in EVERY game (2026-08-27)
|
||||
**Files:** `src/bot.py`, `src/game_stats.py`
|
||||
|
||||
In nightmare the merc dies most games, so `resurrect_merc` runs constantly — and Qual-Kehk
|
||||
detection was failing **100% of the time** (5 timeouts in 5 attempts, 107 hover attempts
|
||||
across 12 games). Each failed hunt costs ~40s, and the code retried once, so a dead merc cost
|
||||
**~80s per game, forever**. Game length blew out to 185-250s versus a normal ~60s.
|
||||
|
||||
**Why the existing guard didn't help:** `GameStats._merc_resurrect_failed` is reset in
|
||||
`log_start_game`, so it only suppresses a second attempt *within one game*. Nothing carried
|
||||
the knowledge across games.
|
||||
|
||||
**CORRECTED 2026-08-27 — the template was NOT the problem.** The identical 0.424 score at
|
||||
unrelated positions looked like a degenerate template, but `QUAL_NAME_TAG_WHITE` scores
|
||||
**1.000** on a frame where the tag is actually rendered, and **0.997** through the
|
||||
`color_filter` path `npc_manager` really uses. The template is fine. The real cause was
|
||||
`SWEEP_TAG_THRESHOLD` — see Bug 30. The lesson stands but points the other way: a repeated
|
||||
identical score means the search is **settling for noise**, so check the threshold before
|
||||
blaming the asset.
|
||||
|
||||
**Fix (cost containment, not detection):** a cross-game circuit breaker on `GameStats`,
|
||||
deliberately NOT reset by `log_start_game`:
|
||||
- `_merc_resurrect_fail_streak` — consecutive failed resurrects
|
||||
- `_merc_resurrect_skip_until` — game number to resume trying at
|
||||
- `Bot._MERC_RESURRECT_FAIL_LIMIT = 2`, `_MERC_RESURRECT_SKIP_GAMES = 15`
|
||||
- the retry is skipped once the streak is ≥1 (a second guaranteed-futile 40s hunt)
|
||||
- both counters reset on any successful resurrect, so a transient failure can't permanently
|
||||
disable resurrecting
|
||||
|
||||
Over 30 games with an undetectable NPC: **60 hunts → 4** (~40 min of waste → ~2.7 min).
|
||||
Measured live: game times went 250s / 185s → **14s, 14s, 43s, 71s, 111s** once the breaker
|
||||
engaged. The bot runs mercless for 15 games, then tries again.
|
||||
|
||||
**Resolved by Bug 30.** The breaker stays as a safety net, but resurrect should now succeed.
|
||||
|
||||
### Bug 30: NPC grid sweep settled for noise — `SWEEP_TAG_THRESHOLD` too low (2026-08-27)
|
||||
**File:** `src/npc_manager.py` — `open_npc_menu()` grid sweep
|
||||
|
||||
`SWEEP_TAG_THRESHOLD = 0.4`. A rendered name tag matches almost perfectly, so anything
|
||||
mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the first thing over
|
||||
0.4, clicked empty ground, and gave up. Scores measured across a full day of sweeps:
|
||||
|
||||
| NPC | score | outcome |
|
||||
|---|---|---|
|
||||
| akara | 0.980 | real — dialogue opened |
|
||||
| halbu | 0.995 | real |
|
||||
| malah | 0.990 | real |
|
||||
| larzuk | 0.996 | real |
|
||||
| **qual_kehk** | **0.424** | **false — clicked nothing** |
|
||||
| **malah** | **0.501** | **false** |
|
||||
| **larzuk** | **0.494** | **false** |
|
||||
|
||||
Real hits cluster at 0.98–1.00, false ones at 0.42–0.50. Raised to **0.7**, which sits in
|
||||
the gap with margin on both sides. This is why qual_kehk failed 100% (5 timeouts in 5
|
||||
attempts) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.
|
||||
|
||||
**How the template was cleared of suspicion:** walked the char to the NPC with the project's
|
||||
own Pather, hovered a grid of positions capturing full-res frames, found the one where
|
||||
`QUAL-KEHK` renders, and scored the stored template against it — 1.000 raw, 0.997 through
|
||||
`color_filter`. Worth repeating for any "stale template" claim: **prove the template fails on
|
||||
a frame where the subject is definitely visible** before recapturing anything.
|
||||
|
||||
Note `NAME_TAG_THRESHOLD = 0.26` (the hover path, line ~289) is deliberately much lower and
|
||||
was NOT changed — Akara genuinely hovers at ~0.28 (Bug 3). The two thresholds serve different
|
||||
paths; don't unify them.
|
||||
|
||||
### Bug 31: merc panel check left the CHRONICLE panel open for the whole game (2026-08-27)
|
||||
**File:** `src/bot.py` — `on_maintenance()`, merc-alive confirmation
|
||||
|
||||
`resurrect_merc` confirms a live merc by pressing `o` and looking for `MercPanelText`. It
|
||||
then closed the panel **only when that check passed**:
|
||||
|
||||
```python
|
||||
keyboard.send("o")
|
||||
merc_panel_open = is_visible(ScreenObjects.MercPanelText)
|
||||
if merc_panel_open:
|
||||
keyboard.send("o") # the ONLY path that closed anything
|
||||
```
|
||||
|
||||
On this client `o` (skill slot 54) opens the **CHRONICLE** collection panel, not the merc
|
||||
panel. So `MercPanelText` never matched, the closing keypress was never sent, and Chronicle
|
||||
stayed open for the rest of the game — a large centred panel that blanks every subsequent
|
||||
template match. The run then died on `click_red_portal` after ~66s of clicking at a covered
|
||||
screen.
|
||||
|
||||
**Why nothing caught it:** the health manager's panel guard looks for `LeftPanel` /
|
||||
`RightPanel`. Chronicle is centred and matches neither, so it was never auto-escaped.
|
||||
|
||||
**Fix:** always dismiss whatever appeared. If `MercPanelText` is not found, send `esc`, then
|
||||
re-check `LeftPanel`/`RightPanel` and send `esc` again if something is still up.
|
||||
|
||||
**The general rule:** *any* keypress that may open a panel must be paired with an
|
||||
unconditional dismiss. Closing only on the happy path leaves the UI wedged on every other
|
||||
path — and here that cost a whole run each time.
|
||||
|
||||
**Symptom to grep for:** `FAIL>` records showing `step=resurrect_merc` followed by
|
||||
`run.approach!` with a long duration, or an error screenshot with a UI panel covering the map.
|
||||
|
||||
---
|
||||
|
||||
## The run timeline — `grep "TL>"`
|
||||
|
||||
One fixed-width, machine-readable line per step, covering the whole cycle from spawn to
|
||||
loot. Every step is **timed**.
|
||||
|
||||
```bash
|
||||
grep "TL>" log/log.txt
|
||||
```
|
||||
```
|
||||
TL> g2 r1 | game | start | start | char=fohdin difficulty=nightmare routes=['run_pindle']
|
||||
TL> g2 r1 | game | spawn | ok | at a5_town_start (act a5_town_start)
|
||||
TL> g2 r1 | town | maintenance | start | at a5_town_start
|
||||
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2 gold_full=False
|
||||
TL> g2 r1 | town | stash_items | skip | nothing kept and gold not full
|
||||
TL> g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
|
||||
TL> g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
|
||||
TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable — continuing mercless
|
||||
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
|
||||
TL> g2 r1 | run | run_pindle | start | from a5_larzuk
|
||||
TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal
|
||||
TL> g2 r2 | game | end | fail | Approach failed for run_pindle [step: click_red_portal]
|
||||
```
|
||||
|
||||
**Columns:** `game rN | phase | step | status | took= | detail`
|
||||
|
||||
- **phase**: `game` | `town` | `run` | `stlth`
|
||||
- **status**: `start` | `ok` | `skip` | `fail`. `skip` states *why*, so a step that did
|
||||
nothing is distinguishable from one that never ran.
|
||||
- **took=**: emitted on every terminating line. `start` stamps the clock in
|
||||
`Bot._tl_starts`, keyed by `(phase, step)`. This is what makes the slow phase findable —
|
||||
the example above shows a failed game spending **113.6s** of its 137.6s town visit on a
|
||||
merc resurrect that failed.
|
||||
|
||||
**Emitters:**
|
||||
- `Bot.tl()` — the instance method (`src/bot.py`).
|
||||
- `Bot.timeline()` — a **static** entry point for modules that cannot import `Bot` without
|
||||
a circular import. `utils/stealth.py` and `inventory/personal.py` both use it via a lazy
|
||||
guarded import, so item sells/stashes/drops and stealth decisions land in the same
|
||||
stream. It is a no-op when no `Bot` is live.
|
||||
|
||||
**Stealth** appears as phase `stlth`: `afk_break` (start/ok around the sleep, so the break
|
||||
duration is timed), `skip_run`, and `wrong_waypoint`.
|
||||
|
||||
**Keep the format stable** — it is meant to be grepped, not read as prose. The `>` in the
|
||||
prefix matters: a bare `TL`/`TOWN` also matches template names like `A5_TOWN_0`.
|
||||
|
||||
---
|
||||
|
||||
## Is it AFK, or is it stuck?
|
||||
|
||||
The bot sits at the D2R **character-select menu** during a normal AFK break —
|
||||
breaks happen *between* games, after save-and-exit, so the menu is expected.
|
||||
The stuck case looks identical on screen. Do not judge by the menu.
|
||||
|
||||
```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 AFK break | Stuck |
|
||||
|---|---|---|
|
||||
| At character select | yes, by design | yes |
|
||||
| `status` | `running=True paused=False` | the same |
|
||||
| `select_char: Could not find online/offline tabs` | none | present |
|
||||
| `Restarting bot` | none | every ~20s |
|
||||
| Log | quiet | a new process, repeatedly |
|
||||
|
||||
**The tell is the log filling with restart lines, not the menu.** `status` cannot
|
||||
distinguish them — it reports the game controller, not what the bot is doing.
|
||||
|
||||
### The configured break length is not the real one
|
||||
|
||||
`maybe_afk_break` calls `wait(minutes*60, minutes*60*1.5)` and `wait()` then
|
||||
applies its own jitter (up to 1.44x). The two compound:
|
||||
|
||||
| planned | actual |
|
||||
|---|---|
|
||||
| 3.9m | ~5m+ |
|
||||
| 11.9m | **19.5m** (`took=1167.7s`) |
|
||||
| 20:56 scheduled | **25.5m** (`took=1531.1s`) |
|
||||
|
||||
So `afk_break_max_m = 12` really meant "up to ~26 minutes". A ~25 minute idle is
|
||||
what left D2R unable to re-enter (`select_char`), and 19.5m resumed fine — the
|
||||
tolerated limit is between them. Capped at 7 (=> ~15m worst case) on 2026-08-28.
|
||||
|
||||
**When changing any break duration, multiply by 1.5 x 1.44 before deciding
|
||||
whether it is safe.**
|
||||
|
||||
## Diagnosing a failure — `grep "FAIL>"`
|
||||
|
||||
Each failed game emits a self-contained record. Use this before opening screenshots.
|
||||
|
||||
```
|
||||
FAIL> g7 r5 | Approach failed for run_pindle [step: click_red_portal]
|
||||
FAIL> g7 r5 | at=a5_larzuk | step=resurrect_merc | shot=./log/screenshots/error/...png
|
||||
FAIL> g7 r5 | slowest: town.resurrect_merc=114s, run.approach=72s, town.repair=19s
|
||||
FAIL> g7 r5 | trail: game.spawn > town.repair(19s) > town.resurrect_merc!(114s) > run.approach!(72s)
|
||||
FAIL> g7 r5 | note: 2 failing steps this game: town.resurrect_merc, run.approach
|
||||
```
|
||||
|
||||
**Read the trail, not just the reason.** The step named in the reason is the one that blew
|
||||
up; it is frequently *not* the one that caused the problem. In the example the approach
|
||||
failed only after a merc resurrect had already burned 114s and stranded the character. `!`
|
||||
marks a failing step; the trail holds the last 12 timed steps of that game.
|
||||
|
||||
`note:` appears when more than one step failed in the same game — a strong hint the first
|
||||
failure caused the second rather than them being independent.
|
||||
|
||||
---
|
||||
|
||||
## Using the timing data to tune runs
|
||||
|
||||
The 2-hourly Discord digest (`general.discord_timing_report_h`, 0 disables) aggregates the
|
||||
same timeline, so log and report cannot disagree.
|
||||
|
||||
```
|
||||
Games: 92 (81 ok, 11 failed - 12.0%)
|
||||
Avg town 31s | approach 46s | battle 21s | cycle ~98s
|
||||
__Slowest steps (avg)__
|
||||
`town.resurrect_merc ` 103s x7 (5 fail)
|
||||
`run.approach ` 46s x92 (9 fail)
|
||||
__Failures by step__
|
||||
`run.approach ` 9
|
||||
```
|
||||
|
||||
**How to read it:**
|
||||
|
||||
| Signal | What it means |
|
||||
|---|---|
|
||||
| High avg + low count + high fail | A broken step retrying into a timeout. Worst kind — pure waste. `resurrect_merc` at 103s x7 with 5 fails is the textbook case. |
|
||||
| High avg + high count | The real cost centre. Tuning this moves runs/hour more than anything else. |
|
||||
| Fail count climbing between reports | A regression, or something drifting (template scores, NPC positions). Compare consecutive digests. |
|
||||
| A step that vanishes from the list | It stopped running at all — check for a `skip` reason in `TL>` before assuming it was fixed. |
|
||||
|
||||
**Rankings deliberately exclude** umbrella entries (`maintenance`, the `run_<name>` step)
|
||||
because they contain the others and would always come first, and the `stlth` phase because
|
||||
an AFK break is intentional idling — it gets its own section.
|
||||
|
||||
Cross-check a suspicious step with:
|
||||
```bash
|
||||
grep "TL>" log/log.txt | grep "resurrect_merc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifying a boss run actually worked
|
||||
|
||||
Learned the hard way (Bug 24). Log lines and `failed:false` are **not** proof of a kill.
|
||||
|
||||
| Signal | Trustworthy? |
|
||||
|---|---|
|
||||
| `End game` / `failed:false` | **No** — the bot's own belief; a false-positive area check fakes it |
|
||||
| `confirmed temple entry (X @ nn%)` | **No** on its own — town scenery hit 0.78 on `PINDLE_7` |
|
||||
| `Loot from <run>: <item>` | **Yes** — items only drop from real kills |
|
||||
| `current_exp` delta in `log/stats/mini_stats_*.json` | **Yes** — but a gain only proves *something* died (minions, merc kills), not that the boss did |
|
||||
| A frame from a screen recording | **Yes** — decisive; costs one screenshot |
|
||||
|
||||
Record before/after XP around a verification run:
|
||||
```bash
|
||||
ls -t log/stats/mini_stats_*.json | head -1 | xargs python -c "import json,sys;d=json.load(open(sys.argv[1]));print(d['current_exp'],d['runs_failed_total'])"
|
||||
```
|
||||
|
||||
**A running bot does not pick up source edits.** Python loads modules at process start, so
|
||||
after changing `src/`, kill and restart `main.py` — otherwise you are testing the old code and
|
||||
the log will show messages that no longer exist in the source.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Run
|
||||
@@ -449,6 +908,37 @@ Convert via `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()
|
||||
|
||||
---
|
||||
|
||||
## Pickit / loot rules — which file is actually live
|
||||
|
||||
**The active pickit set is `config/bnip/Den gode.bnip`, which is gitignored** (`.gitignore:89`
|
||||
= `config/bnip/*`). `config/default.bnip` is only a fallback and is NOT in use — editing it
|
||||
has no effect on runs. Resolution order is in `bnip/actions.py::_resolve_bnip_dir()`:
|
||||
|
||||
```
|
||||
config/profiles/<active>/pickit/ > config/pickit_profiles/<general.pickit_profile>/ > config/bnip/ > default.bnip
|
||||
```
|
||||
|
||||
Confirm which file loaded by the startup line `Loaded N nip files with M total expressions`
|
||||
and re-check M after editing — if the count doesn't move, you edited the wrong file.
|
||||
|
||||
**How the three decisions interact** (`bnip/actions.py`):
|
||||
|
||||
| Function | Evaluates | Notes |
|
||||
|---|---|---|
|
||||
| `should_pickup` | only the part **before** `#` | so a rule with stat conditions still picks the item up unidentified |
|
||||
| `should_id` | returns **False** only if a rule **without** a `#` matches | any rule carrying `#` leaves the item to be identified |
|
||||
| `should_keep` | full expression, returns on the **first** match | so ordering matters: specific rules must sit above any catch-all |
|
||||
|
||||
That combination is what lets a catch-all work: `[Quality] == Rare # [Strength] >= 999`
|
||||
placed last picks up and identifies every rare, can never itself keep one, and lets every
|
||||
specific rare rule above it win. Anything falling through is vendored (`sell_junk=1`).
|
||||
|
||||
**Current setup (2026-08-26):** gem transmuting is disabled (`[transmute] transmute=` empty
|
||||
in `params.ini` — this also defeats `force=True`, unlike `transmute_every_x_game=0`).
|
||||
Flawless and Perfect gems are picked up and stashed; chipped/flawed/standard are ignored.
|
||||
|
||||
---
|
||||
|
||||
## Config System
|
||||
|
||||
Singleton `Config()` merges in priority order: `custom.ini` > `params.ini` > `game.ini` > `shop.ini` > `transmute.ini`. First instantiation loads; subsequent calls return the same object. User overrides go in `custom.ini` (not tracked in git).
|
||||
@@ -493,6 +983,23 @@ discord_hook_url=https://discord.com/api/webhooks/...
|
||||
|
||||
**Adding step tracking to a new failure point** — just set `self.approach_fail_step = "descriptive_name"` before `return False`. No other wiring needed.
|
||||
|
||||
**Screen-record a failure instead of guessing.** A 1 fps DPI-aware grab of the D2R client
|
||||
rect is cheap and settles "where is the character actually standing?" in one frame — that is
|
||||
what proved the wall-walk (Bug 25) and the in-town fake kill (Bug 24). Two gotchas: make the
|
||||
recorder **DPI-aware** (`shcore.SetProcessDpiAwareness(2)`) or a 125%-scaled desktop silently
|
||||
crops your capture to 1024×576 of a 1280×720 window; and re-read the window rect **after**
|
||||
the bot starts, since it resizes D2R to 1280×720 at `(5, 98)` on init.
|
||||
|
||||
**Score templates offline against saved frames** rather than re-running the bot. The error
|
||||
screenshots in `log/screenshots/error/` are full-res PNGs — load them and call
|
||||
`template_finder.search(...)` directly to compare primary vs fallback thresholds.
|
||||
Note `import discord` currently fails in the `botty` env with an `ssl.SSLError`, so a
|
||||
standalone diagnostic must stub `sys.modules["discord"]` before importing `src/` modules.
|
||||
|
||||
**Only one bot instance at a time.** Two `main.py` processes both bind the Hermes socket and
|
||||
fight over the `start`/`pause` toggle — one will pause the other mid-run and the logs become
|
||||
nonsense. Check with `tasklist` before starting.
|
||||
|
||||
---
|
||||
|
||||
## File Quick Reference
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Botty First Run Guide
|
||||
|
||||
After running `install.bat` and seeing "Installation complete!", follow these steps.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create your character profile
|
||||
|
||||
Botty uses **profiles** to store your character setup. Each profile is a single file in `config/profiles/`.
|
||||
|
||||
### Find your character type
|
||||
|
||||
Open `config/params.ini` in Notepad, scroll to `[char]`, find `type=`:
|
||||
|
||||
| If you play... | Set `type=` to |
|
||||
|---|---|
|
||||
| Hammerdin Paladin | `hammerdin` |
|
||||
| FoH Paladin | `fohdin` |
|
||||
| Blizzard Sorceress | `sorceress` |
|
||||
| Bone Necromancer | `necro` |
|
||||
| Traps Assassin | `trapsin` |
|
||||
| Amazon | `amazon` |
|
||||
| Barbarian | `barbarian` |
|
||||
| Warlock Druid | `warlock` |
|
||||
|
||||
### Set your keybinds
|
||||
|
||||
In `config/params.ini`, scroll to `[char]` and match these to your D2R key bindings:
|
||||
|
||||
```ini
|
||||
[char]
|
||||
type=hammerdin ; your build (see table above)
|
||||
tp_key=b ; Teleport key
|
||||
stand_still=capslock ; Hold to stand still (capslock is common)
|
||||
show_belt=k ; Open belt
|
||||
weapon_switch=w ; Swap weapons (for CTA builds)
|
||||
force_move=e ; Force movement through mobs
|
||||
```
|
||||
|
||||
**Important:** `stand_still` cannot be `shift` (conflicts with merc healing). `capslock` is the recommended default.
|
||||
|
||||
### Pick your runs
|
||||
|
||||
Scroll to `[routes]` in `config/params.ini`:
|
||||
|
||||
```ini
|
||||
[routes]
|
||||
; Enable the runs you want (1 = on, 0 = off)
|
||||
run_pindle=1 ; Act 5 Nihlathak temple red portal boss
|
||||
run_countess=0 ; Act 1 Forgotten Tower
|
||||
run_andariel=0 ; Act 1 Catacombs
|
||||
run_arcane=0 ; Act 2 Arcane Sanctuary / Summoner (teleport recommended)
|
||||
run_trav=0 ; Act 3 Travincal council
|
||||
run_mephisto=0 ; Act 3 Durance of Hate
|
||||
run_diablo=0 ; Act 4 Chaos Sanctuary (teleport recommended)
|
||||
run_vizier=0 ; Act 4 Chaos, Vizier only (faster than full Diablo)
|
||||
run_nihlathak=0 ; Act 5 Halls of Vaught (teleport recommended)
|
||||
run_eldritch=0 ; Act 5 Frigid Highlands, Eldritch only
|
||||
run_eldritch_shenk=0 ; Act 5 Eldritch then Shenk in the same game
|
||||
|
||||
; Execution order (only includes enabled runs)
|
||||
order=run_pindle
|
||||
```
|
||||
|
||||
> There is no `run_shenk` option — Shenk is Act 5 and is farmed via
|
||||
> `run_eldritch_shenk`. The commented route list at the top of the `[routes]`
|
||||
> section in `params.ini` is always the authoritative set of names.
|
||||
|
||||
Common combinations:
|
||||
- **Pindle only:** `run_pindle=1`, `order=run_pindle`
|
||||
- **Pindle + Arcane:** `run_pindle=1`, `run_arcane=1`, `order=run_pindle, run_arcane`
|
||||
- **Full farm:** Enable all you want, set `order=` to your preferred sequence
|
||||
|
||||
### Set difficulty
|
||||
|
||||
In `[general]`:
|
||||
|
||||
```ini
|
||||
difficulty=hell ; normal, nightmare, or hell
|
||||
```
|
||||
|
||||
Start with `nightmare` if you're testing. Use `hell` for serious farming.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Save as a profile (optional but recommended)
|
||||
|
||||
Profiles survive git pulls. To save your setup:
|
||||
|
||||
1. Create a folder: `config/profiles/mychar/`
|
||||
2. Create `config/profiles/mychar/profile.ini` with:
|
||||
|
||||
```ini
|
||||
[general]
|
||||
name=mychar
|
||||
difficulty=hell
|
||||
|
||||
[char]
|
||||
type=hammerdin
|
||||
tp_key=b
|
||||
stand_still=capslock
|
||||
show_belt=k
|
||||
weapon_switch=w
|
||||
force_move=e
|
||||
|
||||
[routes]
|
||||
run_pindle=1
|
||||
order=run_pindle
|
||||
```
|
||||
|
||||
3. Select it. The profile is chosen by `config/active_profile.txt`, **not** by
|
||||
`name=` in `params.ini` (that field is only a display/label value). Either:
|
||||
- start the bot and press **`end`** to cycle to your profile, or
|
||||
- put the profile folder name on the first line of `config/active_profile.txt`
|
||||
|
||||
At startup the bot logs which one it loaded:
|
||||
`Profile active: mychar (config\profiles\mychar\profile.ini)`
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Launch
|
||||
|
||||
1. **Start D2R** — set to 1280x720 windowed mode
|
||||
2. **Log in** and select your character
|
||||
3. **Double-click `run_botty.bat`**
|
||||
4. The bot shows a hotkey menu — press **`f11`** to start or pause it. Press **`f12`** to stop.
|
||||
|
||||
> The hotkey menu printed at startup is always the source of truth — it reflects
|
||||
> your actual `config/params.ini` keys. Do **not** press `insert`: that is
|
||||
> `restore_settings_from_backup_key`, which overwrites your current D2R settings
|
||||
> with a previously saved backup.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Optional: Discord notifications
|
||||
|
||||
To get bot status in Discord:
|
||||
|
||||
1. Create a Discord webhook: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL
|
||||
2. In `config/params.ini`, under `[general]`:
|
||||
|
||||
```ini
|
||||
custom_message_hook=https://discord.com/api/webhooks/YOUR/WEBHOOK_URL
|
||||
discord_log_chicken=1
|
||||
discord_log_errors=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Could not find botty conda environment"** — Run `install.bat` first.
|
||||
|
||||
**"D2R is not running"** — Launch D2R before running `run_botty.bat`, or set `auto_login=1` in params.ini with your Battle.net credentials.
|
||||
|
||||
**Bot can't see templates / everything fails** — Make sure D2R is 1280x720 windowed, not fullscreen or borderless.
|
||||
|
||||
**OCR not working** — `install.bat` sets up OCR automatically. Check the install output for "tesserocr: OK" or "pytesseract: OK". At least one must work.
|
||||
|
||||
**Check logs:** Open `log/log.txt` in Notepad for detailed output.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: what to edit
|
||||
|
||||
| File | What to change |
|
||||
|---|---|
|
||||
| `config/params.ini` | `type=`, keybinds, `difficulty=`, routes, Discord webhook |
|
||||
| `config/profiles/*/profile.ini` | Per-character overrides (optional) |
|
||||
| `config/game.ini` | **Don't edit** — templates and UI coordinates |
|
||||
| `config/shop.ini` | **Don't edit** — vendor buy/sell lists |
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -1,5 +0,0 @@
|
||||
"""Botty Next offline-first visual QA harness."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Capture backends for offline fixtures and live observer mode."""
|
||||
|
||||
from botty_next.capture.mss_backend import MssCaptureBackend
|
||||
from botty_next.capture.window import WindowRegion, find_window_region
|
||||
|
||||
__all__ = ["MssCaptureBackend", "WindowRegion", "find_window_region"]
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
from botty_next.capture.window import WindowRegion
|
||||
|
||||
|
||||
class MssCaptureBackend:
|
||||
def grab(self, region: WindowRegion | None = None) -> np.ndarray:
|
||||
with mss.mss() as screen_capture:
|
||||
monitor = region.as_mss_monitor() if region else screen_capture.monitors[1]
|
||||
shot = screen_capture.grab(monitor)
|
||||
|
||||
bgra = np.asarray(shot)
|
||||
return cv2.cvtColor(bgra, cv2.COLOR_BGRA2BGR)
|
||||
|
||||
|
||||
def save_frame(frame: np.ndarray, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cv2.imwrite(str(output), frame):
|
||||
raise RuntimeError(f"failed to write screenshot: {output}")
|
||||
return output
|
||||
@@ -1,47 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowRegion:
|
||||
left: int
|
||||
top: int
|
||||
width: int
|
||||
height: int
|
||||
title: str
|
||||
|
||||
def as_mss_monitor(self) -> dict[str, int]:
|
||||
return {
|
||||
"left": self.left,
|
||||
"top": self.top,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
|
||||
def find_window_region(title_contains: str) -> WindowRegion:
|
||||
import win32gui
|
||||
|
||||
matches: list[WindowRegion] = []
|
||||
|
||||
def collect(hwnd: int, _extra) -> bool:
|
||||
if not win32gui.IsWindowVisible(hwnd):
|
||||
return True
|
||||
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
if title_contains.lower() not in title.lower():
|
||||
return True
|
||||
|
||||
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
if width > 0 and height > 0:
|
||||
matches.append(WindowRegion(left, top, width, height, title))
|
||||
return True
|
||||
|
||||
win32gui.EnumWindows(collect, None)
|
||||
if not matches:
|
||||
raise RuntimeError(f"no visible window found containing title: {title_contains}")
|
||||
|
||||
return max(matches, key=lambda region: region.width * region.height)
|
||||
@@ -1,139 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from botty_next.capture.mss_backend import MssCaptureBackend, save_frame
|
||||
from botty_next.capture.window import find_window_region
|
||||
from botty_next.config import load_config
|
||||
from botty_next.vision.fixtures import load_image
|
||||
from botty_next.vision.ocr import run_tesseract_ocr, save_ocr_preprocess_debug
|
||||
from botty_next.vision.template_matching import match_template, save_match_debug
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="botty-next")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
config_parser = subparsers.add_parser("config")
|
||||
config_subparsers = config_parser.add_subparsers(dest="config_command", required=True)
|
||||
validate_parser = config_subparsers.add_parser("validate")
|
||||
validate_parser.add_argument("-c", "--config", required=True, type=Path)
|
||||
validate_parser.set_defaults(handler=validate_config)
|
||||
|
||||
detect_parser = subparsers.add_parser("detect")
|
||||
detect_parser.add_argument("detector", choices=["template"], help="detector to run")
|
||||
detect_parser.add_argument("--image", required=True, type=Path)
|
||||
detect_parser.add_argument("--template", required=True, type=Path)
|
||||
detect_parser.add_argument("--threshold", type=float, default=0.85)
|
||||
detect_parser.add_argument("--debug-output", type=Path)
|
||||
detect_parser.set_defaults(handler=detect)
|
||||
|
||||
capture_parser = subparsers.add_parser("capture")
|
||||
capture_parser.add_argument("--output", required=True, type=Path)
|
||||
capture_parser.add_argument("--window-title", type=str)
|
||||
capture_parser.set_defaults(handler=capture)
|
||||
|
||||
ocr_parser = subparsers.add_parser("ocr")
|
||||
ocr_parser.add_argument("--image", required=True, type=Path)
|
||||
ocr_parser.add_argument("--lang", default="eng")
|
||||
ocr_parser.add_argument("--psm", type=int, default=6)
|
||||
ocr_parser.add_argument("--tesseract-cmd")
|
||||
ocr_parser.add_argument("--debug-output", type=Path)
|
||||
ocr_parser.set_defaults(handler=ocr)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def validate_config(args: argparse.Namespace) -> int:
|
||||
config = load_config(args.config)
|
||||
print(json.dumps(config.model_dump(mode="json"), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def detect(args: argparse.Namespace) -> int:
|
||||
image = load_image(args.image)
|
||||
template = load_image(args.template)
|
||||
result = match_template(image, template, threshold=args.threshold)
|
||||
|
||||
if args.debug_output:
|
||||
save_match_debug(image, result, args.debug_output)
|
||||
|
||||
print(json.dumps(_result_to_dict(result), indent=2))
|
||||
return 0 if result.passed else 1
|
||||
|
||||
|
||||
def capture(args: argparse.Namespace) -> int:
|
||||
region = find_window_region(args.window_title) if args.window_title else None
|
||||
frame = MssCaptureBackend().grab(region)
|
||||
output = save_frame(frame, args.output)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"output": str(output),
|
||||
"shape": tuple(map(int, frame.shape)),
|
||||
"window": region.title if region else None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def ocr(args: argparse.Namespace) -> int:
|
||||
image = load_image(args.image)
|
||||
if args.debug_output:
|
||||
save_ocr_preprocess_debug(image, args.debug_output)
|
||||
|
||||
try:
|
||||
result = run_tesseract_ocr(
|
||||
image,
|
||||
lang=args.lang,
|
||||
psm=args.psm,
|
||||
tesseract_cmd=args.tesseract_cmd,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"error": str(exc),
|
||||
"debug_output": str(args.debug_output) if args.debug_output else None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 2
|
||||
|
||||
print(json.dumps(_ocr_result_to_dict(result), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _result_to_dict(result) -> dict:
|
||||
return {
|
||||
"confidence": result.confidence,
|
||||
"bbox": result.bbox,
|
||||
"passed": result.passed,
|
||||
"method": result.method,
|
||||
"debug": result.debug,
|
||||
}
|
||||
|
||||
|
||||
def _ocr_result_to_dict(result) -> dict:
|
||||
return {
|
||||
"text": result.text,
|
||||
"confidence": result.confidence,
|
||||
"bbox": result.bbox,
|
||||
"debug": result.debug,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Configuration loading and validation."""
|
||||
|
||||
from botty_next.config.models import BottyNextConfig, load_config
|
||||
|
||||
__all__ = ["BottyNextConfig", "load_config"]
|
||||
@@ -1,13 +0,0 @@
|
||||
profile_name: local
|
||||
capture:
|
||||
backend: fixture
|
||||
monitor: 1
|
||||
fps_limit: 10
|
||||
window_title: null
|
||||
vision:
|
||||
template_threshold: 0.85
|
||||
debug_output_dir: botty_next/debug/output
|
||||
input:
|
||||
enabled: false
|
||||
dry_run: true
|
||||
emergency_stop_key: f12
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class CaptureConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
backend: Literal["fixture", "mss", "dxcam"] = "fixture"
|
||||
monitor: int = 1
|
||||
fps_limit: int = Field(default=10, ge=1, le=240)
|
||||
window_title: str | None = None
|
||||
|
||||
|
||||
class VisionConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
template_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
|
||||
debug_output_dir: Path = Path("botty_next/debug/output")
|
||||
|
||||
|
||||
class InputConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
dry_run: bool = True
|
||||
emergency_stop_key: str = "f12"
|
||||
|
||||
@field_validator("dry_run")
|
||||
@classmethod
|
||||
def dry_run_required_when_disabled(cls, value: bool) -> bool:
|
||||
return value
|
||||
|
||||
|
||||
class BottyNextConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profile_name: str = "local"
|
||||
capture: CaptureConfig = Field(default_factory=CaptureConfig)
|
||||
vision: VisionConfig = Field(default_factory=VisionConfig)
|
||||
input: InputConfig = Field(default_factory=InputConfig)
|
||||
|
||||
@field_validator("input")
|
||||
@classmethod
|
||||
def input_must_be_explicit_and_dry_run_by_default(cls, value: InputConfig) -> InputConfig:
|
||||
if value.enabled and value.dry_run is False:
|
||||
raise ValueError("live input cannot be enabled without a future explicit safety gate")
|
||||
return value
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> BottyNextConfig:
|
||||
config_path = Path(path)
|
||||
with config_path.open("r", encoding="utf-8") as handle:
|
||||
raw = yaml.safe_load(handle) or {}
|
||||
return BottyNextConfig.model_validate(raw)
|
||||
@@ -1 +0,0 @@
|
||||
"""Debug image and report output helpers."""
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Input abstraction layer.
|
||||
|
||||
Live input is intentionally not implemented in the bootstrap harness.
|
||||
"""
|
||||
@@ -1 +0,0 @@
|
||||
"""Offline/private routine replay harness."""
|
||||
@@ -1 +0,0 @@
|
||||
"""State detection and transition logic."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the Botty Next harness."""
|
||||
@@ -1,12 +0,0 @@
|
||||
from botty_next.capture.window import WindowRegion
|
||||
|
||||
|
||||
def test_window_region_converts_to_mss_monitor() -> None:
|
||||
region = WindowRegion(left=10, top=20, width=640, height=480, title="Example")
|
||||
|
||||
assert region.as_mss_monitor() == {
|
||||
"left": 10,
|
||||
"top": 20,
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
from botty_next.cli import main
|
||||
|
||||
|
||||
def test_config_validate_cli_starts(capsys) -> None:
|
||||
exit_code = main(["config", "validate", "-c", "botty_next/config/default.yaml"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert '"profile_name": "local"' in captured.out
|
||||
|
||||
|
||||
def test_detect_cli_runs_template_detector(capsys) -> None:
|
||||
exit_code = main(
|
||||
[
|
||||
"detect",
|
||||
"template",
|
||||
"--image",
|
||||
"fixtures/screenshots/sample_scene.ppm",
|
||||
"--template",
|
||||
"fixtures/templates/sample_marker.ppm",
|
||||
"--threshold",
|
||||
"0.99",
|
||||
]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert '"passed": true' in captured.out
|
||||
@@ -1,10 +0,0 @@
|
||||
from botty_next.config import load_config
|
||||
|
||||
|
||||
def test_load_default_config() -> None:
|
||||
config = load_config("botty_next/config/default.yaml")
|
||||
|
||||
assert config.profile_name == "local"
|
||||
assert config.capture.backend == "fixture"
|
||||
assert config.input.enabled is False
|
||||
assert config.input.dry_run is True
|
||||
@@ -1,13 +0,0 @@
|
||||
from botty_next.vision.fixtures import load_screenshot, load_template
|
||||
|
||||
|
||||
def test_load_sample_screenshot_fixture() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
assert image.shape == (8, 8, 3)
|
||||
|
||||
|
||||
def test_load_sample_template_fixture() -> None:
|
||||
template = load_template("sample_marker.ppm")
|
||||
|
||||
assert template.shape == (3, 3, 3)
|
||||
@@ -1,42 +0,0 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from botty_next.vision.fixtures import load_screenshot
|
||||
from botty_next.vision.ocr import preprocess_for_ocr, run_tesseract_ocr, save_ocr_preprocess_debug
|
||||
|
||||
|
||||
def test_preprocess_for_ocr_returns_thresholded_image() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
processed = preprocess_for_ocr(image)
|
||||
|
||||
assert processed.ndim == 2
|
||||
assert processed.shape == (16, 16)
|
||||
|
||||
|
||||
def test_save_ocr_preprocess_debug(tmp_path) -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
output = save_ocr_preprocess_debug(image, tmp_path / "ocr.png")
|
||||
|
||||
assert output.exists()
|
||||
|
||||
|
||||
def test_run_tesseract_ocr_uses_pytesseract_adapter(monkeypatch) -> None:
|
||||
fake = SimpleNamespace(
|
||||
Output=SimpleNamespace(DICT="dict"),
|
||||
pytesseract=SimpleNamespace(tesseract_cmd=None),
|
||||
image_to_string=lambda *_args, **_kwargs: "Short Sword\n",
|
||||
image_to_data=lambda *_args, **_kwargs: {"conf": ["95", "-1", "85"]},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "pytesseract", fake)
|
||||
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
result = run_tesseract_ocr(image, tesseract_cmd="C:/Tesseract/tesseract.exe")
|
||||
|
||||
assert result.text == "Short Sword"
|
||||
assert result.confidence == pytest.approx(0.9)
|
||||
assert result.debug["backend"] == "pytesseract"
|
||||
assert fake.pytesseract.tesseract_cmd == "C:/Tesseract/tesseract.exe"
|
||||
@@ -1,24 +0,0 @@
|
||||
from botty_next.vision.fixtures import load_screenshot, load_template
|
||||
from botty_next.vision.template_matching import match_template, save_match_debug
|
||||
|
||||
|
||||
def test_template_match_finds_sample_marker() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
template = load_template("sample_marker.ppm")
|
||||
|
||||
result = match_template(image, template, threshold=0.99)
|
||||
|
||||
assert result.passed is True
|
||||
assert result.confidence >= 0.99
|
||||
assert result.bbox == (3, 2, 3, 3)
|
||||
assert "image_shape" in result.debug
|
||||
|
||||
|
||||
def test_template_match_can_save_debug_image(tmp_path) -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
template = load_template("sample_marker.ppm")
|
||||
result = match_template(image, template, threshold=0.99)
|
||||
|
||||
output = save_match_debug(image, result, tmp_path / "marked.png")
|
||||
|
||||
assert output.exists()
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Vision helpers and detectors."""
|
||||
|
||||
from botty_next.vision.ocr import OcrResult, preprocess_for_ocr, run_tesseract_ocr
|
||||
from botty_next.vision.template_matching import MatchResult, match_template
|
||||
|
||||
__all__ = ["MatchResult", "OcrResult", "match_template", "preprocess_for_ocr", "run_tesseract_ocr"]
|
||||
@@ -1,26 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_image(path: str | Path, *, grayscale: bool = False) -> np.ndarray:
|
||||
image_path = Path(path)
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"image fixture does not exist: {image_path}")
|
||||
|
||||
flag = cv2.IMREAD_GRAYSCALE if grayscale else cv2.IMREAD_COLOR
|
||||
image = cv2.imread(str(image_path), flag)
|
||||
if image is None:
|
||||
raise ValueError(f"OpenCV could not read image fixture: {image_path}")
|
||||
return image
|
||||
|
||||
|
||||
def load_screenshot(name: str, root: str | Path = "fixtures/screenshots") -> np.ndarray:
|
||||
return load_image(Path(root) / name)
|
||||
|
||||
|
||||
def load_template(name: str, root: str | Path = "fixtures/templates") -> np.ndarray:
|
||||
return load_image(Path(root) / name)
|
||||
@@ -1,98 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OcrResult:
|
||||
text: str
|
||||
confidence: float
|
||||
bbox: tuple[int, int, int, int] | None
|
||||
debug: dict[str, Any]
|
||||
|
||||
|
||||
def preprocess_for_ocr(image: np.ndarray, *, scale: float = 2.0) -> np.ndarray:
|
||||
if image.size == 0:
|
||||
raise ValueError("image is empty")
|
||||
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
||||
if scale != 1.0:
|
||||
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
denoised = cv2.GaussianBlur(gray, (3, 3), 0)
|
||||
return cv2.adaptiveThreshold(
|
||||
denoised,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY,
|
||||
31,
|
||||
7,
|
||||
)
|
||||
|
||||
|
||||
def run_tesseract_ocr(
|
||||
image: np.ndarray,
|
||||
*,
|
||||
lang: str = "eng",
|
||||
psm: int = 6,
|
||||
tesseract_cmd: str | None = None,
|
||||
) -> OcrResult:
|
||||
try:
|
||||
pytesseract = import_module("pytesseract")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"pytesseract is not installed; run install.bat or install requirements.txt"
|
||||
) from exc
|
||||
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
|
||||
processed = preprocess_for_ocr(image)
|
||||
config = f"--psm {psm}"
|
||||
text = pytesseract.image_to_string(processed, lang=lang, config=config).strip()
|
||||
confidences = _read_confidences(
|
||||
pytesseract.image_to_data(
|
||||
processed,
|
||||
lang=lang,
|
||||
config=config,
|
||||
output_type=pytesseract.Output.DICT,
|
||||
)
|
||||
)
|
||||
confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||
return OcrResult(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=None,
|
||||
debug={
|
||||
"backend": "pytesseract",
|
||||
"lang": lang,
|
||||
"psm": psm,
|
||||
"preprocessed_shape": tuple(map(int, processed.shape)),
|
||||
"word_confidences": confidences,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_ocr_preprocess_debug(image: np.ndarray, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cv2.imwrite(str(output), preprocess_for_ocr(image)):
|
||||
raise RuntimeError(f"failed to write OCR debug image: {output}")
|
||||
return output
|
||||
|
||||
|
||||
def _read_confidences(data: dict[str, list[Any]]) -> list[float]:
|
||||
values: list[float] = []
|
||||
for raw in data.get("conf", []):
|
||||
try:
|
||||
confidence = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if confidence >= 0:
|
||||
values.append(confidence / 100.0)
|
||||
return values
|
||||
@@ -1,93 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchResult:
|
||||
confidence: float
|
||||
bbox: tuple[int, int, int, int]
|
||||
passed: bool
|
||||
method: str
|
||||
debug: dict[str, Any]
|
||||
|
||||
|
||||
def _as_gray(image: np.ndarray) -> np.ndarray:
|
||||
if image.ndim == 2:
|
||||
return image
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
def match_template(
|
||||
image: np.ndarray,
|
||||
template: np.ndarray,
|
||||
*,
|
||||
threshold: float = 0.85,
|
||||
method: int = cv2.TM_CCOEFF_NORMED,
|
||||
) -> MatchResult:
|
||||
if image.size == 0:
|
||||
raise ValueError("image is empty")
|
||||
if template.size == 0:
|
||||
raise ValueError("template is empty")
|
||||
if template.shape[0] > image.shape[0] or template.shape[1] > image.shape[1]:
|
||||
raise ValueError("template cannot be larger than image")
|
||||
|
||||
image_gray = _as_gray(image)
|
||||
template_gray = _as_gray(template)
|
||||
response = cv2.matchTemplate(image_gray, template_gray, method)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(response)
|
||||
|
||||
if method in (cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED):
|
||||
top_left = min_loc
|
||||
confidence = 1.0 - float(min_val)
|
||||
else:
|
||||
top_left = max_loc
|
||||
confidence = float(max_val)
|
||||
|
||||
width = int(template.shape[1])
|
||||
height = int(template.shape[0])
|
||||
bbox = (int(top_left[0]), int(top_left[1]), width, height)
|
||||
return MatchResult(
|
||||
confidence=confidence,
|
||||
bbox=bbox,
|
||||
passed=confidence >= threshold,
|
||||
method=_method_name(method),
|
||||
debug={
|
||||
"threshold": threshold,
|
||||
"min_value": float(min_val),
|
||||
"max_value": float(max_val),
|
||||
"min_location": tuple(map(int, min_loc)),
|
||||
"max_location": tuple(map(int, max_loc)),
|
||||
"image_shape": tuple(map(int, image.shape)),
|
||||
"template_shape": tuple(map(int, template.shape)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_match_debug(image: np.ndarray, result: MatchResult, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
marked = image.copy()
|
||||
x, y, width, height = result.bbox
|
||||
color = (0, 255, 0) if result.passed else (0, 0, 255)
|
||||
cv2.rectangle(marked, (x, y), (x + width, y + height), color, 2)
|
||||
cv2.imwrite(str(output), marked)
|
||||
return output
|
||||
|
||||
|
||||
def _method_name(method: int) -> str:
|
||||
names = {
|
||||
cv2.TM_CCOEFF: "TM_CCOEFF",
|
||||
cv2.TM_CCOEFF_NORMED: "TM_CCOEFF_NORMED",
|
||||
cv2.TM_CCORR: "TM_CCORR",
|
||||
cv2.TM_CCORR_NORMED: "TM_CCORR_NORMED",
|
||||
cv2.TM_SQDIFF: "TM_SQDIFF",
|
||||
cv2.TM_SQDIFF_NORMED: "TM_SQDIFF_NORMED",
|
||||
}
|
||||
return names.get(method, str(method))
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,13 @@ corpse=459,195,414,213
|
||||
chat_icon=7,555,43,41
|
||||
left_panel_header=0,0,455,56
|
||||
right_panel_header=830,0,455,56
|
||||
; A CENTRED panel (Chronicle, and anything else that opens mid-screen) puts its
|
||||
; close button outside both header ROIs above — the Chronicle's X sits at
|
||||
; (952, 56), inside right_panel_header's x-range but exactly at its 56px height
|
||||
; boundary, so the match centre falls out. Nothing then closes it, and a
|
||||
; centred panel blanks every later template search: measured as a 66s
|
||||
; click_red_portal failure on 2026-08-28 (Bug 31's residual).
|
||||
center_panel_header=400,0,620,92
|
||||
npc_dialogue=456,0,30,150
|
||||
bind_skill=516,619,251,29
|
||||
quest_skill_btn=284,455,710,121
|
||||
|
||||
@@ -55,6 +55,10 @@ discord_log_errors=1
|
||||
discord_status_runs=10
|
||||
; discord_status_count: legacy fallback, send periodic status every X games (blank/0 disables)
|
||||
discord_status_count=20
|
||||
; discord_timing_report_h: post a timing + failure digest to Discord every N hours
|
||||
; (0 disables). Aggregated from the same TL> timeline the log uses, so the report and the
|
||||
; log can never disagree. The window resets on every send.
|
||||
discord_timing_report_h=2
|
||||
; pickup_drought_window: warn/alert after this many consecutive games with zero
|
||||
; item pickups. Raise this if you run a strict pickit and 0-pickup streaks are
|
||||
; expected/normal for you (a fast boss-only rush route with a tight filter can
|
||||
@@ -65,9 +69,9 @@ message_api_type=discord
|
||||
|
||||
; breaks
|
||||
; break_length_m: scheduled break duration in minutes (0 = disabled)
|
||||
break_length_m=0
|
||||
break_length_m=15
|
||||
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
|
||||
max_runtime_before_break_m=0
|
||||
max_runtime_before_break_m=120
|
||||
|
||||
; timers / fail handling
|
||||
; d2r_path: Diablo II: Resurrected install path
|
||||
@@ -83,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)
|
||||
@@ -135,12 +139,22 @@ error=1
|
||||
[stealth]
|
||||
; Multiplies all wait() calls by a random value in this range each call
|
||||
; 1.0 = no change. Set range wider for more human-like timing variation.
|
||||
wait_jitter_min = 0.85
|
||||
; wait_jitter_* multiplies EVERY wait() in the codebase, not just mouse moves.
|
||||
; The floor is clamped to jitter_min*0.8, so 0.85 allowed waits to come out 32%
|
||||
; SHORT — the one place jitter stole time from an action instead of adding it
|
||||
; between actions. 0.95 keeps waits at-or-longer (effective floor 0.76 vs the
|
||||
; old 0.68, and the Gaussian centre sits above 1.0), so a wait can no longer
|
||||
; expire before the UI it was waiting on has settled.
|
||||
wait_jitter_min = 0.95
|
||||
wait_jitter_max = 1.20
|
||||
|
||||
; Extra pixel variance added to every mouse click (on top of existing randomize=5)
|
||||
; 0 = off, 10 = +/-10px extra random offset per click
|
||||
click_variance = 8
|
||||
; NOTE: click position randomization lives at the CALL SITES (npc_manager,
|
||||
; ui/waypoint) where it is tuned to real button geometry — 2-3px for NPC
|
||||
; hover targets, +/-9px inside a 47px waypoint button. There is deliberately
|
||||
; no global click-offset knob: stacking one on top of those is what starts
|
||||
; missing NPCs (see CLAUDE.md Bugs 3/4/6/7).
|
||||
|
||||
; Re-shuffle run order after completing a full rotation (vs only at session start)
|
||||
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
|
||||
@@ -156,7 +170,14 @@ skip_run_chance = 10
|
||||
afk_break_chance = 5
|
||||
; Break duration range in minutes
|
||||
afk_break_min_m = 2
|
||||
afk_break_max_m = 12
|
||||
; 12 -> 7 (2026-08-28). The configured value is NOT the real ceiling:
|
||||
; maybe_afk_break does wait(minutes*60, minutes*60*1.5) and wait() then applies
|
||||
; its own jitter (up to 1.44x), so a "12 minute" break can idle for ~26 minutes.
|
||||
; Measured: an 11.9m break ran 1167.7s = 19.5m, and a 20:56 scheduled break ran
|
||||
; 25.5m. A ~25m idle is what left D2R at character select once, unable to
|
||||
; re-enter, with the bot spawning a new process every ~20s.
|
||||
; 7 caps the real-world worst case near 15m, inside the range proven to resume.
|
||||
afk_break_max_m = 7
|
||||
|
||||
; Vary run/battle duration by a Gaussian factor (default +/-15%)
|
||||
; 0.0 = no variation, 0.3 = up to +/-30% variation
|
||||
@@ -167,6 +188,8 @@ micro_pause_min_ms = 20
|
||||
micro_pause_max_ms = 120
|
||||
|
||||
; Vary kill time to avoid perfectly consistent boss fight durations
|
||||
; Varies boss attack duration. Only ever LENGTHENS the fight (1.0-1.4x) —
|
||||
; shortening it risks leaving a boss alive, which is a failed run, not stealth.
|
||||
vary_kill_time = 1
|
||||
|
||||
; Human mouse curve complexity (1.0 = default, 0.5 = more direct, 1.5 = more winding)
|
||||
@@ -174,8 +197,13 @@ human_curve_complexity = 1.0
|
||||
|
||||
; Arrival-to-click delay: human-like pause between mouse arriving and clicking (milliseconds)
|
||||
; 50-800ms range simulates "is this the right thing?" hesitation
|
||||
; Hesitation between arriving at a target and clicking it. OFF by default:
|
||||
; it applies to EVERY click, so the old 800ms ceiling could add minutes per
|
||||
; run. Enable with click_delay_enabled=1 if you want it; 250 is a realistic
|
||||
; ceiling that stays affordable.
|
||||
click_delay_enabled = 0
|
||||
click_delay_min_ms = 50
|
||||
click_delay_max_ms = 800
|
||||
click_delay_max_ms = 250
|
||||
|
||||
; Key press duration variance: how long a key is held (milliseconds)
|
||||
; Most presses are short (20-100ms), some linger (up to 200ms)
|
||||
@@ -189,11 +217,57 @@ skill_hesitation_max_ms = 300
|
||||
|
||||
; Wrong waypoint chance: 2-3% of selecting wrong TP portal then correcting
|
||||
; Humans occasionally misclick waypoint targets
|
||||
; ── session rhythm ───────────────────────────────────────────────────────────
|
||||
; The strongest remaining signal is not per-click timing: averaged over a
|
||||
; session jitter converges, but a player who starts at the same hour, plays the
|
||||
; same length and never does anything unproductive does not.
|
||||
;
|
||||
; session_budget_h: stop the bot after roughly this many hours (0 = unlimited).
|
||||
; Actual length is rolled per run at 0.65-1.35x, so consecutive days differ.
|
||||
; Raised to 10 for an overnight run: the budget is rolled at 0.65-1.35x, so 6
|
||||
; could have stopped the bot after 3.9h and left it idle. 10 guarantees at
|
||||
; least ~6.5h while keeping the day-to-day variation.
|
||||
; Set to 8 for a >= 5h run (2026-08-28). The value is rolled at 0.65-1.35x, so
|
||||
; 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
|
||||
; Per-game chicken threshold spread. Only ever RAISES the threshold (safer) —
|
||||
; a fixed 0.40 every game is a precise tell, but lowering it would cost deaths.
|
||||
chicken_variance = 0.08
|
||||
; Chance of an unproductive town action (open inventory, close it).
|
||||
town_browse_chance = 0.06
|
||||
; Chance of walking past an item the filter wanted. Costs real loot — keep low.
|
||||
pickup_skip_chance = 0.02
|
||||
wrong_waypoint_chance = 0.025
|
||||
|
||||
; Skill mistake chance: 1-2% chance of miscasting and correcting
|
||||
; Simulates human error during combat
|
||||
skill_mistake_chance = 0.015
|
||||
; Presses a DIFFERENT bound skill before the intended one. Disabled: on a
|
||||
; character mid-fight this swaps the active skill or aura, and the attack
|
||||
; sequences rely on ending in a known skill state. The stealth value is small
|
||||
; next to the risk of a stray keypress landing on something that is not a skill
|
||||
; at all — which is exactly how the loot filter ended up being toggled all
|
||||
; session (params.ini [paladin] vigor=f4, unbound on this char, F4 = filter).
|
||||
; Set back to 0.015 to re-enable.
|
||||
skill_mistake_chance = 0
|
||||
|
||||
[routes]
|
||||
; Controls which farm runs Botty performs each game.
|
||||
@@ -216,7 +290,8 @@ skill_mistake_chance = 0.015
|
||||
; run_countess (Act 1 Forgotten Tower)
|
||||
; run_mephisto (Act 3 Durance of Hate)
|
||||
; run_baal (Act 5 Throne of Destruction)
|
||||
order=run_pindle, run_diablo
|
||||
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
|
||||
order=run_baal_xp, run_trav
|
||||
|
||||
[char]
|
||||
; ==========================
|
||||
@@ -229,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
|
||||
@@ -244,7 +319,19 @@ safer_routines=1
|
||||
|
||||
; num_loot_columns: Number of empty columns from left to right of inventory to be used for looting.
|
||||
; Store charms, etc. to the right of the inventory.
|
||||
num_loot_columns=4
|
||||
;
|
||||
; This drives ui_roi[restricted_inventory_area] in config.py: the columns to the RIGHT of
|
||||
; this count are protected — mouse._is_clicking_safe() cancels any click landing there, so
|
||||
; the bot never sells, drops or moves anything in them. Both tomes MUST live in that
|
||||
; reserved area: common.tome_state() only searches restricted_inventory_area for them.
|
||||
; 3 columns = 12 loot slots, 28 reserved
|
||||
; 4 columns = 16 loot slots, 24 reserved <- now
|
||||
; 6 columns = 24 loot slots, 16 reserved (tried 2026-08-27, reverted — shrinking the
|
||||
; reserve is the wrong direction when the
|
||||
; 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=5
|
||||
|
||||
; game hotkeys:
|
||||
; NOTE: each key must match your in-game binding exactly
|
||||
@@ -260,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 ====
|
||||
@@ -311,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
|
||||
@@ -332,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
|
||||
@@ -354,27 +441,55 @@ 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)
|
||||
; 2026-06-10: switched to a5_larzuk — session logs showed Halbu detection failing
|
||||
; 100% (body score ~0.39) and each failed A4 trip desynced the bot's act state.
|
||||
repair_npc=a5_larzuk
|
||||
; resurrect_npc: which NPC revives the mercenary. Blank = whichever act the character is
|
||||
; already in. Set to a4_tyrael to always use Act 4 Tyrael.
|
||||
; Measured 2026-08-27 on this setup:
|
||||
; A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever
|
||||
; A5 Qual-Kehk fail 113.6s / 52.0s / 163.1s / 72.6s - 43 errors
|
||||
; Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC in
|
||||
; the route. A failed hunt costs more than the whole A5->A4 trip, and one 163s hunt pushed
|
||||
; 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
|
||||
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name (recommended safety)
|
||||
protect_shields_from_sell=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
|
||||
; ui_roi[equipped_inventory_area] while the inventory is open. The name rule only ever
|
||||
; hit shields sitting in the INVENTORY grid, which the pickit had already judged.
|
||||
; Cost of leaving it on: 130 blocked sells in one day (FIEND SHIELD 64, AERIN SHIELD 41,
|
||||
; HERALDIC SHIELD 23...). The same shields were re-evaluated and re-blocked every single
|
||||
; game — "Discarding FIEND SHIELD." then "Blocked sell for protected item: FIEND SHIELD" —
|
||||
; so they could never leave the pack and permanently occupied slots.
|
||||
protect_shields_from_sell=0
|
||||
; protect_charms_from_sell: 1 = never drop/vendor charms regardless of pickit verdict
|
||||
; (recommended safety — a misread charm can't be un-dropped). Set to 0 to let your
|
||||
; pickit rules decide keep/discard for charms same as any other item.
|
||||
protect_charms_from_sell=1
|
||||
; (safety net — a misread charm can't be un-sold). Set to 0 to let your pickit rules
|
||||
; decide keep/discard for charms same as any other item.
|
||||
;
|
||||
; KEEP THIS AT 1. Charms only give their bonus while they sit in the INVENTORY, and the
|
||||
; resistances matter in nightmare. Briefly set to 0 on 2026-08-27 to reclaim slots and
|
||||
; that was wrong — it vendored LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF
|
||||
; FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS SMALL CHARM OF VITA (+20 life,
|
||||
; cold resist +7%) before it was reverted.
|
||||
;
|
||||
; The "673 blocked sells" in the log are NOT a bug to fix by selling — they are the guard
|
||||
; 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=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
|
||||
@@ -386,14 +501,21 @@ pick_gold=1
|
||||
stash_destination=0,1,2,3
|
||||
; Add these possible gems to "transmute" to transmute them:
|
||||
; chipped, flawed, standard, flawless
|
||||
; DISABLED 2026-08-26: gems are stashed to the GEMS tab and left alone, no cube/convert
|
||||
; 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=flawless
|
||||
;how often we want to run transmute routine(e.g. every 100 games)
|
||||
transmute_every_x_game=60
|
||||
; number of stash tabs (ROTW has more than 6, adjust as needed)
|
||||
stash_tabs=6
|
||||
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
|
||||
; real label centres are 68/144/220/295/370 — tabs 2, 3 and 4 clicked the gaps between tabs.
|
||||
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
|
||||
|
||||
@@ -443,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
|
||||
@@ -457,9 +579,9 @@ foh=f6
|
||||
holy_bolt=f7
|
||||
|
||||
[hammerdin]
|
||||
blessed_hammer=f1
|
||||
concentration=f8
|
||||
conviction=f5
|
||||
blessed_hammer=f9
|
||||
concentration=f1
|
||||
conviction=
|
||||
|
||||
; =========================
|
||||
; ==== Builds: Warlock ====
|
||||
@@ -612,6 +734,53 @@ buff_1=
|
||||
buff_2=
|
||||
|
||||
|
||||
; ==== Run: Cold Plains clear ====
|
||||
; Roams an outdoor area and kills with ONE configured skill. Works with any build
|
||||
; and from clvl 1, so it is the levelling counterpart to the boss runs.
|
||||
; Enable by adding run_cold_plains to [routes] order above.
|
||||
;
|
||||
; PREREQUISITE: the character must already have the destination waypoint.
|
||||
; A fresh character has none - walk there once by hand first.
|
||||
[cold_plains]
|
||||
; Any Act 1 waypoint label from src/ui/waypoint.py _WAYPOINTS, e.g.
|
||||
; "Cold Plains", "Stony Field", "Dark Wood", "Black Marsh".
|
||||
area=Cold Plains
|
||||
; Key holding the attack skill. NOTE Fireball is clvl 12 - on a fresh character
|
||||
; put Fire Bolt (clvl 1) here and re-bind to Fireball later; nothing else changes.
|
||||
attack_hotkey=
|
||||
; Most sorc/caster skills cast on right click. Melee builds want "left".
|
||||
attack_button=right
|
||||
; How many roam-or-fight steps before heading back to town.
|
||||
max_steps=12
|
||||
; Casts per engagement before re-scanning for targets.
|
||||
casts_per_target=4
|
||||
; Ignore anything further than this many px from the character.
|
||||
target_radius=600
|
||||
; Delay between casts. Raise if you out-run your cast animation.
|
||||
cast_delay=0.25
|
||||
; Hard runtime cap per run, seconds - stops a bad area pinning the bot.
|
||||
max_runtime_s=180
|
||||
; Engagements per step, so an unkillable/misdetected target cannot stall the run.
|
||||
max_engagements=8
|
||||
|
||||
[baal_xp]
|
||||
; Baal XP farm: join public Baal games, hide, collect XP, leave. Repeat.
|
||||
; Enable by adding run_baal_xp to [routes] order above.
|
||||
enabled=1
|
||||
; Substring to match against game names (case-insensitive). Empty = join first game.
|
||||
game_name_filter=
|
||||
; Max seconds to wait in a game before leaving (XP keeps ticking while you hide).
|
||||
max_wait_s=900
|
||||
; Leave early if XP gained in this game reaches this value.
|
||||
xp_threshold=50000000
|
||||
; Leave immediately if HP drops below this percentage (chicken).
|
||||
min_hp_pct=40
|
||||
; Screen position (client coords) to walk to and stand still while hiding.
|
||||
hide_x=640
|
||||
hide_y=360
|
||||
; Max seconds to wait for a matching game to appear in the browser.
|
||||
join_timeout_s=60
|
||||
|
||||
[advanced_options]
|
||||
; startup hotkeys
|
||||
; select_runs_key: open run selector UI
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,258 +0,0 @@
|
||||
# Anti-Detection Framework for Botty-Go
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the multi-layered anti-detection system built into botty-go.
|
||||
Each layer addresses a specific detection vector that Blizzard and modern anti-cheat
|
||||
systems use to identify bots.
|
||||
|
||||
---
|
||||
|
||||
## 1. Server-Side Behavior Analysis Countermeasures
|
||||
|
||||
### Detection: Session length, timing consistency, pathing patterns, repetition
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 1a. Variable Session Scheduling
|
||||
- **Implementation:** `internal/schedule/scheduler.go`
|
||||
- Randomized session start times using a circadian model
|
||||
- Simulated human sleep patterns: 6-10 hour breaks between sessions
|
||||
- Weekend/weekday behavior variance (humans play differently on weekends)
|
||||
- Random session lengths: 20min to 6hours with exponential distribution
|
||||
- Occasional "just 5 more minutes" overtime and "I'm tired" early stops
|
||||
|
||||
#### 1b. Stochastic Pathing
|
||||
- **Implementation:** `internal/pather/stochastic.go`
|
||||
- Add deliberate pathing imperfection: 5-15% deviation from optimal route
|
||||
- Occasional wrong-way teleports followed by course correction
|
||||
- Non-optimal waypoint selections (humants don't always take shortest path)
|
||||
- Variable route ordering with cooldown-dependent choices
|
||||
- 2-3% chance of "getting lost" and using wrong waypoint first
|
||||
|
||||
#### 1c. Skill Rotation Variance
|
||||
- **Implementation:** `internal/char/behavior.go`
|
||||
- Variable pre-buff timing (humans rush sometimes, sometimes take time)
|
||||
- Occasional wrong skill selection followed by correction
|
||||
- Potion usage with human-like hesitation (check multiple times before drinking)
|
||||
- Merc healing variance: sometimes forget, sometimes over-heal
|
||||
|
||||
#### 1d. Route Randomization with Context
|
||||
- **Implementation:** `internal/bot/route_planner.go`
|
||||
- Dynamic route selection based on:
|
||||
- Time since last run of each type
|
||||
- Current TP scroll count (humans adapt)
|
||||
- Gem/transmute urgency
|
||||
- Occasional "feels like it" switches
|
||||
- Never perfect round-robin; use weighted probability with drift
|
||||
|
||||
#### 1e. Farming Repetition Masking
|
||||
- Never run the same route more than 8 times consecutively
|
||||
- Insert "town breaks": stash visit, shrine check, repair, gamble
|
||||
- 1-2% chance of "I'm bored, switching to different run" mid-session
|
||||
- Vary kill strategies: sometimes rush, sometimes methodical
|
||||
|
||||
---
|
||||
|
||||
## 2. Warden / Client Integrity Countermeasures
|
||||
|
||||
### Detection: Loaded modules, injected DLLs, memory signatures, debuggers
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 2a. Pixel-Only Architecture (No Memory Access)
|
||||
- **Implementation:** entire bot reads game state ONLY via screenshots
|
||||
- NO memory reading, NO DLL injection, NO process hooking
|
||||
- Same attack surface as a human with a camera pointed at the screen
|
||||
- This is the #1 defense: if you only use screen capture + input simulation,
|
||||
there's nothing to scan in process memory
|
||||
|
||||
#### 2b. Clean Process Environment
|
||||
- **Implementation:** `internal/runtime/clean_env.go`
|
||||
- Standard Go binary with no suspicious imports
|
||||
- No debuggers, no memory readers, no process manipulation
|
||||
- Run as a normal application, not injected
|
||||
|
||||
#### 2c. Overlay Avoidance
|
||||
- Never draw on top of game window
|
||||
- No window hooking or injection
|
||||
- Screenshot from a separate thread, not an overlay
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Pattern Analysis Countermeasures
|
||||
|
||||
### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 3a. Human Motor Model
|
||||
- **Implementation:** `internal/mouse/human_model.go`
|
||||
- Full biomechanical mouse model based on Fitts' Law and human motion studies
|
||||
- Real human mouse data characteristics:
|
||||
- Multi-segment movement with micro-pauses (1-3 segments per motion)
|
||||
- Acceleration curve: start slow, peak in middle, decelerate into target
|
||||
- Endpoint micro-adjustments: 2-5 pixel wobble before click
|
||||
- Inter-trial variability: each movement is unique even to same target
|
||||
- Asymmetric error distribution: overshoot more right/down (human bias)
|
||||
|
||||
#### 3b. Click Timing Model
|
||||
- **Implementation:** `internal/mouse/click_model.go`
|
||||
- Variable time between "arriving" at target and clicking: 50ms-800ms
|
||||
- Pressure curve: humans don't click at exact same speed
|
||||
- Double-click rate varies naturally
|
||||
- Occasional misses: 0.5-1% of clicks land slightly off (1-3px)
|
||||
|
||||
#### 3c. Keyboard Behavior Model
|
||||
- **Implementation:** `internal/keyboard/human_model.go`
|
||||
- Key press duration variance: not all keypresses are identical
|
||||
- Typing rhythm for skill hotkeys: natural cadence with micro-pauses
|
||||
- Occasional key repeat (holding too long = rapid fire)
|
||||
- Realistic key-up/key-down timing ratios
|
||||
|
||||
#### 3d. Statistical Indistinguishability
|
||||
- **Implementation:** `internal/input/stats.go`
|
||||
- All input streams modeled from real human motion capture data
|
||||
- Entropy analysis of output matches human baselines
|
||||
- Auto-calibration: measure user's own input if they do manual play
|
||||
- Periodically inject "manual-looking" variance spikes
|
||||
|
||||
---
|
||||
|
||||
## 4. Economy and Item-Flow Countermeasures
|
||||
|
||||
### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 4a. Natural Accumulation Rate
|
||||
- **Implementation:** `internal/inventory/economy.go`
|
||||
- Vary farming intensity: some sessions heavy, some light
|
||||
- Match accumulation to stated playtime (more sessions = more loot)
|
||||
- Occasionally "waste" items on gambling/repairs like a real player
|
||||
|
||||
#### 4b. Realistic Trading Patterns
|
||||
- No mass item funneling
|
||||
- If trading, do it in human-sized batches with natural pauses
|
||||
- Vary trade partners and timing
|
||||
|
||||
#### 4c. Rune Farming Variance
|
||||
- Don't farm the same runes every session
|
||||
- Match rune acquisition to character progression
|
||||
- Occasionally skip rune picks when "full"
|
||||
|
||||
---
|
||||
|
||||
## 5. Ban Wave Defense
|
||||
|
||||
### Detection: Delayed batch bans
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 5a. Graceful Degradation
|
||||
- **Implementation:** `internal/runtime/safe_mode.go`
|
||||
- If one account gets banned, immediately reduce intensity across all
|
||||
- Auto-pause farming for 48-72 hours (simulating "taking a break")
|
||||
- Gradual return with reduced session lengths
|
||||
- Change behavior patterns after any ban event
|
||||
|
||||
#### 5b. Account Diversity
|
||||
- Each account has distinct "personality":
|
||||
- Different session timing preferences
|
||||
- Different route preferences
|
||||
- Different response timing distributions
|
||||
- Different play styles (rusher vs methodical)
|
||||
|
||||
---
|
||||
|
||||
## 6. Server Authority Countermeasures
|
||||
|
||||
### Detection: Server-side validation of movement, drops, combat, inventory
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 6a. Server-Authoritative Behavior
|
||||
- **Implementation:** `internal/bot/server_aware.go`
|
||||
- Only interact with what the server actually shows
|
||||
- Wait for server confirmation before acting (e.g., confirm item picked up)
|
||||
- Respect server-enforced movement limits (no speed hacks)
|
||||
- Process drops in game-authorized order
|
||||
|
||||
#### 6b. No Client Manipulation
|
||||
- Never try to spoof packets, modify client, or exploit desync
|
||||
- Purely reactive: see screen -> decide -> act -> wait for response
|
||||
|
||||
---
|
||||
|
||||
## 7. Social/Reporting System Countermeasures
|
||||
|
||||
### Detection: Player reports + telemetry correlation
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 7a. Social Stealth
|
||||
- **Implementation:** `internal/social/stealth.go`
|
||||
- Play during off-peak hours less suspiciously
|
||||
- Avoid solo-public routes that attract attention
|
||||
- Occasionally join other players' games (with reduced automation)
|
||||
- Inherit human-like chat behavior if configured
|
||||
|
||||
---
|
||||
|
||||
## 8. Hardware/Identity Correlation Countermeasures
|
||||
|
||||
### Detection: IP patterns, hardware fingerprints, VMs, account clusters
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 8a. Clean Deployment
|
||||
- **Implementation:** `internal/deploy/clean.go`
|
||||
- Run on real hardware, not VMs
|
||||
- Use residential IP, not datacenter
|
||||
- One account per hardware profile
|
||||
- No VPN/proxy during play sessions
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
```
|
||||
internal/
|
||||
├── input/ # Human-like input generation
|
||||
│ ├── mouse_model.go # Fitts' Law mouse movement
|
||||
│ ├── click_model.go # Human click timing
|
||||
│ ├── keyboard_model.go # Keyboard behavior
|
||||
│ └── stats.go # Statistical verification
|
||||
├── behavior/ # High-level human behavior simulation
|
||||
│ ├── scheduler.go # Session scheduling
|
||||
│ ├── route_planner.go # Dynamic route selection
|
||||
│ ├── fatigue.go # Simulated fatigue/boredom
|
||||
│ └── personality.go # Per-account personality
|
||||
├── economy/ # Economic behavior masking
|
||||
│ ├── accumulation.go # Natural loot accumulation
|
||||
│ └── trading.go # Human-like trading patterns
|
||||
├── safe_mode/ # Graceful degradation
|
||||
│ ├── detection.go # Ban wave detection
|
||||
│ └── cooldown.go # Auto-pause and return
|
||||
└── deploy/ # Clean deployment helpers
|
||||
└── check.go # Pre-flight integrity checks
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Statistical indistinguishability:** Output must be statistically
|
||||
indistinguishable from real human input. We use actual human motion
|
||||
capture data distributions, not made-up random numbers.
|
||||
|
||||
2. **Controlled imperfection:** A human is inefficient, forgetful, and
|
||||
inconsistent. The bot should be too — but in a way that matches
|
||||
real human distributions.
|
||||
|
||||
3. **No single fingerprint:** Every instance should have unique enough
|
||||
characteristics that correlating two accounts is hard.
|
||||
|
||||
4. **Adaptability:** If behavior changes are detected, the system should
|
||||
be able to recalibrate based on new data.
|
||||
|
||||
5. **Defense in depth:** No single countermeasure is sufficient. The
|
||||
combination across all layers is what provides real protection.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Botty-Go
|
||||
|
||||
D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows).
|
||||
|
||||
Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite
|
||||
in Go that maintains compatibility with the same config files, templates, and run
|
||||
logic while adding native Linux support.
|
||||
|
||||
## Features
|
||||
|
||||
- Cross-platform: Linux (X11/Wayland) and Windows
|
||||
- Same config format as original Botty (params.ini, game.ini, shop.ini)
|
||||
- Template matching with OpenCV Go bindings
|
||||
- Tesseract OCR for item identification
|
||||
- Human-like mouse movement (Bezier curves)
|
||||
- BNIP pickit language
|
||||
- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.)
|
||||
- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo)
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
go build -o botty ./cmd/botty
|
||||
|
||||
# Windows (from Linux with cross-compile)
|
||||
GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config/` from the original Botty project. Params, routes, and character
|
||||
config work identically.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Legacy: Go Rewrite Design Notes
|
||||
|
||||
These docs are archived from an abandoned `~/git/botty-go` directory (May 2026).
|
||||
That project was a planned ground-up Go rewrite of `johannes-do/botty` for
|
||||
cross-platform (Linux + Windows) support. Only design docs existed — no `.go`
|
||||
source was ever written.
|
||||
|
||||
The Python `my-botty` project (this repo) is the active path. These docs are
|
||||
kept here as **reference material**, primarily for Milestone 2 (anti-detection /
|
||||
stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`.
|
||||
|
||||
## Files
|
||||
|
||||
- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers
|
||||
server-side behavior analysis countermeasures (session scheduling, stochastic
|
||||
pathing, skill rotation variance) and more. Directly applicable as the design
|
||||
basis for the Python stealth layer.
|
||||
- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project.
|
||||
Context only — explains feature scope and what the rewrite was aiming for.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Paladalla Melee Hunt Test Log
|
||||
|
||||
Date: 2026-08-23
|
||||
Workspace: `C:\Users\alex\my-botty`
|
||||
Character/profile: `paladalla`
|
||||
Script: `tools/melee_hunt.py`
|
||||
|
||||
## Current State
|
||||
|
||||
- Testing was stopped at the user's request.
|
||||
- No `melee_hunt` Python process was running when checked.
|
||||
- Paladalla was alive in the Rogue Encampment at the end of the session.
|
||||
- The four visible belt potions were manually consumed. The last screenshot shows
|
||||
partial life and an empty belt, so refill and fully heal before the next run.
|
||||
- The game must not be controlled automatically until the user asks to resume.
|
||||
|
||||
Final-state screenshot: [healed_from_belt.png](../log/screenshots/manual/healed_from_belt.png)
|
||||
|
||||
## Commands Used
|
||||
|
||||
The successful short run used:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left
|
||||
```
|
||||
|
||||
The later long tests used:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 1440
|
||||
```
|
||||
|
||||
`F12` is intended to stop the script. It worked during one run but was not
|
||||
reliable while an in-game overlay was open. `Ctrl+C` in the script terminal was
|
||||
the reliable emergency stop.
|
||||
|
||||
## Verified Results
|
||||
|
||||
### Initial ten-minute run
|
||||
|
||||
- Started outside town and completed its configured timer normally.
|
||||
- Result: `engaged 107, roamed 15`.
|
||||
- XP changed from `261/500` to `357/500`.
|
||||
- Final reported life was `69%`.
|
||||
- The process then exited because the default duration is ten minutes. Paladalla
|
||||
later died because nothing was controlling or protecting the character.
|
||||
|
||||
### Corpse recovery and level gain
|
||||
|
||||
- The death prompt was cleared and the game was cycled through Save and Exit.
|
||||
- The corpse appeared beside Paladalla in town and was recovered successfully.
|
||||
- Weapon and attack slots were restored.
|
||||
- Paladalla was manually routed across the bridge into the Blood Moor.
|
||||
- Manual movement/attack clicks later produced a level-up. The next script start
|
||||
read XP as `564/1500`, confirming level 2 and working melee damage.
|
||||
|
||||
Evidence:
|
||||
|
||||
- [corpse_recovered.png](../log/screenshots/manual/corpse_recovered.png)
|
||||
- [blood_moor_ready2.png](../log/screenshots/manual/blood_moor_ready2.png)
|
||||
- [deep_moor_restart.png](../log/screenshots/manual/deep_moor_restart.png)
|
||||
|
||||
## Failures Found
|
||||
|
||||
### 1. Ten-minute default looked like a crash
|
||||
|
||||
The first hunt was not dead or hung. It reached its default ten-minute deadline
|
||||
and exited cleanly. Long unattended tests must pass an explicit `--minutes`
|
||||
value or implement a true continuous mode.
|
||||
|
||||
### 2. Friendly bridge NPC was treated as motion target
|
||||
|
||||
The first 24-hour attempt remained near the town bridge. At the two-minute
|
||||
checkpoint it showed repeated attacks, full life, and unchanged XP
|
||||
(`357/500`). The screenshot showed it attacking the friendly rogue near the
|
||||
bridge. Motion alone cannot distinguish that NPC from a monster.
|
||||
|
||||
Evidence: [hunt_2min_checkpoint.png](../log/screenshots/manual/hunt_2min_checkpoint.png)
|
||||
|
||||
Workaround used: stop the script and move several screens deeper into the Blood
|
||||
Moor before restarting. A permanent NPC rejection/stall detector is still
|
||||
needed.
|
||||
|
||||
### 3. Animated HUD controls were treated as targets
|
||||
|
||||
After reaching level 2, the pulsing new-stat/new-skill controls appeared above
|
||||
the original HUD mask. The motion detector could target that region. During the
|
||||
second long attempt an in-game Loot Filter overlay opened and the script kept
|
||||
issuing clicks behind it.
|
||||
|
||||
Evidence: [restart_live_40s.png](../log/screenshots/manual/restart_live_40s.png)
|
||||
|
||||
### 4. No-potion condition was unsafe
|
||||
|
||||
Life fell through `18%`, `14%`, `10%`, `8%`, and eventually `0%`. The script
|
||||
logged `no healing potion in belt!` but continued its loop. Belt detection also
|
||||
reported no potion even though red potions were visibly present, so belt
|
||||
detection or its profile assumptions need separate investigation.
|
||||
|
||||
### 5. XP OCR became invalid after level-up/UI changes
|
||||
|
||||
The log reported XP OCR values such as `'-'` and `''`, then incorrectly printed
|
||||
`*** LEVEL UP *** now 0/0`. Invalid OCR should not be interpreted as a level-up,
|
||||
and `xp_last` should retain the last valid sample.
|
||||
|
||||
## Code Changes Made
|
||||
|
||||
`tools/melee_hunt.py` was changed in two ways:
|
||||
|
||||
1. The target scan bottom boundary was raised from `640` to `510`, excluding the
|
||||
animated level-up/stat controls and the interactive HUD from motion targets.
|
||||
2. Failure to detect or use a healing potion at the configured health threshold
|
||||
now raises `SafetyStop`, exits the hunt loop, and prints a safety-stop reason.
|
||||
|
||||
The edited script passed:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe -m py_compile tools\melee_hunt.py
|
||||
```
|
||||
|
||||
These changes were not runtime-verified after editing because testing was
|
||||
stopped. The narrower scan may ignore monsters near the bottom of the viewport;
|
||||
that tradeoff must be checked visually.
|
||||
|
||||
## Next Test Checklist
|
||||
|
||||
1. Confirm no old hunt process is running.
|
||||
2. Refill the belt with healing potions and fully heal at Akara.
|
||||
3. Save and Exit, re-enter, and recover any corpse if one appears.
|
||||
4. Spend or dismiss the new stat/skill notifications if practical.
|
||||
5. Walk across the bridge and several screens into the Blood Moor before launch.
|
||||
6. Start with a short five-minute test, not a 24-hour run:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 5
|
||||
```
|
||||
|
||||
7. Capture and inspect screenshots at launch, two minutes, and five minutes.
|
||||
8. At each checkpoint verify: outside town, no modal open, attacking a hostile,
|
||||
XP increasing, life stable, movement occurring, and no repeated target lock.
|
||||
9. Force a controlled low-health/no-potion condition only after normal combat is
|
||||
proven, and verify `SAFETY STOP` exits immediately.
|
||||
10. Use `Ctrl+C` if any modal opens, XP remains unchanged across two checkpoints,
|
||||
life drops rapidly, or the character approaches the town bridge NPC.
|
||||
|
||||
## Follow-up Engineering Work
|
||||
|
||||
- Add a stall detector based on unchanged XP plus repeated target coordinates.
|
||||
- Temporarily blacklist a target position after excessive swings without XP.
|
||||
- Reject invalid XP samples instead of converting them into `0/0` level-ups.
|
||||
- Investigate why `belt.drink_potion("health", ...)` missed visible red potions.
|
||||
- Detect death and modal overlays explicitly and stop before sending more input.
|
||||
- Consider an explicit `--continuous` option instead of using a large minute
|
||||
count.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Post-mortem — run_pindle total failure (26–27 Aug 2026)
|
||||
|
||||
**Outcome:** failure rate 100% → ~8%. Character 32 → 69. Cycle time 185–250s → ~52s.
|
||||
18 commits, `98e12c0` … `402c768`.
|
||||
(Hashes changed on 27 Aug: history was rewritten before the PR to purge a Discord
|
||||
webhook that had been committed into `config/params.ini` by mistake.)
|
||||
|
||||
Published rendering: `docs/postmortems/pindle-2026-08-27.html`
|
||||
(artifact: https://claude.ai/code/artifact/00668e28-dbb8-40e8-b585-db4c8b790d0a)
|
||||
|
||||
Per-bug detail with code references lives in `CLAUDE.md` under **Known Bugs and Fixes**
|
||||
(Bugs 23–30). This document is the narrative: what happened, in what order, and what to do
|
||||
differently.
|
||||
|
||||
---
|
||||
|
||||
## 1. The expensive part: a green log is not evidence
|
||||
|
||||
The original symptom was loud and honest — every run died on
|
||||
`Wanted to select A5_RED_PORTAL, but could not find it`.
|
||||
|
||||
After the portal template was fixed, the logs went clean **and stayed wrong**. Five
|
||||
consecutive games produced:
|
||||
|
||||
```
|
||||
INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)
|
||||
INFO Loot from run_pindle: nothing picked up
|
||||
INFO End game. Elapsed time: 50.98s
|
||||
"runs_failed_total": 0
|
||||
```
|
||||
|
||||
Every line green. The character was standing in the middle of Harrogath swinging at nothing.
|
||||
|
||||
**What gave it away was not the log — it was the XP counter.** 6,779,050 before five
|
||||
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
|
||||
`nothing picked up` is not variance, it is a run that never happened.
|
||||
|
||||
> A boss run's success is only provable by things the bot cannot fake: an item in the loot
|
||||
> line, or a jump in `current_exp`. `failed:false` is the bot's *opinion*, derived from the
|
||||
> very check that was lying.
|
||||
|
||||
---
|
||||
|
||||
## 2. The causal chain
|
||||
|
||||
Four of the first five causes were one failure propagating. Fixing only the visible end
|
||||
would have left the rest armed.
|
||||
|
||||
```
|
||||
Portal template is masked (scores 0.50-0.60, needs 0.68, matches the wrong place)
|
||||
-> portal click fails, approach re-paths from a hardcoded town start it no longer occupies
|
||||
-> landmarks miss, the pather's 0.55 fallback invents a node position from scenery
|
||||
-> char walks at the phantom and wedges on the town wall, or onto the waypoint stone
|
||||
-> waypoint panel opens, health manager reads it as a threat, chickens at full HP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Root causes, in order of cost
|
||||
|
||||
| Bug | Cause | Severity |
|
||||
|---|---|---|
|
||||
| 24 | "Already in Pindle area?" shortcut fired **in town** (0.76–0.79 on `PINDLE_7` vs its 0.62 bar), returned success without clicking the portal | **Silent** |
|
||||
| 28 | `detect_current_act` committed on a **1.7pp margin** (`A5_TOWN_1` 0.636 vs `A4_TOWN_5` 0.619) → A5 pathing ran inside Act 4. **25% of games** | **Silent** |
|
||||
| 30 | NPC grid sweep `SWEEP_TAG_THRESHOLD=0.4` settled for noise (real hits 0.98–1.00, false 0.42–0.50) | Loud |
|
||||
| 23 | `a5_red_portal.png` was the only **masked** template in its folder → unsupported masked `TM_CCOEFF_NORMED` | Loud |
|
||||
| 25 | Pather 0.55 **first-match** fallback fabricated node positions → walked into the town wall | Loud |
|
||||
| 29 | Undetectable resurrect NPC re-hunted every game (~80s), per-game flag reset by `log_start_game` | Loud |
|
||||
| 26 | Waypoint panel counted toward a chicken — 3 of 6 games died at full HP | Loud |
|
||||
| 27 | `GEMS_TAB_Y=100` missed the tab row (labels at y 63–78, grid starts y≈87) → 800+ silent warnings | Loud |
|
||||
| 9b | Pindle retry re-pathed from a hardcoded `A5_TOWN_START` | Loud |
|
||||
|
||||
The two marked **Silent** cost the most, because they reported success.
|
||||
|
||||
---
|
||||
|
||||
## 4. Four corrections — times the obvious answer was wrong
|
||||
|
||||
These are recorded because in each case the wrong answer looked correct.
|
||||
|
||||
**1. The red-portal guard.** To stop the false "already in Pindle area" I added: *if the red
|
||||
portal is on screen we must still be in town.* The portal is visible from **both sides** —
|
||||
the return portal renders inside the temple. The guard blocked every genuine entry. It
|
||||
traded a silent false-success for an honest false-failure: an improvement, still a bug.
|
||||
|
||||
**2. XP as proof of a boss kill.** An XP gain proves *something* died — the minion pack and
|
||||
the mercenary's kills both count. Confirmation needed a loot line naming an actual drop.
|
||||
|
||||
**3. "The template is degenerate."** I blamed the Qual-Kehk asset for a repeated identical
|
||||
0.424 score. Walking the character to the NPC and scoring the stored template against a
|
||||
frame where the tag renders gave **1.000** (0.997 through the real `color_filter` path). The
|
||||
template was perfect; the *threshold* was wrong.
|
||||
|
||||
> A repeated identical score means the search is settling for **noise**. Check the threshold
|
||||
> before blaming the asset.
|
||||
|
||||
**4. Selling the charms.** I read 673 blocked-sell lines as a clog to clear and disabled
|
||||
`protect_charms_from_sell`. It vendored five resistance charms before it was caught. The
|
||||
blocked lines were **the guard working**, and the pickit's `Discarding` verdict was never a
|
||||
judgement that they were junk — `LAPIS SMALL CHARM OF VITA` failed its rule only because the
|
||||
rule wanted `coldresist >= 11` and it had 7.
|
||||
|
||||
---
|
||||
|
||||
## 5. What actually made this expensive
|
||||
|
||||
Every bug above cost hours because the log recorded **events, not structure**. There was no
|
||||
way to ask "where did this game spend its time?" without reading thousands of lines.
|
||||
|
||||
That is now fixed — see `CLAUDE.md` → *The run timeline* and *Diagnosing a failure*:
|
||||
|
||||
- **`grep "TL>"`** — one fixed-width line per step, spawn to loot, every step timed.
|
||||
- **`grep "FAIL>"`** — a self-contained failure record: reason, location, screenshot, the
|
||||
three slowest steps, and a breadcrumb trail of the game with `!` marking failures.
|
||||
- **A Discord digest every 2h** (`general.discord_timing_report_h`), aggregated from the same
|
||||
timeline so report and log cannot drift.
|
||||
|
||||
The first thing the timeline surfaced, unprompted:
|
||||
|
||||
```
|
||||
TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable
|
||||
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
|
||||
TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal
|
||||
```
|
||||
|
||||
That failed game spent **113.6s of its 137.6s town visit** on a merc resurrect that achieved
|
||||
nothing — 83% of the time, on a step that was not the one that ended the game.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rules carried forward
|
||||
|
||||
1. **Verify boss runs with XP delta and loot lines**, never `failed:false`.
|
||||
2. **Never mask part of a template** to hide an artifact — masked `TM_CCOEFF_NORMED` is
|
||||
unsupported in OpenCV and fails unpredictably. Recapture it clean.
|
||||
3. **Prove a template fails on a frame where the subject is definitely visible** before
|
||||
recapturing anything.
|
||||
4. **Confirm any click that changes UI mode** — tab, page, panel. An unverified click that
|
||||
"should" have worked fails silently forever.
|
||||
5. **Never let a detector guess on a thin margin.** Refusing to answer is usually safe; a
|
||||
wrong confident answer skips the recovery path entirely.
|
||||
6. **Read repeated warnings as a system working**, not a clog to clear.
|
||||
7. **Restart the bot after editing `src/` or config** — both load at process start.
|
||||
8. **A "we might already be there" fast path is dangerous** when its test can false-positive
|
||||
at the origin. It converts a loud failure into a silent one.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,726 @@
|
||||
<title>Pindle Run Post-Mortem</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ground:#EDF0F4;
|
||||
--surface:#FFFFFF;
|
||||
--surface-2:#E3E8EF;
|
||||
--ink:#15181E;
|
||||
--ink-2:#3D4652;
|
||||
--slate:#5A6675;
|
||||
--hair:#CBD3DE;
|
||||
--accent:#B01B2E;
|
||||
--accent-soft:#F6E4E7;
|
||||
--good:#1B6B52;
|
||||
--good-soft:#DFEFE8;
|
||||
--warn:#8A5A0B;
|
||||
--warn-soft:#F7EBD6;
|
||||
--shadow:0 1px 2px rgba(21,24,30,.06),0 8px 24px -12px rgba(21,24,30,.18);
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--ground:#101317;
|
||||
--surface:#171B21;
|
||||
--surface-2:#1F242C;
|
||||
--ink:#E6EAF0;
|
||||
--ink-2:#BAC3CF;
|
||||
--slate:#8B97A6;
|
||||
--hair:#2A313A;
|
||||
--accent:#FF5F70;
|
||||
--accent-soft:#2C1A1F;
|
||||
--good:#4FC79B;
|
||||
--good-soft:#14261F;
|
||||
--warn:#E0A44A;
|
||||
--warn-soft:#2A2114;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--ground:#101317;
|
||||
--surface:#171B21;
|
||||
--surface-2:#1F242C;
|
||||
--ink:#E6EAF0;
|
||||
--ink-2:#BAC3CF;
|
||||
--slate:#8B97A6;
|
||||
--hair:#2A313A;
|
||||
--accent:#FF5F70;
|
||||
--accent-soft:#2C1A1F;
|
||||
--good:#4FC79B;
|
||||
--good-soft:#14261F;
|
||||
--warn:#E0A44A;
|
||||
--warn-soft:#2A2114;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;
|
||||
background:var(--ground);
|
||||
color:var(--ink);
|
||||
font-family:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
font-size:16.5px;
|
||||
line-height:1.65;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 24px 96px}
|
||||
.col{max-width:672px;margin-inline:auto}
|
||||
|
||||
h1,h2,h3{font-family:Fraunces,Georgia,serif;text-wrap:balance;margin:0}
|
||||
h1{font-size:clamp(2.1rem,5.2vw,3.15rem);font-weight:600;line-height:1.08;letter-spacing:-.015em}
|
||||
h2{font-size:clamp(1.4rem,3vw,1.75rem);font-weight:600;line-height:1.2}
|
||||
h3{font-size:1.06rem;font-weight:600;line-height:1.3}
|
||||
p{margin:0}
|
||||
a{color:var(--accent)}
|
||||
code,.mono{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace}
|
||||
|
||||
.eyebrow{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:500;
|
||||
letter-spacing:.16em;text-transform:uppercase;color:var(--slate);
|
||||
}
|
||||
|
||||
/* ---- masthead ---- */
|
||||
header{padding:72px 0 40px;border-bottom:1px solid var(--hair)}
|
||||
.head-inner{display:flex;flex-direction:column;gap:20px}
|
||||
.lede{font-size:1.12rem;color:var(--ink-2);max-width:64ch}
|
||||
.verdict{
|
||||
display:inline-flex;align-items:center;gap:10px;align-self:flex-start;
|
||||
background:var(--good-soft);color:var(--good);
|
||||
border:1px solid color-mix(in srgb,var(--good) 30%,transparent);
|
||||
border-radius:2px;padding:7px 13px;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.76rem;font-weight:600;letter-spacing:.06em;
|
||||
}
|
||||
.verdict::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--good)}
|
||||
|
||||
/* ---- metric band ---- */
|
||||
.metrics{
|
||||
display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));
|
||||
gap:1px;background:var(--hair);border:1px solid var(--hair);
|
||||
margin:40px 0 0;
|
||||
}
|
||||
.metric{background:var(--surface);padding:18px 20px;display:flex;flex-direction:column;gap:5px}
|
||||
.metric .k{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--slate);font-family:"IBM Plex Mono",monospace}
|
||||
.metric .v{font-family:Fraunces,serif;font-size:1.6rem;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.metric .n{font-size:.8rem;color:var(--slate)}
|
||||
.metric.hi .v{color:var(--good)}
|
||||
.metric.lo .v{color:var(--accent)}
|
||||
|
||||
section{padding-top:64px}
|
||||
.section-head{display:flex;flex-direction:column;gap:8px;margin-bottom:26px}
|
||||
.body-flow{display:flex;flex-direction:column;gap:18px}
|
||||
|
||||
/* ---- the trap callout ---- */
|
||||
.trap{
|
||||
background:var(--accent-soft);
|
||||
border-left:3px solid var(--accent);
|
||||
padding:22px 24px;display:flex;flex-direction:column;gap:12px;
|
||||
}
|
||||
.trap h3{color:var(--accent)}
|
||||
|
||||
/* ---- log evidence ---- */
|
||||
.log{
|
||||
background:var(--surface);border:1px solid var(--hair);
|
||||
padding:16px 18px;overflow-x:auto;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.79rem;line-height:1.75;
|
||||
color:var(--ink-2);white-space:pre;
|
||||
}
|
||||
.log .ok{color:var(--good);font-weight:600}
|
||||
.log .bad{color:var(--accent);font-weight:600}
|
||||
.log .dim{color:var(--slate)}
|
||||
figure{margin:0;display:flex;flex-direction:column;gap:9px}
|
||||
figcaption{font-size:.82rem;color:var(--slate)}
|
||||
|
||||
/* ---- causal chain ---- */
|
||||
.chain{display:flex;flex-direction:column;gap:0;margin-top:8px}
|
||||
.node{
|
||||
background:var(--surface);border:1px solid var(--hair);
|
||||
padding:14px 18px;display:flex;gap:14px;align-items:baseline;
|
||||
}
|
||||
.node .tag{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;
|
||||
color:var(--accent);letter-spacing:.05em;flex-shrink:0;min-width:52px;
|
||||
}
|
||||
.node .txt{font-size:.93rem;color:var(--ink-2)}
|
||||
.node strong{color:var(--ink);font-weight:600}
|
||||
.arrow{
|
||||
height:22px;width:1px;background:var(--hair);margin-left:34px;position:relative;flex-shrink:0;
|
||||
}
|
||||
.arrow::after{
|
||||
content:"";position:absolute;bottom:0;left:-3px;
|
||||
border-left:3.5px solid transparent;border-right:3.5px solid transparent;
|
||||
border-top:5px solid var(--hair);
|
||||
}
|
||||
.node.terminal{border-color:color-mix(in srgb,var(--accent) 45%,var(--hair));background:var(--accent-soft)}
|
||||
.node.terminal .txt strong{color:var(--accent)}
|
||||
|
||||
/* ---- cause cards ---- */
|
||||
.causes{display:flex;flex-direction:column;gap:14px}
|
||||
.cause{
|
||||
background:var(--surface);border:1px solid var(--hair);box-shadow:var(--shadow);
|
||||
display:flex;flex-direction:column;
|
||||
}
|
||||
.cause-head{
|
||||
display:flex;gap:14px;align-items:flex-start;
|
||||
padding:20px 24px 16px;border-bottom:1px solid var(--hair);
|
||||
}
|
||||
.bugid{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;letter-spacing:.06em;
|
||||
color:var(--surface);background:var(--ink);padding:4px 8px;border-radius:2px;flex-shrink:0;
|
||||
margin-top:3px;
|
||||
}
|
||||
.cause-head .titles{display:flex;flex-direction:column;gap:3px}
|
||||
.cause-head .file{font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate)}
|
||||
.cause-body{padding:20px 24px 24px;display:flex;flex-direction:column;gap:16px}
|
||||
.field{display:flex;flex-direction:column;gap:5px}
|
||||
.field .lbl{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.67rem;letter-spacing:.13em;
|
||||
text-transform:uppercase;color:var(--slate);
|
||||
}
|
||||
.field p{font-size:.94rem;color:var(--ink-2)}
|
||||
.field.fix .lbl{color:var(--good)}
|
||||
.sev{
|
||||
margin-left:auto;flex-shrink:0;font-family:"IBM Plex Mono",monospace;
|
||||
font-size:.66rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
|
||||
padding:4px 8px;border-radius:2px;white-space:nowrap;margin-top:3px;
|
||||
}
|
||||
.sev.silent{background:var(--accent-soft);color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent)}
|
||||
.sev.loud{background:var(--warn-soft);color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 30%,transparent)}
|
||||
|
||||
/* ---- tables ---- */
|
||||
.tablewrap{overflow-x:auto;border:1px solid var(--hair);background:var(--surface)}
|
||||
table{border-collapse:collapse;width:100%;font-size:.86rem}
|
||||
th,td{text-align:left;padding:10px 16px;border-bottom:1px solid var(--hair)}
|
||||
th{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--slate);font-weight:500;background:var(--surface-2);
|
||||
}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
td.num{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
td .yes{color:var(--good);font-weight:600}
|
||||
td .no{color:var(--accent);font-weight:600}
|
||||
|
||||
/* ---- checklist ---- */
|
||||
.checks{display:flex;flex-direction:column;gap:1px;background:var(--hair);border:1px solid var(--hair)}
|
||||
.check{background:var(--surface);padding:15px 20px;display:flex;gap:13px;align-items:flex-start}
|
||||
.check .mark{color:var(--good);font-weight:700;flex-shrink:0;line-height:1.6}
|
||||
.check.dont .mark{color:var(--accent)}
|
||||
.check .ct{font-size:.92rem;color:var(--ink-2)}
|
||||
.check .ct strong{color:var(--ink)}
|
||||
|
||||
/* ---- wrong turns ---- */
|
||||
.turn{
|
||||
border:1px dashed var(--hair);background:transparent;
|
||||
padding:18px 20px;display:flex;flex-direction:column;gap:8px;
|
||||
}
|
||||
.turn .lbl{font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--warn)}
|
||||
.turn p{font-size:.92rem;color:var(--ink-2)}
|
||||
|
||||
footer{
|
||||
margin-top:80px;padding-top:26px;border-top:1px solid var(--hair);
|
||||
display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate);
|
||||
}
|
||||
@media (max-width:640px){
|
||||
header{padding-top:48px}
|
||||
.cause-head{flex-wrap:wrap}
|
||||
.sev{margin-left:0}
|
||||
}
|
||||
</style>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="col head-inner">
|
||||
<span class="eyebrow">Post-mortem · run_pindle · 26–27 Aug 2026</span>
|
||||
<h1>The bot reported five perfect runs and killed nothing</h1>
|
||||
<p class="lede">
|
||||
<code>run_pindle</code> was failing 100% of the time. Then a fix made it worse in the
|
||||
most expensive way possible: it started reporting success. Nine root causes, four wrong
|
||||
turns of my own, and the instrumentation built so the next one takes minutes.
|
||||
</p>
|
||||
<span class="verdict">Resolved · 17 commits</span>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="metrics">
|
||||
<div class="metric lo">
|
||||
<span class="k">Failure rate</span>
|
||||
<span class="v">100%</span>
|
||||
<span class="n">at the start</span>
|
||||
</div>
|
||||
<div class="metric hi">
|
||||
<span class="k">Now</span>
|
||||
<span class="v">~8%</span>
|
||||
<span class="n">1 in 28 approaches</span>
|
||||
</div>
|
||||
<div class="metric hi">
|
||||
<span class="k">Character</span>
|
||||
<span class="v">32 → 69</span>
|
||||
<span class="n">37 levels</span>
|
||||
</div>
|
||||
<div class="metric hi">
|
||||
<span class="k">Cycle time</span>
|
||||
<span class="v">~52s</span>
|
||||
<span class="n">was 185–250s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">The expensive part</span>
|
||||
<h2>A green log is not evidence</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
The original symptom was loud and honest: every run died on
|
||||
<code>Wanted to select A5_RED_PORTAL, but could not find it</code>. After the template
|
||||
was fixed, the logs went clean — and stayed wrong. Five consecutive games produced this:
|
||||
</p>
|
||||
|
||||
<figure>
|
||||
<div class="log"><span class="ok">INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)</span>
|
||||
<span class="ok">INFO Loot from run_pindle: nothing picked up</span>
|
||||
<span class="ok">INFO End game. Elapsed time: 50.98s</span>
|
||||
<span class="dim"> "runs_failed_total": 0</span></div>
|
||||
<figcaption>Every line green. The character was standing in the middle of town, swinging at nothing.</figcaption>
|
||||
</figure>
|
||||
|
||||
<div class="trap">
|
||||
<h3>What gave it away</h3>
|
||||
<p>
|
||||
Not the logs — the <strong>XP counter never moved</strong>. 6,779,050 before five
|
||||
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
|
||||
<code>nothing picked up</code> is not variance, it is a run that never happened.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
A boss run's success is only provable by things the bot cannot fake: an item in the loot
|
||||
line, or a jump in <code>current_exp</code>. The <code>failed:false</code> flag is the
|
||||
bot's <em>opinion</em>, derived from the very template check that was lying.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">How one bad asset cascaded</span>
|
||||
<h2>The causal chain</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
Four of the first five causes are one failure propagating. This is a real dependency
|
||||
chain, so fixing only the visible end would have left the rest armed.
|
||||
</p>
|
||||
<div class="chain">
|
||||
<div class="node">
|
||||
<span class="tag">CAUSE</span>
|
||||
<span class="txt"><strong>Portal template is masked</strong> — scores 0.50–0.60, below the 0.68 it needs, matching the wrong place entirely.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node">
|
||||
<span class="tag">THEN</span>
|
||||
<span class="txt">Portal click fails, so the approach <strong>re-paths from a hardcoded town start</strong> the character no longer occupies.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node">
|
||||
<span class="tag">THEN</span>
|
||||
<span class="txt">Landmarks miss, so the pather's <strong>0.55 fallback invents a node position</strong> from unrelated scenery.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node">
|
||||
<span class="tag">THEN</span>
|
||||
<span class="txt">The character walks toward the phantom and <strong>wedges against the town wall</strong> — or onto the waypoint stone.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node terminal">
|
||||
<span class="tag">RESULT</span>
|
||||
<span class="txt">The waypoint panel opens, the health manager reads it as a threat, and <strong>chickens a game at full HP</strong>.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">Root causes</span>
|
||||
<h2>Nine bugs, in order of cost</h2>
|
||||
</div>
|
||||
<div class="col causes">
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 24</span>
|
||||
<div class="titles">
|
||||
<h3>An optimistic shortcut faked every kill</h3>
|
||||
<span class="file">src/run/pindle.py — approach()</span>
|
||||
</div>
|
||||
<span class="sev silent">Silent</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
<code>approach()</code> opened with an "are we already there?" shortcut that ran
|
||||
before clicking the portal. Harrogath scenery near the portal scores
|
||||
<strong>0.76–0.79 on <code>PINDLE_7</code></strong> — above its 0.62 bar — so the
|
||||
shortcut fired <em>in town</em>, returned <code>A5_PINDLE_START</code> without ever
|
||||
clicking the portal, and the bot ran <code>kill_pindle()</code> in the middle of
|
||||
Harrogath.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>Shortcut deleted. Entry is proven only by the portal click's loading screen. Real temple entries score <strong>85.8–88.7%</strong>, clear of the town false positives.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="lbl">Generalise</span>
|
||||
<p>A "we might already be there" fast path is dangerous whenever its test can false-positive at the origin. It converts a loud failure into a silent one — the worst trade in an unattended system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 28</span>
|
||||
<div class="titles">
|
||||
<h3>The act was decided by a 1.7-point margin</h3>
|
||||
<span class="file">src/town/town_manager.py — detect_current_act()</span>
|
||||
</div>
|
||||
<span class="sev silent">Silent</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
The biggest cause of ordinary failures: <strong>25% of games</strong>. A consumables
|
||||
trip sends the character A5→A4, and <code>go_to_act(5)</code> then asked
|
||||
<code>detect_current_act()</code> to verify. It committed to whatever cleared 0.68
|
||||
first:
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<div class="log"><span class="bad">A5_TOWN_1 0.636</span> <span class="dim"><- phantom, always at (1046, 40), the top-right corner</span>
|
||||
<span class="ok">A4_TOWN_5 0.619</span> <span class="dim"><- the genuine marker for the act actually occupied</span></div>
|
||||
<figcaption>A 1.7 percentage point gap decided which act the bot believed it was in — so it skipped the travel and ran A5 pathing inside Act 4.</figcaption>
|
||||
</figure>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>
|
||||
The winner must now beat the best marker <strong>from any other act</strong> by 5pp.
|
||||
Below that it refuses to answer. Refusing is the safe outcome: callers treat
|
||||
<code>None</code> as "keep the assumed act", so <code>go_to_act</code> actually
|
||||
travels. <strong>Failure rate 25% → 9%.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 30</span>
|
||||
<div class="titles">
|
||||
<h3>The NPC search settled for noise</h3>
|
||||
<span class="file">src/npc_manager.py — grid sweep</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
<code>SWEEP_TAG_THRESHOLD = 0.4</code>. A rendered name tag matches almost perfectly,
|
||||
so anything mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the
|
||||
first match over threshold, clicked empty ground, and gave up.
|
||||
</p>
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>NPC</th><th>Score</th><th>Outcome</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>akara</td><td class="num">0.980</td><td><span class="yes">real — dialogue opened</span></td></tr>
|
||||
<tr><td>halbu</td><td class="num">0.995</td><td><span class="yes">real</span></td></tr>
|
||||
<tr><td>larzuk</td><td class="num">0.996</td><td><span class="yes">real</span></td></tr>
|
||||
<tr><td>qual_kehk</td><td class="num">0.424</td><td><span class="no">false — clicked nothing</span></td></tr>
|
||||
<tr><td>malah</td><td class="num">0.501</td><td><span class="no">false</span></td></tr>
|
||||
<tr><td>larzuk</td><td class="num">0.494</td><td><span class="no">false</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>Raised to <strong>0.7</strong>, in the gap with margin either side. This is why qual_kehk failed 100% (5 timeouts in 5) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 23</span>
|
||||
<div class="titles">
|
||||
<h3>The only masked template in the folder</h3>
|
||||
<span class="file">assets/templates/a5_town/a5_red_portal.png</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
The capture had the portal's hover tooltip baked in, and alpha was used to hide it.
|
||||
That made <code>alpha_to_mask</code> return a mask, routing the search to
|
||||
<code>cv2.matchTemplate(…, TM_CCOEFF_NORMED, mask=…)</code> — a combination
|
||||
<strong>OpenCV only supports for <code>TM_SQDIFF</code> and
|
||||
<code>TM_CCORR_NORMED</code></strong>.
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<div class="log">a5_red_portal.png 4 chan <span class="bad"> 60.9% opaque MASKED</span> <span class="dim"><- the only one that failed</span>
|
||||
a5_town_1..12.png 3 chan <span class="ok">100.0% opaque no mask</span> <span class="dim"><- every one that works</span></div>
|
||||
<figcaption>Found by auditing the folder rather than the file: every working template is opaque; the failing one was the sole outlier.</figcaption>
|
||||
</figure>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>Recaptured as a plain 3-channel opaque crop of the portal's <strong>upper arch</strong> — the lower ring is occluded by branches. Present 0.949–1.000, absent 0.398–0.514.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 25</span>
|
||||
<div class="titles">
|
||||
<h3>The pather invented positions and walked into a wall</h3>
|
||||
<span class="file">src/pather.py — find_abs_node_pos()</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
When the primary 0.68 search missed, the fallback dropped to a <strong>0.55
|
||||
first-match</strong> full-image search. <code>A5_TOWN_1</code> scored 0.60–0.62 on
|
||||
unrelated scenery at three different coordinates across three consecutive frames.
|
||||
The landmarks were healthy where genuinely visible (<code>A5_TOWN_6 @ 0.93</code>) —
|
||||
only the fallback was lying.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix — three parts</span>
|
||||
<p>
|
||||
Threshold to <strong>0.62</strong>; fallback forced to <code>best_match=True</code>
|
||||
(it was <code>False</code>, so ambiguous frames were decided by <em>dict ordering</em>);
|
||||
and a heading gate rejecting any <em>low-confidence</em> match implying a >90°
|
||||
reversal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 29</span>
|
||||
<div class="titles">
|
||||
<h3>An undetectable NPC cost ~80s every game</h3>
|
||||
<span class="file">src/bot.py · src/game_stats.py</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
In nightmare the merc dies most games, so resurrect ran constantly — and it retried
|
||||
once. <code>_merc_resurrect_failed</code> is reset by <code>log_start_game</code>, so
|
||||
it only ever suppressed a second attempt <em>within one game</em>. Game length blew
|
||||
out to 185–250s against a normal ~60s.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>
|
||||
A cross-game circuit breaker that <code>log_start_game</code> deliberately does
|
||||
<em>not</em> reset. Over 30 games with an undetectable NPC: <strong>60 hunts → 4</strong>.
|
||||
Measured live, game times went 250s / 185s → 14s, 14s, 43s, 71s.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 26·27</span>
|
||||
<div class="titles">
|
||||
<h3>Two clicks that missed by ~30 pixels</h3>
|
||||
<span class="file">src/health_manager.py · src/transmute/transmute.py</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Waypoint panel counted as a chicken</span>
|
||||
<p>A mis-steered pather walks the char over the waypoint stone, opening its panel. The panel check counted it twice and killed the game — <strong>3 of 6 games died this way at full health</strong>. Now escaped without counting, bounded at six attempts.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="lbl">Gem conversion ran in the wrong tab</span>
|
||||
<p>
|
||||
<code>GEMS_TAB_Y = 100</code>, but the stash tab labels occupy <strong>y 63–78</strong>
|
||||
and the grid starts at y≈87 — so every tab-switch click landed on a stash slot. The
|
||||
whole routine ran in PERSONAL, logging the same warning <strong>800+ times</strong>
|
||||
without one error. Tab switches are now verified, not fire-and-forget.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">Course corrections</span>
|
||||
<h2>Four times I was wrong</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
Each looked correct in isolation, and each was caught by the user or by checking rather
|
||||
than by reasoning. They are recorded because the wrong answer is often the obvious one.
|
||||
</p>
|
||||
<div class="turn">
|
||||
<span class="lbl">1 — the red-portal guard</span>
|
||||
<p>
|
||||
To stop the false "already in Pindle area", I added: if the red portal is on screen we
|
||||
must still be in town. <strong>The portal is visible from both sides</strong> — the
|
||||
return portal renders inside the temple. The guard blocked every genuine entry. It
|
||||
traded a silent false-success for an honest false-failure, which is an improvement and
|
||||
still a bug.
|
||||
</p>
|
||||
</div>
|
||||
<div class="turn">
|
||||
<span class="lbl">2 — XP as proof of a boss kill</span>
|
||||
<p>An XP gain proves <em>something</em> died — the minion pack and the mercenary's kills both count. Confirmation required a loot line naming an actual drop.</p>
|
||||
</div>
|
||||
<div class="turn">
|
||||
<span class="lbl">3 — "the template is degenerate"</span>
|
||||
<p>
|
||||
I blamed the Qual-Kehk asset for a repeated identical 0.424 score. Walking the character
|
||||
to the NPC and scoring the stored template against a frame where the tag renders gave
|
||||
<strong>1.000</strong>. The template was perfect; the <em>threshold</em> was wrong. A
|
||||
repeated identical score means the search is settling for noise — check the threshold
|
||||
before blaming the asset.
|
||||
</p>
|
||||
</div>
|
||||
<div class="turn">
|
||||
<span class="lbl">4 — selling the charms</span>
|
||||
<p>
|
||||
I read 673 blocked-sell log lines as a clog to clear and disabled the guard. It vendored
|
||||
five resistance charms before it was caught. <strong>The blocked lines were the guard
|
||||
working</strong>, and the pickit's "Discarding" verdict was never a judgement that they
|
||||
were junk — <code>LAPIS SMALL CHARM OF VITA</code> failed its rule only because the rule
|
||||
wanted <code>coldresist >= 11</code> and it had 7.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">The durable part</span>
|
||||
<h2>Instrumentation, so the next one takes minutes</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
Every bug above cost hours because the log recorded events, not <em>structure</em>. The
|
||||
last commits fix that: one fixed-width, machine-readable line per step, spawn to loot,
|
||||
every step timed.
|
||||
</p>
|
||||
<figure>
|
||||
<div class="log"><span class="dim">grep "TL>" log/log.txt</span>
|
||||
|
||||
TL> g2 r1 | game | spawn | ok | at a5_town_start
|
||||
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2
|
||||
TL> g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
|
||||
TL> g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
|
||||
<span class="bad">TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable</span>
|
||||
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
|
||||
<span class="bad">TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal</span></div>
|
||||
<figcaption>
|
||||
That failed game spent <strong>113.6s of its 137.6s town visit</strong> on a merc
|
||||
resurrect that achieved nothing — 83% of the time, on a step that was not the one that
|
||||
ended the game. That proportion was invisible before.
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
Failures get a self-contained record (<code>grep "FAIL>"</code>) carrying the reason,
|
||||
location, screenshot, the three slowest steps, and a breadcrumb trail of the whole game
|
||||
with <code>!</code> marking failures. <strong>The step named in the reason is frequently
|
||||
not the one that caused the problem</strong> — the trail is what shows that.
|
||||
</p>
|
||||
<p>
|
||||
A digest posts to Discord every two hours, aggregated from the same timeline so the report
|
||||
and the log cannot drift apart.
|
||||
</p>
|
||||
<figure>
|
||||
<div class="log">**Timing report** - last 2.0h
|
||||
Games: 12 (11 ok, 1 failed - 8.3%)
|
||||
Avg town 20s | approach 14s | battle 19s | cycle ~52s
|
||||
|
||||
__Slowest steps (avg)__
|
||||
`town.resurrect_merc ` 33s x6 (2 fail)
|
||||
`run.approach ` 14s x28 (1 fail)</div>
|
||||
<figcaption>Real measured output. Rankings exclude umbrella steps and deliberate stealth pauses, which would otherwise always come first and say nothing.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">Prevention</span>
|
||||
<h2>Rules carried into the docs</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<div class="checks">
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Verify boss runs with XP delta and loot lines.</strong> Never with <code>failed:false</code> — that is the bot agreeing with itself.</span>
|
||||
</div>
|
||||
<div class="check dont">
|
||||
<span class="mark">✕</span>
|
||||
<span class="ct"><strong>Never mask part of a template to hide an artifact.</strong> Masked <code>TM_CCOEFF_NORMED</code> is unsupported in OpenCV and fails unpredictably.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Prove a template fails on a frame where the subject is definitely visible</strong> before recapturing it. A repeated identical score means the threshold is too low, not that the asset is bad.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Confirm any click that changes UI mode</strong> — a tab, a page, a panel. An unverified click that "should" have worked will fail silently forever.</span>
|
||||
</div>
|
||||
<div class="check dont">
|
||||
<span class="mark">✕</span>
|
||||
<span class="ct"><strong>Never let a detector guess on a thin margin.</strong> Refusing to answer is usually the safe outcome; a wrong confident answer skips the recovery path entirely.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Read repeated warnings as a system working, not a clog to clear.</strong> 673 blocked sells were a guard doing its job.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Restart the bot after editing <code>src/</code> or config.</strong> Python loads modules and config at process start; a running bot keeps executing the old code.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="col" style="display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;width:100%">
|
||||
<span>my-botty · branch <strong>stable</strong> · 17 commits</span>
|
||||
<span>9 root causes · 4 corrections · verified live</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -8,27 +8,4 @@ dependencies:
|
||||
- pkg-config
|
||||
- rapidfuzz=2.15.1
|
||||
- pip:
|
||||
- pyinstaller
|
||||
- opencv-python==4.5.5.64
|
||||
- transitions
|
||||
- mss==7.0.1
|
||||
- numpy==1.26.4
|
||||
- mouse
|
||||
- keyboard
|
||||
- beautifultable
|
||||
- pytweening
|
||||
- requests
|
||||
- coverage
|
||||
- pytest
|
||||
- pytest-env
|
||||
- pytest-mock
|
||||
- pyparsing
|
||||
- graphviz
|
||||
- psutil
|
||||
- cryptography
|
||||
- pillow
|
||||
- discord.py
|
||||
- parse
|
||||
- typing_extensions
|
||||
- colorama
|
||||
- dataclasses-json
|
||||
- -r requirements.txt
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "botty"
|
||||
version = "0.8.4"
|
||||
version = "0.10.0"
|
||||
description = "Pixelbot for Diablo 2 Resurrected"
|
||||
requires-python = ">=3.10,<3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
aiohappyeyeballs==2.6.2
|
||||
pywin32==311
|
||||
pywin32==311; sys_platform=="win32"
|
||||
aiohttp==3.14.1
|
||||
aiosignal==1.4.0
|
||||
async-timeout==5.0.1
|
||||
|
||||
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 162 KiB |
@@ -36,6 +36,19 @@ class IChar:
|
||||
}
|
||||
self._use_safer_routines = Config().char["safer_routines"]
|
||||
|
||||
def atk_len(self, name: str) -> float:
|
||||
"""Attack window for a boss, with stealth kill-time variance applied.
|
||||
|
||||
THE single correct way to read an atk_len_* value. Reading
|
||||
Config().char["atk_len_x"] directly works but silently opts that boss
|
||||
out of vary_kill_time — the failure mode this whole subsystem keeps
|
||||
producing. randomize_run_duration only ever LENGTHENS (1.0-1.4x), so a
|
||||
varied window can never cut a kill short.
|
||||
"""
|
||||
from utils.stealth import randomize_run_duration
|
||||
key = name if name.startswith("atk_len_") else f"atk_len_{name}"
|
||||
return randomize_run_duration(float(Config().char[key]))
|
||||
|
||||
def _set_active_skill(self, mouse_click_type: str = "left", skill: str =""):
|
||||
self._active_skill[mouse_click_type] = skill
|
||||
|
||||
@@ -130,7 +143,8 @@ class IChar:
|
||||
success_func: Callable = None,
|
||||
timeout: float = 8,
|
||||
threshold: float = 0.68,
|
||||
telekinesis: bool = False
|
||||
telekinesis: bool = False,
|
||||
roi: list = None
|
||||
) -> bool:
|
||||
"""
|
||||
Finds any template from the template finder and interacts with it
|
||||
@@ -138,6 +152,10 @@ class IChar:
|
||||
:param success_func: Function that will return True if the interaction is successful e.g. return True when loading screen is reached, defaults to None
|
||||
:param timeout: Timeout for the whole template selection, defaults to None
|
||||
:param threshold: Threshold which determines if a template is found or not. None will use default form .ini files
|
||||
:param roi: Optional [left, top, width, height] to restrict the search. Pass
|
||||
ui_roi["cut_skill_bar"] for WORLD objects: the HUD is static, so a template
|
||||
that happens to resemble part of it wins every frame and the real object is
|
||||
never clicked. Cost a whole evening of A5_WP failures on 2026-08-27.
|
||||
:return: True if success. False otherwise
|
||||
"""
|
||||
templates = template_type if isinstance(template_type, list) else [template_type]
|
||||
@@ -148,7 +166,7 @@ class IChar:
|
||||
wait(0.2, 0.3)
|
||||
start = time.time()
|
||||
while timeout is None or (time.time() - start) < timeout:
|
||||
template_match = template_finder.search(template_type, grab(), threshold=threshold)
|
||||
template_match = template_finder.search(template_type, grab(), threshold=threshold, roi=roi)
|
||||
if template_match.valid:
|
||||
Logger.debug(f"Select {template_match.name} ({template_match.score*100:.1f}% confidence)")
|
||||
mouse.move(*template_match.center_monitor)
|
||||
@@ -213,7 +231,41 @@ class IChar:
|
||||
if skills.wait_until_right_skill_selected("TELE_ACTIVE") == False:
|
||||
Logger.error("timeout waiting for tele skill to activate")
|
||||
|
||||
@staticmethod
|
||||
def _hud_safe_target(pos_monitor: tuple[float, float], jitter: int) -> tuple[int, int]:
|
||||
"""Jitter FIRST, then snap out of the HUD. Returns a point to click with
|
||||
randomize=0.
|
||||
|
||||
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
|
||||
construction lies exactly on the mask boundary. Applying mouse.move's
|
||||
randomize AFTER it therefore pushes the cursor straight back inside:
|
||||
randomize=3 offsets each axis by randrange(-3, 3), i.e. -3..+2. That
|
||||
made the loot-filter clicks intermittent rather than fixed — the first
|
||||
version of this guard had exactly that hole, and its test only checked
|
||||
the guard's output, never the cursor that was finally clicked.
|
||||
|
||||
Order matters: jitter, then guard, then move with no further
|
||||
randomisation. The human-like offset is preserved; the guarantee is not
|
||||
given away.
|
||||
"""
|
||||
x, y = int(pos_monitor[0]), int(pos_monitor[1])
|
||||
if jitter and jitter > 0:
|
||||
x += random.randrange(-jitter, jitter)
|
||||
y += random.randrange(-jitter, jitter)
|
||||
return get_closest_non_hud_pixel((x, y), "monitor")
|
||||
|
||||
def move(self, pos_monitor: tuple[float, float], force_tp: bool = False, force_move: bool = False):
|
||||
# Never click into the HUD. Neither branch below did this, so a move
|
||||
# target low on the screen landed on the interface — most visibly the
|
||||
# loot-filter toggles at the bottom-left (screen x 395-560, y 692-712),
|
||||
# which a right-click flips. Reported after Larzuk trips: he stands on
|
||||
# the left of Harrogath, so moves to and from him aim at that corner.
|
||||
#
|
||||
# Latent for a long time and surfaced by Enigma: the walk branch shrinks
|
||||
# its target toward centre via adjust_factor, which mostly kept clicks
|
||||
# off the HUD by accident, while the teleport branch clicks the raw
|
||||
# target. The pather's anti-stuck path already guarded this; move() did
|
||||
# not.
|
||||
factor = Config().advanced_options["pathing_delay_factor"]
|
||||
if "teleport" in Config().char and Config().char["teleport"] and (
|
||||
force_tp
|
||||
@@ -223,7 +275,8 @@ class IChar:
|
||||
)
|
||||
):
|
||||
self._set_active_skill("right", "teleport")
|
||||
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, delay_factor=[factor*0.1, factor*0.14])
|
||||
tx, ty = self._hud_safe_target(pos_monitor, 3)
|
||||
mouse.move(tx, ty, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
mouse.click(button="right")
|
||||
wait(self._cast_duration, self._cast_duration + 0.02)
|
||||
@@ -237,7 +290,8 @@ class IChar:
|
||||
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
|
||||
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
|
||||
x, y = convert_abs_to_monitor(pos_abs)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
|
||||
x, y = self._hud_safe_target((x, y), 5)
|
||||
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
if force_move:
|
||||
keyboard.send(Config().char["force_move"])
|
||||
@@ -255,7 +309,9 @@ class IChar:
|
||||
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
|
||||
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
|
||||
x, y = convert_abs_to_monitor(pos_abs)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
|
||||
# Same HUD guard as move(). walk() had the identical unguarded click.
|
||||
x, y = self._hud_safe_target((x, y), 5)
|
||||
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
if force_move:
|
||||
keyboard.send(Config().char["force_move"])
|
||||
|
||||
@@ -128,7 +128,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_pindle(self) -> bool:
|
||||
atk_len_dur = float(Config().char["atk_len_pindle"])
|
||||
atk_len_dur = self.atk_len("atk_len_pindle")
|
||||
pindle_pos_abs = convert_screen_to_abs(Config().path["pindle_end"][0])
|
||||
|
||||
cast_pos_abs = [pindle_pos_abs[0] * 0.80, pindle_pos_abs[1] * 0.80]
|
||||
@@ -162,7 +162,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_council(self) -> bool:
|
||||
atk_len_dur = float(Config().char["atk_len_trav"])
|
||||
atk_len_dur = self.atk_len("atk_len_trav")
|
||||
|
||||
keyboard.send(self._skill_hotkeys["conviction"])
|
||||
wait(.15)
|
||||
@@ -184,7 +184,7 @@ class FoHdin(Paladin):
|
||||
|
||||
def kill_eldritch(self) -> bool:
|
||||
eld_pos_abs = convert_screen_to_abs(Config().path["eldritch_end"][0])
|
||||
atk_len_dur = float(Config().char["atk_len_eldritch"])
|
||||
atk_len_dur = self.atk_len("atk_len_eldritch")
|
||||
|
||||
self._generic_foh_attack_sequence(default_target_abs=eld_pos_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, default_spray=70)
|
||||
|
||||
@@ -202,7 +202,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_shenk(self):
|
||||
atk_len_dur = float(Config().char["atk_len_shenk"])
|
||||
atk_len_dur = self.atk_len("atk_len_shenk")
|
||||
|
||||
# traverse to shenk
|
||||
keyboard.send(self._skill_hotkeys["conviction"])
|
||||
@@ -221,7 +221,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_nihlathak(self, end_nodes: list[int]) -> bool:
|
||||
atk_len_dur = Config().char["atk_len_nihlathak"]
|
||||
atk_len_dur = self.atk_len("atk_len_nihlathak")
|
||||
# Move close to nihlathak
|
||||
self._pather.traverse_nodes(end_nodes, self, timeout=0.8, do_pre_move=False)
|
||||
if self._select_skill("blessed_hammer"):
|
||||
@@ -238,7 +238,7 @@ class FoHdin(Paladin):
|
||||
|
||||
def kill_summoner(self) -> bool:
|
||||
# Attack
|
||||
atk_len_dur = Config().char["atk_len_arc"]
|
||||
atk_len_dur = self.atk_len("atk_len_arc")
|
||||
self._generic_foh_attack_sequence(min_duration=atk_len_dur, max_duration=atk_len_dur*2, default_spray=80)
|
||||
self._activate_cleanse_redemption()
|
||||
return True
|
||||
@@ -784,7 +784,7 @@ class FoHdin(Paladin):
|
||||
def kill_diablo(self) -> bool:
|
||||
### APPROACH ###
|
||||
### ATTACK ###
|
||||
atk_len_dur = float(Config().char["atk_len_diablo"])
|
||||
atk_len_dur = self.atk_len("atk_len_diablo")
|
||||
Logger.debug("Attacking Diablo at position 1/1")
|
||||
diablo_abs = [100,-100] #hardcoded dia pos.
|
||||
self._generic_foh_attack_sequence(default_target_abs=diablo_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, aura="concentration", foh_to_holy_bolt_ratio=2)
|
||||
|
||||
@@ -30,6 +30,11 @@ class Paladin(IChar):
|
||||
self._action_frame = 6
|
||||
|
||||
def cast_buffs(self, casting_delay: float):
|
||||
# Skip entirely when Holy Shield is unbound. keyboard.send() tolerates
|
||||
# the empty key now, but the right-click after it would still fire and
|
||||
# cast whatever happens to be on the right slot instead.
|
||||
if not self._skill_hotkeys.get("holy_shield"):
|
||||
return
|
||||
keyboard.send(self._skill_hotkeys["holy_shield"])
|
||||
wait(0.04, 0.1)
|
||||
mouse.click(button="right")
|
||||
|
||||
@@ -26,6 +26,8 @@ class Config:
|
||||
ui_pos = {}
|
||||
routes = {}
|
||||
routes_order = []
|
||||
cold_plains = {}
|
||||
baal_xp = {}
|
||||
char = {}
|
||||
colors = {}
|
||||
shop = {}
|
||||
@@ -259,6 +261,7 @@ class Config:
|
||||
"custom_message_hook": self._select_val("general", "custom_message_hook"),
|
||||
"custom_loot_message_hook": self._select_val("general", "custom_loot_message_hook"),
|
||||
"discord_status_count": False if not self._select_val("general", "discord_status_count") else int(self._select_val("general", "discord_status_count")),
|
||||
"discord_timing_report_h": 2.0 if not self._select_val("general", "discord_timing_report_h") else float(self._select_val("general", "discord_timing_report_h")),
|
||||
"discord_status_runs": False if not self._select_optional("general", "discord_status_runs", "") else int(self._select_optional("general", "discord_status_runs", "")),
|
||||
"discord_log_chicken": bool(int(self._select_val("general", "discord_log_chicken"))),
|
||||
"discord_log_errors": bool(int(self._select_optional("general", "discord_log_errors", "1"))),
|
||||
@@ -295,7 +298,6 @@ class Config:
|
||||
self.stealth = {
|
||||
"wait_jitter_min": float(self._select_optional("stealth", "wait_jitter_min", "0.85")),
|
||||
"wait_jitter_max": float(self._select_optional("stealth", "wait_jitter_max", "1.20")),
|
||||
"click_variance": int(self._select_optional("stealth", "click_variance", "8")),
|
||||
"reshuffle_each_rotation": bool(int(self._select_optional("stealth", "reshuffle_each_rotation", "1"))),
|
||||
"skip_run_chance": int(self._select_optional("stealth", "skip_run_chance", "10")),
|
||||
"afk_break_chance": int(self._select_optional("stealth", "afk_break_chance", "5")),
|
||||
@@ -306,6 +308,37 @@ class Config:
|
||||
"micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")),
|
||||
"human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")),
|
||||
"vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))),
|
||||
# These eight were present in params.ini but never loaded here, so
|
||||
# utils.stealth's cfg.get(key, <hardcoded>) always won and editing
|
||||
# params.ini had NO effect. They only looked correct because the
|
||||
# hardcoded fallbacks happened to match the shipped ini values.
|
||||
"click_delay_min_ms": int(self._select_optional("stealth", "click_delay_min_ms", "50")),
|
||||
"click_delay_max_ms": int(self._select_optional("stealth", "click_delay_max_ms", "250")),
|
||||
"key_press_min_ms": int(self._select_optional("stealth", "key_press_min_ms", "20")),
|
||||
"key_press_max_ms": int(self._select_optional("stealth", "key_press_max_ms", "200")),
|
||||
"skill_hesitation_min_ms": int(self._select_optional("stealth", "skill_hesitation_min_ms", "80")),
|
||||
"skill_hesitation_max_ms": int(self._select_optional("stealth", "skill_hesitation_max_ms", "300")),
|
||||
"wrong_waypoint_chance": float(self._select_optional("stealth", "wrong_waypoint_chance", "0.025")),
|
||||
"skill_mistake_chance": float(self._select_optional("stealth", "skill_mistake_chance", "0.015")),
|
||||
# Opt-in switches for behaviours that alter WHERE or WHEN a click
|
||||
# lands. Both default OFF: the per-call-site randomize= values in
|
||||
# npc_manager/waypoint are hand-tuned against real button geometry,
|
||||
# and stacking a global offset on top is what starts missing NPCs.
|
||||
"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")),
|
||||
"pickup_skip_chance": float(self._select_optional("stealth", "pickup_skip_chance", "0.02")),
|
||||
}
|
||||
|
||||
self.routes = {}
|
||||
@@ -373,6 +406,7 @@ class Config:
|
||||
"runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")),
|
||||
"runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")),
|
||||
"repair_npc": self._select_optional("char", "repair_npc", "a4_halbu").strip().lower(),
|
||||
"resurrect_npc": self._select_optional("char", "resurrect_npc", "").strip().lower(),
|
||||
"gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","),
|
||||
"sell_junk": bool(int(self._select_val("char", "sell_junk"))),
|
||||
"protect_shields_from_sell": bool(int(self._select_optional("char", "protect_shields_from_sell", "1"))),
|
||||
@@ -382,6 +416,36 @@ class Config:
|
||||
"enable_no_pickup": bool(int(self._select_val("char", "enable_no_pickup"))),
|
||||
"safer_routines": bool(int(self._select_val("char", "safer_routines"))),
|
||||
}
|
||||
# Cold Plains clear run
|
||||
self.cold_plains = dict(self.configs["config"]["parser"]["cold_plains"])
|
||||
if "cold_plains" in self.configs["profile"]["parser"]:
|
||||
self.cold_plains.update(dict(self.configs["profile"]["parser"]["cold_plains"]))
|
||||
if "cold_plains" in self.configs["custom"]["parser"]:
|
||||
self.cold_plains.update(dict(self.configs["custom"]["parser"]["cold_plains"]))
|
||||
# Baal XP farm (join public games, hide, collect XP, leave)
|
||||
self.baal_xp = {
|
||||
"enabled": self._select_optional("baal_xp", "enabled", "0") not in (None, "", "0", "false", "False"),
|
||||
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", "") or "",
|
||||
"max_wait_s": float(self._select_optional("baal_xp", "max_wait_s", "900")),
|
||||
"xp_threshold": int(self._select_optional("baal_xp", "xp_threshold", "50000000")),
|
||||
"min_hp_pct": float(self._select_optional("baal_xp", "min_hp_pct", "40")),
|
||||
"hide_x": int(self._select_optional("baal_xp", "hide_x", "640")),
|
||||
"hide_y": int(self._select_optional("baal_xp", "hide_y", "360")),
|
||||
"join_timeout_s": float(self._select_optional("baal_xp", "join_timeout_s", "60")),
|
||||
}
|
||||
for _baal_xp_src in ("profile", "custom"):
|
||||
if "baal_xp" in self.configs[_baal_xp_src]["parser"]:
|
||||
_baal_xp_over = self.configs[_baal_xp_src]["parser"]["baal_xp"]
|
||||
for k in list(self.baal_xp.keys()):
|
||||
if k not in _baal_xp_over:
|
||||
continue
|
||||
raw = _baal_xp_over[k]
|
||||
if k == "enabled":
|
||||
self.baal_xp[k] = raw not in (None, "", "0", "false", "False")
|
||||
elif isinstance(self.baal_xp[k], str):
|
||||
self.baal_xp[k] = raw if raw is not None else ""
|
||||
else:
|
||||
self.baal_xp[k] = type(self.baal_xp[k])(raw)
|
||||
# Sorc base config
|
||||
sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"])
|
||||
if "sorceress" in self.configs["profile"]["parser"]:
|
||||
|
||||
@@ -97,6 +97,11 @@ class GameStats:
|
||||
self._last_status_report_run = 0
|
||||
self._last_failure_reason = None
|
||||
self._merc_resurrect_failed = False
|
||||
# Cross-game merc-resurrect circuit breaker. Qual-Kehk detection can fail 100% of
|
||||
# the time (stale name-tag template -> a degenerate constant 0.424 match), and the
|
||||
# per-game flag above does not stop that being retried every game at ~40s a hunt.
|
||||
self._merc_resurrect_fail_streak = 0
|
||||
self._merc_resurrect_skip_until = 0
|
||||
# Auto-downgrade tracking: timestamps of chickens+deaths
|
||||
self._downgrade_events: deque[float] = deque()
|
||||
os.makedirs("log/stats", exist_ok=True)
|
||||
@@ -271,6 +276,10 @@ class GameStats:
|
||||
self._game_counter += 1
|
||||
self._timer = time.time()
|
||||
self._merc_resurrect_failed = False
|
||||
# NOTE: _merc_resurrect_fail_streak / _merc_resurrect_skip_until are deliberately
|
||||
# NOT reset here. They are the cross-game circuit breaker — resetting the per-game
|
||||
# flag alone meant a permanently undetectable resurrect NPC was re-hunted (twice,
|
||||
# ~40s each) in every single game.
|
||||
# Clear the previous game's failure reason so this game's events can't be
|
||||
# attributed to a stale reason from an earlier failure.
|
||||
self._last_failure_reason = None
|
||||
|
||||
@@ -18,6 +18,9 @@ from ui_manager import ScreenObjects, is_visible
|
||||
|
||||
class HealthManager:
|
||||
_instance = None
|
||||
# Consecutive esc attempts allowed against a waypoint panel before it stops being
|
||||
# treated as benign. Reset as soon as a poll sees no panel at all.
|
||||
_MAX_WP_PANEL_ESCAPES = 6
|
||||
|
||||
def __init__(self):
|
||||
HealthManager._instance = self
|
||||
@@ -33,6 +36,9 @@ class HealthManager:
|
||||
self._callback = None
|
||||
self._last_chicken_screenshot = None
|
||||
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
|
||||
@@ -50,6 +56,7 @@ class HealthManager:
|
||||
# game don't cause an immediate chicken on the next game's first panel.
|
||||
with self._state_lock:
|
||||
self._count_panel_detects = 0
|
||||
self._count_wp_panel_detects = 0
|
||||
|
||||
def get_pause_state(self):
|
||||
with self._state_lock:
|
||||
@@ -155,7 +162,7 @@ class HealthManager:
|
||||
f"but HP is safe at {(health_percentage*100):.1f}%; not chickening"
|
||||
)
|
||||
|
||||
chicken_threshold = Config().char["chicken"]
|
||||
chicken_threshold = get_game_chicken_threshold()
|
||||
if loot_priority_active():
|
||||
chicken_threshold = max(chicken_threshold * 0.5, LOOT_PRIORITY_HP_FLOOR)
|
||||
if health_percentage <= chicken_threshold:
|
||||
@@ -172,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]):
|
||||
@@ -197,7 +220,69 @@ class HealthManager:
|
||||
self._last_merc_heal = time.time()
|
||||
|
||||
# Close any open panels that might block detection
|
||||
# A CENTRED panel (Chronicle) matches neither header ROI, so
|
||||
# nothing ever closed it — and a centred panel blanks every
|
||||
# later template search. Measured 2026-08-28: Chronicle open,
|
||||
# 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:
|
||||
Logger.debug(
|
||||
f"Centred panel open (blocks all template matching) — 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("Centred panel would not close — treating as a blocking panel")
|
||||
|
||||
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
|
||||
# A waypoint panel is self-inflicted: the pather walked the char
|
||||
# over the WP stone. That is a navigation problem, not a threat,
|
||||
# and chickening on it threw away healthy games. Escape it without
|
||||
# counting — but stay bounded, so a WP panel we genuinely cannot
|
||||
# close still falls through to the normal chicken path.
|
||||
if is_visible(ScreenObjects.WaypointLabel, img):
|
||||
self._count_wp_panel_detects += 1
|
||||
if self._count_wp_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
|
||||
Logger.debug(
|
||||
f"Waypoint panel open (walked onto the WP) — closing it "
|
||||
f"({self._count_wp_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("Waypoint panel would not close — treating as a blocking panel")
|
||||
self._count_panel_detects += 1
|
||||
if self._count_panel_detects >= 2:
|
||||
self._count_panel_detects = 0
|
||||
@@ -210,6 +295,10 @@ class HealthManager:
|
||||
from input_layer import keyboard as kb
|
||||
kb.send("esc")
|
||||
wait(0.1, 0.2)
|
||||
else:
|
||||
# Screen is clear, so any waypoint panel we escaped did close.
|
||||
self._count_wp_panel_detects = 0
|
||||
self._count_center_panel_detects = 0
|
||||
|
||||
fn_end = time.perf_counter()
|
||||
# Target ~15 FPS polling with anti-cheat jitter
|
||||
@@ -248,6 +337,25 @@ def get_pause_state():
|
||||
return HealthManager._instance.get_pause_state()
|
||||
return True
|
||||
|
||||
_game_chicken_threshold = None
|
||||
|
||||
|
||||
def set_game_chicken_threshold(value):
|
||||
"""Per-game chicken threshold, rolled by the BOT thread at game start.
|
||||
|
||||
The health manager thread is a read-only monitor by design and must not do
|
||||
the rolling itself — see the threading rules in CLAUDE.md.
|
||||
"""
|
||||
global _game_chicken_threshold
|
||||
_game_chicken_threshold = value
|
||||
|
||||
|
||||
def get_game_chicken_threshold() -> float:
|
||||
if _game_chicken_threshold is not None:
|
||||
return _game_chicken_threshold
|
||||
return Config().char["chicken"]
|
||||
|
||||
|
||||
def set_pause_state(state: bool):
|
||||
"""Backwards-compatible wrapper that delegates to the singleton instance."""
|
||||
if HealthManager._instance is not None:
|
||||
|
||||
@@ -11,6 +11,8 @@ This is a drop-in replacement for the existing `import keyboard` and
|
||||
On non-Windows (Docker/Linux), uses bridge_input to talk to a Windows host
|
||||
via TCP. Set BOTTY_BRIDGE_HOST / BOTTY_BRIDGE_PORT env vars.
|
||||
"""
|
||||
import re as _re
|
||||
from logger import Logger
|
||||
import os as _os
|
||||
import threading as _threading
|
||||
|
||||
@@ -21,14 +23,32 @@ if _os.name == "nt":
|
||||
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
|
||||
)
|
||||
else:
|
||||
from .bridge_input import (
|
||||
_get_vk, key_down, key_up, key_press, key_state,
|
||||
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
|
||||
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
|
||||
)
|
||||
try:
|
||||
from .bridge_input import (
|
||||
_get_vk, key_down, key_up, key_press, key_state,
|
||||
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
|
||||
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
|
||||
)
|
||||
except ImportError:
|
||||
# bridge_input not available - provide stubs
|
||||
_get_vk = None
|
||||
key_down = None
|
||||
key_up = None
|
||||
key_press = None
|
||||
key_state = None
|
||||
mouse_move = None
|
||||
mouse_down = None
|
||||
mouse_up = None
|
||||
mouse_click = None
|
||||
mouse_wheel = None
|
||||
get_cursor_pos = None
|
||||
send_text = None
|
||||
VK_MAP = {}
|
||||
_USE_ABSOLUTE_MOUSE = False
|
||||
# bridge_input has no send_key; provide stub
|
||||
def send_key(key):
|
||||
key_press(key)
|
||||
if key_press:
|
||||
key_press(key)
|
||||
|
||||
if _os.name == "nt":
|
||||
from .mouse_impl import mouse
|
||||
@@ -37,6 +57,7 @@ else:
|
||||
# Provide a thin wrapper that delegates to bridge_input.
|
||||
class _BridgeMouse:
|
||||
def move_to(self, x, y): mouse_move(x, y)
|
||||
def move(self, x, y): mouse_move(x, y)
|
||||
def click(self, button="left"): mouse_click(button)
|
||||
def down(self, button="left"): mouse_down(button)
|
||||
def up(self, button="left"): mouse_up(button)
|
||||
@@ -44,6 +65,63 @@ else:
|
||||
def get_pos(self): return get_cursor_pos()
|
||||
mouse = _BridgeMouse()
|
||||
|
||||
# ─── Stealth key classification ───────────────────────────────────────────────
|
||||
# Skill casts may carry human hesitation. Potion/belt keys MUST NOT: they are
|
||||
# driven by health_manager at low HP, so any added latency is a death risk.
|
||||
#
|
||||
# The previous gate was `vk in range(ord('1'), ord('0') + 1)`. ord('1')=49 and
|
||||
# ord('0')=48, so that range is EMPTY and never matched. Only the string
|
||||
# fallback fired — and it listed the digits, i.e. the potion keys. The gate was
|
||||
# therefore aimed at the exact inverse of its intent: every potion press got
|
||||
# 80-300ms of hesitation and a 1.5% chance of pressing a different potion first,
|
||||
# while actual skill casts (f1-f12) were never touched.
|
||||
_SKILL_KEY_RE = _re.compile(r"^f([1-9]|1[0-2])$")
|
||||
_key_class_cache = {}
|
||||
|
||||
|
||||
def _never_stealth_keys() -> set:
|
||||
"""Potion and belt keys — exempt from all stealth timing, by construction."""
|
||||
if "never" not in _key_class_cache:
|
||||
keys = {"1", "2", "3", "4"}
|
||||
try:
|
||||
from config import Config
|
||||
char = Config().char
|
||||
for name in ("potion1", "potion2", "potion3", "potion4", "show_belt"):
|
||||
val = char.get(name)
|
||||
if val:
|
||||
keys.add(str(val).strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
_key_class_cache["never"] = keys
|
||||
return _key_class_cache["never"]
|
||||
|
||||
|
||||
def _bound_skill_keys() -> list:
|
||||
"""Skill hotkeys actually bound by the active character."""
|
||||
if "skills" not in _key_class_cache:
|
||||
found = set()
|
||||
try:
|
||||
from config import Config
|
||||
cfg = Config()
|
||||
section = getattr(cfg, str(cfg.char.get("type", "")).lower(), None)
|
||||
if isinstance(section, dict):
|
||||
for val in section.values():
|
||||
k = str(val).strip().lower()
|
||||
if _SKILL_KEY_RE.match(k):
|
||||
found.add(k)
|
||||
except Exception:
|
||||
pass
|
||||
_key_class_cache["skills"] = sorted(found - _never_stealth_keys())
|
||||
return _key_class_cache["skills"]
|
||||
|
||||
|
||||
def _is_skill_key(key: str) -> bool:
|
||||
k = str(key or "").strip().lower()
|
||||
if k in _never_stealth_keys():
|
||||
return False
|
||||
return bool(_SKILL_KEY_RE.match(k))
|
||||
|
||||
|
||||
class _Keyboard:
|
||||
"""
|
||||
Drop-in replacement for the `keyboard` library.
|
||||
@@ -103,16 +181,22 @@ class _Keyboard:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Press a random skill key first (simulates miscast)
|
||||
# Press a different BOUND SKILL first (simulates a miscast). The old code
|
||||
# built its candidate list from range(ord('1'), ord('0')+1) — an empty
|
||||
# range — so random.choice() raised and the except branch always ran,
|
||||
# picking from ['1'..'5']: potion keys. A "miscast" that drinks a potion
|
||||
# is not a miscast, it is a bug with a costume on.
|
||||
import random
|
||||
candidates = [k for k in _bound_skill_keys() if k != str(key).strip().lower()]
|
||||
if not candidates:
|
||||
return False
|
||||
wrong_key = random.choice(candidates)
|
||||
|
||||
try:
|
||||
from config import Config
|
||||
skill_keys = list(range(ord('1'), ord('0') + 1)) # Keys 1-0
|
||||
wrong_key = chr(random.choice(skill_keys))
|
||||
if wrong_key == key:
|
||||
wrong_key = chr(random.choice(skill_keys))
|
||||
from utils.stealth import _tl
|
||||
_tl("skill_mistake", "ok", f"miscast {wrong_key} before {key}")
|
||||
except Exception:
|
||||
wrong_key = random.choice(['1', '2', '3', '4', '5'])
|
||||
pass
|
||||
|
||||
# Send the wrong key
|
||||
wrong_vk = _get_vk(wrong_key)
|
||||
@@ -128,9 +212,19 @@ class _Keyboard:
|
||||
"""
|
||||
Send a key press event with stealth timing.
|
||||
|
||||
An EMPTY key is a no-op, not an error. An unbound optional skill is a
|
||||
legitimate configuration — a FoHdin with no Holy Shield, no Vigor, no
|
||||
Cleansing — and there are 90+ `keyboard.send(self._skill_hotkeys[x])`
|
||||
call sites across the paladin classes alone, almost none of which check
|
||||
first. Raising here kills the whole bot thread for a skill the
|
||||
character was never meant to cast. Guard once, at the boundary.
|
||||
|
||||
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
|
||||
Supports do_release=False (hold key) and do_press=False (release-only).
|
||||
"""
|
||||
if key is None or str(key).strip() == "":
|
||||
Logger.debug("keyboard.send: empty key (unbound skill) — skipping")
|
||||
return
|
||||
self._stealth_before()
|
||||
|
||||
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
|
||||
@@ -173,8 +267,10 @@ class _Keyboard:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(delay, delay * 1.2)
|
||||
|
||||
# Tier 2 stealth: skill hesitation (only for skill hotkeys 1-0)
|
||||
if vk in range(ord('1'), ord('0') + 1) or key in ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0'):
|
||||
# Tier 2 stealth: hesitation + miscast, SKILL HOTKEYS ONLY.
|
||||
# Potion/belt keys are excluded by _is_skill_key — see the note above
|
||||
# _SKILL_KEY_RE for why the old digit-based gate did the opposite.
|
||||
if _is_skill_key(key):
|
||||
self._skill_rotation_hesitation()
|
||||
self._maybe_skill_mistake(key)
|
||||
|
||||
|
||||
@@ -203,34 +203,6 @@ class mouse:
|
||||
_native_move(int(point[0]), int(point[1]))
|
||||
mouse.sleep(delta)
|
||||
|
||||
@staticmethod
|
||||
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
|
||||
delay_factor: tuple[float, float] = [0.4, 0.6]):
|
||||
"""Move with full stealth chain: pre-movement pause -> randomized position -> endpoint wobble."""
|
||||
try:
|
||||
from utils.stealth import randomize_click_position, add_micro_pause, endpoint_wobble
|
||||
rx, ry = randomize_click_position(x, y)
|
||||
add_micro_pause()
|
||||
except Exception:
|
||||
from logger import Logger
|
||||
Logger.warning("[Stealth] randomize_click_position/add_micro_pause failed, using manual fallback")
|
||||
try:
|
||||
from config import Config
|
||||
variance = Config().stealth["click_variance"]
|
||||
except Exception:
|
||||
variance = 0
|
||||
rx = x + random.randint(-variance, variance)
|
||||
ry = y + random.randint(-variance, variance)
|
||||
mouse.move(rx, ry, absolute=absolute, randomize=5 + variance, delay_factor=delay_factor)
|
||||
try:
|
||||
from utils.stealth import endpoint_wobble
|
||||
wx, wy = endpoint_wobble(rx, ry)
|
||||
_native_move(wx, wy)
|
||||
except Exception:
|
||||
from logger import Logger
|
||||
Logger.warning("[Stealth] endpoint_wobble failed, skipping micro-adjustment")
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _is_clicking_safe():
|
||||
try:
|
||||
@@ -262,13 +234,6 @@ class mouse:
|
||||
if button != "left" or mouse._is_clicking_safe():
|
||||
# DISABLED STEALTH FOR DEBUGGING
|
||||
# try:
|
||||
# from utils.stealth import apply_click_delay
|
||||
# apply_click_delay()
|
||||
# except Exception:
|
||||
# from logger import Logger
|
||||
# Logger.warning("[Stealth] apply_click_delay failed, falling back to manual delay")
|
||||
# from utils.misc import wait as _wait
|
||||
# _wait(0.05, 0.3)
|
||||
from logger import Logger
|
||||
Logger.debug(f"Executing native click: {button}")
|
||||
_native_click(button)
|
||||
|
||||
@@ -300,6 +300,11 @@ def mouse_move(x: int, y: int):
|
||||
Logger.warning(f"SetCursorPos failed for ({target_x}, {target_y}); continuing with current cursor position")
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure the cursor is exactly on target before any click (SetCursorPos can
|
||||
# land 1-2px off on some DPI/virtual-desktop setups; D2R UI hitboxes are tight).
|
||||
cur_x, cur_y = get_cursor_pos()
|
||||
if abs(cur_x - target_x) > 0 or abs(cur_y - target_y) > 0:
|
||||
user32.SetCursorPos(target_x, target_y)
|
||||
|
||||
def mouse_down(button: str = "left"):
|
||||
"""Press mouse button down."""
|
||||
|
||||
@@ -479,7 +479,24 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
|
||||
Logger.error(f"transfer_items: Can't perform, vendor is not open")
|
||||
case "stash":
|
||||
if is_visible(ScreenObjects.GoldBtnStash, img):
|
||||
filtered = [ item for item in items if item.keep == True ]
|
||||
# Kept items, PLUS anything the protection guard has made both unsellable
|
||||
# and undroppable. Without the second group a protected item the pickit
|
||||
# rejected can never leave the pack: sell and drop are blocked and this
|
||||
# filter used to exclude it, so it occupied its slot forever (673 blocked
|
||||
# sells across 7 charms in one session).
|
||||
#
|
||||
# Scoped to the LOOT columns on purpose. A charm's bonus only applies from
|
||||
# the inventory, and the pickit cannot tell a wanted res charm from junk —
|
||||
# LAPIS SMALL CHARM OF VITA (+20 life, cold res 7%) is "Discarding" simply
|
||||
# because the rule wants coldresist >= 11. So treat position as the intent
|
||||
# signal: charms parked in the RESERVED columns are deliberate keepers and
|
||||
# are left alone (the click guard makes them untouchable anyway), while
|
||||
# freshly looted ones landing in the loot columns get stashed.
|
||||
loot_cols = Config().char["num_loot_columns"]
|
||||
filtered = [
|
||||
item for item in items
|
||||
if item.keep or (_is_protected(item) and item.column < loot_cols)
|
||||
]
|
||||
else:
|
||||
Logger.error(f"transfer_items: Can't perform, stash is not open")
|
||||
case _:
|
||||
@@ -524,6 +541,13 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
|
||||
# item successfully transferred, delete from list
|
||||
item_label = f" '{item.name}'" if item.name else ""
|
||||
Logger.debug(f"Confirmed {action}{item_label} at position {item.pos}")
|
||||
# Mirror into the TOWN timeline so sells/stashes/drops of individual items
|
||||
# appear in the same greppable sequence as the maintenance steps.
|
||||
try:
|
||||
from bot import Bot
|
||||
Bot.timeline("town", f"item_{action}", "ok", f"{item.name or '?'} @ {item.pos}")
|
||||
except Exception:
|
||||
pass
|
||||
for cnt, o_item in enumerate(items):
|
||||
if o_item.pos == item.pos:
|
||||
items.pop(cnt)
|
||||
|
||||
@@ -216,6 +216,30 @@ class PickIt:
|
||||
self._prev_item_pickup_attempt = item
|
||||
return self._yoink_item(item, char)
|
||||
|
||||
def _should_walk_past(self, item) -> bool:
|
||||
"""Per-ITEM stealth skip, decided once and remembered.
|
||||
|
||||
pick_up_items loops until a timeout, re-locating items after every
|
||||
pickup, so the same item is evaluated many times. Rolling on each
|
||||
evaluation made the effective skip rate far higher than configured and
|
||||
could re-roll an item already skipped.
|
||||
|
||||
The caller MUST advance item_count before continuing — it is incremented
|
||||
at the end of the loop body, so a bare `continue` re-evaluates the same
|
||||
item until the pickit phase times out.
|
||||
"""
|
||||
if not hasattr(self, "_stealth_skipped"):
|
||||
self._stealth_skipped = {}
|
||||
key = getattr(item, "ID", None) or getattr(item, "Name", None)
|
||||
if key is None:
|
||||
return False
|
||||
if key not in self._stealth_skipped:
|
||||
from utils.stealth import should_skip_pickup
|
||||
self._stealth_skipped[key] = should_skip_pickup()
|
||||
if self._stealth_skipped[key]:
|
||||
Logger.debug(f"Stealth: walking past {item.Name}")
|
||||
return self._stealth_skipped[key]
|
||||
|
||||
def pick_up_items(self, char: IChar, force: bool = False) -> bool:
|
||||
"""
|
||||
To be called everytime the bot wants to pick up items
|
||||
@@ -273,6 +297,14 @@ class PickIt:
|
||||
cached_pickup = True
|
||||
raw_expression = "pick_rares_for_gold=1 (rare quality override)"
|
||||
if cached_pickup and not (self._ignore_consumable(item) or self._ignore_gold(item)):
|
||||
# Stealth: occasionally walk past something the filter wanted.
|
||||
# Decided ONCE per item id and remembered: this loop re-scans
|
||||
# and re-evaluates until a timeout, so rolling per evaluation
|
||||
# both compounds the real skip rate and re-rolls an item that
|
||||
# was already skipped.
|
||||
if self._should_walk_past(item):
|
||||
item_count += 1
|
||||
continue
|
||||
Logger.debug(f"Pick up expression: {raw_expression}")
|
||||
Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}")
|
||||
pick_up_res = self._pick_up_item(char, item)
|
||||
@@ -298,6 +330,9 @@ class PickIt:
|
||||
if not pickup:
|
||||
Logger.debug(f"Skip item {item.Name} at distance {item.Distance}: no matching pickit rule")
|
||||
if pickup:
|
||||
if self._should_walk_past(item):
|
||||
item_count += 1
|
||||
continue
|
||||
Logger.debug(f"Pick up expression: {raw_expression}")
|
||||
Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}")
|
||||
pick_up_res = self._pick_up_item(char, item)
|
||||
|
||||
@@ -60,6 +60,37 @@ def start_or_pause_bot(controllers: Controllers):
|
||||
screen.start_detecting_window()
|
||||
controllers.game.start()
|
||||
|
||||
def handle_hermes_command(data: str, controllers: Controllers):
|
||||
"""Handle one hermes control-socket command. Returns a reply string or None.
|
||||
|
||||
'start' used to be a plain alias for 'pause' — the same toggle. A caller
|
||||
retrying a timed-out 'start' therefore PAUSED the bot. On 2026-08-28 that
|
||||
left it frozen for 4h50m, and `status` still answered running=True because
|
||||
that field reports the game controller, not Bot._pausing.
|
||||
|
||||
'start' is now idempotent: it starts a stopped bot, resumes a paused one,
|
||||
and does nothing to a healthy one. Use 'pause'/'toggle' for the toggle.
|
||||
"""
|
||||
bot = getattr(controllers.game, "bot", None)
|
||||
paused = bool(getattr(bot, "_pausing", False))
|
||||
|
||||
if data == "start":
|
||||
if not controllers.game.is_running:
|
||||
start_or_pause_bot(controllers)
|
||||
elif paused:
|
||||
start_or_pause_bot(controllers) # resume
|
||||
return None # already running and not paused: no-op
|
||||
if data in ("pause", "toggle"):
|
||||
start_or_pause_bot(controllers)
|
||||
return None
|
||||
if data == "stop":
|
||||
on_exit(controllers)
|
||||
return None
|
||||
if data == "status":
|
||||
return f"running={controllers.game.is_running} paused={paused}"
|
||||
return None
|
||||
|
||||
|
||||
def start_or_stop_graphic_debugger(controllers: Controllers):
|
||||
if controllers.debugger.is_running:
|
||||
controllers.debugger.stop()
|
||||
@@ -311,18 +342,33 @@ def main():
|
||||
if rlist:
|
||||
try:
|
||||
conn, _ = _hermes_socket.accept()
|
||||
# select() only says a connection is PENDING — the
|
||||
# client's bytes may not have arrived yet. The accepted
|
||||
# socket inherits the listener's non-blocking mode, so
|
||||
# recv() raised BlockingIOError (WinError 10035), the
|
||||
# error was swallowed, and the caller saw a timeout.
|
||||
# That is the root of every "no response from bot
|
||||
# (socket timeout)" and of the retry loops built around
|
||||
# it — one of which paused the bot for 4h50m.
|
||||
conn.setblocking(True)
|
||||
conn.settimeout(2.0)
|
||||
data = conn.recv(1024).decode().strip().lower()
|
||||
if data == 'start' or data == 'pause':
|
||||
start_or_pause_bot(controllers)
|
||||
elif data == 'stop':
|
||||
on_exit(controllers)
|
||||
elif data == 'status':
|
||||
status = f"running={controllers.game.is_running}"
|
||||
conn.sendall(status.encode())
|
||||
_reply = handle_hermes_command(data, controllers)
|
||||
if _reply is not None:
|
||||
conn.sendall(_reply.encode())
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Never swallow this silently. A raising handler leaves
|
||||
# the connection in CLOSE_WAIT and every control command
|
||||
# times out, which reads exactly like "the bot is wedged"
|
||||
# and cost a long debugging detour on 2026-08-28.
|
||||
Logger.error(f"hermes command {data!r} failed: {type(e).__name__}: {e}")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
Logger.debug(f"hermes poll loop error: {type(e).__name__}: {e}")
|
||||
time.sleep(0.5)
|
||||
|
||||
if _hermes_socket is not None:
|
||||
@@ -343,5 +389,9 @@ if __name__ == "__main__":
|
||||
try:
|
||||
if __import__('sys').stdin and __import__('sys').stdin.isatty():
|
||||
input()
|
||||
except (OSError, ValueError):
|
||||
except (OSError, ValueError, EOFError):
|
||||
# EOFError is the one input() actually raises when stdin is closed or not
|
||||
# interactive — isatty() can still report True in some detached/redirected
|
||||
# launches. Leaving it out turned every shutdown into an "Uncaught exception"
|
||||
# traceback in the log, which reads like a crash rather than a clean exit.
|
||||
pass
|
||||
|
||||
@@ -5,11 +5,51 @@ import cv2
|
||||
from utils.log_rotation import safe_imwrite
|
||||
import datetime
|
||||
import traceback
|
||||
import discord
|
||||
from version import __version__
|
||||
import numpy as np
|
||||
from discord import SyncWebhook, Color
|
||||
import json
|
||||
|
||||
# Discord is an OPTIONAL notification dependency, but importing it pulls in
|
||||
# aiohttp, which builds a default SSL context at import time. On a machine whose
|
||||
# OpenSSL is newer than this Python build expects, that raises
|
||||
# ssl.SSLError: [ASN1: NOT_ENOUGH_DATA]
|
||||
# while loading the Windows certificate store. Importing it eagerly therefore
|
||||
# took the ENTIRE bot down at startup — ui_manager -> messages -> discord is on
|
||||
# the import path of every run. Degrade to "no Discord notifications" instead.
|
||||
try:
|
||||
import discord
|
||||
from discord import SyncWebhook, Color
|
||||
DISCORD_AVAILABLE = True
|
||||
DISCORD_IMPORT_ERROR = None
|
||||
except Exception as _exc: # ImportError, ssl.SSLError, ...
|
||||
import types as _types
|
||||
|
||||
# Inert stand-ins so the send_* methods can still build their payloads
|
||||
# without special-casing every call site. _get_webhook returns None when
|
||||
# Discord is unavailable and _send_embed already early-returns on that, so
|
||||
# nothing is ever transmitted.
|
||||
class _NullEmbed:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def _noop(self, *a, **k):
|
||||
return self
|
||||
|
||||
add_field = set_image = set_thumbnail = set_footer = set_author = _noop
|
||||
|
||||
class _NullColor:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *a, **k: None
|
||||
|
||||
discord = _types.SimpleNamespace(Embed=_NullEmbed, File=lambda *a, **k: None)
|
||||
SyncWebhook = None
|
||||
Color = _NullColor()
|
||||
DISCORD_AVAILABLE = False
|
||||
DISCORD_IMPORT_ERROR = _exc
|
||||
Logger.warning(
|
||||
f"Discord notifications disabled — could not import discord ({type(_exc).__name__}: {_exc}). "
|
||||
"The bot runs normally; only Discord messaging is off."
|
||||
)
|
||||
class DiscordEmbeds(GenericApi):
|
||||
_logged_webhook_errors = set()
|
||||
|
||||
@@ -27,6 +67,8 @@ class DiscordEmbeds(GenericApi):
|
||||
Logger.warning(f"Discord webhook disabled for URL '{hook_url}': {reason}")
|
||||
|
||||
def _get_webhook(self, hook_url: str):
|
||||
if not DISCORD_AVAILABLE:
|
||||
return None
|
||||
hook = None
|
||||
if not hook_url:
|
||||
hook_url = Config().general['custom_message_hook']
|
||||
|
||||
@@ -436,7 +436,17 @@ def open_npc_menu(npc_key: Npc) -> bool:
|
||||
from screen import convert_screen_to_monitor
|
||||
Logger.debug(f"NPC {npc_key} - body-template hunt timed out, starting grid hover sweep")
|
||||
_close_open_panels()
|
||||
SWEEP_TAG_THRESHOLD = 0.4
|
||||
# Raised 0.4 -> 0.7 (2026-08-27). A rendered name tag matches almost perfectly, so
|
||||
# anything mediocre is noise, and at 0.4 the noise won. Measured across a full day of
|
||||
# sweeps — real hits that opened the dialogue vs false hits that clicked empty ground:
|
||||
# akara 0.980 halbu 0.995 malah 0.990 larzuk 0.996 <- real
|
||||
# qual_kehk 0.424 malah 0.501 larzuk 0.494 <- false
|
||||
# qual_kehk failed 100% of the time (5 timeouts / 5 attempts) because every sweep
|
||||
# stopped on a 0.424 match, clicked nothing, and gave up instead of carrying on to the
|
||||
# position where the tag actually renders. Verified directly: the stored
|
||||
# QUAL_NAME_TAG_WHITE template scores 1.000 on a frame where the tag IS shown, and
|
||||
# 0.997 through the color_filter path — the template was never the problem.
|
||||
SWEEP_TAG_THRESHOLD = 0.7
|
||||
# Pass 1: the NPC's known ROI. Pass 2: the whole NPC search area — patch
|
||||
# updates and pathing drift can put the NPC outside the stored ROI.
|
||||
sweep_rois = [npcs[npc_key]["roi"]] if "roi" in npcs[npc_key] else []
|
||||
|
||||
@@ -610,7 +610,23 @@ class Pather:
|
||||
return True
|
||||
|
||||
|
||||
def find_abs_node_pos(self, node_idx: int, img: np.ndarray, threshold: float = 0.68) -> tuple[float, float]:
|
||||
# Low-confidence fallback for find_abs_node_pos. The old 0.55 first-match search
|
||||
# fabricated node positions: A5_TOWN_1 scored 0.60-0.62 on unrelated scenery at
|
||||
# (532,110) / (857,522) / (1209,86) across three consecutive failure frames, and the
|
||||
# pather steered the char into the Harrogath wall on each. Raised, and forced to
|
||||
# best_match so an ambiguous frame can no longer be decided by node dict ordering.
|
||||
_FALLBACK_THRESHOLD = 0.62
|
||||
# Consecutive heading-gate rejections before a traverse gives up. Four was the
|
||||
# observed count before the pather started guessing and walked into the wall.
|
||||
_MAX_HEADING_REJECTS = 3
|
||||
|
||||
def find_abs_node_pos(
|
||||
self,
|
||||
node_idx: int,
|
||||
img: np.ndarray,
|
||||
threshold: float = 0.68,
|
||||
last_direction: tuple[float, float] = None,
|
||||
) -> tuple[float, float]:
|
||||
node = self._nodes[node_idx]
|
||||
# Try with cropped ROI first (cuts skill bar for better matching)
|
||||
template_match = template_finder.search(
|
||||
@@ -621,13 +637,14 @@ class Pather:
|
||||
roi=Config().ui_roi["cut_skill_bar"],
|
||||
use_grayscale=True
|
||||
)
|
||||
confident = template_match.valid
|
||||
# If cropped search fails, try full image with lower threshold to handle window offset drift
|
||||
if not template_match.valid:
|
||||
if not confident:
|
||||
template_match = template_finder.search(
|
||||
[*node],
|
||||
img,
|
||||
best_match=False,
|
||||
threshold=0.55,
|
||||
best_match=True,
|
||||
threshold=self._FALLBACK_THRESHOLD,
|
||||
use_grayscale=True
|
||||
)
|
||||
if template_match.valid:
|
||||
@@ -637,9 +654,32 @@ class Pather:
|
||||
node_pos_rel = self._get_node(node_idx, template_match.name)
|
||||
node_pos_abs = self._convert_rel_to_abs(node_pos_rel, ref_pos_abs)
|
||||
node_pos_abs = get_closest_non_hud_pixel(pos = node_pos_abs, pos_type="abs")
|
||||
# A fallback match is by definition uncertain. While approaching a single node
|
||||
# the heading should stay roughly stable, so a low-confidence match that wants
|
||||
# us to walk backwards is a false positive, not a course correction — drop it
|
||||
# and let the caller's recovery sweep run instead of walking into scenery.
|
||||
if not confident and not self._heading_is_plausible(node_pos_abs, last_direction):
|
||||
Logger.debug(
|
||||
f"Pather: rejecting low-confidence {template_match.name} "
|
||||
f"({template_match.score*100:.1f}%) for node {node_idx} — implies reversal"
|
||||
)
|
||||
self._heading_rejects = getattr(self, "_heading_rejects", 0) + 1
|
||||
return None
|
||||
self._heading_rejects = 0
|
||||
return node_pos_abs
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _heading_is_plausible(node_pos_abs: tuple[float, float], last_direction: tuple[float, float]) -> bool:
|
||||
"""True unless node_pos_abs points more than ~90 deg away from last_direction."""
|
||||
if last_direction is None:
|
||||
return True
|
||||
mag = math.dist(node_pos_abs, (0, 0)) * math.dist(last_direction, (0, 0))
|
||||
if mag == 0:
|
||||
return True
|
||||
dot = node_pos_abs[0] * last_direction[0] + node_pos_abs[1] * last_direction[1]
|
||||
return (dot / mag) > 0
|
||||
|
||||
def traverse_nodes(
|
||||
self,
|
||||
path: tuple[Location, Location] | list[int],
|
||||
@@ -658,6 +698,9 @@ class Pather:
|
||||
:param force_move: Bool value if force move should be used for pathing
|
||||
:return: Bool if traversed successful or False if it got stuck
|
||||
"""
|
||||
# Per-traverse counter. A stale count from a previous traverse would abort
|
||||
# the next one immediately.
|
||||
self._heading_rejects = 0
|
||||
if len(path) == 0:
|
||||
Logger.error("Path must be a list of integers or a tuple with start and end location!")
|
||||
return False
|
||||
@@ -700,6 +743,9 @@ class Pather:
|
||||
did_force_move = False
|
||||
teleport_count = 0
|
||||
recovery_idx = 0
|
||||
# Heading held only while approaching THIS node. Across nodes a >90 deg turn
|
||||
# is normal, so the reversal check must not carry over from the previous one.
|
||||
node_last_dir = None
|
||||
while not continue_to_next_node:
|
||||
img = grab(force_new=True)
|
||||
# Handle timeout
|
||||
@@ -732,6 +778,39 @@ class Pather:
|
||||
Logger.error(f"Got stuck exit pather (node {node_idx}, recovery sweep exhausted)")
|
||||
return False
|
||||
|
||||
# Repeated heading-gate rejections mean the only thing visible is a
|
||||
# match we have decided not to trust. This MUST come before the
|
||||
# anti-stuck force-move below: that path moves along last_direction,
|
||||
# and when the character is already wedged against the Harrogath wall
|
||||
# it shoves it further in. Measured 2026-08-28: four rejections of
|
||||
# A5_TOWN_1 at 65-68%, then "random guess towards (-423, 247)", then
|
||||
# the red portal was unreachable for the rest of the run. Declaring a
|
||||
# position untrustworthy and then moving on an arbitrary vector are
|
||||
# contradictory — abort and let the caller re-anchor.
|
||||
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
|
||||
|
||||
# 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:
|
||||
@@ -759,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)
|
||||
if node_pos_abs is not None:
|
||||
dist = math.dist(node_pos_abs, (0, 0))
|
||||
if dist < Config().ui_pos["reached_node_dist"]:
|
||||
@@ -770,6 +847,7 @@ class Pather:
|
||||
x_m, y_m = convert_abs_to_monitor(node_pos_abs)
|
||||
char.move((x_m, y_m), force_tp=force_tp, force_move=force_move)
|
||||
last_direction = node_pos_abs
|
||||
node_last_dir = node_pos_abs
|
||||
last_move = time.time()
|
||||
|
||||
return True
|
||||
|
||||