Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
35aa13473e | ||
|
|
e03f866fbb | ||
|
|
968c75fefd | ||
|
|
7839b4ed82 | ||
|
|
75e88dbdc2 | ||
|
|
c5d9fe4541 | ||
|
|
648dc1b25c | ||
|
|
00e759ec0a | ||
|
|
6b044a17ea | ||
|
|
8f9185cde3 | ||
|
|
02e9f7856a | ||
|
|
15524e1608 | ||
|
|
6bcfa1af35 | ||
|
|
523cb54e46 | ||
|
|
7431807ee5 | ||
|
|
40ee587fb0 | ||
|
|
d1ba551832 | ||
|
|
3f8e08296e | ||
|
|
4e730e0c5b | ||
|
|
cef59a7df2 | ||
|
|
78f9d07545 | ||
|
|
eeb620696b | ||
|
|
0c246245c4 | ||
|
|
3d12a75b72 | ||
|
|
7cb15837d4 | ||
|
|
ab3f5633fc | ||
|
|
81f160d400 | ||
|
|
7fd0555468 | ||
|
|
b627172f1e | ||
|
|
512f63e0e5 | ||
|
|
6a07865f43 | ||
|
|
a539d3a236 | ||
|
|
710c1c709a | ||
|
|
61a88d2968 | ||
|
|
c1367dbfbd | ||
|
|
a2e2acfbde | ||
|
|
e8a9cdc6cd |
@@ -1,167 +1,209 @@
|
||||
name: Botty - CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, mine]
|
||||
# Pushing a version tag (e.g. `git tag v0.8.5 && git push --tags`) builds,
|
||||
# smoke-tests, then creates the GitHub release with the zip attached — all
|
||||
# in one run. If the build fails, no release is ever created.
|
||||
tags: ['v*']
|
||||
name: CI
|
||||
|
||||
# Default GITHUB_TOKEN is read-only; the build job's release step needs
|
||||
# contents:write to create the release and attach the built zip
|
||||
# (else HTTP 403 "Resource not accessible by integration").
|
||||
permissions:
|
||||
contents: write
|
||||
on:
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
|
||||
# Cancel in-progress runs on new push
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
install-and-test:
|
||||
name: Install & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout (skipped - workspace pre-populated)
|
||||
run: echo "Using pre-populated workspace"
|
||||
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment-win11.yml
|
||||
use-only-tar-bz2: false
|
||||
python-version: "3.10"
|
||||
# cache disabled for act_runner
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Install Tesseract OCR
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tesseract-ocr libgl1 libglib2.0-0
|
||||
|
||||
- name: Python version
|
||||
shell: powershell
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -c "import sys; print(sys.version)"
|
||||
run: python -c "import sys; print(sys.version)"
|
||||
|
||||
- name: Syntax check
|
||||
shell: powershell
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: python -m compileall -q src tools test scripts
|
||||
|
||||
- name: Verify core imports
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m compileall -q src tools test scripts
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
modules = [
|
||||
'config',
|
||||
'logger',
|
||||
'screen',
|
||||
'pather',
|
||||
'template_finder',
|
||||
'game_stats',
|
||||
'health_manager',
|
||||
'death_manager',
|
||||
'd2r_image',
|
||||
'item',
|
||||
'item.pickit',
|
||||
'transmute',
|
||||
'shop',
|
||||
'char',
|
||||
'utils',
|
||||
'utils.os_detect',
|
||||
'messages',
|
||||
]
|
||||
for mod in modules:
|
||||
try:
|
||||
__import__(mod)
|
||||
print(f' {mod}: OK')
|
||||
except ImportError as e:
|
||||
print(f' {mod}: FAILED - {e}')
|
||||
sys.exit(1)
|
||||
print('All core imports successful.')
|
||||
"
|
||||
|
||||
- name: Tests
|
||||
shell: powershell
|
||||
- name: Verify botty runs (import main modules)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src')
|
||||
import ssl
|
||||
ssl.SSLContext.load_default_certs = lambda *a, **k: None
|
||||
|
||||
from version import __version__
|
||||
print(f' Version: {__version__}')
|
||||
|
||||
from config import Config
|
||||
print(' Config: OK')
|
||||
|
||||
from game_controller import GameController
|
||||
print(' GameController: OK')
|
||||
|
||||
from bot import Bot
|
||||
print(' Bot: OK')
|
||||
|
||||
from run.diablo import Diablo
|
||||
print(' Diablo run: OK')
|
||||
|
||||
from run.pindle import Pindle
|
||||
print(' Pindle run: OK')
|
||||
|
||||
from run.arcane import Arcane
|
||||
print(' Arcane run: OK')
|
||||
|
||||
from run.vizier import Vizier
|
||||
print(' Vizier run: OK')
|
||||
|
||||
print('All botty entry modules import successfully.')
|
||||
"
|
||||
|
||||
- name: Verify OCR (pytesseract)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os, tempfile
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
import pytesseract
|
||||
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
version = pytesseract.get_tesseract_version()
|
||||
print(f' Tesseract version: {version}')
|
||||
|
||||
# Create a simple test image with text
|
||||
img = np.full((50, 200), 255, dtype=np.uint8)
|
||||
cv2.putText(img, 'Hello Botty', (20, 35),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 1)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
||||
cv2.imwrite(f.name, img)
|
||||
text = pytesseract.image_to_string(f.name, lang='eng').strip()
|
||||
print(f' OCR result: {text}')
|
||||
|
||||
print('OCR pytesseract: OK')
|
||||
"
|
||||
|
||||
- name: Verify OCR (botty ocr module)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
PYTESSERACT_TESSERACT_CMD: /usr/bin/tesseract
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
# Import the botty OCR module - it reads PYTESSERACT_TESSERACT_CMD from env
|
||||
from d2r_image.ocr import image_to_text, pytesseract
|
||||
import cv2
|
||||
import numpy as np
|
||||
import tempfile
|
||||
|
||||
if pytesseract is None:
|
||||
print(' pytesseract not available — skipping')
|
||||
sys.exit(0)
|
||||
|
||||
# Create test image (3-channel for invert)
|
||||
img = np.full((50, 200, 3), 255, dtype=np.uint8)
|
||||
cv2.putText(img, '123', (80, 35),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
|
||||
|
||||
# Run through botty's image_to_text
|
||||
results = image_to_text(
|
||||
[img],
|
||||
model='hover-eng_inconsolata_inv_th_fast',
|
||||
psm=7,
|
||||
crop_pad=False,
|
||||
invert=True,
|
||||
threshold=25,
|
||||
)
|
||||
result_text = results[0].text if results else 'empty'
|
||||
print(f' OCR result: {result_text}')
|
||||
print('OCR botty module: OK')
|
||||
"
|
||||
|
||||
- name: Log analyzer tests (self-healing framework)
|
||||
env:
|
||||
PYTHONPATH: ./src:.
|
||||
RUN_ENV: test
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m coverage run -m pytest -v
|
||||
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
|
||||
|
||||
- name: Coverage report
|
||||
shell: powershell
|
||||
- name: Tests with coverage
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
PYTHONPATH: ./src:.
|
||||
RUN_ENV: test
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m coverage xml --ignore-errors
|
||||
python -m coverage run -m pytest -v --tb=short --ignore=test/auto/test_self_healing.py --ignore=test/smoke_test.py --ignore=test/test_version_consistency.py
|
||||
python -m coverage xml --ignore-errors || true
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment-win11.yml
|
||||
use-only-tar-bz2: false
|
||||
|
||||
- name: Install Tesseract (bundled into the release for click-and-run OCR)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
choco install tesseract --no-progress -y
|
||||
if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) {
|
||||
throw "Tesseract install did not produce tesseract.exe"
|
||||
}
|
||||
|
||||
- name: Build exe
|
||||
shell: powershell
|
||||
env:
|
||||
BOTTY_NO_RENAME: '1'
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python build.py --conda_path C:\Miniconda
|
||||
|
||||
- name: Verify Tesseract was bundled
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
$tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe"
|
||||
if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" }
|
||||
Write-Host "Bundled: $tess"
|
||||
|
||||
- name: Launch smoke test (built executables)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
if (-not $BOTTY_DIR) { throw "No botty_v* build directory found." }
|
||||
|
||||
$mainExe = Join-Path $BOTTY_DIR "main.exe"
|
||||
$shopperExe = Join-Path $BOTTY_DIR "shopper.exe"
|
||||
if (-not (Test-Path $mainExe)) { throw "Missing $mainExe" }
|
||||
if (-not (Test-Path $shopperExe)) { throw "Missing $shopperExe" }
|
||||
|
||||
$procs = @()
|
||||
try {
|
||||
$mainProc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 6
|
||||
if ($mainProc.HasExited) { throw "main.exe exited early with code $($mainProc.ExitCode)" }
|
||||
$procs += $mainProc
|
||||
|
||||
$shopperProc = Start-Process -FilePath $shopperExe -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 6
|
||||
if ($shopperProc.HasExited) { throw "shopper.exe exited early with code $($shopperProc.ExitCode)" }
|
||||
$procs += $shopperProc
|
||||
}
|
||||
finally {
|
||||
foreach ($p in $procs) {
|
||||
if ($p -and -not $p.HasExited) {
|
||||
Stop-Process -Id $p.Id -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Prepare release zip
|
||||
shell: powershell
|
||||
run: |
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
Write-Host "Botty dir: $BOTTY_DIR"
|
||||
Get-ChildItem -Path $BOTTY_DIR -Recurse | Select-Object -Property FullName, Length
|
||||
$ZIP = "${BOTTY_DIR}.zip"
|
||||
Compress-Archive -Path "${BOTTY_DIR}\*" -DestinationPath $ZIP -Force
|
||||
Write-Host "Release zip: $ZIP"
|
||||
Get-Item $ZIP | Select-Object -Property FullName, Length
|
||||
|
||||
- name: Upload build artifacts
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: botty-build
|
||||
path: botty_v*/
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
|
||||
# On a version-tag push, create the release (if absent) and attach the
|
||||
# zip atomically. Runs only after the build + smoke test above succeed.
|
||||
- name: Create release and upload zip
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: botty_v*.zip
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -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
|
||||
@@ -100,3 +121,6 @@ src/input_layer/bridge_input.py
|
||||
.hermes/
|
||||
fixtures/
|
||||
test/run/
|
||||
|
||||
# Installer output (generated by run_install_capture.bat)
|
||||
install_log.txt
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
; If you chicken/die repeatedly, drop to Nightmare first.
|
||||
difficulty=hell
|
||||
; name: bot profile name used in logs/messages and mod launch option replacement
|
||||
name=fistman
|
||||
name=profile1
|
||||
; randomize_runs: 0 = run in listed order, 1 = shuffle run order
|
||||
randomize_runs=0
|
||||
; target_tz: target Terror Zone id (leave as default unless you know the mapping)
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Auto Skill + Attribute Allocation Plan
|
||||
|
||||
## Goal
|
||||
Add an optional system that automatically assigns:
|
||||
- skill points
|
||||
- attribute points
|
||||
|
||||
based on:
|
||||
- active character profile (`blizz_sorc`, `fohdin`, `hammerdin`, etc.)
|
||||
- current character level
|
||||
|
||||
without breaking existing manual setups.
|
||||
|
||||
## Scope
|
||||
- Planning and architecture for Botty repo.
|
||||
- No forced behavior changes: feature must be opt-in.
|
||||
|
||||
## Requirements
|
||||
1. Determine current level reliably at runtime.
|
||||
2. Select a build template by character profile.
|
||||
3. Apply points safely only when unspent points exist.
|
||||
4. Record every allocation in logs/events for audit/replay.
|
||||
5. Abort safely on uncertainty (wrong UI state, OCR mismatch, missing templates).
|
||||
|
||||
## Current Level Detection Strategy
|
||||
|
||||
### Primary path
|
||||
Use `player_bar.get_experience()` (already used in `game_stats.log_exp`) to derive level from XP table.
|
||||
|
||||
### Secondary fallback
|
||||
Open character panel (`C`) and OCR level/name line directly from upper-left panel region.
|
||||
|
||||
### Tertiary fallback
|
||||
If OCR fails repeatedly:
|
||||
- keep previous known good level for session,
|
||||
- do **not** allocate points until confidence is restored.
|
||||
|
||||
### Confidence rules
|
||||
- Require two consistent reads before first allocation in a session.
|
||||
- Reject impossible jumps (e.g., +5 levels at once).
|
||||
- Persist `last_known_level` in session stats snapshot.
|
||||
|
||||
## Build Template Model
|
||||
|
||||
Add config-backed build templates, e.g.:
|
||||
- `config/auto_builds/blizz_sorc.ini`
|
||||
- `config/auto_builds/hammerdin.ini`
|
||||
- `config/auto_builds/fohdin.ini`
|
||||
|
||||
Each template defines per-level targets:
|
||||
- desired skill totals by level milestone
|
||||
- desired attribute distribution (str/dex/vit/ene)
|
||||
|
||||
Example concept:
|
||||
- Level 1-17: early progression targets
|
||||
- Level 18-29: mid-game unlock path
|
||||
- Level 30+: core skill maxing order
|
||||
|
||||
## Runtime Flow
|
||||
1. Enter town and open character/skill UI.
|
||||
2. Detect level and unspent points.
|
||||
3. Load template for `Config().char["type"]`.
|
||||
4. Compute delta between current allocation and target-at-level.
|
||||
5. Apply points stepwise:
|
||||
- attributes first (optional toggle),
|
||||
- skills second.
|
||||
6. Verify post-apply state.
|
||||
7. Log allocation summary and persist snapshot.
|
||||
|
||||
## Safety Guards
|
||||
- Only run in town.
|
||||
- Require stash/vendor windows closed.
|
||||
- Hard cap per cycle (e.g., max 10 clicks per stat/skill group).
|
||||
- On mismatch/timeout:
|
||||
- stop allocation immediately,
|
||||
- screenshot + structured error event,
|
||||
- continue bot without crashing.
|
||||
|
||||
## Config Additions (Planned)
|
||||
In `[char]` or new `[auto_build]` section:
|
||||
- `auto_assign_skills=0/1`
|
||||
- `auto_assign_attributes=0/1`
|
||||
- `auto_build_profile=` (defaults to `char.type`)
|
||||
- `auto_build_check_every_x_games=`
|
||||
- `auto_build_safe_mode=1` (extra verification)
|
||||
|
||||
## Logging / Telemetry
|
||||
Add structured events:
|
||||
- `auto_build_check_started`
|
||||
- `auto_build_level_detected`
|
||||
- `auto_build_points_detected`
|
||||
- `auto_build_applied`
|
||||
- `auto_build_skipped`
|
||||
- `auto_build_error`
|
||||
|
||||
Include:
|
||||
- profile
|
||||
- level
|
||||
- points spent
|
||||
- before/after snapshots
|
||||
|
||||
## UI / Input Dependencies
|
||||
Need stable template references for:
|
||||
- character panel level region
|
||||
- unspent attribute points indicator
|
||||
- unspent skill points indicator
|
||||
- individual plus-buttons for stats/skills
|
||||
|
||||
## Test Plan
|
||||
1. Unit tests:
|
||||
- level-to-target mapping
|
||||
- delta computation
|
||||
- guard conditions
|
||||
2. Integration dry-run mode:
|
||||
- compute and log planned actions without clicking.
|
||||
3. Live smoke tests per profile:
|
||||
- `blizz_sorc`, `hammerdin`, `fohdin`
|
||||
4. Regression:
|
||||
- ensure normal runs unaffected with feature disabled.
|
||||
|
||||
## Inputs Needed From You
|
||||
1. Screenshots for each supported class at:
|
||||
- character panel open,
|
||||
- skill tree open,
|
||||
- visible unspent points.
|
||||
2. Preferred leveling templates:
|
||||
- exact skill priority order by level range.
|
||||
- attribute rules (e.g., str to gear breakpoint, then vit).
|
||||
3. Whether respec-aware logic is needed in v1.
|
||||
|
||||
## Rollout Phases
|
||||
1. Phase 1: Level detection + dry-run planner only.
|
||||
2. Phase 2: Attribute auto-assign (safer, fewer UI branches).
|
||||
3. Phase 3: Skill auto-assign with full verification.
|
||||
4. Phase 4: Expanded profile templates + docs.
|
||||
|
||||
## Definition of Done
|
||||
- Feature is opt-in and stable for `blizz_sorc`, `hammerdin`, `fohdin`.
|
||||
- Level detection is reliable with fallback behavior.
|
||||
- No crash on detection/allocation failure.
|
||||
- Full logs available for every auto-allocation decision.
|
||||
@@ -0,0 +1,539 @@
|
||||
# Codex Fix Analysis
|
||||
|
||||
Source files read:
|
||||
|
||||
- `docs/fix_plan.md`
|
||||
- `src/run/nihlathak.py`
|
||||
- `src/town/town_manager.py`
|
||||
- `src/inventory/vendor.py`
|
||||
- `src/char/i_char.py` (`src/char.py` does not exist in this repo; CTA is implemented here)
|
||||
- `config/game.ini`
|
||||
- Supporting files needed to trace the failures: `src/town/a1.py`, `src/town/a5.py`, `src/town/a4.py`, `src/npc_manager.py`, `src/pather.py`, and the CTA key section in `config/params.ini`
|
||||
|
||||
## Priority 1: Nihlathak approach fails
|
||||
|
||||
### Finding
|
||||
|
||||
The Nihlathak route has three brittle points:
|
||||
|
||||
1. `approach()` returns success immediately after clicking the waypoint and never verifies that the Halls of Pain actually loaded.
|
||||
2. Level 1 layout detection has no fallback if `NI1_A`, `NI1_B`, or `NI1_C` is stale.
|
||||
3. `traverse_nodes_fixed()` always returns `True`, so a bad static path in `config/game.ini` cannot be detected until the stairs click times out.
|
||||
|
||||
The `config/game.ini` Nihlathak path keys are present:
|
||||
|
||||
```ini
|
||||
ni1_a=871,472, 1205,600, 1162,600, 1169,584, 1169,584, 1232,213, 1221,237, 1164,228, 1145,572, 1146,547, 1223,185
|
||||
ni1_b=23,187, 23,187, 23,187, 23,187, 12,192, 10,192, 10,190, 123,70, 378,120
|
||||
ni1_c=118,500, 158,602, 187,577, 217,563, 184,551, 70,413, 127,240, 154,493, 197,504, 218,545, 83,246, 45,526, 300,380
|
||||
```
|
||||
|
||||
So the immediate code fix is not "add missing keys"; it is to verify waypoint/area entry and reduce the failure blast radius when stale templates or coordinates are encountered.
|
||||
|
||||
### Exact code that needs to change
|
||||
|
||||
`src/run/nihlathak.py`:
|
||||
|
||||
```python
|
||||
wait(0.4)
|
||||
if waypoint.use_wp("Halls of Pain"): # use Halls of Pain Waypoint (5th in A5)
|
||||
return Location.A5_NIHLATHAK_START
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
|
||||
if not template_match.valid:
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char)
|
||||
```
|
||||
|
||||
### Proposed fix
|
||||
|
||||
Replace the waypoint block with a verified load:
|
||||
|
||||
```python
|
||||
wait(0.4)
|
||||
if not waypoint.use_wp("Halls of Pain"):
|
||||
return False
|
||||
if not template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.55, timeout=8).valid:
|
||||
Logger.error("Nihlathak approach: waypoint click did not land in Halls of Pain")
|
||||
return False
|
||||
return Location.A5_NIHLATHAK_START
|
||||
```
|
||||
|
||||
Replace layout detection and static path traversal with a lower-threshold retry and an explicit path result check:
|
||||
|
||||
```python
|
||||
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
|
||||
if not template_match.valid:
|
||||
Logger.warning("Nihlathak: strict NI1 layout detection failed, retrying with grayscale/lower threshold")
|
||||
template_match = template_finder.search_and_wait(
|
||||
["NI1_A", "NI1_B", "NI1_C"],
|
||||
threshold=0.55,
|
||||
best_match=True,
|
||||
timeout=6,
|
||||
use_grayscale=True,
|
||||
)
|
||||
if not template_match.valid:
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
if not self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char):
|
||||
Logger.error(f"Nihlathak: failed static route {template_match.name.lower()}")
|
||||
return False
|
||||
```
|
||||
|
||||
If `search_and_wait()` does not support `use_grayscale` in this repo version, use this compatible form instead:
|
||||
|
||||
```python
|
||||
if not template_match.valid:
|
||||
start = time.time()
|
||||
while time.time() - start < 6:
|
||||
template_match = template_finder.search(
|
||||
["NI1_A", "NI1_B", "NI1_C"],
|
||||
grab(),
|
||||
threshold=0.55,
|
||||
best_match=True,
|
||||
use_grayscale=True,
|
||||
)
|
||||
if template_match.valid:
|
||||
break
|
||||
wait(0.2)
|
||||
```
|
||||
|
||||
That compatible form also needs imports:
|
||||
|
||||
```python
|
||||
import time
|
||||
from screen import grab, convert_abs_to_monitor
|
||||
```
|
||||
|
||||
### Why it will work
|
||||
|
||||
This turns the approach from "clicked the waypoint, assume success" into "clicked the waypoint, confirm an NI1 layout is visible." If the waypoint interaction fails or lands somewhere unexpected, the run fails immediately instead of burning 600+ seconds.
|
||||
|
||||
The lower-threshold/grayscale retry handles the likely stale-template case without permanently weakening the first pass. The strict threshold still wins when templates are good; the fallback only runs when the current behavior would fail.
|
||||
|
||||
The static route check makes future changes safer. `traverse_nodes_fixed()` currently returns `True`, but guarding the call is still correct because it protects this runner if path traversal later gains real validation.
|
||||
|
||||
Fresh templates and re-recorded `ni1_*` coordinates are still required if the fallback logs low-confidence matches or reaches the wrong stairs side. The code fix limits total game loss and gives a useful failure point.
|
||||
|
||||
## Priority 2: Vendor trade button not found
|
||||
|
||||
### Finding
|
||||
|
||||
The failure is not in `src/inventory/vendor.py`; that file buys items after the vendor panel is already open. The failing log comes from `src/npc_manager.py`:
|
||||
|
||||
```python
|
||||
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
|
||||
```
|
||||
|
||||
The current town code also returns a vendor location even if pressing the trade button failed. For A5 Malah:
|
||||
|
||||
```python
|
||||
def open_trade_menu(self, curr_loc: Location) -> Location | bool:
|
||||
if not self._pather.traverse_nodes((curr_loc, Location.A5_MALAH), self._char, force_move=True): return False
|
||||
if open_npc_menu(Npc.MALAH):
|
||||
press_npc_btn(Npc.MALAH, "trade")
|
||||
return Location.A5_MALAH
|
||||
return False
|
||||
```
|
||||
|
||||
And `press_npc_btn()` does not return `True` on success:
|
||||
|
||||
```python
|
||||
if res.valid:
|
||||
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
|
||||
wait(0.2, 0.4)
|
||||
mouse.click(button="left")
|
||||
wait(0.04, 0.08)
|
||||
center_mouse()
|
||||
else:
|
||||
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
|
||||
keyboard.send("esc")
|
||||
```
|
||||
|
||||
The template threshold is also hard-coded very high for white and blue text:
|
||||
|
||||
```python
|
||||
filtered_inp_w, 0.85, roi=Config().ui_roi["cut_skill_bar"]
|
||||
```
|
||||
|
||||
### Exact code that needs to change
|
||||
|
||||
`src/npc_manager.py`, `press_npc_btn()` needs to return a boolean and use a retry/fallback. A5/A1/A4 trade functions need to check that boolean or verify the vendor panel.
|
||||
|
||||
### Proposed fix
|
||||
|
||||
Replace `press_npc_btn()` with:
|
||||
|
||||
```python
|
||||
def press_npc_btn(npc_key: Npc, action_btn_key: str) -> bool:
|
||||
global npcs
|
||||
for threshold in (0.85, 0.78):
|
||||
img = grab()
|
||||
img = escape_dialogue(img)
|
||||
_, filtered_inp_w = color_filter(img, Config().colors["white"])
|
||||
res = template_finder.search(
|
||||
npcs[npc_key]["action_btns"][action_btn_key]["white"],
|
||||
filtered_inp_w,
|
||||
threshold,
|
||||
roi=Config().ui_roi["cut_skill_bar"],
|
||||
)
|
||||
if not res.valid and "blue" in npcs[npc_key]["action_btns"][action_btn_key]:
|
||||
_, filtered_inp_b = color_filter(img, Config().colors["blue"])
|
||||
res = template_finder.search(
|
||||
npcs[npc_key]["action_btns"][action_btn_key]["blue"],
|
||||
filtered_inp_b,
|
||||
threshold,
|
||||
roi=Config().ui_roi["cut_skill_bar"],
|
||||
)
|
||||
if not res.valid:
|
||||
res = template_finder.search(
|
||||
npcs[npc_key]["action_btns"][action_btn_key]["white"],
|
||||
img,
|
||||
threshold,
|
||||
roi=Config().ui_roi["cut_skill_bar"],
|
||||
use_grayscale=True,
|
||||
)
|
||||
if res.valid:
|
||||
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
|
||||
wait(0.2, 0.4)
|
||||
mouse.click(button="left")
|
||||
wait(0.2, 0.3)
|
||||
center_mouse()
|
||||
return True
|
||||
|
||||
if "red" in npcs[npc_key]["action_btns"][action_btn_key]:
|
||||
img = grab()
|
||||
_, filtered_inp_r = color_filter(img, Config().colors["red"])
|
||||
res = template_finder.search(
|
||||
npcs[npc_key]["action_btns"][action_btn_key]["red"],
|
||||
filtered_inp_r,
|
||||
0.78,
|
||||
roi=Config().ui_roi["cut_skill_bar"],
|
||||
)
|
||||
if res.valid:
|
||||
Logger.warning(f"Cannot afford {action_btn_key} (red button detected). Skipping...")
|
||||
keyboard.send("esc")
|
||||
wait(0.3)
|
||||
return False
|
||||
|
||||
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
|
||||
keyboard.send("esc")
|
||||
return False
|
||||
```
|
||||
|
||||
Then change A5 Malah trade from:
|
||||
|
||||
```python
|
||||
if open_npc_menu(Npc.MALAH):
|
||||
press_npc_btn(Npc.MALAH, "trade")
|
||||
return Location.A5_MALAH
|
||||
return False
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
if open_npc_menu(Npc.MALAH):
|
||||
if press_npc_btn(Npc.MALAH, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
|
||||
return Location.A5_MALAH
|
||||
return False
|
||||
```
|
||||
|
||||
Make the same pattern in `src/town/a1.py`:
|
||||
|
||||
```python
|
||||
if open_npc_menu(Npc.AKARA):
|
||||
if press_npc_btn(Npc.AKARA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
|
||||
return Location.A1_AKARA
|
||||
return False
|
||||
```
|
||||
|
||||
and in `src/town/a4.py`:
|
||||
|
||||
```python
|
||||
if open_npc_menu(Npc.JAMELLA):
|
||||
if press_npc_btn(Npc.JAMELLA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
|
||||
return Location.A4_JAMELLA
|
||||
return False
|
||||
```
|
||||
|
||||
### Why it will work
|
||||
|
||||
The bot currently proceeds as if trade opened even when the button was not clicked. Returning `False` stops `TownManager.buy_consumables()` at the correct point:
|
||||
|
||||
```python
|
||||
new_loc = self._acts[curr_act].open_trade_menu(curr_loc)
|
||||
if not (new_loc and common.wait_for_left_inventory()): return False, items
|
||||
```
|
||||
|
||||
The fallback search keeps the current exact template behavior first, then retries with a slightly lower threshold and grayscale. That covers text color/anti-aliasing differences without making every match permissive.
|
||||
|
||||
Verifying `ScreenObjects.GoldBtnVendor` makes the action result state-based. Even if the template click returns true, the caller only continues when the vendor panel is actually open.
|
||||
|
||||
Fresh `TRADE` / `TRADE_BLUE` templates are still recommended, but this code fix prevents false success and reduces sensitivity to minor UI rendering differences.
|
||||
|
||||
## Priority 3: Stash detection fails
|
||||
|
||||
### Finding
|
||||
|
||||
The stash failures are in act-specific methods, not in `TownManager.stash()` itself. A1 and A5 use default `IChar.select_by_template()` threshold `0.68`:
|
||||
|
||||
```python
|
||||
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
|
||||
return False
|
||||
```
|
||||
|
||||
The common selector only closes the waypoint menu for A5 stash templates:
|
||||
|
||||
```python
|
||||
if type(template_type) == list and "A5_STASH" in template_type:
|
||||
# sometimes waypoint is opened and stash not found because of that, check for that
|
||||
if is_visible(ScreenObjects.WaypointLabel):
|
||||
keyboard.send("esc")
|
||||
```
|
||||
|
||||
So A1 stash can fail when a waypoint/dialog is left open, and both A1/A5 have no lower-threshold retry.
|
||||
|
||||
### Exact code that needs to change
|
||||
|
||||
`src/town/a1.py`:
|
||||
|
||||
```python
|
||||
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
|
||||
return False
|
||||
```
|
||||
|
||||
`src/town/a5.py`:
|
||||
|
||||
```python
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
|
||||
return False
|
||||
```
|
||||
|
||||
`src/char/i_char.py`:
|
||||
|
||||
```python
|
||||
if type(template_type) == list and "A5_STASH" in template_type:
|
||||
# sometimes waypoint is opened and stash not found because of that, check for that
|
||||
if is_visible(ScreenObjects.WaypointLabel):
|
||||
keyboard.send("esc")
|
||||
```
|
||||
|
||||
### Proposed fix
|
||||
|
||||
Change the selector guard in `src/char/i_char.py` to handle all stash templates:
|
||||
|
||||
```python
|
||||
templates = template_type if isinstance(template_type, list) else [template_type]
|
||||
if any(template in ["A1_TOWN_0", "A5_STASH", "A5_STASH_2"] for template in templates):
|
||||
# sometimes waypoint is opened and stash not found because of that, check for that
|
||||
if is_visible(ScreenObjects.WaypointLabel):
|
||||
keyboard.send("esc")
|
||||
wait(0.2, 0.3)
|
||||
```
|
||||
|
||||
Change A1 stash to retry lower after the default threshold fails:
|
||||
|
||||
```python
|
||||
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.68, timeout=4.0):
|
||||
Logger.warning("A1 stash: default threshold failed, retrying with lower threshold")
|
||||
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.58, timeout=4.0):
|
||||
return False
|
||||
```
|
||||
|
||||
Change A5 stash similarly:
|
||||
|
||||
```python
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.68, timeout=4.0, telekinesis=True):
|
||||
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.58, timeout=4.0, telekinesis=True):
|
||||
return False
|
||||
```
|
||||
|
||||
### Why it will work
|
||||
|
||||
The default threshold remains unchanged for normal cases. The lower threshold only runs after a specific stash attempt fails, which narrows the risk of false-positive clicks.
|
||||
|
||||
Closing the waypoint menu for A1 and A5 prevents stale UI overlays from blocking the stash templates. This directly addresses the logged sequence where town/stash templates are not found after other town interactions.
|
||||
|
||||
The success function already checks for stash/inventory gold buttons:
|
||||
|
||||
```python
|
||||
found = is_visible(ScreenObjects.GoldBtnInventory, img)
|
||||
found |= is_visible(ScreenObjects.GoldBtnStash, img)
|
||||
```
|
||||
|
||||
That means a lower-threshold click must still produce the actual stash UI to count as success.
|
||||
|
||||
Fresh `A1_TOWN_0`, `A5_STASH`, and `A5_STASH_2` templates should still be captured if logs continue to show low match confidence. The code change makes the current templates less brittle and prevents open UI overlays from causing avoidable failures.
|
||||
|
||||
## Priority 4: CTA weapon switch fails
|
||||
|
||||
### Finding
|
||||
|
||||
The CTA code is in `src/char/i_char.py`. It depends on `Config().char["weapon_switch"]`, which comes from `config/params.ini`, not `config/game.ini`:
|
||||
|
||||
```ini
|
||||
weapon_switch=w
|
||||
battle_orders=f6
|
||||
battle_command=f5
|
||||
```
|
||||
|
||||
The current CTA routine has two reliability problems:
|
||||
|
||||
1. It invalidates active-skill cache implicitly by switching weapons but does not reset `_active_skill`.
|
||||
2. It verifies the switch back by comparing a screenshot of the previous right-skill icon. That can fail when the same skill exists on both swaps, when the icon is visually similar, or when the UI updates slightly late.
|
||||
|
||||
Current code:
|
||||
|
||||
```python
|
||||
while time.time() - start < 4:
|
||||
keyboard.send(Config().char["weapon_switch"])
|
||||
wait(0.4, 0.45)
|
||||
keyboard.send(Config().char["battle_command"])
|
||||
wait(0.2, 0.3)
|
||||
if skills.is_right_skill_selected(["BC", "BO"]):
|
||||
switch_sucess = True
|
||||
break
|
||||
else:
|
||||
Logger.warning("Failed to find Battle Command, swapping weapons again.")
|
||||
```
|
||||
|
||||
```python
|
||||
while time.time() - start < 4:
|
||||
keyboard.send(Config().char["weapon_switch"])
|
||||
wait(0.4, 0.45)
|
||||
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
|
||||
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
|
||||
if max_val > 0.8:
|
||||
switch_sucess = True
|
||||
break
|
||||
else:
|
||||
Logger.warning("Failed to switch weapon, try again")
|
||||
wait(0.5)
|
||||
return switch_sucess
|
||||
```
|
||||
|
||||
### Exact code that needs to change
|
||||
|
||||
Add a helper inside `IChar` and use it whenever the weapon switch key is sent in `_pre_buff_cta()`.
|
||||
|
||||
### Proposed fix
|
||||
|
||||
Add this method to `IChar`:
|
||||
|
||||
```python
|
||||
def _weapon_switch(self):
|
||||
keyboard.send(Config().char["weapon_switch"])
|
||||
self._set_active_skill("left", "")
|
||||
self._set_active_skill("right", "")
|
||||
wait(0.55, 0.65)
|
||||
```
|
||||
|
||||
Change the first CTA-side check from:
|
||||
|
||||
```python
|
||||
if skills.is_right_skill_selected(["BC", "BO"]):
|
||||
keyboard.send(Config().char["weapon_switch"])
|
||||
wait(0.4, 0.45)
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
if skills.is_right_skill_selected(["BC", "BO"]):
|
||||
self._weapon_switch()
|
||||
```
|
||||
|
||||
Change the switch-to-CTA loop from:
|
||||
|
||||
```python
|
||||
keyboard.send(Config().char["weapon_switch"])
|
||||
wait(0.4, 0.45)
|
||||
keyboard.send(Config().char["battle_command"])
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
self._weapon_switch()
|
||||
keyboard.send(Config().char["battle_command"])
|
||||
```
|
||||
|
||||
Change the switch-back loop from:
|
||||
|
||||
```python
|
||||
keyboard.send(Config().char["weapon_switch"])
|
||||
wait(0.4, 0.45)
|
||||
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
|
||||
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
|
||||
if max_val > 0.8:
|
||||
switch_sucess = True
|
||||
break
|
||||
else:
|
||||
Logger.warning("Failed to switch weapon, try again")
|
||||
wait(0.5)
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
self._weapon_switch()
|
||||
if not skills.is_right_skill_selected(["BC", "BO"]):
|
||||
switch_sucess = True
|
||||
break
|
||||
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
|
||||
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
|
||||
if max_val > 0.8:
|
||||
switch_sucess = True
|
||||
break
|
||||
Logger.warning("Failed to switch weapon, try again")
|
||||
wait(0.5)
|
||||
```
|
||||
|
||||
Also fix the spelling while touching this code:
|
||||
|
||||
```python
|
||||
switch_success = False
|
||||
```
|
||||
|
||||
instead of:
|
||||
|
||||
```python
|
||||
switch_sucess = False
|
||||
```
|
||||
|
||||
### Why it will work
|
||||
|
||||
Resetting `_active_skill` after weapon swap prevents `_select_skill()` from skipping a hotkey press because it thinks the old right skill is still selected. Weapon swap changes the available skill bar, so the cache is no longer trustworthy.
|
||||
|
||||
The longer wait gives D2R more time to update the skill icon and weapon state before validation. The current 0.4 second delay is close to the UI transition timing and is likely why the failure is intermittent.
|
||||
|
||||
The switch-back validation now accepts the most direct state: the right skill is no longer Battle Command/Battle Orders. The old image comparison remains as a fallback, so this still handles cases where BC/BO detection briefly lags.
|
||||
|
||||
The configured key should be checked in `config/params.ini`, not `config/game.ini`. If the user's actual D2R weapon switch key is Caps Lock, then this line must change:
|
||||
|
||||
```ini
|
||||
weapon_switch=w
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ini
|
||||
weapon_switch=capslock
|
||||
```
|
||||
|
||||
Only do that if the in-game key binding is actually Caps Lock; otherwise leave it as `w`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# d2jsp Semi-Automated Scraping Guide
|
||||
|
||||
Due to Cloudflare's aggressive bot protection, fully automated scraping is currently restricted. This project uses a **Semi-Automated (Offline) Workflow** that leverages your authenticated browser session to safely collect market data.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Python Dependencies:** Ensure `keyboard` and `requests` are installed (included in `requirements.txt`).
|
||||
2. **Browser:** Google Chrome is recommended.
|
||||
3. **Authentication:** Be logged into [forums.d2jsp.org](https://forums.d2jsp.org/) in your browser.
|
||||
|
||||
---
|
||||
|
||||
## The Workflow
|
||||
|
||||
### 1. Initial Directory Setup
|
||||
Before running the automation, you must set the default "Save As" path in your browser:
|
||||
1. Open any topic on d2jsp.
|
||||
2. Press `Ctrl + S`.
|
||||
3. Navigate to your bot folder: `data/d2jsp_pages/`.
|
||||
4. Save the file. Your browser will now remember this location as the default.
|
||||
|
||||
### 2. Collect Topic URLs
|
||||
If you don't have a fresh list of URLs, run the collector on a saved forum listing page:
|
||||
```powershell
|
||||
python tools/d2jsp_topic_collector.py --offline-dir data/d2jsp_pages --out data/d2jsp_topic_urls.txt
|
||||
```
|
||||
|
||||
### 3. Run Browser Automation
|
||||
This script will open the first 20 topics from your list, wait for Cloudflare to pass, and simulate the save command.
|
||||
```powershell
|
||||
python tools/browser_auto_save.py
|
||||
```
|
||||
**Important:**
|
||||
* Keep your browser as the active window.
|
||||
* Do not move the mouse or type while the script is running.
|
||||
* It will automatically `Ctrl + S` -> `Enter` -> `Ctrl + W` for each tab.
|
||||
|
||||
### 4. Generate Price Estimates
|
||||
Once the HTML files are saved in `data/d2jsp_pages/`, run the offline scraper to update your bot's configuration:
|
||||
```powershell
|
||||
python tools/fg_market_scraper.py --ladder-start-date 2026-05-20 --offline-dir data/d2jsp_pages --out config/fg_daily_estimates.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cloudflare "Just a Moment" Loop
|
||||
If the automation is too fast and hits a "Just a Moment" screen that doesn't resolve:
|
||||
1. Increase the `time.sleep(8)` value in `tools/browser_auto_save.py`.
|
||||
2. Manually solve one challenge in the browser to "warm up" the IP clearance.
|
||||
|
||||
### Files Not Saving to Correct Folder
|
||||
If files are saving to your "Downloads" folder instead of `data/d2jsp_pages`, the browser's default path was reset. Repeat **Step 1** to fix it.
|
||||
|
||||
### Date Parsing Errors
|
||||
If the scraper reports 0 topics scanned or dates are missing, ensure your browser language isn't translating the page, as the scraper expects English month names (Jan, Feb, Mar, etc.).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Botty Fix Plan — 2026-06-05
|
||||
|
||||
## Symptoms (from today's logs)
|
||||
|
||||
- 4 sessions today: 31 + 1 + 9 + 6 games = 47 games total
|
||||
- 51 deaths across all sessions
|
||||
- 0 valuable items found (only charms, jewels, gold)
|
||||
- Nihlathak run fails every time, ending entire games
|
||||
- Vendor trade button never found (can't buy potions)
|
||||
- Stash never opens (can't stash items)
|
||||
- CTA weapon switch fails occasionally
|
||||
|
||||
---
|
||||
|
||||
## Priority 1: Nihlathak approach fails (game-ending)
|
||||
|
||||
**Error:** `Approach failed for run_nihlathak` — ends game after 600-650s
|
||||
**Impact:** 3+ games ended per session
|
||||
|
||||
**Root cause:** Teleport activation times out, bot can't reach Nihlathak
|
||||
**Fix:**
|
||||
- Check `config/game.ini` for Nihlathak coordinates (`a5_nihlathak_*`)
|
||||
- Likely missing or stale path nodes
|
||||
- Re-record path with `node_recorder.py`
|
||||
- May need fresh template images for Nihlathak area
|
||||
|
||||
**Files to check:**
|
||||
- `config/game.ini` (Nihlathak section)
|
||||
- `src/run/nihlathak.py`
|
||||
- `assets/templates/nihlathak/`
|
||||
|
||||
---
|
||||
|
||||
## Priority 2: Vendor trade button not found
|
||||
|
||||
**Error:** `Could not find trade btn. Should not happen!`
|
||||
**Impact:** Can't buy potions every town return
|
||||
|
||||
**Root cause:** Trade button template outdated or offset wrong for Hell difficulty UI
|
||||
**Fix:**
|
||||
- Take fresh screenshot of vendor trade window
|
||||
- Update trade button template in `assets/templates/`
|
||||
- Check if trade button position shifted between Normal/Nightmare/Hell
|
||||
|
||||
**Files to check:**
|
||||
- `assets/templates/` (trade button template)
|
||||
- `src/` (vendor interaction code — search for "trade btn")
|
||||
|
||||
---
|
||||
|
||||
## Priority 3: Stash detection fails
|
||||
|
||||
**Error:** `select_by_template: could not find ['A1_TOWN_0']` then `['A5_STASH', 'A5_STASH_2']`
|
||||
**Impact:** Can't stash items, inventory fills with junk
|
||||
|
||||
**Root cause:** Stash template confidence threshold too high or template outdated
|
||||
**Fix:**
|
||||
- Take fresh screenshot of stash UI in Hell difficulty
|
||||
- Update A1_TOWN_0, A5_STASH, A5_STASH_2 templates
|
||||
- Consider lowering confidence threshold (currently 0.68)
|
||||
|
||||
**Files to check:**
|
||||
- `assets/templates/a1_town/`
|
||||
- `assets/templates/a5_stash/`
|
||||
- Template matching threshold in code
|
||||
|
||||
---
|
||||
|
||||
## Priority 4: CTA weapon switch fails
|
||||
|
||||
**Error:** `_pre_buff_cta: switch to CTA slot failed — retrying`
|
||||
**Impact:** Occasional, bot retries
|
||||
|
||||
**Root cause:** Weapon switch key or template unreliable
|
||||
**Fix:**
|
||||
- Verify weapon switch key binding (capslock per memory)
|
||||
- Check CTA slot detection template
|
||||
- May need more robust retry logic
|
||||
|
||||
**Files to check:**
|
||||
- `src/` (search for `_pre_buff_cta`)
|
||||
- `config/` (weapon_switch key)
|
||||
|
||||
---
|
||||
|
||||
## Approach
|
||||
|
||||
All four issues are template/coordinate problems — stale screenshots or wrong positions.
|
||||
Fix pattern: screenshot current game UI at those locations, update templates.
|
||||
|
||||
Do in order: Nihlathak > vendor > stash > CTA.
|
||||
@@ -0,0 +1,360 @@
|
||||
# Hermes Takeover Guide
|
||||
|
||||
Practical handoff for continuing Botty development. Read every section before touching code — the trap patterns section alone will save you hours.
|
||||
|
||||
---
|
||||
|
||||
## 1) Working model
|
||||
|
||||
- Treat Botty as a **state machine app** with side effects across D2R UI automation, OCR parsing, inventory/sell/stash routines, and messaging (Discord/webhooks).
|
||||
- Prefer **small, production-safe patches** over broad refactors.
|
||||
- Prioritize runtime stability: avoid failing full runs because one subsystem is flaky.
|
||||
- **Always reproduce from logs first** — most bugs leave a clear trail.
|
||||
|
||||
---
|
||||
|
||||
## 2) Daily workflow
|
||||
|
||||
1. Get fresh logs:
|
||||
- `log/log.txt`
|
||||
- `log/stats/events_*.jsonl`
|
||||
- `log/stats/stats_*.log`
|
||||
2. Confirm failure path in code with `rg`.
|
||||
3. Patch narrowly with targeted edits.
|
||||
4. Validate:
|
||||
- `python -m compileall <changed files>`
|
||||
- targeted pytest where available
|
||||
5. Commit with a clear, single-purpose message.
|
||||
|
||||
---
|
||||
|
||||
## 3) Core debug commands
|
||||
|
||||
From repo root (PowerShell):
|
||||
|
||||
```powershell
|
||||
# Tail errors/warnings
|
||||
rg -n "ERROR|WARNING|Failed|chicken|exception" log/log.txt | tail -50
|
||||
|
||||
# Last 200 log lines
|
||||
Get-Content log/log.txt | Select-Object -Last 200
|
||||
|
||||
# Search source code
|
||||
rg -n "<keyword>" src/
|
||||
|
||||
# Compile check after edits
|
||||
python -m compileall src/health_manager.py src/item/pickit.py
|
||||
|
||||
# Config smoke test (confirm a key's resolved value)
|
||||
python -c "import sys; sys.path.insert(0,'src'); from config import Config; print(Config().char['show_belt'])"
|
||||
|
||||
# Run test suite
|
||||
python -m pytest -q test/
|
||||
```
|
||||
|
||||
Use `rg` first. It's the fastest way to localize faults.
|
||||
|
||||
---
|
||||
|
||||
## 4) High-risk areas
|
||||
|
||||
### 4.1 Repair/vendor flow
|
||||
|
||||
- A5 Larzuk detection is noisy and can fail.
|
||||
- Current behavior: A5 normal flow → Larzuk direct template click → Act 4 Halbu fallback.
|
||||
- `repair_npc=a4_halbu` config key skips Larzuk entirely.
|
||||
|
||||
Files: `src/town/a5.py`, `src/town/town_manager.py`, `src/bot.py`
|
||||
|
||||
### 4.2 Discord messaging
|
||||
|
||||
Known issue: `'NoneType' object has no attribute 'to_dict'`
|
||||
|
||||
Current fix: `src/messages/discord_embeds.py` `_send_embed` only passes `file=` when attachment exists. Plain text fallback on embed failure.
|
||||
|
||||
Config: `config/params.ini` → `[discord_events]`
|
||||
|
||||
### 4.3 Selling safety
|
||||
|
||||
Current protections:
|
||||
- Sell logs include item names (not coordinates only).
|
||||
- `protect_shields_from_sell=1` blocks selling items whose detected name includes "shield".
|
||||
|
||||
Files: `src/inventory/personal.py`, `config/params.ini`, `src/config.py`
|
||||
|
||||
### 4.4 XP logging
|
||||
|
||||
Two separate concerns:
|
||||
1. OCR extraction: `src/ui/player_bar.py` — parser handles `I/l/|→1`, `O/o→0`.
|
||||
2. XP status projection math: `src/game_stats.py` `_create_msg()` — zero denominators guarded.
|
||||
|
||||
### 4.5 HealthManager (rejuv / chicken logic) ⚠️
|
||||
|
||||
The most subtle source of false positives. See Section 10.2 for full details.
|
||||
|
||||
Key thresholds (params.ini `[char]`):
|
||||
- `take_rejuv_potion_health = 0.80` — drink rejuv if HP ≤ 80%
|
||||
- `take_rejuv_potion_mana = 0.55` — drink rejuv if mana ≤ 55%
|
||||
- `chicken = 0.50` — flee if HP ≤ 50%
|
||||
|
||||
The "two juvs in 8s → chicken" check **must also verify HP was the trigger**, not just mana. Hammerdins burn mana fast; mana can legitimately trigger back-to-back rejuvs at full HP.
|
||||
|
||||
### 4.6 PickIt gold loop ⚠️
|
||||
|
||||
`_yoink_item()` **always returns `PickedUpResult.PickedUp`** regardless of actual success (line 129). This means pickup failures are only detectable through `_pick_up_item`'s same-ID or same-UID checks — and different gold pile amounts produce different IDs, bypassing those checks entirely.
|
||||
|
||||
Fix in place: `pick_up_items()` now blacklists `item.ID` in `_cached_pickit_items` on `PickedUpFailed`. Do not remove this case.
|
||||
|
||||
---
|
||||
|
||||
## 5) State machine (bot.py)
|
||||
|
||||
States: `initialization → hero_selection → town → [run state] → town → ...`
|
||||
|
||||
Full state list: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
|
||||
|
||||
Key transitions:
|
||||
| Trigger | Source | Dest | Handler |
|
||||
|---|---|---|---|
|
||||
| `init` | initialization | initialization | `on_init` |
|
||||
| `select_character` | initialization | hero_selection | `on_select_character` |
|
||||
| `start_from_town` | initialization/hero_selection | town | `on_start_from_town` |
|
||||
| `maintenance` | town | town | `on_maintenance` |
|
||||
| `run_pindle` | town | pindle | `on_run_pindle` |
|
||||
| `run_arcane` | town | arcane | `on_run_arcane` |
|
||||
| `end_run` | any run state | town | `on_end_run` |
|
||||
| `end_game` | town/any run state | initialization | `on_end_game` |
|
||||
|
||||
**`on_maintenance` guard**: If `_curr_loc` is None (e.g. after a chicken recovery), defaults to `A1_TOWN_START` with a warning log. Do not remove this guard.
|
||||
|
||||
**`end_game` vs `end_run`**: When TP charges run out, trigger `end_game` so the bot restarts and restocks in the next game — not `end_run` which tries to TP back and loops.
|
||||
|
||||
---
|
||||
|
||||
## 6) Config system
|
||||
|
||||
Priority order (highest first):
|
||||
```
|
||||
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
|
||||
```
|
||||
|
||||
`custom.ini` is gitignored — user-only overrides. Never edit `game.ini` for user settings.
|
||||
|
||||
### Key detection flow
|
||||
|
||||
On every `Config()` instantiation:
|
||||
1. Reads `Saved Games/Diablo II Resurrected/<charname>.keyo` binary file.
|
||||
2. Parses slot-to-key mapping (`CHAR_BINDING_SLOTS` in `key_detector.py`).
|
||||
3. Slot 41 = `show_belt`, slot 36 = `stand_still`, slot 44 = `weapon_switch`, etc.
|
||||
4. Compares each detected key against params.ini value.
|
||||
5. If they match → use detected key silently.
|
||||
6. If they differ → logs `"Keeping configured key binding for X: 'params_val' (detected 'keyo_val')"` and keeps the params.ini value.
|
||||
|
||||
**What "Keeping..." means**: params.ini disagrees with the in-game binding. Usually indicates a config typo or stale params.ini. If you see `Keeping show_belt: 'k' (detected 'n')`, the fix is `show_belt=n` in params.ini `[char]`.
|
||||
|
||||
### Config singleton pattern
|
||||
|
||||
`Config` uses `__new__` with a `data_loaded` class variable. Calling `Config()` multiple times in the same process returns the same loaded instance. After editing params.ini at runtime, `Config().reload()` is needed (or restart the bot).
|
||||
|
||||
---
|
||||
|
||||
## 7) Current bot profile (Fistman — as of 2026-06-05)
|
||||
|
||||
- **Character**: Hammerdin (`src/char/paladin/hammerdin.py`)
|
||||
- **Runs**: Pindle + Arcane Sanctuary
|
||||
- **Key bindings** (from .keyo + params.ini):
|
||||
- `show_belt = n` (belt hotkey)
|
||||
- `stand_still = capslock` (overrides detected 'shift')
|
||||
- `weapon_switch = w`
|
||||
- `show_items = alt`
|
||||
- `potion1..4 = 1,2,3,4`
|
||||
- **Capabilities**: `can_teleport_natively = True` (set via `override_capabilities` in `[advanced_options]`)
|
||||
- **Thresholds**: `take_rejuv_potion_health=0.80`, `take_rejuv_potion_mana=0.55`, `chicken=0.50`
|
||||
|
||||
---
|
||||
|
||||
## 8) Testing strategy by change type
|
||||
|
||||
### Messaging changes
|
||||
```powershell
|
||||
python -m pytest -q test/test_discord_embeds.py
|
||||
```
|
||||
|
||||
### Config parsing changes
|
||||
```powershell
|
||||
python -m compileall src/config.py
|
||||
python -c "import sys; sys.path.insert(0,'src'); from config import Config; c=Config(); print(c.char['show_belt'], c.char['stand_still'])"
|
||||
```
|
||||
|
||||
### Inventory/sell logic
|
||||
- Validate no exceptions in `inspect_items` / `transfer_items`.
|
||||
- Log outputs should include item names (not just coordinates).
|
||||
- Prefer dry functional checks from logs before gameplay runs.
|
||||
|
||||
### HealthManager changes
|
||||
- Check that chicken thresholds still fire at the right HP%.
|
||||
- Verify the two-rejuv check only triggers when `health_percentage <= take_rejuv_potion_health`.
|
||||
|
||||
### PickIt changes
|
||||
- Confirm `_cached_pickit_items` is populated on both `PickedUp` (cached True) and `PickedUpFailed` (cached False).
|
||||
- Confirm `_yoink_item` return value is not relied on for success detection.
|
||||
|
||||
---
|
||||
|
||||
## 9) Git hygiene
|
||||
|
||||
- Keep untracked: `.env`, `config/custom.ini`, `log/`, `log/screenshots/`
|
||||
- Do not revert unrelated user changes.
|
||||
- Commit frequently with single-purpose messages.
|
||||
- `python -m compileall src/` must pass cleanly before committing.
|
||||
|
||||
---
|
||||
|
||||
## 10) Module internals and trap patterns
|
||||
|
||||
### 10.1 Config / key detection traps
|
||||
|
||||
**Trap**: `apply_key_bindings` runs *after* `self.char` is built in `config.py`. If you add a new key to `self.char` dict and it doesn't exist in `CHAR_BINDING_SLOTS`, the keyo detector won't touch it — but the user still needs to have it in params.ini.
|
||||
|
||||
**Trap**: "Keeping configured key binding" is NOT an error. It means the user explicitly configured something different from the game default. It becomes a problem only if the params.ini value is wrong (e.g., 'k' instead of 'n' for show_belt).
|
||||
|
||||
**Trap**: `Config()` is a singleton via `__new__`. The first call loads everything. Subsequent calls within the same process return the cached instance. Do NOT expect param changes at runtime to be visible without `Config().reload()`.
|
||||
|
||||
### 10.2 HealthManager rejuv traps
|
||||
|
||||
The rejuv logic in `start_monitor()`:
|
||||
```python
|
||||
if last_drink > 0.60: # minimum between rejuvs
|
||||
if health <= take_rejuv_potion_health or mana <= take_rejuv_potion_mana:
|
||||
drink_rejuv()
|
||||
self._last_rejuv = time.time()
|
||||
# Two juvs in 8 seconds → chicken ONLY if HP was the trigger
|
||||
if last_drink < 8 and health_percentage <= Config().char["take_rejuv_potion_health"]:
|
||||
self._do_chicken(img)
|
||||
```
|
||||
|
||||
**Critical**: The `last_drink < 8` chicken check MUST also check `health_percentage`. Without it, any mana-triggered second rejuv (common for Hammerdins) will false-chicken at 99.9% HP. The fix is already in place — do not revert it.
|
||||
|
||||
**Timing**: The monitor polls every `3/25s * jitter(±20%)` ≈ 96–144ms. At 25 FPS that's every 3 frames.
|
||||
|
||||
**Thread safety**: `_pause_state` and `_panel_check_paused` are protected by `_state_lock`. Module-level `get_pause_state()` / `set_pause_state()` functions delegate to the singleton. Always use these functions from external code.
|
||||
|
||||
### 10.3 PickIt ID/UID system
|
||||
|
||||
`GroundItem` has two identifiers:
|
||||
|
||||
```python
|
||||
ID = slugify(f"{Name}_{'_'.join([str(v) for _,v in as_dict().items()])}")
|
||||
# Includes Amount in the string. Two gold piles with different amounts = different IDs.
|
||||
|
||||
UID = f"{ID}_{'_'.join([str(v) for v in center])}"
|
||||
# ID + screen position. Same pile at same coordinates = same UID.
|
||||
```
|
||||
|
||||
**Trap**: `_pick_up_item`'s gold-fail detection uses `item.ID == prev.ID`. If two nearby gold piles have different amounts (e.g., 338g and 157g), they alternate as the "next" item and each one's ID never matches the previous, so the same-ID fail check never fires. The loop runs until timeout (20s).
|
||||
|
||||
**Trap**: `_yoink_item` ALWAYS returns `PickedUpResult.PickedUp`. It never returns `PickedUpFailed`. Pickup failures for teleport builds are silently swallowed.
|
||||
|
||||
**Fix in place**: `pick_up_items()` match block now has:
|
||||
```python
|
||||
case PickedUpResult.PickedUpFailed:
|
||||
self._cached_pickit_items[item.ID] = False # blacklist this session
|
||||
```
|
||||
This stops the alternating-gold loop by blacklisting the item after the first confirmed failure.
|
||||
|
||||
### 10.4 Belt system open() key chain
|
||||
|
||||
`belt.open()` tries keys in this order:
|
||||
```python
|
||||
[config_val, "n", "k", "`", "~"] # deduplicated
|
||||
```
|
||||
|
||||
If `show_belt = n` in params.ini, the first key tried is 'n'. If it works, no fallback keys appear in logs. If you see `"Trying to open belt with key: k"` it means 'n' failed — check if `show_belt` is actually set to 'n' and if the D2R window is focused.
|
||||
|
||||
### 10.5 TownManager location routing
|
||||
|
||||
`get_act_from_location(loc)` returns `None` for non-string inputs (e.g., `True`, `False`). The isinstance guard at line 36 (`if not isinstance(loc, str): return None`) prevents `AttributeError: 'bool' object has no attribute 'upper'`. Do not remove it.
|
||||
|
||||
All town methods that receive a `Location` return `False` (not `None`) on failure. Callers should check `if not new_loc` not `if new_loc is None`.
|
||||
|
||||
### 10.6 State machine: `end_game` vs `end_run`
|
||||
|
||||
`end_run` sends a TP, waits in town, does maintenance, then starts another run. If something prevents getting back to town (no TP scrolls, merc dead with no body, disconnected), `end_run` loops.
|
||||
|
||||
`end_game` saves and exits, restarts the game fresh. Use it when:
|
||||
- TP charges = 0 (bot will restock on next game start)
|
||||
- Unrecoverable in-game state
|
||||
- Max consecutive failed runs reached
|
||||
|
||||
Triggering `end_run` when TP is gone causes an infinite "No TP charges left, trying to walk back" loop (pre-fix behavior).
|
||||
|
||||
### 10.7 distance calculation (processing_helpers.py)
|
||||
|
||||
The y-center of the screen for distance math is `screen_height / 2`, NOT `screen_width / 2`. Using the wrong dimension skews distance sorting for items on the top/bottom half of the screen. Fix is already applied.
|
||||
|
||||
---
|
||||
|
||||
## 11) Bugs fixed in 2026-06-04/05 session
|
||||
|
||||
All fixes were applied and verified by Python compile/config tests:
|
||||
|
||||
| Bug | File | Symptom in logs | Fix |
|
||||
|---|---|---|---|
|
||||
| `show_belt` wrong key (`n` instead of `k`) | `config/params.ini` | "Recovered belt hotkey using 'k'" on first game, then silent in-memory mutation | `show_belt=n` → `show_belt=k` |
|
||||
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | Crash in `get_act_from_location` when `True`/`False` passed as loc | Added `isinstance(loc, str)` guard |
|
||||
| No-TP → infinite loop | `src/bot.py` | "No TP charges left, trying to walk back" repeated forever | Trigger `end_game` instead of `end_run` on zero TP |
|
||||
| Distance y-axis wrong | `src/d2r_image/processing_helpers.py` | Items sorted by wrong distance; far items picked first | `screen_width/2` → `screen_height/2` for y |
|
||||
| `on_maintenance` crash with no location | `src/bot.py` | Crash after chicken recovery when `_curr_loc=None` | Guard: default to `A1_TOWN_START` if None |
|
||||
| False-positive chicken on mana rejuv | `src/health_manager.py:133` | "Two juvs drank within 0.63s. Chicken, HP 99.9%!" | Added `and health_percentage <= take_rejuv_potion_health` |
|
||||
| Gold pickup infinite loop | `src/item/pickit.py:241` | 338g/157g alternating in logs for 20s | Added `PickedUpFailed` case to blacklist `item.ID` |
|
||||
| Health pots sold when needed | `src/inventory/personal.py:351` | "Discarding SUPER HEALING POTION." + "Confirmed sell SUPER HEALING POTION" despite health needs | Check `get_needs()` before dropping consumable; `continue` to skip sell/drop when pot is needed |
|
||||
| No fill_from_inventory after failed buy | `src/bot.py` (after line 438) | Belt empty all game despite pots sitting in inventory; "Out of gold" then nothing fills belt | After buy_consumables block, call `fill_up_belt_from_inventory` + `update_pot_needs` when needs > 0 |
|
||||
| Wrong weapon in combat after chicken mid-buff | `src/char/i_char.py` `_pre_buff_cta` | Character dies immediately; dies with CTA flail/shield instead of main weapon | Added BC skill-bar template verification after each `weapon_switch`; corrects slot if wrong at game start; retries once on failure |
|
||||
|
||||
---
|
||||
|
||||
## 12) Known pending issues (as of 2026-06-05)
|
||||
|
||||
- **C10** (IMPROVEMENTS.md): `kill_thread()` uses `PyThreadState_SetAsyncExc` — can leave locks inconsistent. Replace with `threading.Event` cooperative shutdown. High risk.
|
||||
- **optipng pass on assets/**: Pending. Run `asset_manager.py batch` or `optipng -o7` on all PNGs.
|
||||
- **Thread safety** (H14 in IMPROVEMENTS.md): `health_manager` and `death_manager` shared state — Lock is now present in HealthManager but verify all paths use it.
|
||||
- **PickedUpResult enum gap** (M14): Values are 0,1,3,4,5. Value 2 is missing. Non-critical but confusing.
|
||||
- **Gold vicious cycle**: Low gold → can't buy pots → health empty → more chickens/deaths → less gold. Monitor runs after the personal.py + bot.py fix — if the cycle still triggers, also check that `inspect_items` isn't being called with vendor_open=True before `fill_up_belt_from_inventory`.
|
||||
|
||||
---
|
||||
|
||||
## 13) Fast triage mapping
|
||||
|
||||
| Symptom in logs | Where to look | Likely cause |
|
||||
|---|---|---|
|
||||
| "Recovered belt hotkey using 'k'" on game 1, then silent | `config/params.ini` | `show_belt=n` should be `show_belt=k` |
|
||||
| "Two juvs drank... Chicken" at HP > 80% | `src/health_manager.py:133` | Missing HP check on two-rejuv condition |
|
||||
| Gold pile (XYZg) repeating 5+ times | `src/item/pickit.py:241` | `PickedUpFailed` case missing; item not blacklisted |
|
||||
| "Failed to pick up X" then same X again immediately | `_yoink_item` / `_cached_pickit_items` | Blacklist not being set on failure |
|
||||
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | isinstance guard removed or bypassed |
|
||||
| "No TP charges left, trying to walk back" (repeating) | `src/bot.py` around `end_run` | `end_run` triggered when should be `end_game` |
|
||||
| "No current location set" | `src/bot.py on_maintenance` | `_curr_loc` was None after chicken/recovery |
|
||||
| Discord embed errors | `src/messages/discord_embeds.py` | `file=` kwarg passed when attachment is None |
|
||||
| Repair fail loops | `src/town/a5.py` + `town_manager.py` | Larzuk template noise; check A4 fallback path |
|
||||
| "Failed to log exp" | `src/ui/player_bar.py` | OCR misread; check for `I/l` → `1` ambiguity |
|
||||
| Sell includes wrong items | `src/inventory/personal.py` | `protect_shields_from_sell` or item filter issue |
|
||||
| "Discarding SUPER HEALING POTION" + "Confirmed sell..." | `src/inventory/personal.py:351` | Consumable sold despite belt need — fixed by get_needs() guard |
|
||||
| Belt needs stay health=3/mana=3 game after game; pots never drunk | `src/bot.py` after buy_consumables + `personal.py:351` | Health pots sold during inspect; no fill_from_inventory fallback |
|
||||
| "started on CTA slot" in logs; dies in first seconds of run | `src/char/i_char.py _pre_buff_cta` | Game saved with CTA slot active (interrupted buff). Use `BC` template check at startup to detect and correct |
|
||||
| Character enters run at partial HP (e.g. 40% after chicken) | `src/bot.py on_maintenance` | Health manager paused in town; no town-heal loop. Check `meters.get_health` and drink belt pots in maintenance before `update_pot_needs` |
|
||||
|
||||
---
|
||||
|
||||
## 14) "Done" checklist for a fix
|
||||
|
||||
- [ ] Reproduced from logs
|
||||
- [ ] Root cause identified in source
|
||||
- [ ] Patch applied in smallest reasonable scope
|
||||
- [ ] `python -m compileall <changed_files>` passes
|
||||
- [ ] Target tests pass (or explicitly explain why unavailable)
|
||||
- [ ] Python smoke test confirms the fix (e.g., `Config().char['show_belt']`)
|
||||
- [ ] Behavior documented here or in README/params if user-visible
|
||||
|
||||
---
|
||||
|
||||
If you need to continue immediately: start from latest `main`, run a short bot session, then inspect only the newest 200–300 log lines before changing anything.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Linux Port Plan (Botty)
|
||||
|
||||
## Goal
|
||||
Make Botty runnable on Linux in phased steps, with clear checkpoints and minimal regressions for current Windows users.
|
||||
|
||||
## Current status
|
||||
Botty is currently Windows-first. Full gameplay flow does not run on Linux due to:
|
||||
- Windows input stack (`win_input`, Win32 hotkey polling).
|
||||
- Windows process/window management (`taskkill`, Win32 window APIs, `os.startfile`).
|
||||
- Windows dependency assumptions (`pywin32`, Windows tesserocr wheel guidance).
|
||||
- Windows path/env assumptions (`APPDATA`, `C:\...`, `D2R.exe`, `.bat` scripts).
|
||||
|
||||
## Principles
|
||||
- Keep Windows behavior unchanged while adding Linux support.
|
||||
- Introduce platform abstractions before replacing implementations.
|
||||
- Land small, testable phases.
|
||||
- Prefer graceful `NotImplemented` behavior over hard crashes on unsupported paths.
|
||||
|
||||
## Phase 1: Platform abstraction layer
|
||||
1. Add a `platform_adapter` module with interfaces for:
|
||||
- Input (keyboard/mouse send + hotkeys)
|
||||
- Window management (find game window, set top-most, geometry)
|
||||
- Process control (start/stop/check D2R/Battle.net)
|
||||
2. Route existing Windows calls through adapters.
|
||||
3. Add Linux stub implementations that fail gracefully with actionable logs.
|
||||
4. Add unit tests for adapter selection and fallback behavior.
|
||||
|
||||
## Phase 2: Linux-safe startup and tooling
|
||||
1. Add Linux entry script (`run_botty.sh`) and dependency checker shell script.
|
||||
2. Update startup to avoid Windows-only calls unless platform is Windows.
|
||||
3. Normalize path handling to `pathlib` where feasible.
|
||||
4. Ensure `main.py` can start on Linux without immediate import/runtime crashes.
|
||||
|
||||
## Phase 3: Linux input backend
|
||||
1. Implement Linux input backend (X11/Wayland-compatible strategy):
|
||||
- Candidate libs: `pynput`, `python-xlib`, or tool-backed approach (`xdotool` for X11).
|
||||
2. Match required Botty features:
|
||||
- Key press/hold/release
|
||||
- Mouse move/click with jitter and timing controls
|
||||
- Hotkey registration/polling
|
||||
3. Add integration tests/mocks for input primitives.
|
||||
|
||||
## Phase 4: Linux screen/window backend
|
||||
1. Validate capture compatibility for `mss` under target Linux desktop/session.
|
||||
2. Implement Linux window discovery/focus/geometry handling.
|
||||
3. Rework DPI/coordinate normalization independent of Win32 APIs.
|
||||
4. Add diagnostics tool to verify coordinates, capture ROI, and template matching on Linux.
|
||||
|
||||
## Phase 5: Process and launcher integration
|
||||
1. Linux-compatible process management (replace `taskkill` paths).
|
||||
2. Replace `os.startfile` launcher logic with cross-platform process spawning.
|
||||
3. Add platform-specific config defaults for game executable path conventions.
|
||||
|
||||
## Phase 6: Dependency and OCR strategy
|
||||
1. Split dependencies by platform (base + windows extras + linux extras).
|
||||
2. Document Linux OCR setup (tesseract/leptonica packages + python bindings).
|
||||
3. Add CI matrix entries:
|
||||
- Windows: full current pipeline
|
||||
- Linux: import/startup + unit/integration subset first, expand later
|
||||
|
||||
## Phase 7: Feature parity validation
|
||||
1. Verify end-to-end flows:
|
||||
- Start game, run cycle, maintenance, save/exit, restart handling
|
||||
2. Validate pickit, stash/sell, discord messaging, stats logging.
|
||||
3. Benchmark timing-sensitive routines and tune Linux defaults.
|
||||
|
||||
## Risk register
|
||||
- Wayland restrictions can block synthetic input/screen capture depending on compositor.
|
||||
- Template matching thresholds may differ due to capture pipeline differences.
|
||||
- Hotkey handling behavior can differ across desktop environments.
|
||||
- OCR reliability can vary based on font rendering stack.
|
||||
|
||||
## Suggested delivery milestones
|
||||
1. **M1**: Linux no-crash startup + stubs + docs.
|
||||
2. **M2**: Linux input backend functional in sandbox diagnostics.
|
||||
3. **M3**: Linux screen/window backend and maintenance loop stable.
|
||||
4. **M4**: End-to-end run support in supported Linux environments.
|
||||
|
||||
## Acceptance criteria
|
||||
- Botty starts on Linux and logs clear capability status.
|
||||
- No Windows-only hard failures on Linux code paths.
|
||||
- Core run loop can execute in a supported Linux environment.
|
||||
- Windows behavior remains stable and covered by existing tests/CI.
|
||||
@@ -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
|
||||
|
||||
@@ -22,8 +22,15 @@ for %%C in (
|
||||
)
|
||||
)
|
||||
|
||||
echo ERROR: Could not find botty conda environment.
|
||||
echo Run install.bat first.
|
||||
echo.
|
||||
echo ERROR: The 'botty' Python environment is not installed yet.
|
||||
echo.
|
||||
echo Fix: double-click install.bat in this folder and wait for
|
||||
echo "Installation complete!", then try again.
|
||||
echo.
|
||||
echo Already ran install.bat? Then it did not finish successfully.
|
||||
echo Run run_install_capture.bat and check install_log.txt for the error.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
P3
|
||||
8 8
|
||||
255
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
|
||||
0 0 0 10 10 10 10 10 10 255 0 0 0 255 0 0 0 255 0 0 0 0 0 0
|
||||
0 0 0 10 10 10 10 10 10 0 255 0 0 0 255 255 0 0 0 0 0 0 0 0
|
||||
0 0 0 10 10 10 10 10 10 0 0 255 255 255 0 0 255 255 0 0 0 0 0 0
|
||||
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
@@ -0,0 +1,6 @@
|
||||
P3
|
||||
3 3
|
||||
255
|
||||
255 0 0 0 255 0 0 0 255
|
||||
0 255 0 0 0 255 255 0 0
|
||||
0 0 255 255 255 0 0 255 255
|
||||
@@ -59,13 +59,26 @@ echo This is a one-time setup (~100 MB). Please wait.
|
||||
echo.
|
||||
|
||||
:: --- Method 1: winget (cleanest; available on Win10 1709+ and all Win11) ---
|
||||
:: --scope user is critical: without it winget defaults to a machine-wide
|
||||
:: install (lands in %ProgramData%) which REQUIRES admin. A normal double-click
|
||||
:: (no elevation) then fails silently and conda never installs. --scope user
|
||||
:: installs to %USERPROFILE%\miniforge3 with no admin needed — which is also one
|
||||
:: of the locations :rescan_conda searches.
|
||||
:: winget returns non-zero when the package is already installed, so don't
|
||||
:: trust the exit code -- rescan for conda and only fall through to the direct
|
||||
:: download if it's genuinely still missing. Keep this comment outside the block:
|
||||
:: a "::" line inside a ( ) block is a parse error.
|
||||
winget --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo Installing via winget...
|
||||
winget install --id CondaForge.Miniforge3 --exact --silent ^
|
||||
winget install --id CondaForge.Miniforge3 --exact --silent --scope user ^
|
||||
--accept-package-agreements --accept-source-agreements
|
||||
if !errorlevel! equ 0 goto :rescan_conda
|
||||
echo winget install failed — falling back to direct download.
|
||||
for %%C in (
|
||||
"%LOCALAPPDATA%\miniforge3\Scripts\conda.exe"
|
||||
"%USERPROFILE%\miniforge3\Scripts\conda.exe"
|
||||
"%ProgramData%\miniforge3\Scripts\conda.exe"
|
||||
) do if exist %%C goto :rescan_conda
|
||||
echo winget did not produce a usable conda — falling back to direct download.
|
||||
echo.
|
||||
)
|
||||
|
||||
@@ -78,6 +91,7 @@ if %errorlevel% equ 0 (
|
||||
set "MF_INSTALLER=%TEMP%\Miniforge3-installer.exe"
|
||||
set "MF_URL=https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe"
|
||||
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
echo Downloading Miniforge3...
|
||||
curl -Lk --progress-bar "%MF_URL%" -o "%MF_INSTALLER%" 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
@@ -87,23 +101,52 @@ if %errorlevel% neq 0 (
|
||||
)
|
||||
if not exist "%MF_INSTALLER%" (
|
||||
echo.
|
||||
echo ERROR: Could not download Miniforge3. Check your internet connection, or
|
||||
echo install it manually then re-run install.bat:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo ERROR: Could not download Miniforge3 ^(the Python/conda runtime^).
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo Nothing was downloaded from:
|
||||
echo %MF_URL%
|
||||
echo Usual causes: no internet, or a firewall/proxy blocking github.com.
|
||||
echo.
|
||||
echo Workaround: install Miniforge yourself, then run install.bat again.
|
||||
echo It will detect it and carry on:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo ^(pick Miniforge3-Windows-x86_64.exe and keep the default location^)
|
||||
goto :fail
|
||||
)
|
||||
|
||||
:: Verify the download is a real installer, not a truncated file or an HTML
|
||||
:: error page served with a 200. The Miniforge installer is ~78 MB; anything
|
||||
:: under 40 MB means the download failed even though a file exists.
|
||||
for %%A in ("%MF_INSTALLER%") do set "MF_SIZE=%%~zA"
|
||||
if not defined MF_SIZE set "MF_SIZE=0"
|
||||
if %MF_SIZE% LSS 41943040 (
|
||||
echo.
|
||||
echo ERROR: The Miniforge3 download is incomplete and was not run.
|
||||
echo Got: %MF_SIZE% bytes
|
||||
echo Expected: about 78 MB
|
||||
echo.
|
||||
echo The connection dropped, or a proxy/filter returned an error page
|
||||
echo instead of the file. The bad file has been deleted.
|
||||
echo.
|
||||
echo Try again on a different network, or install Miniforge yourself and
|
||||
echo re-run install.bat:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo Installing Miniforge3 (this takes ~1 minute)...
|
||||
start /wait "" "%MF_INSTALLER%" /S /InstallationType=JustMe /AddToPath=0 /RegisterPython=0 /NoShortcuts=1 /NoRegistry=1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).
|
||||
echo Try running it manually: %MF_INSTALLER%
|
||||
echo ERROR: The Miniforge3 installer did not complete ^(exit code %errorlevel%^).
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo Usual causes: antivirus blocked it, or a UAC prompt was declined.
|
||||
echo.
|
||||
echo Run it by hand to see the real error, accepting any prompts:
|
||||
echo %MF_INSTALLER%
|
||||
echo then run install.bat again.
|
||||
goto :fail
|
||||
)
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
echo Miniforge3 installed.
|
||||
@@ -127,10 +170,18 @@ for %%C in (
|
||||
goto :found_conda
|
||||
)
|
||||
)
|
||||
echo ERROR: Miniforge3 installed but conda.exe still not found. Please restart
|
||||
echo the installer or contact support.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Miniforge3 reported success but conda.exe cannot be found.
|
||||
echo.
|
||||
echo install.bat looked in every standard location, including:
|
||||
echo %%LOCALAPPDATA%%\miniforge3\Scripts\conda.exe
|
||||
echo %%USERPROFILE%%\miniforge3\Scripts\conda.exe
|
||||
echo %%ProgramData%%\miniforge3\Scripts\conda.exe
|
||||
echo.
|
||||
echo If you installed conda somewhere custom, the simplest fix is to
|
||||
echo install Miniforge to its default location:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo then run install.bat again.
|
||||
goto :fail
|
||||
|
||||
:found_conda
|
||||
echo Found conda: %CONDA_EXE%
|
||||
@@ -141,11 +192,16 @@ echo Testing conda...
|
||||
"%CONDA_EXE%" --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: conda found but failed to run. Your conda installation may be broken.
|
||||
echo Try re-installing Miniforge: https://github.com/conda-forge/miniforge
|
||||
echo ERROR: conda was found but will not run.
|
||||
echo Location: %CONDA_EXE%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo The install is damaged or blocked. Try, in order:
|
||||
echo 1. Run this by hand to see the real error:
|
||||
echo "%CONDA_EXE%" --version
|
||||
echo 2. Check antivirus is not quarantining conda
|
||||
echo 3. Reinstall Miniforge, then run install.bat again:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
goto :fail
|
||||
)
|
||||
echo conda is working.
|
||||
echo.
|
||||
@@ -168,14 +224,48 @@ for %%C in (
|
||||
)
|
||||
)
|
||||
|
||||
:: --- Pre-flight: free disk space ---
|
||||
:: The botty env is ~4 GB once conda unpacks it, plus ~1 GB of downloaded
|
||||
:: package archives. Running out midway makes conda fail with a long, opaque
|
||||
:: error, so check up front and say so plainly instead.
|
||||
set "FREE_GB="
|
||||
for /f "delims=" %%A in ('powershell -NoProfile -Command "[int]((Get-PSDrive %SystemDrive:~0,1%).Free/1GB)" 2^>nul') do set "FREE_GB=%%A"
|
||||
if defined FREE_GB (
|
||||
echo Free disk space: %FREE_GB% GB
|
||||
if %FREE_GB% LSS 3 (
|
||||
echo.
|
||||
echo ERROR: Not enough free disk space on %SystemDrive%
|
||||
echo Free now: %FREE_GB% GB
|
||||
echo Needed: about 6 GB ^(the botty environment is ~4 GB, plus
|
||||
echo ~1 GB of package downloads^)
|
||||
echo.
|
||||
echo Free up space and run install.bat again. Quick wins:
|
||||
echo - Empty the Recycle Bin
|
||||
echo - Windows Settings ^> System ^> Storage ^> Temporary files
|
||||
echo - Uninstall apps you no longer use
|
||||
goto :fail
|
||||
)
|
||||
if %FREE_GB% LSS 6 (
|
||||
echo WARNING: Only %FREE_GB% GB free. The install needs about 6 GB and
|
||||
echo may fail partway. Free up space if it does.
|
||||
)
|
||||
)
|
||||
|
||||
:: --- Create botty env ---
|
||||
echo Creating 'botty' conda environment...
|
||||
"%CONDA_EXE%" env create -f "%ENV_FILE%"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: conda env create failed. See output above.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not create the 'botty' conda environment.
|
||||
echo.
|
||||
echo The detailed reason is in the conda output above this message.
|
||||
echo Most common causes:
|
||||
echo - Out of disk space ^(needs about 6 GB free^)
|
||||
echo - No internet, or a company/school network blocking conda-forge
|
||||
echo - Antivirus blocking conda while it unpacks files
|
||||
echo - A previous half-finished install: run this to clear it, then retry
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
goto :fail
|
||||
)
|
||||
|
||||
:check_env
|
||||
@@ -198,9 +288,16 @@ for %%C in (
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ERROR: botty env was created but python.exe was not found.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: The 'botty' environment exists but its python.exe is missing.
|
||||
echo.
|
||||
echo This usually means the environment was only partly created -- often
|
||||
echo because the install ran out of disk space, or antivirus removed files
|
||||
echo while conda was unpacking them.
|
||||
echo.
|
||||
echo Fix: delete the environment and install again:
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
echo install.bat
|
||||
goto :fail
|
||||
|
||||
:env_ready
|
||||
echo Botty Python: %PYTHON%
|
||||
@@ -215,9 +312,17 @@ echo Installing Botty requirements from %REQ_FILE%...
|
||||
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off -r "%REQ_FILE%"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: pip requirements install failed for %REQ_FILE%.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not install the Python packages from %REQ_FILE%.
|
||||
echo.
|
||||
echo The failing package and reason are in the pip output above.
|
||||
echo Most common causes:
|
||||
echo - No internet, or a proxy/firewall blocking pypi.org
|
||||
echo - Out of disk space
|
||||
echo - Antivirus blocking pip while it writes files
|
||||
echo.
|
||||
echo The conda environment itself is fine -- you can just run
|
||||
echo install.bat again; it will skip straight to this step.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo.
|
||||
@@ -249,9 +354,14 @@ for %%C in (
|
||||
goto :env_found
|
||||
)
|
||||
)
|
||||
echo ERROR: Could not find botty env directory.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not locate the 'botty' environment folder.
|
||||
echo.
|
||||
echo Python was found but the environment directory around it was not,
|
||||
echo which means the conda install is in an unexpected layout.
|
||||
echo Fix: remove and recreate the environment:
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
echo install.bat
|
||||
goto :fail
|
||||
|
||||
:env_found
|
||||
set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_ENV_DIR%\DLLs;%BOTTY_ENV_DIR%\Scripts"
|
||||
@@ -267,8 +377,27 @@ set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_EN
|
||||
:: Remove any existing pip/wheel tesserocr
|
||||
"%PYTHON%" -m pip uninstall tesserocr -y >nul 2>&1
|
||||
|
||||
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs
|
||||
"%CONDA_EXE%" install -n botty "tesseract=4.*" -c conda-forge -y >nul 2>&1
|
||||
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs.
|
||||
:: libdeflate is required explicitly: the libtiff that ships with tesseract 4.x
|
||||
:: imports libdeflate, and without it the whole chain below fails to load.
|
||||
"%CONDA_EXE%" install -n botty "tesseract=4.*" libdeflate -c conda-forge -y >nul 2>&1
|
||||
|
||||
:: libdeflate.dll compatibility alias.
|
||||
:: THIS IS WHAT MAKES tesserocr WORK. The DLL chain is:
|
||||
:: tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll -> libdeflate.dll
|
||||
:: Current conda-forge libdeflate (>=1.20) installs the library as "deflate.dll",
|
||||
:: but the older tiff.dll from the tesseract=4.x stack still imports the previous
|
||||
:: name "libdeflate.dll". Nothing provides that name, so tiff.dll fails to load,
|
||||
:: and every DLL above it in the chain fails with WinError 126 ("The specified
|
||||
:: module could not be found") -- which surfaced as tesserocr being permanently
|
||||
:: unavailable and the bot silently falling back to the slower pytesseract.
|
||||
:: Copying deflate.dll to the old name satisfies the import; the exported symbols
|
||||
:: are the same library, verified by tesserocr initialising and running real OCR.
|
||||
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
|
||||
copy /y "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" >nul
|
||||
)
|
||||
)
|
||||
|
||||
:: conda tesseract.exe crashes on Win10 and Win11 — disable it, keep the DLLs
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\tesseract.exe" (
|
||||
@@ -288,23 +417,83 @@ if exist "dependencies\tesserocr.cp310-win_amd64.pyd" (
|
||||
)
|
||||
|
||||
:: --- Backend 2: pytesseract (reliable fallback) ---
|
||||
:: Needs tesseract.exe from winget. Works on any Python version.
|
||||
:: On Win10: winget may not be available -- if install fails, offer manual link.
|
||||
:: This is the backend that actually carries OCR on most machines (tesserocr's
|
||||
:: MSVC DLL chain frequently fails), so it must install without admin rights and
|
||||
:: without winget -- neither is guaranteed on a clean Win10 box.
|
||||
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off pytesseract >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo WARNING: Could not install pytesseract Python wrapper.
|
||||
)
|
||||
|
||||
if not exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
echo Installing Tesseract OCR via winget...
|
||||
winget install --id tesseract-ocr.tesseract --silent --accept-package-agreements --accept-source-agreements 2>nul
|
||||
if !errorlevel! neq 0 (
|
||||
echo winget failed -- on Windows 10 you may need to install manually:
|
||||
echo https://github.com/tesseract-ocr/tesseract/releases
|
||||
echo Download the win64 installer, run it, keep the default install path.
|
||||
:: Try winget machine scope, then user scope -- the latter needs no admin.
|
||||
:: winget's exit code is unreliable, being non-zero when the package is already
|
||||
:: installed, so after each attempt we re-resolve tesseract.exe instead of
|
||||
:: trusting errorlevel.
|
||||
:: NOTE: comments must stay OUTSIDE the parenthesised blocks below. A "::" line
|
||||
:: inside a ( ) block is a parse error, and any parenthesis in the comment text
|
||||
:: closes the block early.
|
||||
call :find_tesseract
|
||||
if not defined TESS_EXE (
|
||||
echo Installing Tesseract OCR...
|
||||
winget --version >nul 2>&1
|
||||
if !errorlevel! equ 0 (
|
||||
winget install --id tesseract-ocr.tesseract --exact --silent ^
|
||||
--accept-package-agreements --accept-source-agreements >nul 2>&1
|
||||
call :find_tesseract
|
||||
if not defined TESS_EXE (
|
||||
winget install --id tesseract-ocr.tesseract --exact --silent --scope user ^
|
||||
--accept-package-agreements --accept-source-agreements >nul 2>&1
|
||||
call :find_tesseract
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:: Last resort: direct download of the official NSIS installer. Covers clean
|
||||
:: Win10 machines where winget is missing or fails.
|
||||
:: The size check guards against a truncated download or an HTML error page;
|
||||
:: the installer is ~50 MB.
|
||||
:: NOTE on /D=: measured behaviour is that the official Tesseract installer
|
||||
:: self-elevates (it requests admin) and the elevated relaunch DISCARDS /D=, so
|
||||
:: it always lands machine-wide in "C:\Program Files\Tesseract-OCR" regardless
|
||||
:: of TS_DEST. /D= is kept as best-effort only. The practical consequence is
|
||||
:: that Tesseract needs admin/UAC -- there is no per-user install path with the
|
||||
:: official installer. Either outcome is fine at runtime because find_tesseract
|
||||
:: and src\d2r_image\ocr.py both search the machine-wide and per-user paths.
|
||||
:: /D= must still come last and unquoted, and breaks on paths with spaces, so
|
||||
:: it is only passed when the target path has none.
|
||||
if not defined TESS_EXE (
|
||||
echo winget unavailable or failed -- downloading Tesseract directly...
|
||||
set "TS_INSTALLER=%TEMP%\tesseract-setup.exe"
|
||||
set "TS_URL=https://github.com/tesseract-ocr/tesseract/releases/download/5.5.0/tesseract-ocr-w64-setup-5.5.0.20241111.exe"
|
||||
set "TS_DEST=%LOCALAPPDATA%\Programs\Tesseract-OCR"
|
||||
del /q "!TS_INSTALLER!" >nul 2>&1
|
||||
curl -Lk --progress-bar "!TS_URL!" -o "!TS_INSTALLER!" 2>&1
|
||||
if not exist "!TS_INSTALLER!" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$ProgressPreference='SilentlyContinue'; try { Invoke-WebRequest -Uri '!TS_URL!' -OutFile '!TS_INSTALLER!' -UseBasicParsing; exit 0 } catch { exit 1 }"
|
||||
)
|
||||
set "TS_SIZE=0"
|
||||
if exist "!TS_INSTALLER!" for %%A in ("!TS_INSTALLER!") do set "TS_SIZE=%%~zA"
|
||||
if !TS_SIZE! GEQ 20971520 (
|
||||
echo !TS_DEST! | find " " >nul
|
||||
if !errorlevel! equ 0 (
|
||||
start /wait "" "!TS_INSTALLER!" /S
|
||||
) else (
|
||||
start /wait "" "!TS_INSTALLER!" /S /D=!TS_DEST!
|
||||
)
|
||||
del /q "!TS_INSTALLER!" >nul 2>&1
|
||||
call :find_tesseract
|
||||
)
|
||||
)
|
||||
|
||||
if defined TESS_EXE (
|
||||
echo Tesseract: !TESS_EXE!
|
||||
) else (
|
||||
echo WARNING: Tesseract could not be installed automatically. Install manually:
|
||||
echo https://github.com/tesseract-ocr/tesseract/releases
|
||||
echo Download the win64 installer, run it, then re-run install.bat.
|
||||
)
|
||||
|
||||
:: --- Verify OCR: at least one backend must work ---
|
||||
echo.
|
||||
echo Checking OCR backends...
|
||||
@@ -319,8 +508,8 @@ if %errorlevel% == 0 (
|
||||
echo tesserocr: not available ^(DLL issue -- bot will use pytesseract instead^)
|
||||
)
|
||||
|
||||
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'C:\Program Files\Tesseract-OCR\tesseract.exe'; pytesseract.get_tesseract_version()" >nul 2>&1
|
||||
if defined TESS_EXE (
|
||||
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'!TESS_EXE!'; pytesseract.get_tesseract_version()" >nul 2>&1
|
||||
if !errorlevel! == 0 (
|
||||
echo pytesseract: OK ^(reliable fallback^)
|
||||
set "OCR_READY=1"
|
||||
@@ -384,3 +573,52 @@ echo Installation complete!
|
||||
echo Run botty with: run_botty.bat
|
||||
echo ============================================
|
||||
echo.
|
||||
:: Hold the window open. Every failure path already pauses, but the success
|
||||
:: path did not -- so a user double-clicking install.bat from Explorer saw the
|
||||
:: console vanish the instant it finished and never got to read the result or
|
||||
:: the dependency/OCR verification above. Redirected runs are unaffected:
|
||||
:: run_install_capture.bat feeds stdin from the log redirect, and any
|
||||
:: non-interactive run should invoke this script with "< nul".
|
||||
pause
|
||||
goto :eof
|
||||
|
||||
:: --- Shared failure exit ---
|
||||
:: Every fatal path jumps here with "goto :fail" after printing what went wrong
|
||||
:: and how to fix it. This adds the one instruction that makes a bug report
|
||||
:: actionable: how to produce a full log.
|
||||
:fail
|
||||
echo.
|
||||
echo ------------------------------------------------------------
|
||||
echo INSTALL FAILED - nothing else was changed on your PC.
|
||||
echo.
|
||||
echo Still stuck? Produce a full log and include it when asking
|
||||
echo for help:
|
||||
echo 1. Double-click run_install_capture.bat
|
||||
echo 2. Attach the install_log.txt it creates
|
||||
echo ------------------------------------------------------------
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:: --- Resolve tesseract.exe into TESS_EXE ---
|
||||
:: Checks machine-wide (winget default / manual install) and per-user (winget
|
||||
:: --scope user / our direct NSIS fallback) locations, plus PATH. Sets TESS_EXE
|
||||
:: to the first hit, or clears it if none found.
|
||||
:find_tesseract
|
||||
set "TESS_EXE="
|
||||
for %%T in (
|
||||
"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
|
||||
"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
|
||||
"%ProgramData%\Tesseract-OCR\tesseract.exe"
|
||||
) do (
|
||||
if exist %%T (
|
||||
set "TESS_EXE=%%~T"
|
||||
goto :eof
|
||||
)
|
||||
)
|
||||
for /f "delims=" %%T in ('where tesseract 2^>nul') do (
|
||||
set "TESS_EXE=%%T"
|
||||
goto :eof
|
||||
)
|
||||
goto :eof
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "botty"
|
||||
version = "0.8.1"
|
||||
version = "0.10.0"
|
||||
description = "Pixelbot for Diablo 2 Resurrected"
|
||||
requires-python = ">=3.10,<3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[pytest]
|
||||
pythonpath =
|
||||
.
|
||||
src
|
||||
env =
|
||||
PYTHONPATH=./src
|
||||
PYTHONPATH=./src:.
|
||||
RUN_ENV=test
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiohappyeyeballs==2.6.2
|
||||
pywin32==311; sys_platform=="win32"
|
||||
aiohttp==3.14.1
|
||||
aiosignal==1.4.0
|
||||
async-timeout==5.0.1
|
||||
|
||||
@@ -17,9 +17,16 @@ set "CONDA_PREFIX=%_ENV%"
|
||||
set "PYTHONUTF8=1"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
set "SSL_CERT_DIR="
|
||||
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation)
|
||||
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation).
|
||||
:: Only export the path if it actually exists -- on machines where Tesseract was
|
||||
:: installed per-user (no admin), it lives under %LOCALAPPDATA%\Programs instead,
|
||||
:: and src\d2r_image\ocr.py resolves that itself.
|
||||
set "TESSDATA_PREFIX=%_ENV%\Library\share"
|
||||
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
) else if exist "%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe" (
|
||||
set "PYTESSERACT_TESSERACT_CMD=%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
|
||||
)
|
||||
|
||||
echo Launching Botty ...
|
||||
"%PYTHON%" "%BOTTY_DIR%src\main.py"
|
||||
|
||||
@@ -1,2 +1,18 @@
|
||||
@echo off
|
||||
call "%~dp0install.bat" > "%~dp0install_log.txt" 2>&1
|
||||
:: Runs install.bat and captures everything to install_log.txt for support.
|
||||
:: stdin is fed from nul so install.bat's trailing "pause" (and any failure
|
||||
:: pause) cannot silently block behind the redirected output -- otherwise the
|
||||
:: user would see an empty window waiting on a keypress they cannot see.
|
||||
echo Installing and writing a full log to install_log.txt ...
|
||||
echo This can take several minutes. Please wait.
|
||||
call "%~dp0install.bat" < nul > "%~dp0install_log.txt" 2>&1
|
||||
set "RC=%ERRORLEVEL%"
|
||||
echo.
|
||||
if "%RC%"=="0" (
|
||||
echo Install finished. Full log: "%~dp0install_log.txt"
|
||||
) else (
|
||||
echo Install FAILED with exit code %RC%. Send this file for support:
|
||||
echo "%~dp0install_log.txt"
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
|
||||
|
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 |
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
"""Hermes bot control — send commands to botty via TCP socket on 127.0.0.1:18899.
|
||||
|
||||
Usage:
|
||||
python scripts/hermes_bot_control.py start # start/pause bot
|
||||
python scripts/hermes_bot_control.py pause # toggle pause
|
||||
python scripts/hermes_bot_control.py stop # stop bot
|
||||
python scripts/hermes_bot_control.py status # get bot status
|
||||
python scripts/hermes_bot_control.py logs [n] # last n log lines
|
||||
python scripts/hermes_bot_control.py errors [n] # last n error lines
|
||||
python scripts/hermes_bot_control.py runs # run stats
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import glob
|
||||
import socket
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||
LOG_FILE = os.path.join(PROJECT_ROOT, 'log', 'log.txt')
|
||||
SOCKET_PORT = 18899
|
||||
|
||||
def send_command(cmd):
|
||||
"""Send a command to the bot's control socket."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(3.0)
|
||||
s.connect(('127.0.0.1', SOCKET_PORT))
|
||||
s.sendall(cmd.encode())
|
||||
response = s.recv(1024).decode().strip()
|
||||
s.close()
|
||||
if response:
|
||||
print(response)
|
||||
else:
|
||||
print(f"OK: command '{cmd}' sent")
|
||||
except socket.timeout:
|
||||
print(f"ERROR: no response from bot (socket timeout)")
|
||||
except ConnectionRefusedError:
|
||||
print(f"ERROR: bot not listening on port {SOCKET_PORT} (is it running?)")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
def read_logs(n=20):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
print("No log file found")
|
||||
return
|
||||
result = subprocess.run(["tail", "-n", str(n), LOG_FILE],
|
||||
capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
|
||||
def read_errors(n=10):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
print("No log file found")
|
||||
return
|
||||
result = subprocess.run(["grep", "-E", "ERROR|WARNING|Failed|failed|ERROR.*step", LOG_FILE],
|
||||
capture_output=True, text=True)
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for line in lines[-n:]:
|
||||
print(line)
|
||||
|
||||
def run_stats():
|
||||
stats_dir = os.path.join(PROJECT_ROOT, 'log', 'stats')
|
||||
stats_files = glob.glob(os.path.join(stats_dir, 'stats_*.log'))
|
||||
if stats_files:
|
||||
latest = max(stats_files, key=os.path.getmtime)
|
||||
with open(latest) as f:
|
||||
print(f.read())
|
||||
else:
|
||||
print("No stats files found")
|
||||
|
||||
def check_status():
|
||||
send_command('status')
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd in ('start', 'pause', 'stop'):
|
||||
send_command(cmd)
|
||||
elif cmd == 'status':
|
||||
check_status()
|
||||
elif cmd == 'logs':
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||
read_logs(n)
|
||||
elif cmd == 'errors':
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
read_errors(n)
|
||||
elif cmd == 'runs':
|
||||
run_stats()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
@@ -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)
|
||||
|
||||
@@ -1278,14 +1278,14 @@ class Hammerdin(Paladin):
|
||||
Logger.debug("Waiting for Diablo to appear...")
|
||||
start = time.time()
|
||||
diablo_found = False
|
||||
while (time.time() - start) < 15:
|
||||
while (time.time() - start) < 20:
|
||||
if get_visible_targets():
|
||||
diablo_found = True
|
||||
Logger.info("Diablo has spawned, engaging!")
|
||||
break
|
||||
wait(0.5, 0.6)
|
||||
if not diablo_found:
|
||||
Logger.warning("Diablo did not appear within 15s, attacking anyway")
|
||||
Logger.warning("Diablo did not appear within 20s, attacking anyway")
|
||||
### ATTACK WITH CONCENTRATION ###
|
||||
# Concentration is the Blessed Hammer damage synergy and a party aura the
|
||||
# merc benefits from. Conviction does nothing for magic-damage hammers, and
|
||||
@@ -1295,10 +1295,17 @@ class Hammerdin(Paladin):
|
||||
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
|
||||
Logger.debug("Attacking Diablo at position 1/1")
|
||||
self._cast_hammers(Config().char["atk_len_diablo"], "concentration")
|
||||
# Re-verify targets mid-fight; if Diablo moved, reposition
|
||||
if get_visible_targets():
|
||||
pos_m = convert_abs_to_monitor((0, 0))
|
||||
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
|
||||
self._move_and_attack((60, 30), Config().char["atk_len_diablo"], "concentration")
|
||||
self._move_and_attack((-60, -30), Config().char["atk_len_diablo"], "concentration")
|
||||
wait(0.1, 0.15)
|
||||
self._cast_hammers(1.2, "redemption")
|
||||
# Final redemption burst to ensure kill
|
||||
wait(0.1, 0.2)
|
||||
self._cast_hammers(0.8, "redemption")
|
||||
### LOOT ###
|
||||
# force=True: Diablo is dead; his death animation/lingering effects register
|
||||
# as targets and the mobs-alive guard would skip his drops entirely.
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -5,7 +5,8 @@ from d2r_image.processing_data import Runeword
|
||||
try:
|
||||
from rapidfuzz.string_metric import levenshtein
|
||||
except ImportError:
|
||||
from rapidfuzz.distance import Levenshtein as levenshtein
|
||||
from rapidfuzz.distance import Levenshtein as _Levenshtein_mod
|
||||
levenshtein = _Levenshtein_mod.distance
|
||||
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
|
||||
from bnip.NTIPAliasStat import NTIPAliasStat as NTIP_STATS
|
||||
from logger import Logger
|
||||
|
||||
@@ -46,6 +46,11 @@ try:
|
||||
os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release
|
||||
shutil.which("tesseract"),
|
||||
r"C:\Program Files\Tesseract-OCR\tesseract.exe",
|
||||
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
|
||||
# Per-user installs (winget --scope user, or install.bat's direct NSIS
|
||||
# fallback) land here and are not on PATH -- needed on machines where
|
||||
# the user has no admin rights.
|
||||
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Tesseract-OCR", "tesseract.exe"),
|
||||
]
|
||||
_cmd = next((c for c in _candidates if c and os.path.isfile(c)), None)
|
||||
if _cmd:
|
||||
|
||||
@@ -169,8 +169,8 @@ class GameController:
|
||||
Logger.warning("Your D2R settings differ from the requiered ones. Please use Auto Settings to adjust them. The differences are:")
|
||||
Logger.warning(f"{diff}")
|
||||
set_d2r_always_on_top()
|
||||
if enforce_d2r_window(5, 98):
|
||||
find_and_set_window_position(force=True)
|
||||
enforce_d2r_window(5, 98)
|
||||
find_and_set_window_position(force=True)
|
||||
self.setup_screen()
|
||||
self.start_health_manager_thread()
|
||||
self.start_death_manager_thread()
|
||||
|
||||
@@ -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]):
|
||||
@@ -196,8 +219,70 @@ class HealthManager:
|
||||
if belt.drink_potion("health", merc=True, stats=[merc_health]):
|
||||
self._last_merc_heal = time.time()
|
||||
|
||||
# Close any open panels that might block detection
|
||||
# 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
|
||||
@@ -205,7 +290,15 @@ class HealthManager:
|
||||
self._do_chicken(img)
|
||||
continue
|
||||
Logger.debug("Found an open panel. Closing it.")
|
||||
common.close()
|
||||
# Send Escape directly — more reliable than common.close() which
|
||||
# only checks inventory_is_open() and may miss belt/panel states
|
||||
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
|
||||
@@ -244,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:
|
||||
|
||||
@@ -7,13 +7,101 @@ Import as:
|
||||
|
||||
This is a drop-in replacement for the existing `import keyboard` and
|
||||
`from utils.custom_mouse import mouse` patterns.
|
||||
|
||||
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.
|
||||
"""
|
||||
from .win_input import (
|
||||
_get_vk, key_down, key_up, key_press, send_key, key_state,
|
||||
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
|
||||
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
|
||||
)
|
||||
from .mouse_impl import mouse
|
||||
import re as _re
|
||||
from logger import Logger
|
||||
import os as _os
|
||||
import threading as _threading
|
||||
|
||||
if _os.name == "nt":
|
||||
from .win_input import (
|
||||
_get_vk, key_down, key_up, key_press, send_key, key_state,
|
||||
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
|
||||
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
|
||||
)
|
||||
# bridge_input has no send_key; provide stub
|
||||
def send_key(key):
|
||||
key_press(key)
|
||||
|
||||
if _os.name == "nt":
|
||||
from .mouse_impl import mouse
|
||||
else:
|
||||
# In bridge mode, mouse_impl imports from win_input which won't work.
|
||||
# Provide a thin wrapper that delegates to bridge_input.
|
||||
class _BridgeMouse:
|
||||
def move_to(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)
|
||||
def wheel(self, clicks): mouse_wheel(clicks)
|
||||
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:
|
||||
"""
|
||||
@@ -74,16 +162,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)
|
||||
@@ -99,9 +193,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')
|
||||
@@ -144,8 +248,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)
|
||||
|
||||
@@ -176,11 +282,22 @@ class _Keyboard:
|
||||
|
||||
def add_hotkey(self, key: str, callback, suppress: bool = False):
|
||||
"""Register a global hotkey callback."""
|
||||
if _os.name != "nt":
|
||||
# In Docker, hotkeys are not available — no-op
|
||||
return
|
||||
from .hotkey import add_hotkey as _add_hotkey
|
||||
_add_hotkey(key, callback, suppress=suppress)
|
||||
|
||||
def wait(self, key: str = None, suppress: bool = False):
|
||||
"""Block until the key is pressed. If key is None, wait for any key."""
|
||||
if _os.name != "nt":
|
||||
# In Docker, block forever (or until SIGTERM) — bot runs headless
|
||||
import signal
|
||||
event = _threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: event.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: event.set())
|
||||
event.wait()
|
||||
return
|
||||
from .hotkey import wait as _wait
|
||||
return _wait(key, suppress=suppress)
|
||||
|
||||
@@ -190,11 +307,15 @@ class _Keyboard:
|
||||
|
||||
def hook(self, callback, suppress: bool = False):
|
||||
"""Register a callback for all key events (dev tools only)."""
|
||||
if _os.name != "nt":
|
||||
return
|
||||
from .hotkey import hook as _hook
|
||||
return _hook(callback, suppress=suppress)
|
||||
|
||||
def pause(self, seconds: float = 0, suppress: bool = False):
|
||||
"""Pause key processing (used by npc_auto_label.py)."""
|
||||
if _os.name != "nt":
|
||||
return
|
||||
from .hotkey import pause as _pause
|
||||
return _pause(seconds, suppress)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Global hotkey polling via GetAsyncKeyState.
|
||||
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
|
||||
No kernel driver - pure user-mode polling thread.
|
||||
Global hotkey via keyboard library's WH_KEYBOARD_LL hook.
|
||||
Intercepts all keystrokes regardless of which window has focus.
|
||||
Falls back to GetAsyncKeyState polling if the keyboard library
|
||||
fails to install its hook (e.g. antivirus interference).
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
@@ -9,24 +10,81 @@ import ctypes
|
||||
from ctypes import wintypes
|
||||
from .win_input import _get_vk, VK_MAP, user32
|
||||
|
||||
try:
|
||||
import keyboard as _keyboard
|
||||
_HAS_KEYBOARD = True
|
||||
except Exception:
|
||||
_HAS_KEYBOARD = False
|
||||
|
||||
|
||||
class _HotkeyManager:
|
||||
def __init__(self):
|
||||
self._callbacks = {} # vk -> [(key_str, callback), ...]
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._suppress = {} # vk -> bool (suppress key after callback fires)
|
||||
self._suppress = {} # vk -> bool
|
||||
self._lock = threading.Lock()
|
||||
self._suppressed = set() # vks currently being held down in suppress mode
|
||||
self._held = set() # vks seen down on the previous poll (edge-trigger)
|
||||
self._held = set()
|
||||
self._suppressed = set()
|
||||
self._poll_thread = None
|
||||
# Track which keys are registered with the keyboard library
|
||||
self._keyboard_callbacks = {} # vk -> keyboard callback wrapper
|
||||
|
||||
def _ensure_running(self):
|
||||
if not self._running:
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
|
||||
if _HAS_KEYBOARD:
|
||||
# Use keyboard library's global hook - works even when D2R has focus
|
||||
try:
|
||||
self._keyboard_hook = _keyboard.hook(self._keyboard_callback, suppress=False)
|
||||
return
|
||||
except Exception:
|
||||
pass # Fall through to polling
|
||||
|
||||
# Fallback: GetAsyncKeyState polling (only works when bot has focus)
|
||||
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._poll_thread.start()
|
||||
|
||||
def _keyboard_callback(self, event):
|
||||
"""Callback from keyboard library's global hook."""
|
||||
if event.event_type != _keyboard.KEY_DOWN:
|
||||
return
|
||||
|
||||
# Map keyboard event name to VK code
|
||||
vk = None
|
||||
try:
|
||||
# keyboard library uses names like 'f11', 'f12', etc.
|
||||
vk = _get_vk(event.name)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if vk is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if vk not in self._callbacks:
|
||||
return
|
||||
if vk in self._held or vk in self._suppressed:
|
||||
return
|
||||
self._held.add(vk)
|
||||
entries = list(self._callbacks[vk])
|
||||
|
||||
for key_str, cb in entries:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if self._suppress.get(vk, False):
|
||||
self._suppressed.add(vk)
|
||||
# Suppress: block the key from reaching the app
|
||||
event.suppress()
|
||||
break
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Poll GetAsyncKeyState for registered hotkeys."""
|
||||
"""Fallback: poll GetAsyncKeyState when global hook is unavailable."""
|
||||
while self._running:
|
||||
with self._lock:
|
||||
items = list(self._callbacks.items())
|
||||
@@ -36,7 +94,6 @@ class _HotkeyManager:
|
||||
self._held.discard(vk)
|
||||
continue
|
||||
if vk in self._held or vk in self._suppressed:
|
||||
# Still held since last poll - fire only on the down edge
|
||||
continue
|
||||
self._held.add(vk)
|
||||
for key_str, cb in entries:
|
||||
@@ -48,20 +105,18 @@ class _HotkeyManager:
|
||||
if self._suppress.get(vk, False):
|
||||
self._suppressed.add(vk)
|
||||
|
||||
# Wait for key release if suppressed
|
||||
if self._suppressed:
|
||||
still_suppressed = set()
|
||||
for vk in self._suppressed:
|
||||
state = user32.GetAsyncKeyState(vk)
|
||||
if not (state & 0x8000):
|
||||
# Key released
|
||||
pass
|
||||
else:
|
||||
still_suppressed.add(vk)
|
||||
self._suppressed = still_suppressed
|
||||
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
|
||||
_wait(0.018, 0.024)
|
||||
|
||||
def add_hotkey(self, key: str, callback, suppress: bool = False):
|
||||
vk = _get_vk(key)
|
||||
@@ -115,15 +170,12 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.018, 0.024)
|
||||
else:
|
||||
# keyboard.wait() with no key blocks forever (keeps main thread alive
|
||||
# while the daemon hotkey/bot threads run) - match that semantic.
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
|
||||
def hook(self, callback, suppress: bool = False):
|
||||
"""Register a callback for all key events.
|
||||
This is a simplified version - polls all known keys and calls callback.
|
||||
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
|
||||
Simplified polling version for dev tools."""
|
||||
def _poll_all():
|
||||
while self._running:
|
||||
for vk in range(1, 256):
|
||||
@@ -142,7 +194,6 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.02, 0.02)
|
||||
self._ensure_running()
|
||||
# Run hook in its own thread
|
||||
t = threading.Thread(target=_poll_all, daemon=True)
|
||||
t.start()
|
||||
|
||||
@@ -151,6 +202,16 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(seconds, seconds)
|
||||
|
||||
def stop(self):
|
||||
"""Uninstall the hook and stop threads."""
|
||||
self._running = False
|
||||
if _HAS_KEYBOARD and hasattr(self, '_keyboard_hook'):
|
||||
try:
|
||||
_keyboard.unhook(self._keyboard_hook)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Singleton
|
||||
_hotkey_manager = _HotkeyManager()
|
||||
|
||||
@@ -173,3 +234,6 @@ def hook(callback, suppress: bool = False):
|
||||
|
||||
def pause(seconds: float = 0, suppress: bool = False):
|
||||
return _hotkey_manager.pause(seconds, suppress)
|
||||
|
||||
def stop_hotkeys():
|
||||
_hotkey_manager.stop()
|
||||
@@ -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."""
|
||||
|
||||
@@ -54,6 +54,12 @@ def open(img: np.ndarray = None) -> np.ndarray:
|
||||
opened = _try_open_with_click()
|
||||
if not opened:
|
||||
Logger.warning("Could not open belt after key and click recovery attempts")
|
||||
# Force-close any partially-open belt to prevent panel detection from triggering chicken
|
||||
try:
|
||||
keyboard.send("esc")
|
||||
wait(0.2, 0.3)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
img = grab()
|
||||
return img
|
||||
|
||||
@@ -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()
|
||||
@@ -80,6 +111,11 @@ def on_exit(controllers: Controllers):
|
||||
Logger.warning(f"Failed to save session report: {e}")
|
||||
screen.stop_detecting_window()
|
||||
restore_d2r_window_visibility()
|
||||
try:
|
||||
from input_layer.hotkey import stop_hotkeys
|
||||
stop_hotkeys()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(1)
|
||||
|
||||
def _log_platform_info():
|
||||
@@ -92,8 +128,11 @@ def _log_platform_info():
|
||||
Logger.info(f"Environment profile: {os_info.environment_file}")
|
||||
Logger.info(f"Requirements profile: {os_info.requirements_file}")
|
||||
|
||||
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
|
||||
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
|
||||
try:
|
||||
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
|
||||
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
|
||||
except ImportError:
|
||||
mouse_mode = "bridge (Docker)"
|
||||
Logger.info(f"Mouse input mode: {mouse_mode}")
|
||||
|
||||
# OCR backend check
|
||||
@@ -143,15 +182,19 @@ def main():
|
||||
startup_checks()
|
||||
|
||||
# Auto-launch D2R only when explicitly enabled in params.ini (auto_login=1)
|
||||
from utils.restart import process_exists, restart_game
|
||||
if not process_exists("D2R.exe"):
|
||||
if Config().general["auto_login"]:
|
||||
Logger.info("D2R is not running, launching with auto-login...")
|
||||
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
|
||||
# In Docker, D2R runs on the Windows host — skip process checks
|
||||
if os.name == "nt":
|
||||
from utils.restart import process_exists, restart_game
|
||||
if not process_exists("D2R.exe"):
|
||||
if Config().general["auto_login"]:
|
||||
Logger.info("D2R is not running, launching with auto-login...")
|
||||
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
|
||||
else:
|
||||
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
|
||||
else:
|
||||
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
|
||||
Logger.info("D2R is already running")
|
||||
else:
|
||||
Logger.info("D2R is already running")
|
||||
Logger.info("Running in Docker — D2R process check skipped (bridge server handles host interaction)")
|
||||
|
||||
print(f"============ Botty {__version__} [name: {Config().general['name']}] ============")
|
||||
_profiles = Config.list_profiles()
|
||||
@@ -201,6 +244,20 @@ def main():
|
||||
keyboard.add_hotkey(Config().advanced_options['resume_key'], lambda: start_or_pause_bot(controllers))
|
||||
keyboard.add_hotkey(Config().advanced_options["exit_key"], lambda: on_exit(controllers))
|
||||
|
||||
# Hermes Agent control socket — TCP server on localhost:18899
|
||||
# Accepts commands: start, pause, stop, status
|
||||
try:
|
||||
_hermes_socket = __import__('socket').socket(__import__('socket').AF_INET, __import__('socket').SOCK_STREAM)
|
||||
_hermes_socket.setsockopt(__import__('socket').SOL_SOCKET, __import__('socket').SO_REUSEADDR, 1)
|
||||
_hermes_socket.settimeout(1.0)
|
||||
_hermes_socket.bind(('127.0.0.1', 18899))
|
||||
_hermes_socket.listen(5)
|
||||
_hermes_socket.setblocking(False)
|
||||
Logger.info("Hermes control socket listening on 127.0.0.1:18899")
|
||||
except Exception as _e:
|
||||
Logger.debug(f"Hermes control socket failed: {_e}")
|
||||
_hermes_socket = None
|
||||
|
||||
def _cycle_profile():
|
||||
profiles = Config.list_profiles()
|
||||
if not profiles:
|
||||
@@ -261,7 +318,63 @@ def main():
|
||||
f.write(content)
|
||||
keyboard.add_hotkey(Config().advanced_options['cycle_pickit_profile_key'], _cycle_pickit_profile)
|
||||
|
||||
keyboard.wait()
|
||||
# In Docker, auto-start the bot instead of waiting for hotkey
|
||||
if os.name != "nt":
|
||||
Logger.info("Docker mode — auto-starting bot")
|
||||
screen.start_detecting_window()
|
||||
controllers.game.start()
|
||||
# Wait for SIGTERM/SIGINT to shut down
|
||||
keyboard.wait()
|
||||
else:
|
||||
# Poll loop: checks hermes control socket + waits for keyboard events
|
||||
import threading
|
||||
_shutdown_event = threading.Event()
|
||||
|
||||
def _hermes_poll():
|
||||
"""Poll hermes control socket for commands."""
|
||||
import select
|
||||
while not _shutdown_event.is_set():
|
||||
try:
|
||||
if _hermes_socket is None:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
rlist, _, _ = select.select([_hermes_socket], [], [], 0.5)
|
||||
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()
|
||||
_reply = handle_hermes_command(data, controllers)
|
||||
if _reply is not None:
|
||||
conn.sendall(_reply.encode())
|
||||
conn.close()
|
||||
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:
|
||||
threading.Thread(target=_hermes_poll, daemon=True).start()
|
||||
|
||||
keyboard.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -276,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']
|
||||
|
||||
@@ -303,6 +303,12 @@ def open_npc_menu(npc_key: Npc) -> bool:
|
||||
wait(0.3, 0.4)
|
||||
return True
|
||||
return False
|
||||
# Also close the waypoint panel — an open WP covers the center of the screen
|
||||
# and prevents NPC template matching.
|
||||
if is_visible(ScreenObjects.WaypointLabel, grab()):
|
||||
Logger.debug("open_npc_menu: closing waypoint panel before NPC search")
|
||||
keyboard.send("esc")
|
||||
wait(0.2, 0.3)
|
||||
_close_open_panels()
|
||||
# Search for npc name tags by hovering to all template locations that are found
|
||||
start = time.time()
|
||||
@@ -430,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
|
||||
|
||||
@@ -10,3 +10,4 @@ from .baal import Baal
|
||||
from .mephisto import Mephisto
|
||||
from .andariel import Andariel
|
||||
from .countess import Countess
|
||||
from .cold_plains import ColdPlains
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Cold Plains clear — leave town by waypoint and kill everything with one
|
||||
configured attack skill.
|
||||
|
||||
Unlike the boss runs this has no fixed path and no boss. It roams the area,
|
||||
casts a single configured skill at whatever `target_detect` reports, loots, and
|
||||
returns. Two consequences worth knowing:
|
||||
|
||||
* It works with ANY build, because it sends its own hotkey rather than going
|
||||
through the build's `_skill_hotkeys`. A blizz_sorc profile can run this with
|
||||
Fire Bolt long before Blizzard (clvl 24) exists.
|
||||
* It works from clvl 1, which the boss runs do not — those assume endgame
|
||||
damage and, mostly, teleport.
|
||||
|
||||
PREREQUISITE: the destination waypoint must already be discovered on the
|
||||
character. A fresh character has none, so walk to Cold Plains once by hand
|
||||
first; after that this run is self-sufficient.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
|
||||
from char import IChar
|
||||
from config import Config
|
||||
from input_layer import keyboard, mouse
|
||||
from inventory import belt as belt_mod
|
||||
from item.pickit import PickIt
|
||||
from logger import Logger
|
||||
from pather import Pather, Location
|
||||
from screen import convert_abs_to_monitor
|
||||
from target_detect import get_visible_targets
|
||||
from town.town_manager import TownManager
|
||||
from ui import meters, waypoint
|
||||
from utils.misc import wait
|
||||
|
||||
|
||||
class ColdPlains:
|
||||
"""Roam an outdoor area and kill with one configured skill."""
|
||||
|
||||
name = "run_cold_plains"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pather: Pather,
|
||||
town_manager: TownManager,
|
||||
char: IChar,
|
||||
pickit: PickIt,
|
||||
runs: OrderedDict
|
||||
):
|
||||
self._pather = pather
|
||||
self._town_manager = town_manager
|
||||
self._char = char
|
||||
self._pickit = pickit
|
||||
self._runs = runs
|
||||
self.approach_fail_step: str | None = None
|
||||
|
||||
cfg = Config().cold_plains
|
||||
self._area = str(cfg.get("area", "Cold Plains")).strip() or "Cold Plains"
|
||||
self._attack_hotkey = str(cfg.get("attack_hotkey", "")).strip()
|
||||
self._attack_button = str(cfg.get("attack_button", "right")).strip().lower()
|
||||
self._max_steps = int(cfg.get("max_steps", 12))
|
||||
self._casts_per_target = int(cfg.get("casts_per_target", 4))
|
||||
self._target_radius = int(cfg.get("target_radius", 600))
|
||||
self._cast_delay = float(cfg.get("cast_delay", 0.25))
|
||||
self._max_runtime_s = float(cfg.get("max_runtime_s", 180))
|
||||
# Engagements per step, so a target we cannot actually kill (immune,
|
||||
# out of reach, a misdetection) cannot pin the run in place forever.
|
||||
self._max_engagements = int(cfg.get("max_engagements", 8))
|
||||
# Self-sustain for levelling chars: drink belt potions during the run
|
||||
# instead of waiting for town (a lvl 1 sorc dies before it gets there).
|
||||
self._chicken = float(Config().char.get("chicken", 0.5))
|
||||
self._take_mana = float(Config().char.get("take_mana_potion", 0.3))
|
||||
self._last_mana_check = 0.0
|
||||
# True once we've confirmed the character has no teleport at all —
|
||||
# then the end-of-run trip home is a walk, not a TP.
|
||||
self._no_tp = False
|
||||
|
||||
def approach(self, start_loc: Location, do_pre_buff: bool) -> bool | Location:
|
||||
self.approach_fail_step = None
|
||||
|
||||
# Fail loudly here rather than roaming harmlessly for 3 minutes doing
|
||||
# no damage — with no hotkey there is nothing to attack with.
|
||||
if not self._attack_hotkey:
|
||||
self.approach_fail_step = "no_attack_hotkey"
|
||||
Logger.error(
|
||||
"Cold Plains approach: [cold_plains] attack_hotkey is empty — "
|
||||
"set it to the key holding your attack skill"
|
||||
)
|
||||
return False
|
||||
|
||||
loc = self._town_manager.go_to_act(1, start_loc)
|
||||
if not loc:
|
||||
self.approach_fail_step = "go_to_act1"
|
||||
Logger.error("Cold Plains approach: go_to_act(1) failed")
|
||||
return False
|
||||
|
||||
if do_pre_buff:
|
||||
self._char.pre_buff()
|
||||
|
||||
# A levelling char has no teleport until clvl 18 — the end-of-run trip
|
||||
# home is a walk, not a TP. Cache it so battle() can skip the TP wait.
|
||||
self._no_tp = (not Config().char["teleport"]
|
||||
and not self._char.capabilities.can_teleport_natively
|
||||
and not self._char.capabilities.can_teleport_with_charges)
|
||||
|
||||
if not self._town_manager.open_wp(loc):
|
||||
self.approach_fail_step = "open_wp"
|
||||
Logger.error("Cold Plains approach: open_wp failed")
|
||||
return False
|
||||
wait(0.4, 0.6)
|
||||
|
||||
if not waypoint.use_wp(label=self._area):
|
||||
self.approach_fail_step = f"use_wp_{self._area.lower().replace(' ', '_')}"
|
||||
Logger.error(f"Cold Plains approach: use_wp({self._area!r}) failed")
|
||||
return False
|
||||
|
||||
# No pather node for the open areas — the bot TPs home at end of run,
|
||||
# so report the town it will arrive back in.
|
||||
return Location.A1_TOWN_START
|
||||
|
||||
def return_to_town(self) -> Location:
|
||||
"""Walk back to town (no-TP levelling chars) or no-op (TP chars).
|
||||
|
||||
The bot's on_end_run() normally TPs home. A pre-clvl-18 character has
|
||||
no teleport, so this walks the pather's A1 outdoor->town nodes
|
||||
(705 -> 702) in reverse, then verifies with a town-marker scan. The
|
||||
pather's auto-recovery sweep handles mis-positioning — if a node
|
||||
template can't be found, it walks an expanding pattern until it
|
||||
re-acquires. If the pather fails entirely, fall back to the last
|
||||
known town location: the next maintenance's open_wp() re-detects the
|
||||
physical act and traverses from wherever the character actually is.
|
||||
"""
|
||||
if not self._no_tp:
|
||||
return Location.A1_TOWN_START
|
||||
Logger.info(f" {self._area}: no teleport — walking back to town via pather")
|
||||
# Walk the A1 outdoor->town path in reverse: 705 (south WP) -> 702
|
||||
# (north WP). The pather's auto-recovery sweep handles mis-positioning.
|
||||
if self._pather.traverse_nodes([705, 702], self._char, timeout=5.0, force_move=True):
|
||||
Logger.info(f" {self._area}: pather walk-back complete")
|
||||
else:
|
||||
Logger.warning(f" {self._area}: pather walk-back failed — continuing anyway")
|
||||
# Verify we're actually in town before reporting success.
|
||||
if self._town_manager.detect_current_act(timeout=3.0):
|
||||
return Location.A1_TOWN_START
|
||||
Logger.warning(f" {self._area}: no town marker after walk-back — continuing anyway")
|
||||
return Location.A1_TOWN_START
|
||||
|
||||
def battle(self) -> bool | tuple[Location, bool]:
|
||||
picked_up_items = False
|
||||
deadline = time.time() + self._max_runtime_s
|
||||
Logger.info(f"=== {self._area} clear: {self._max_steps} steps, key={self._attack_hotkey} ===")
|
||||
|
||||
for step in range(self._max_steps):
|
||||
if time.time() > deadline:
|
||||
Logger.info(f" {self._area}: runtime budget reached, heading back")
|
||||
break
|
||||
|
||||
# Self-sustain: drink belt potions before fighting. A lvl 1 sorc
|
||||
# dies before it can walk back to town, so the belt is the only
|
||||
# heal source mid-run. Mana is checked less often (cast_delay
|
||||
# already paces the firebolt spam).
|
||||
self._drink_if_needed()
|
||||
|
||||
targets = get_visible_targets(radius_max=self._target_radius)
|
||||
if targets:
|
||||
Logger.info(f" Step {step + 1}/{self._max_steps}: {len(targets)} target(s)")
|
||||
self._clear_visible(deadline)
|
||||
picked_up_items |= self._pickit.pick_up_items(self._char)
|
||||
else:
|
||||
Logger.debug(f" Step {step + 1}/{self._max_steps}: nothing visible, roaming")
|
||||
self._roam()
|
||||
|
||||
return (Location.A1_TOWN_START, picked_up_items)
|
||||
|
||||
def _drink_if_needed(self) -> None:
|
||||
"""Drink belt potions when HP/mana drop below the configured thresholds."""
|
||||
from screen import grab
|
||||
img = grab()
|
||||
hp = meters.get_health(img)
|
||||
if hp <= self._chicken:
|
||||
Logger.info(f" {self._area}: HP {hp * 100:.0f}% <= chicken {self._chicken * 100:.0f}% — drinking health")
|
||||
if not belt_mod.drink_potion("health", stats=[hp, meters.get_mana(img)]):
|
||||
Logger.warning(f" {self._area}: no health potion in belt at HP {hp * 100:.0f}%")
|
||||
wait(1.5, 2.0) # let the potion tick
|
||||
return
|
||||
now = time.time()
|
||||
if now - self._last_mana_check > 3.0:
|
||||
self._last_mana_check = now
|
||||
mp = meters.get_mana(img)
|
||||
if mp <= self._take_mana:
|
||||
Logger.info(f" {self._area}: mana {mp * 100:.0f}% <= {self._take_mana * 100:.0f}% — drinking mana")
|
||||
if not belt_mod.drink_potion("mana", stats=[hp, mp]):
|
||||
Logger.warning(f" {self._area}: no mana potion in belt at mana {mp * 100:.0f}%")
|
||||
wait(1.0, 1.5)
|
||||
|
||||
def _clear_visible(self, deadline: float) -> None:
|
||||
"""Cast at the nearest target until the area is clear or patience runs out."""
|
||||
for _ in range(self._max_engagements):
|
||||
if time.time() > deadline:
|
||||
return
|
||||
# A potion drink can leave the char at low HP mid-fight — re-check
|
||||
# before every engagement so we don't walk into a pack on 20%.
|
||||
self._drink_if_needed()
|
||||
targets = get_visible_targets(radius_max=self._target_radius)
|
||||
if not targets:
|
||||
return
|
||||
self._cast_at(targets[0].center_monitor)
|
||||
|
||||
def _cast_at(self, pos_monitor: tuple[float, float], spray: int = 8) -> None:
|
||||
"""Select the configured skill, then cast it at a position a few times."""
|
||||
keyboard.send(self._attack_hotkey)
|
||||
wait(0.06, 0.12)
|
||||
for _ in range(self._casts_per_target):
|
||||
x = pos_monitor[0] + random.randint(-spray, spray)
|
||||
y = pos_monitor[1] + random.randint(-spray, spray)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[0.2, 0.4])
|
||||
mouse.click(button=self._attack_button)
|
||||
wait(self._cast_delay, self._cast_delay * 1.4)
|
||||
|
||||
def _roam(self) -> None:
|
||||
"""Step in a random direction to find new mobs.
|
||||
|
||||
y is halved because D2's projection is isometric — an equal x/y step
|
||||
moves visibly further vertically than it does horizontally.
|
||||
"""
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
dist = random.randint(150, 260)
|
||||
pos_abs = (math.cos(angle) * dist, math.sin(angle) * dist * 0.5)
|
||||
self._char.walk(convert_abs_to_monitor(pos_abs), force_move=True)
|
||||
wait(0.9, 1.4)
|
||||
@@ -32,7 +32,7 @@ class Pindle:
|
||||
match = template_finder.search_and_wait_stable(
|
||||
self._PINDLE_AREA_TEMPLATES,
|
||||
threshold=0.62,
|
||||
timeout=timeout,
|
||||
timeout=max(timeout, 5.0),
|
||||
confirmations=2,
|
||||
suppress_debug=True,
|
||||
)
|
||||
@@ -65,14 +65,40 @@ class Pindle:
|
||||
found_loading_screen_func = lambda: loading.wait_for_loading_screen(2.0)
|
||||
# Re-detect window before template search
|
||||
find_and_set_window_position(force=True)
|
||||
# NO pre-click "are we already there?" shortcut. Harrogath scenery around the
|
||||
# portal scores 0.76-0.79 on PINDLE_7 — above its 0.62 bar — so that shortcut
|
||||
# fired in town, returned A5_PINDLE_START without ever clicking the portal, and
|
||||
# the bot "killed Pindle" in town: five straight runs reported success with zero
|
||||
# loot and zero XP. Entry must be proven by the portal click's loading screen.
|
||||
if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False):
|
||||
if self._verify_in_pindle_area():
|
||||
if do_pre_buff:
|
||||
self._char.pre_buff()
|
||||
return Location.A5_PINDLE_START
|
||||
Logger.warning("Pindle approach: first red portal click failed, retrying from town start")
|
||||
Logger.warning("Pindle approach: first red portal click failed, re-verifying position before retry")
|
||||
find_and_set_window_position(force=True)
|
||||
if not self._pather.traverse_nodes((Location.A5_TOWN_START, Location.A5_NIHLATHAK_PORTAL), self._char):
|
||||
# Never re-run A5 node pathing from an assumed location. The failed portal
|
||||
# click leaves the char somewhere unknown, and the old hardcoded
|
||||
# A5_TOWN_START start walked it into the Harrogath wall — and onto the
|
||||
# waypoint, whose panel the health manager then read as a chicken.
|
||||
retry_loc = self._town_manager.detect_current_act()
|
||||
if retry_loc is None:
|
||||
# No town marker in view. That is NOT evidence we left the act — it happens
|
||||
# routinely by the portal in Harrogath's NE corner, where none of the
|
||||
# TOWN_MARKERS render. `loc` was confirmed to be act 5 at the top of this
|
||||
# run and a failed portal click cannot move us between acts, so reuse it.
|
||||
# Treating None as "wrong act" hard-failed 6 runs and burned the 5-strike
|
||||
# circuit breaker, ending an otherwise healthy 31-game session.
|
||||
Logger.debug("Pindle approach: no town marker in view — reusing the act-5 location confirmed at run start")
|
||||
retry_loc = loc
|
||||
elif retry_loc != Location.A5_TOWN_START:
|
||||
Logger.warning(f"Pindle approach: not confirmed in A5 (detected {retry_loc}) — traveling to act 5")
|
||||
retry_loc = self._town_manager.go_to_act(5, retry_loc)
|
||||
if not retry_loc:
|
||||
self.approach_fail_step = "retry_traverse_to_portal"
|
||||
Logger.error("Pindle approach: could not confirm A5 town position for retry")
|
||||
return False
|
||||
if not self._pather.traverse_nodes((retry_loc, Location.A5_NIHLATHAK_PORTAL), self._char):
|
||||
self.approach_fail_step = "retry_traverse_to_portal"
|
||||
return False
|
||||
wait(0.5, 0.7)
|
||||
|
||||
@@ -7,6 +7,8 @@ import template_finder
|
||||
from utils.misc import wait
|
||||
from ui_manager import ScreenObjects, is_visible
|
||||
from logger import Logger
|
||||
from config import Config
|
||||
from input_layer import mouse
|
||||
|
||||
|
||||
class A5(IAct):
|
||||
@@ -89,11 +91,19 @@ class A5(IAct):
|
||||
# Thresholds verified 2026-06-11: stash scores ~0.51 at current D2R settings
|
||||
# (templates captured on older rendering). Safe to go low: stash_is_open_func
|
||||
# gates success, so a false-positive click just fails the gate and retries.
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.60, timeout=4.0, telekinesis=True):
|
||||
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.45, timeout=4.0, telekinesis=True):
|
||||
return False
|
||||
return Location.A5_STASH
|
||||
# Try progressively lower thresholds: 0.60 -> 0.50 -> 0.40
|
||||
for threshold in (0.60, 0.50, 0.40):
|
||||
if self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=threshold, timeout=4.0, telekinesis=True):
|
||||
return Location.A5_STASH
|
||||
Logger.debug(f"A5 stash: threshold {threshold} failed, retrying lower")
|
||||
# Final fallback: direct click on stash area without template, just click center
|
||||
# of screen where stash chest should be after pathing to A5_STASH location
|
||||
Logger.warning("A5 stash: all template thresholds failed, trying direct center click")
|
||||
mouse.click(button="left")
|
||||
wait(1.0, 1.5)
|
||||
if stash_is_open_func():
|
||||
return Location.A5_STASH
|
||||
return False
|
||||
|
||||
def open_trade_and_repair_menu(self, curr_loc: Location) -> Location | bool:
|
||||
from ui_manager import wait_until_visible
|
||||
@@ -137,15 +147,46 @@ class A5(IAct):
|
||||
deadline = time.time() + self._OPEN_WP_BUDGET_S
|
||||
found_wp_func = lambda: is_visible(ScreenObjects.WaypointLabel)
|
||||
|
||||
def _try_click_wp(threshold: float = 0.55) -> bool:
|
||||
# The waypoint is a WORLD object, so never let the HUD compete. main's recaptured
|
||||
# a5_wp.png matches the belt/mana-orb area at 0.966 — above threshold and higher
|
||||
# than the real stone — so every search locked onto (980, 656), clicked the HUD,
|
||||
# and open_wp burned ~83s escalating through anchors before giving up. That took
|
||||
# buy_consumables to 222-253s and tripped the 240s maintenance timeout.
|
||||
_wp_roi = Config().ui_roi["cut_skill_bar"]
|
||||
# Search BOTH waypoint captures and take the best. main recaptured a5_wp.png
|
||||
# (125x112); on this client it only reaches ~0.50-0.58 on the real stone while the
|
||||
# older capture (a5_wp_2.png, 201x120) historically hit 0.73-0.98. Rather than pick
|
||||
# a winner and break the other setup, let best_match decide per frame.
|
||||
_wp_tpl = ["A5_WP", "A5_WP_2"]
|
||||
|
||||
def _try_click_wp(threshold: float = 0.55, require_visible: bool = False) -> bool:
|
||||
wait(0.4, 0.6)
|
||||
# Direct full-screen scan first — if the WP stone is on screen, walk onto
|
||||
# it and click, regardless of how we got here.
|
||||
wp_match = template_finder.search("A5_WP", grab(), threshold=threshold)
|
||||
if wp_match.valid:
|
||||
wp_match = template_finder.search(_wp_tpl, grab(), threshold=threshold, roi=_wp_roi, best_match=True)
|
||||
if not wp_match.valid:
|
||||
# Nothing on screen. For the speculative pre-scan below, stop here:
|
||||
# calling select_by_template anyway just spins out its 4s timeout and
|
||||
# logs "Wanted to select A5_WP, but could not find it" before we have
|
||||
# even tried walking there. That fired 321 times in one session (~9.4s
|
||||
# and two scary ERROR lines per waypoint use) purely because the char
|
||||
# was still at town start rather than standing on the stone.
|
||||
if require_visible:
|
||||
return False
|
||||
else:
|
||||
self._char.move(wp_match.center_monitor, force_move=True)
|
||||
wait(0.4, 0.6)
|
||||
return self._char.select_by_template("A5_WP", found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True)
|
||||
return self._char.select_by_template(_wp_tpl, found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True, roi=_wp_roi)
|
||||
|
||||
# 0) Immediate direct WP scan — after TP-back from Pindle the char often spawns
|
||||
# near the WP and a quick scan avoids all pathing issues from stale curr_loc.
|
||||
# Use lower threshold (0.45) first to catch degraded template matches, then
|
||||
# retry at 0.55 if needed. Both are speculative: skip instantly when the
|
||||
# stone is not actually on screen.
|
||||
if _try_click_wp(threshold=0.45, require_visible=True):
|
||||
return True
|
||||
if _try_click_wp(threshold=0.55, require_visible=True):
|
||||
return True
|
||||
|
||||
# 1) Direct node path from the believed location.
|
||||
if self._pather.traverse_nodes((curr_loc, Location.A5_WP), self._char, force_move=True):
|
||||
@@ -176,7 +217,7 @@ class A5(IAct):
|
||||
# the WP may now be on screen — try a direct scan.
|
||||
if _try_click_wp(threshold=0.50):
|
||||
return True
|
||||
# Anchors exhausted within budget. Last resort: direct WP scan from current
|
||||
# Anchors exhausted within budget. Last resort: direct WP scan from current
|
||||
# position. If the WP stone is on screen despite the path failing, click it.
|
||||
if time.time() < deadline:
|
||||
Logger.warning("A5 open_wp: anchors failed — trying direct WP scan from current position")
|
||||
|
||||
@@ -19,8 +19,24 @@ TOWN_MARKERS = [
|
||||
"A1_TOWN_1", "A1_TOWN_3"
|
||||
]
|
||||
|
||||
# marker -> the act-town Location it belongs to, so detect_current_act() can compare a
|
||||
# candidate against the best marker from a DIFFERENT act.
|
||||
TOWN_MARKERS_ACT = {
|
||||
m: (Location.A5_TOWN_START if m.startswith("A5_") else
|
||||
Location.A4_TOWN_START if m.startswith("A4_") else
|
||||
Location.A3_TOWN_START if m.startswith("A3_") else
|
||||
Location.A2_TOWN_START if m.startswith("A2_") else
|
||||
Location.A1_TOWN_START)
|
||||
for m in TOWN_MARKERS
|
||||
}
|
||||
|
||||
class TownManager:
|
||||
|
||||
# How far the winning town marker must beat the best marker from another act before
|
||||
# detect_current_act() will commit. Measured failure: A5_TOWN_1 0.636 vs A4_TOWN_5
|
||||
# 0.619 while standing in Act 4 — 1.7pp. Anything under this is a coin flip.
|
||||
_ACT_DETECT_MARGIN = 0.05
|
||||
|
||||
def __init__(self, a1: A1, a2: A2, a3: A3, a4: A4, a5: A5):
|
||||
self._acts: dict[Location, IAct] = {
|
||||
Location.A1_TOWN_START: a1,
|
||||
@@ -70,7 +86,35 @@ class TownManager:
|
||||
save_debug=True,
|
||||
)
|
||||
if template_match.valid:
|
||||
return TownManager.get_act_from_location(template_match.name)
|
||||
act = TownManager.get_act_from_location(template_match.name)
|
||||
# Bug 28 again, in its twin. detect_current_act was fixed to require a
|
||||
# margin over the best marker from any OTHER act; this function was left
|
||||
# committing to whatever best_match returned. Measured live 2026-08-28:
|
||||
# the character spawned in Harrogath and this reported a4_town_start every
|
||||
# game, so the bot hunted an A4 waypoint that was not there and
|
||||
# go_to_act(5) failed with the char already standing in Act 5.
|
||||
#
|
||||
# Same rule as detect_current_act: a marginal winner is not evidence.
|
||||
if act is not None:
|
||||
# Score BOTH sides on one image. Comparing the winner's score from
|
||||
# the match above against a rival scored on a later grab is not a
|
||||
# comparison at all — the character may have moved between them.
|
||||
img = grab(force_new=True)
|
||||
own = [m for m in TOWN_MARKERS if TOWN_MARKERS_ACT.get(m) == act]
|
||||
rivals = [m for m in TOWN_MARKERS if TOWN_MARKERS_ACT.get(m) != act]
|
||||
mine = template_finder.search(own, img, threshold=0.01, best_match=True)
|
||||
rival = template_finder.search(rivals, img, threshold=0.01, best_match=True)
|
||||
margin = (mine.score or 0.0) - (rival.score or 0.0)
|
||||
if margin < self._ACT_DETECT_MARGIN:
|
||||
Logger.warning(
|
||||
f"wait_for_town_spawn: ambiguous — {mine.name} "
|
||||
f"{(mine.score or 0)*100:.1f}% vs {rival.name} {(rival.score or 0)*100:.1f}% "
|
||||
f"(margin {margin*100:.1f}pp); deferring to detect_current_act"
|
||||
)
|
||||
# Fall through to the caller's detection path rather than
|
||||
# committing. A wrong act here poisons the whole game.
|
||||
return None
|
||||
return act
|
||||
return None
|
||||
|
||||
def detect_current_act(self, timeout: float = 6) -> Location | None:
|
||||
@@ -81,14 +125,40 @@ class TownManager:
|
||||
whatever act it save+exited from, so a single desync poisons every later game
|
||||
if locations are assumed instead of verified.
|
||||
"""
|
||||
template_match = template_finder.search_and_wait(
|
||||
TOWN_MARKERS, best_match=True, timeout=timeout, suppress_debug=True
|
||||
)
|
||||
if template_match.valid:
|
||||
act = TownManager.get_act_from_location(template_match.name)
|
||||
Logger.debug(f"detect_current_act: matched {template_match.name} -> {act}")
|
||||
return act
|
||||
Logger.warning("detect_current_act: no town marker found — character may not be in town")
|
||||
import time
|
||||
deadline = time.time() + timeout
|
||||
closest = None
|
||||
while True:
|
||||
img = grab(force_new=True)
|
||||
best = template_finder.search(TOWN_MARKERS, img, threshold=0.01, best_match=True)
|
||||
act = TownManager.get_act_from_location(best.name) if best.name else None
|
||||
if act is not None and best.score >= 0.68:
|
||||
# A marginal winner is not evidence. In Act 4 the A5_TOWN_1 template
|
||||
# scores 0.636 on scenery in the top-right corner while the genuine
|
||||
# A4_TOWN_5 scores 0.619 — a 1.7pp gap decided the act, and the bot ran
|
||||
# A5 pathing inside Act 4 after every Halbu repair trip. Require the
|
||||
# winner to beat the best marker from ANY OTHER act by a real margin.
|
||||
rivals = [m for m in TOWN_MARKERS if TOWN_MARKERS_ACT.get(m) != act]
|
||||
rival = template_finder.search(rivals, img, threshold=0.01, best_match=True)
|
||||
margin = best.score - (rival.score or 0.0)
|
||||
if margin >= self._ACT_DETECT_MARGIN:
|
||||
Logger.debug(f"detect_current_act: matched {best.name} ({best.score*100:.1f}%) -> {act}")
|
||||
return act
|
||||
closest = (best.name, best.score, rival.name, rival.score, margin)
|
||||
if time.time() >= deadline:
|
||||
break
|
||||
wait(0.2, 0.3)
|
||||
if closest:
|
||||
bn, bs, rn, rs, mg = closest
|
||||
# Ambiguous is NOT the same as "wrong act". Callers treat None as "keep the
|
||||
# assumed act", which makes go_to_act actually travel instead of wrongly
|
||||
# early-returning — the safe outcome.
|
||||
Logger.warning(
|
||||
f"detect_current_act: ambiguous — {bn} {bs*100:.1f}% vs {rn} {rs*100:.1f}% "
|
||||
f"(margin {mg*100:.1f}pp < {self._ACT_DETECT_MARGIN*100:.0f}pp); refusing to guess the act"
|
||||
)
|
||||
else:
|
||||
Logger.warning("detect_current_act: no town marker found — character may not be in town")
|
||||
return None
|
||||
|
||||
def wait_for_tp(self, curr_loc: Location):
|
||||
@@ -255,6 +325,43 @@ class TownManager:
|
||||
if curr_act is None:
|
||||
Logger.error(f"resurrect: could not determine act from location {curr_loc}")
|
||||
return False
|
||||
# Prefer A4 Tyrael over A5 Qual-Kehk when configured. Tyrael stands on a fixed spot
|
||||
# next to the A4 waypoint and detects reliably; Qual-Kehk is the worst NPC in the
|
||||
# route. Measured over 2026-08-27:
|
||||
# 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
|
||||
# A failed Qual-Kehk hunt costs more than the whole A5->A4 waypoint trip, and one
|
||||
# 163s hunt took the town visit past the 240s maintenance timeout and killed the
|
||||
# game outright.
|
||||
resurrect_pref = (Config().char.get("resurrect_npc") or "").lower()
|
||||
if curr_act == Location.A5_TOWN_START and resurrect_pref in ("a4_tyrael", "tyrael", "act4"):
|
||||
Logger.info("Resurrect preference is Act 4 Tyrael — traveling from A5")
|
||||
a4_loc = self.go_to_act(4, curr_loc)
|
||||
if a4_loc:
|
||||
set_panel_check_paused(True)
|
||||
res = self._acts[Location.A4_TOWN_START].resurrect(a4_loc)
|
||||
if res:
|
||||
return res
|
||||
set_panel_check_paused(False)
|
||||
# Travelled but failed — leave the breadcrumb so the caller re-anchors to
|
||||
# A4 rather than assuming A5 (Bug 9).
|
||||
self.last_known_loc = a4_loc
|
||||
Logger.warning("Resurrect: Tyrael failed after travelling to A4")
|
||||
return False
|
||||
# Do NOT fall back to Qual-Kehk. Measured across the whole log:
|
||||
# A5 Qual-Kehk 0 successful resurrects, 49 hunt timeouts
|
||||
# A4 Tyrael 3 successful resurrects
|
||||
# The fallback is not a second chance, it is a guaranteed ~100s loss,
|
||||
# and the retry above doubles it. On 2026-08-28 that pair spent 213s
|
||||
# and took the town visit past the 240s maintenance timeout, killing
|
||||
# a game that was otherwise fine. Running mercless for one game is
|
||||
# far cheaper; the merc is re-checked next game.
|
||||
Logger.warning(
|
||||
"Resurrect: could not travel to A4 for Tyrael — skipping the resurrect "
|
||||
"rather than falling back to Qual-Kehk (0 successes in 49 attempts). "
|
||||
"Continuing mercless this game."
|
||||
)
|
||||
return False
|
||||
# check if we can resurrect in current act
|
||||
if self._acts[curr_act].can_resurrect():
|
||||
set_panel_check_paused(True)
|
||||
@@ -458,12 +565,29 @@ class TownManager:
|
||||
common.close()
|
||||
return new_loc, items
|
||||
return False, False
|
||||
Logger.info("TownManager repair: going to A5 for repair")
|
||||
new_loc = self.go_to_act(5, curr_loc)
|
||||
# The current act cannot repair (e.g. A3 on a Travincal route), so we must
|
||||
# travel. This used to be hardcoded to A5/Larzuk, which meant repair_npc was
|
||||
# ignored on every route that does not already stand in A5 — the preference
|
||||
# is only consulted in the A5 branch above. On run_trav that cost 22-37s per
|
||||
# repair, and when Larzuk's menu failed to open the whole trip ran twice
|
||||
# (~84s) and stranded the char in the wrong act, failing the following
|
||||
# open_wp. Honour repair_npc here too.
|
||||
_repair_dest = {
|
||||
"a1_charsi": (1, Location.A1_TOWN_START, "Charsi"),
|
||||
"charsi": (1, Location.A1_TOWN_START, "Charsi"),
|
||||
"act1": (1, Location.A1_TOWN_START, "Charsi"),
|
||||
"a4_halbu": (4, Location.A4_TOWN_START, "Halbu"),
|
||||
"halbu": (4, Location.A4_TOWN_START, "Halbu"),
|
||||
"act4": (4, Location.A4_TOWN_START, "Halbu"),
|
||||
}
|
||||
act_num, act_loc, npc_name = _repair_dest.get(
|
||||
repair_preference, (5, Location.A5_TOWN_START, "Larzuk"))
|
||||
Logger.info(f"TownManager repair: going to A{act_num} for repair ({npc_name})")
|
||||
new_loc = self.go_to_act(act_num, curr_loc)
|
||||
if not new_loc: return False, False
|
||||
Logger.info("TownManager repair: opening Larzuk trade/repair menu")
|
||||
Logger.info(f"TownManager repair: opening {npc_name} trade/repair menu")
|
||||
set_panel_check_paused(True)
|
||||
new_loc = self._acts[Location.A5_TOWN_START].open_trade_and_repair_menu(new_loc)
|
||||
new_loc = self._acts[act_loc].open_trade_and_repair_menu(new_loc)
|
||||
Logger.info(f"TownManager repair: open_trade_and_repair_menu returned {new_loc}")
|
||||
if not new_loc:
|
||||
set_panel_check_paused(False)
|
||||
|
||||
@@ -239,28 +239,62 @@ class Transmute:
|
||||
|
||||
# ── New-UI (D2R named-tab stash) gem helpers ──────────────────────────
|
||||
|
||||
# Named-tab stash UI (1280x720). 5 tabs ~76px wide starting at x=33:
|
||||
# PERSONAL≈71 SHARED≈147 GEMS≈223 MATERIALS≈299 RUNES≈375
|
||||
PERSONAL_TAB_X = 71
|
||||
GEMS_TAB_X = 223
|
||||
GEMS_TAB_Y = 100
|
||||
# Named-tab stash UI (1280x720), measured off the live client:
|
||||
# labels occupy the row y=63..78, so TAB_Y must sit on that row. It was 100, which is
|
||||
# ~20px BELOW the tab bar (the stash grid starts at y=87) — every "switch tab" click
|
||||
# landed on a stash slot, the tab never changed, and the whole gem conversion ran in
|
||||
# whichever tab happened to be open (PERSONAL). It failed silently for 800+ transmutes.
|
||||
# PERSONAL 68 | SHARED 144 | GEMS 220 | MATERIALS 295 | RUNES 370
|
||||
TAB_Y = 70
|
||||
PERSONAL_TAB_X = 68
|
||||
SHARED_TAB_X = 144
|
||||
GEMS_TAB_X = 220
|
||||
MATERIALS_TAB_X = 295
|
||||
RUNES_TAB_X = 370
|
||||
# The active tab is drawn with a lighter cell background. Measure the BACKGROUND
|
||||
# (30th percentile of the label cell), not the glyphs: text brightness varies with
|
||||
# label length — "PERSONAL" peaks at 215 while an active "GEMS" only reaches 167, so
|
||||
# a glyph-based threshold misreads short labels as inactive.
|
||||
# Measured across both states: active bg ≈ 67-68, inactive bg ≈ 38-39.
|
||||
TAB_ACTIVE_BG = 52
|
||||
GEMS_CONVERT_BUTTON = (225, 500)
|
||||
GEMS_CONVERT_PANEL_ROI = (160, 296, 128, 160)
|
||||
GEMS_CONVERT_FIRST_SLOT = (181, 318)
|
||||
|
||||
def _switch_to_personal_tab(self) -> None:
|
||||
x, y = convert_screen_to_monitor((self.PERSONAL_TAB_X, self.GEMS_TAB_Y))
|
||||
mouse.move(x, y)
|
||||
self._wait()
|
||||
mouse.click("left")
|
||||
wait(0.4, 0.5)
|
||||
def _tab_is_active(self, tab_x: int, img: np.ndarray = None) -> bool:
|
||||
"""True if the stash tab centred on tab_x is the selected one."""
|
||||
img = grab() if img is None else img
|
||||
label = img[63:78, max(0, tab_x - 34):tab_x + 34]
|
||||
if label.size == 0:
|
||||
return False
|
||||
gray = cv2.cvtColor(label, cv2.COLOR_BGR2GRAY)
|
||||
return float(np.percentile(gray, 30)) > self.TAB_ACTIVE_BG
|
||||
|
||||
def _switch_to_gems_tab(self) -> None:
|
||||
x, y = convert_screen_to_monitor((self.GEMS_TAB_X, self.GEMS_TAB_Y))
|
||||
mouse.move(x, y)
|
||||
self._wait()
|
||||
mouse.click("left")
|
||||
wait(0.4, 0.5)
|
||||
def _switch_to_tab(self, tab_x: int, name: str, attempts: int = 3) -> bool:
|
||||
"""Click a stash tab and confirm it actually became active.
|
||||
|
||||
Verified rather than fire-and-forget: an unverified tab click is what let gem
|
||||
conversion run in the wrong tab indefinitely without a single error line.
|
||||
"""
|
||||
for attempt in range(attempts):
|
||||
if self._tab_is_active(tab_x):
|
||||
return True
|
||||
x, y = convert_screen_to_monitor((tab_x, self.TAB_Y))
|
||||
mouse.move(x, y)
|
||||
self._wait()
|
||||
mouse.click("left")
|
||||
wait(0.4, 0.5)
|
||||
if self._tab_is_active(tab_x):
|
||||
return True
|
||||
Logger.debug(f" {name} tab did not activate (attempt {attempt + 1}/{attempts})")
|
||||
Logger.error(f"_switch_to_tab: could not activate the {name} stash tab")
|
||||
return False
|
||||
|
||||
def _switch_to_personal_tab(self) -> bool:
|
||||
return self._switch_to_tab(self.PERSONAL_TAB_X, "PERSONAL")
|
||||
|
||||
def _switch_to_gems_tab(self) -> bool:
|
||||
return self._switch_to_tab(self.GEMS_TAB_X, "GEMS")
|
||||
|
||||
def _click_gems_tab_convert_button(self) -> None:
|
||||
"""Click the native convert button in the GEMS tab panel."""
|
||||
@@ -866,8 +900,11 @@ class Transmute:
|
||||
Logger.error("convert_all_gems: stash not open")
|
||||
return
|
||||
|
||||
# Switch to GEMS tab before counting and planning.
|
||||
self._switch_to_gems_tab()
|
||||
# Switch to GEMS tab before counting and planning. Abort if it did not take —
|
||||
# counting and converting in the wrong tab is worse than not running at all.
|
||||
if not self._switch_to_gems_tab():
|
||||
Logger.error("convert_all_gems: GEMS tab not active — aborting instead of converting in the wrong tab")
|
||||
return
|
||||
wait(0.4, 0.5)
|
||||
|
||||
Logger.info("convert_all_gems: reading gem counts via OCR")
|
||||
@@ -910,7 +947,9 @@ class Transmute:
|
||||
if not self._reopen_stash():
|
||||
Logger.error(" could not reopen stash -- aborting")
|
||||
return
|
||||
self._switch_to_gems_tab()
|
||||
if not self._switch_to_gems_tab():
|
||||
Logger.error(f" [{i+1}] GEMS tab not active -- aborting rather than clicking blind")
|
||||
return
|
||||
wait(0.3, 0.4)
|
||||
|
||||
if not self._ensure_gems_convert_panel_empty():
|
||||
|
||||
@@ -236,7 +236,7 @@ def _select_char_by_ocr(char_name: str) -> bool:
|
||||
if result and result[0].text:
|
||||
detected = result[0].text.strip().lower()
|
||||
Logger.debug(f"Row {row}: OCR detected '{detected}'")
|
||||
# OCR mangles names badly ('fistman' reads as 'fabiman'), so
|
||||
# OCR mangles names badly ('profile1' reads as 'pro1file1'), so
|
||||
# fuzzy-match the first word of the row against the char name.
|
||||
import difflib
|
||||
first_line = detected.splitlines()[0].strip() if detected else ""
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Game browser: join a public game from the D2R main menu.
|
||||
|
||||
Flow: Play button (hover) -> Join Game tab -> scan game list (OCR) -> click game -> loading screen.
|
||||
|
||||
No dedicated templates exist for the browser UI, so this module relies on:
|
||||
- ScreenObjects.PlayBtn for the Play button (existing template)
|
||||
- Coordinate-based clicking for the "Join Game" tab (right of Play button)
|
||||
- OCR for reading game names in the list
|
||||
- ScreenObjects.Loading / InGame for confirming the join succeeded
|
||||
"""
|
||||
import time
|
||||
from input_layer import mouse
|
||||
from utils.misc import wait, cut_roi
|
||||
from logger import Logger
|
||||
from ui_manager import detect_screen_object, is_visible, ScreenObjects
|
||||
from screen import grab, convert_screen_to_monitor, find_and_set_window_position, stop_detecting_window, start_detecting_window
|
||||
from d2r_image import ocr
|
||||
|
||||
# Game list area (screen coords, 1280x720 client). The list occupies the center
|
||||
# of the screen when the Join Game tab is active. Rows are ~28px tall.
|
||||
_GAME_LIST_ROI = (150, 130, 980, 480)
|
||||
_ROW_HEIGHT = 28
|
||||
_JOIN_TAB_OFFSET_X = 360 # "Join Game" tab is ~360px right of the Play button center
|
||||
|
||||
|
||||
def _ocr_row(img, roi) -> str:
|
||||
"""OCR a single game-list row. Returns cleaned text or '' on failure."""
|
||||
try:
|
||||
row_img = cut_roi(img, roi)
|
||||
result = ocr.image_to_text(
|
||||
images=row_img,
|
||||
model="hover-eng_inconsolata_inv_th_fast",
|
||||
psm=7,
|
||||
scale=1.2,
|
||||
crop_pad=False,
|
||||
erode=False,
|
||||
invert=False,
|
||||
threshold=0,
|
||||
digits_only=False,
|
||||
fix_regexps=True,
|
||||
check_known_errors=False,
|
||||
correct_words=False,
|
||||
)
|
||||
if result and result[0].text:
|
||||
return result[0].text.strip()
|
||||
except Exception as e:
|
||||
Logger.debug(f"game_browser OCR row failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _scan_game_list(img) -> list[dict]:
|
||||
"""Scan the game list area and return a list of {name, row, y_center} entries."""
|
||||
x, y, w, h = _GAME_LIST_ROI
|
||||
games = []
|
||||
num_rows = max(1, h // _ROW_HEIGHT)
|
||||
for row in range(num_rows):
|
||||
row_y = y + row * _ROW_HEIGHT
|
||||
row_h = min(_ROW_HEIGHT + 4, y + h - row_y)
|
||||
if row_h < 5:
|
||||
break
|
||||
text = _ocr_row(img, [x, row_y, w, row_h])
|
||||
if text:
|
||||
# Filter out obvious non-game text (headers, empty)
|
||||
cleaned = text.replace(" ", "").lower()
|
||||
if len(cleaned) >= 2 and not cleaned.isdigit():
|
||||
games.append({"name": text, "row": row, "y_center": row_y + row_h // 2})
|
||||
return games
|
||||
|
||||
|
||||
def _click_game_row(y_center_screen: int) -> None:
|
||||
"""Click a game row at the given screen y-coordinate (center of list horizontally).
|
||||
Single click — D2R's game browser joins on one click (no confirm dialog)."""
|
||||
x, y, w, h = _GAME_LIST_ROI
|
||||
click_x = x + w // 2
|
||||
pos = convert_screen_to_monitor((click_x, y_center_screen))
|
||||
mouse.move(*pos)
|
||||
wait(0.15, 0.3)
|
||||
mouse.click("left")
|
||||
wait(0.2, 0.4)
|
||||
|
||||
|
||||
def _open_join_tab() -> bool:
|
||||
"""Click the 'Join Game' tab next to the Play button. Returns True if the
|
||||
game list area appears to have populated (OCR finds at least one row)."""
|
||||
# Find the Play button to anchor our click
|
||||
m = detect_screen_object(ScreenObjects.PlayBtn)
|
||||
if not m.valid:
|
||||
Logger.error("game_browser: Play button not found — cannot locate Join Game tab")
|
||||
return False
|
||||
# Join Game tab is to the right of the Play button
|
||||
tab_x = m.center[0] + _JOIN_TAB_OFFSET_X
|
||||
tab_y = m.center[1]
|
||||
pos = convert_screen_to_monitor((tab_x, tab_y))
|
||||
Logger.debug(f"game_browser: clicking Join Game tab at screen ({tab_x}, {tab_y})")
|
||||
mouse.move(*pos)
|
||||
wait(0.2, 0.4)
|
||||
mouse.click("left")
|
||||
wait(1.0, 1.5) # give the list time to populate
|
||||
|
||||
# Verify: try OCR on the list area
|
||||
img = grab()
|
||||
games = _scan_game_list(img)
|
||||
if games:
|
||||
Logger.debug(f"game_browser: game list populated, {len(games)} entries visible")
|
||||
return True
|
||||
# List might be empty (no games) — that's still a valid "tab opened" state.
|
||||
# We can't distinguish "empty list" from "wrong tab" without a template,
|
||||
# so assume success and let the caller handle the no-games case.
|
||||
Logger.warning("game_browser: game list empty after opening tab (no games or wrong tab)")
|
||||
return True
|
||||
|
||||
|
||||
def _join_attempt(name_filter: str, max_wait_s: float) -> bool:
|
||||
"""One attempt to join a public game from the main menu.
|
||||
|
||||
:return: True if the join was initiated (loading screen or in-game detected).
|
||||
"""
|
||||
# Step 1: Make sure we're at the main menu with the Play button visible
|
||||
start = time.time()
|
||||
while True:
|
||||
if is_visible(ScreenObjects.InGame):
|
||||
Logger.warning("game_browser: already in a game")
|
||||
return True
|
||||
if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid:
|
||||
break
|
||||
if is_visible(ScreenObjects.MainMenu):
|
||||
# Main menu visible but Play button not detected — wait for it to activate
|
||||
wait(1, 2)
|
||||
else:
|
||||
Logger.error("game_browser: not at main menu")
|
||||
return False
|
||||
if time.time() - start > 30:
|
||||
Logger.error("game_browser: Play button never appeared")
|
||||
return False
|
||||
|
||||
# Step 2: Open the Join Game tab
|
||||
if not _open_join_tab():
|
||||
return False
|
||||
|
||||
# Step 3: Find and click a matching game
|
||||
start = time.time()
|
||||
while time.time() - start < max_wait_s:
|
||||
img = grab()
|
||||
games = _scan_game_list(img)
|
||||
if games:
|
||||
target = None
|
||||
if name_filter:
|
||||
for g in games:
|
||||
if name_filter.lower() in g["name"].lower():
|
||||
target = g
|
||||
break
|
||||
else:
|
||||
target = games[0]
|
||||
|
||||
if target:
|
||||
Logger.info(f"game_browser: joining '{target['name']}' (row {target['row']})")
|
||||
_click_game_row(target["y_center"])
|
||||
# Step 4: Wait for loading screen or in-game
|
||||
return _wait_for_join()
|
||||
|
||||
Logger.debug(f"game_browser: {len(games)} games visible, none match filter '{name_filter}'")
|
||||
else:
|
||||
Logger.debug("game_browser: no games visible, waiting...")
|
||||
wait(2, 4)
|
||||
|
||||
Logger.error(f"game_browser: no matching game found within {max_wait_s}s")
|
||||
return False
|
||||
|
||||
|
||||
def join_game(name_filter: str = "", max_wait_s: float = 60.0, attempts: int = 3) -> bool:
|
||||
"""
|
||||
Join a public game from the main menu.
|
||||
|
||||
Retries the whole browser flow up to `attempts` times: a single attempt can
|
||||
fail for transient reasons (game full, kicked back to the menu, a missed
|
||||
click, an empty list at that instant). The bot must never stop, so we keep
|
||||
re-trying rather than giving up on the first failure.
|
||||
|
||||
:param name_filter: substring to match against game names (case-insensitive).
|
||||
Empty string = join the first game in the list.
|
||||
:param max_wait_s: how long each attempt waits for a matching game to appear.
|
||||
:param attempts: how many times to retry the whole join flow before giving up.
|
||||
:return: True if the join was initiated (loading screen or in-game detected).
|
||||
"""
|
||||
Logger.info(f"game_browser: joining game (filter='{name_filter or 'first'}', attempts={attempts})")
|
||||
stop_detecting_window()
|
||||
find_and_set_window_position(force=True)
|
||||
|
||||
try:
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
if _join_attempt(name_filter, max_wait_s):
|
||||
return True
|
||||
except Exception as e:
|
||||
Logger.warning(f"game_browser: join attempt {attempt} raised {type(e).__name__}: {e}")
|
||||
if attempt < attempts:
|
||||
Logger.warning(f"game_browser: join attempt {attempt}/{attempts} failed — retrying")
|
||||
# Give the client a moment to settle back at the main menu before
|
||||
# re-anchoring the window and re-opening the browser.
|
||||
wait(2, 4)
|
||||
find_and_set_window_position(force=True)
|
||||
Logger.error(f"game_browser: all {attempts} join attempts failed")
|
||||
return False
|
||||
finally:
|
||||
start_detecting_window()
|
||||
|
||||
|
||||
def _wait_for_join(timeout: float = 45.0) -> bool:
|
||||
"""Wait for the loading screen or in-game state after clicking a game."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if is_visible(ScreenObjects.Loading):
|
||||
Logger.info("game_browser: loading screen detected — join in progress")
|
||||
return True
|
||||
if is_visible(ScreenObjects.InGame):
|
||||
Logger.info("game_browser: already in game — join succeeded")
|
||||
return True
|
||||
# Server error or kicked back to menu?
|
||||
if is_visible(ScreenObjects.ServerError):
|
||||
Logger.error("game_browser: server error during join")
|
||||
return False
|
||||
# If we're back at the main menu with Play button, the join failed
|
||||
if (m := detect_screen_object(ScreenObjects.PlayBtn)).valid:
|
||||
# Could be a transient state — give it a moment
|
||||
wait(1, 2)
|
||||
if (m2 := detect_screen_object(ScreenObjects.PlayBtn)).valid:
|
||||
Logger.error("game_browser: back at main menu — join failed (game full/left)")
|
||||
return False
|
||||
wait(0.5, 1.0)
|
||||
Logger.error(f"game_browser: no loading/in-game screen within {timeout}s")
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_in_game(timeout: float = 60.0) -> bool:
|
||||
"""Wait until we're actually in-game (InGame marker visible)."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if is_visible(ScreenObjects.InGame):
|
||||
Logger.info("game_browser: in-game confirmed")
|
||||
return True
|
||||
if is_visible(ScreenObjects.MainMenu):
|
||||
Logger.error("game_browser: back at main menu — never entered game")
|
||||
return False
|
||||
wait(1, 2)
|
||||
Logger.error(f"game_browser: InGame not detected within {timeout}s")
|
||||
return False
|
||||
@@ -71,6 +71,21 @@ def save_and_exit() -> bool:
|
||||
Performes save and exit action from within game
|
||||
:return: Bool if action was successful
|
||||
"""
|
||||
# Pause the panel check for the WHOLE sequence. This function deliberately
|
||||
# opens the in-game ESC menu, and the health manager now closes that menu on
|
||||
# sight (its LOOT FILTER / CHRONICLE / OPTIONS buttons sit under movement
|
||||
# clicks). Callers only pause AFTER save_and_exit returns, so without this
|
||||
# the guard raced the shutdown — observed as 75 escapes across 15 games,
|
||||
# interleaved with the save/exit clicks.
|
||||
from health_manager import set_panel_check_paused
|
||||
set_panel_check_paused(True)
|
||||
try:
|
||||
return _save_and_exit_inner()
|
||||
finally:
|
||||
set_panel_check_paused(False)
|
||||
|
||||
|
||||
def _save_and_exit_inner() -> bool:
|
||||
# if exit button isn't detected already, press escape
|
||||
attempts = 1
|
||||
success = False
|
||||
|
||||
@@ -225,6 +225,22 @@ class ScreenObjects:
|
||||
threshold=0.8,
|
||||
use_grayscale=True
|
||||
)
|
||||
GameMenu=ScreenObject(
|
||||
# The in-game ESC menu. It has NO close button, so CenterPanel cannot
|
||||
# see it, and it carries LOOT FILTER / CHRONICLE / OPTIONS buttons at
|
||||
# screen centre — where the HUD mask deliberately allows clicks. A
|
||||
# stray esc opens it and the next movement click lands on a button.
|
||||
ref=["SAVE_AND_EXIT_NO_HIGHLIGHT", "SAVE_AND_EXIT_HIGHLIGHT"],
|
||||
roi="reduce_to_center",
|
||||
threshold=0.8,
|
||||
use_grayscale=True
|
||||
)
|
||||
CenterPanel=ScreenObject(
|
||||
ref=["CLOSE_PANEL_2", "CLOSE_PANEL"],
|
||||
roi="center_panel_header",
|
||||
threshold=0.8,
|
||||
use_grayscale=True
|
||||
)
|
||||
NPCDialogue=ScreenObject(
|
||||
ref="NPC_DIALOGUE",
|
||||
roi="npc_dialogue",
|
||||
|
||||
@@ -43,10 +43,15 @@ if os.name == 'nt':
|
||||
IsWindowVisible,
|
||||
)
|
||||
from win32api import GetMonitorInfo, MonitorFromWindow
|
||||
import pywintypes
|
||||
from win32process import GetWindowThreadProcessId
|
||||
else:
|
||||
# Linux stubs — these functions are never called outside os.name == 'nt' blocks,
|
||||
# but they must exist for the module to import cleanly on non-Windows systems.
|
||||
class _PywintypesStub:
|
||||
class error(Exception):
|
||||
pass
|
||||
pywintypes = _PywintypesStub()
|
||||
HWND_TOPMOST = None
|
||||
SWP_NOMOVE = None
|
||||
SWP_NOSIZE = None
|
||||
@@ -183,7 +188,11 @@ def move_d2r_window(client_x, client_y, client_width=None, client_height=None):
|
||||
outer_y = client_y - (client_top - wr_top)
|
||||
outer_w = (client_width if client_width else (c_right - c_left)) + border_w
|
||||
outer_h = (client_height if client_height else (c_bottom - c_top)) + border_h
|
||||
SetWindowPos(hwnd, HWND_TOPMOST, outer_x, outer_y, outer_w, outer_h, SWP_SHOWWINDOW)
|
||||
try:
|
||||
SetWindowPos(hwnd, HWND_TOPMOST, outer_x, outer_y, outer_w, outer_h, SWP_SHOWWINDOW)
|
||||
except pywintypes.error:
|
||||
Logger.debug("SetWindowPos denied in move_d2r_window (D2R elevated) — skipping")
|
||||
return False
|
||||
Logger.debug(
|
||||
f"Moved D2R client area to ({client_x}, {client_y}) "
|
||||
f"with size {client_width or c_right - c_left}x{client_height or c_bottom - c_top}"
|
||||
@@ -202,16 +211,49 @@ def enforce_d2r_window(client_x=5, client_y=98):
|
||||
)
|
||||
|
||||
def focus_d2r_window():
|
||||
"""Bring D2R to the foreground before sending UI clicks/keys."""
|
||||
if os.name == 'nt':
|
||||
for hwnd, _ in _find_d2r_hwnds():
|
||||
try:
|
||||
"""Bring D2R to the foreground before sending UI clicks/keys.
|
||||
|
||||
Windows refuses SetForegroundWindow from a process that does not already own
|
||||
the foreground, and the refusal is SILENT — it returns an error that is easy
|
||||
to swallow, the window never comes forward, and every subsequent key and
|
||||
click lands in whatever window IS focused. Attaching our input queue to the
|
||||
current foreground thread lifts that restriction for the duration of the
|
||||
call, which is the documented way round the foreground lock.
|
||||
|
||||
Returns True only if D2R actually ended up in the foreground.
|
||||
"""
|
||||
if os.name != 'nt':
|
||||
return False
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
for hwnd, _ in _find_d2r_hwnds():
|
||||
try:
|
||||
if user32.IsIconic(hwnd):
|
||||
ShowWindow(hwnd, SW_RESTORE)
|
||||
SetForegroundWindow(hwnd)
|
||||
fg = user32.GetForegroundWindow()
|
||||
if fg == hwnd:
|
||||
return True
|
||||
except Exception as e:
|
||||
Logger.warning(f"Could not focus D2R window: {e}")
|
||||
return False
|
||||
cur_tid = kernel32.GetCurrentThreadId()
|
||||
fg_tid = user32.GetWindowThreadProcessId(fg, None) if fg else 0
|
||||
attached = False
|
||||
if fg_tid and fg_tid != cur_tid:
|
||||
attached = bool(user32.AttachThreadInput(cur_tid, fg_tid, True))
|
||||
try:
|
||||
user32.AllowSetForegroundWindow(-1)
|
||||
user32.BringWindowToTop(hwnd)
|
||||
SetForegroundWindow(hwnd)
|
||||
finally:
|
||||
if attached:
|
||||
user32.AttachThreadInput(cur_tid, fg_tid, False)
|
||||
wait(0.15, 0.25)
|
||||
if user32.GetForegroundWindow() == hwnd:
|
||||
return True
|
||||
Logger.warning("focus_d2r_window: D2R did not come to the foreground")
|
||||
return False
|
||||
except Exception as e:
|
||||
Logger.warning(f"Could not focus D2R window: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
def set_d2r_always_on_top():
|
||||
@@ -222,7 +264,12 @@ def set_d2r_always_on_top():
|
||||
found = False
|
||||
for w in windows_list:
|
||||
if "Diablo II" in w[1]:
|
||||
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
try:
|
||||
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
except pywintypes.error:
|
||||
Logger.debug("SetWindowPos denied (D2R may be elevated) — skipping always-on-top")
|
||||
found = True
|
||||
break
|
||||
Logger.debug("Set D2R to be always on top")
|
||||
found = True
|
||||
break
|
||||
@@ -239,8 +286,12 @@ def restore_d2r_window_visibility():
|
||||
EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list)
|
||||
for w in windows_list:
|
||||
if w[1] == "Diablo II: Resurrected":
|
||||
SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
Logger.debug("Restored D2R window visibility")
|
||||
try:
|
||||
SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
except pywintypes.error:
|
||||
Logger.debug("SetWindowPos denied on restore (D2R may be elevated) — skipping")
|
||||
else:
|
||||
Logger.debug("Restored D2R window visibility")
|
||||
else:
|
||||
Logger.debug('OS not supported, unable to set D2R always on top')
|
||||
|
||||
@@ -282,6 +333,14 @@ def wait(min_seconds, max_seconds = None):
|
||||
center = (jitter_min + jitter_max) / 2
|
||||
sigma = (jitter_max - jitter_min) / 4
|
||||
jitter = random.gauss(center, sigma)
|
||||
# Session personality: a constant bias for the whole run, so the mean
|
||||
# differs between sessions instead of converging to the same value
|
||||
# every time. Per-action jitter alone cannot produce that.
|
||||
from utils.stealth import get_session_bias
|
||||
jitter *= get_session_bias()
|
||||
# Clamp LAST so the floor holds whatever the bias is. Clamping before
|
||||
# the multiply let a 0.92x session push waits back under the floor —
|
||||
# exactly the 32%-short behaviour raising wait_jitter_min removed.
|
||||
jitter = max(jitter_min * 0.8, min(jitter_max * 1.2, jitter))
|
||||
except Exception:
|
||||
jitter = 1.0
|
||||
|
||||
@@ -59,13 +59,19 @@ def get_build_skill_checks(config_instance: Config, char_type: str | None = None
|
||||
char_type = char_type or config_instance.char.get("type", "")
|
||||
checks = []
|
||||
|
||||
if char_type == "hammerdin":
|
||||
build_cfg = getattr(config_instance, "hammerdin", {})
|
||||
if char_type in ("hammerdin", "fohdin"):
|
||||
build_cfg = getattr(config_instance, char_type, {})
|
||||
# blessed hammer lives on left-click; auras/teleport go to right
|
||||
# hotkeys select onto the RIGHT slot; left holds the hammer permanently
|
||||
# hotkeys select onto the RIGHT slot; left holds the hammer permanently.
|
||||
# NOT required: having no hotkey is the CORRECT configuration. _cast_hammers
|
||||
# deliberately keeps the hammer off the F-keys — pressing one would select it
|
||||
# onto the right slot and REPLACE the damage aura. Marking this required made
|
||||
# every startup log "blessed_hammer has no configured hotkey" and advise running
|
||||
# set_binds_from_params.py, which would bind it and break the aura. If a hotkey
|
||||
# IS configured it is still visually validated below.
|
||||
checks.append(SkillCheck(char_type, "blessed_hammer",
|
||||
_configured_hotkey(build_cfg, "blessed_hammer"),
|
||||
"BLESSED_HAMMER", "right", True))
|
||||
"BLESSED_HAMMER", "right", False))
|
||||
checks.append(SkillCheck(char_type, "concentration",
|
||||
_configured_hotkey(build_cfg, "concentration"),
|
||||
"CONCENTRATION", "right", True))
|
||||
@@ -111,6 +117,14 @@ def _skill_roi(side: str, pad: int = 6) -> list[int]:
|
||||
def _check_skill_icon(check: SkillCheck, threshold: float = 0.84) -> tuple[bool | None, str, float]:
|
||||
template_name = _first_existing_template(TEMPLATE_ALIASES.get(check.skill, (check.template,)))
|
||||
if template_name is None:
|
||||
# Template missing — skip required skills (assume user bound correctly),
|
||||
# log warning but don't block the bot.
|
||||
if check.required:
|
||||
Logger.warning(
|
||||
f"Skill visual preflight: {check.skill} template {check.template} missing — "
|
||||
f"assuming correct bind and continuing"
|
||||
)
|
||||
return True, check.template, 1.0
|
||||
return None, check.template, -1.0
|
||||
|
||||
keyboard.send(check.hotkey)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib as _hashlib
|
||||
import os as _os
|
||||
import random
|
||||
import time
|
||||
from input_layer import keyboard
|
||||
@@ -6,6 +8,27 @@ from logger import Logger
|
||||
from utils.misc import wait
|
||||
|
||||
|
||||
# Fresh per process, so the session personality differs run to run while
|
||||
# staying constant within one run.
|
||||
_SESSION_SALT = int.from_bytes(_os.urandom(4), "big")
|
||||
_session_bias = None
|
||||
_manifest_logged = False
|
||||
|
||||
|
||||
def _tl(step: str, status: str = "ok", detail: str = "") -> None:
|
||||
"""Mirror stealth decisions into the run timeline (grep "TL>").
|
||||
|
||||
Imported lazily and guarded: utils.stealth is imported BY bot.py, so a module-level
|
||||
import of Bot here would be circular, and stealth helpers are also called from
|
||||
contexts with no live Bot.
|
||||
"""
|
||||
try:
|
||||
from bot import Bot
|
||||
Bot.timeline("stlth", step, status, detail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def maybe_afk_break():
|
||||
"""Call after each run. Randomly takes an unscheduled AFK break."""
|
||||
try:
|
||||
@@ -15,7 +38,9 @@ def maybe_afk_break():
|
||||
if random.randint(1, 100) <= cfg["afk_break_chance"]:
|
||||
minutes = random.uniform(cfg["afk_break_min_m"], cfg["afk_break_max_m"])
|
||||
Logger.info(f"[Stealth] Taking unscheduled AFK break for {minutes:.1f} minutes")
|
||||
_tl("afk_break", "start", f"planned {minutes:.1f}m (chance {cfg['afk_break_chance']}%)")
|
||||
wait(minutes * 60, minutes * 60 * 1.5)
|
||||
_tl("afk_break", "ok", "resuming")
|
||||
Logger.info("[Stealth] AFK break over, resuming")
|
||||
|
||||
|
||||
@@ -27,35 +52,11 @@ def should_skip_run() -> bool:
|
||||
return False
|
||||
if cfg["skip_run_chance"] > 0 and random.randint(1, 100) <= cfg["skip_run_chance"]:
|
||||
Logger.info("[Stealth] Randomly skipping this run for anti-detection")
|
||||
_tl("skip_run", "ok", f"rolled skip (chance {cfg['skip_run_chance']}%)")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def randomize_click_position(x: int, y: int) -> tuple:
|
||||
"""
|
||||
Returns a slightly offset click position to simulate imperfect aim.
|
||||
Uses a 2D Gaussian distribution so most clicks cluster near target
|
||||
with rare larger misses (like real humans).
|
||||
"""
|
||||
try:
|
||||
cfg = Config().stealth
|
||||
variance = cfg["click_variance"]
|
||||
except Exception:
|
||||
variance = 8
|
||||
|
||||
# Gaussian distribution: most clicks land close, occasional larger miss
|
||||
dx = int(random.gauss(0, variance / 2))
|
||||
dy = int(random.gauss(0, variance / 2))
|
||||
|
||||
# Occasionally add a small directional bias (simulates hand drift)
|
||||
if random.random() < 0.1:
|
||||
bias = random.randint(2, max(1, variance // 2))
|
||||
dx += bias * random.choice([-1, 1])
|
||||
dy += bias * random.choice([-1, 1])
|
||||
|
||||
return x + dx, y + dy
|
||||
|
||||
|
||||
def randomize_run_duration(base_duration: float) -> float:
|
||||
"""
|
||||
Varies the effective run duration to avoid bot-like consistency.
|
||||
@@ -67,9 +68,17 @@ def randomize_run_duration(base_duration: float) -> float:
|
||||
except Exception:
|
||||
variance = 0.15
|
||||
|
||||
# Most runs complete within +/- 15% of base, with occasional outliers
|
||||
factor = random.gauss(1.0, variance)
|
||||
factor = max(0.7, min(1.4, factor)) # Clamp to 70%-140%
|
||||
# Only ever LENGTHEN. A shortened attack window leaves the boss alive, which
|
||||
# is a failed run rather than convincing behaviour — the asymmetry is
|
||||
# deliberate, do not restore the 0.7 floor.
|
||||
try:
|
||||
if not Config().stealth.get("vary_kill_time", True):
|
||||
return base_duration
|
||||
except Exception:
|
||||
return base_duration
|
||||
|
||||
factor = abs(random.gauss(0.0, variance)) + 1.0
|
||||
factor = min(1.4, factor) # Clamp to 100%-140%
|
||||
|
||||
return base_duration * factor
|
||||
|
||||
@@ -94,17 +103,6 @@ def add_micro_pause():
|
||||
|
||||
# ─── Tier 1: Input-level stealth ─────────────────────────────────────────────
|
||||
|
||||
def endpoint_wobble(x: int, y: int) -> tuple[int, int]:
|
||||
"""
|
||||
Adds 2-5 pixel micro-adjustment before final click position (human hand tremor).
|
||||
Called just before clicking to simulate endpoint micro-correction.
|
||||
"""
|
||||
# Small Gaussian wobble: most land within 3px, occasional 5px+
|
||||
dx = int(random.gauss(0, 1.5))
|
||||
dy = int(random.gauss(0, 1.5))
|
||||
return x + dx, y + dy
|
||||
|
||||
|
||||
def click_delay() -> float:
|
||||
"""
|
||||
Returns the delay (in seconds) between arriving at a target and clicking.
|
||||
@@ -122,8 +120,17 @@ def click_delay() -> float:
|
||||
return delay_ms / 1000.0
|
||||
|
||||
|
||||
def click_delay_enabled() -> bool:
|
||||
try:
|
||||
return bool(Config().stealth.get("click_delay_enabled", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def apply_click_delay():
|
||||
"""Sleep for a human-like delay before clicking."""
|
||||
if not click_delay_enabled():
|
||||
return
|
||||
wait(click_delay(), click_delay() * 1.2)
|
||||
|
||||
|
||||
@@ -146,34 +153,6 @@ def key_press_duration(base_duration: float = 0.05) -> float:
|
||||
return duration_ms / 1000.0
|
||||
|
||||
|
||||
def human_key_press(key: str):
|
||||
"""
|
||||
Press a key with human-like timing: variable duration, micro-pause before and after.
|
||||
Replaces direct keyboard.press/release for stealth.
|
||||
"""
|
||||
add_micro_pause()
|
||||
duration = key_press_duration()
|
||||
keyboard.press(key)
|
||||
wait(duration, duration * 1.2)
|
||||
keyboard.release(key)
|
||||
add_micro_pause()
|
||||
|
||||
|
||||
def human_keyboard_send(key: str):
|
||||
"""
|
||||
Send a key press with full human-like timing.
|
||||
Replaces keyboard.send() for stealth.
|
||||
"""
|
||||
add_micro_pause()
|
||||
duration = key_press_duration(0.03)
|
||||
keyboard.press(key)
|
||||
wait(duration, duration * 1.2)
|
||||
keyboard.release(key)
|
||||
add_micro_pause()
|
||||
|
||||
|
||||
# ─── Tier 2: Behavior-level stealth ───────────────────────────────────────────
|
||||
|
||||
def should_wrong_waypoint() -> bool:
|
||||
"""
|
||||
2-3% chance of "getting lost" and selecting wrong waypoint first,
|
||||
@@ -184,7 +163,10 @@ def should_wrong_waypoint() -> bool:
|
||||
chance = cfg.get("wrong_waypoint_chance", 0.025)
|
||||
except Exception:
|
||||
chance = 0.025
|
||||
return random.random() < chance
|
||||
hit = random.random() < chance
|
||||
if hit:
|
||||
_tl("wrong_waypoint", "ok", f"deliberate misclick (chance {chance})")
|
||||
return hit
|
||||
|
||||
|
||||
def skill_rotation_hesitation() -> float:
|
||||
@@ -217,22 +199,394 @@ def should_correct_skill_mistake() -> bool:
|
||||
# ─── Tier 3: Session-level stealth ────────────────────────────────────────────
|
||||
|
||||
def get_personality_seed(char_name: str) -> int:
|
||||
"""Stable per-character seed.
|
||||
|
||||
The previous version used the builtin hash(), which Python randomizes per
|
||||
process (PYTHONHASHSEED) for str — so it produced a DIFFERENT value every
|
||||
session, the opposite of what its docstring promised. hashlib is stable
|
||||
across processes and machines.
|
||||
"""
|
||||
Generate a deterministic seed from character name.
|
||||
Each character has a unique "personality" — different timing distributions,
|
||||
different error rates — but consistent across sessions.
|
||||
"""
|
||||
return hash(char_name) & 0xFFFFFFFF
|
||||
digest = _hashlib.sha256(str(char_name).encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:4], "big")
|
||||
|
||||
|
||||
def apply_personality(char_name: str):
|
||||
def get_session_bias() -> float:
|
||||
"""A speed bias held constant for this whole session.
|
||||
|
||||
Real players are internally consistent within a session and different
|
||||
between them — a little quicker one evening, slower the next. Per-action
|
||||
jitter alone cannot produce that: averaged over hundreds of actions it
|
||||
converges to the same mean every session, which is itself a signature.
|
||||
|
||||
Derived from the character seed plus the session start, so it is stable for
|
||||
the process lifetime and differs run to run. Range 0.9-1.15: deliberately
|
||||
narrower on the fast side, since being systematically quicker is the
|
||||
direction that breaks things.
|
||||
"""
|
||||
Configure random distributions based on character-specific personality.
|
||||
Called once at bot start to seed per-character behavior.
|
||||
global _session_bias
|
||||
if _session_bias is None:
|
||||
try:
|
||||
from config import Config
|
||||
name = str(Config().general.get("name") or Config().char.get("type") or "botty")
|
||||
except Exception:
|
||||
name = "botty"
|
||||
rnd = random.Random(get_personality_seed(name) ^ _SESSION_SALT)
|
||||
_session_bias = round(rnd.uniform(0.90, 1.15), 4)
|
||||
Logger.info(f"[Stealth] Session personality: timing bias {_session_bias:.3f}x")
|
||||
return _session_bias
|
||||
|
||||
|
||||
# ─── Tier 4: observability ────────────────────────────────────────────────────
|
||||
# Every defect in this subsystem was invisible for the same reason: a behaviour
|
||||
# that never fires looks exactly like one whose roll has not come up yet. AFK
|
||||
# breaks sat dead for 225 games behind that ambiguity. The manifest removes it
|
||||
# by reporting, at startup, whether each behaviour has a REACHABLE call site —
|
||||
# independently of whether it happens to fire.
|
||||
|
||||
def _count_calls(module_name: str, needle: str) -> int:
|
||||
try:
|
||||
import importlib, inspect
|
||||
return inspect.getsource(importlib.import_module(module_name)).count(needle)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
|
||||
def manifest() -> list:
|
||||
"""[(behaviour, configured, status)] — status is about REACHABILITY."""
|
||||
try:
|
||||
cfg = Config().stealth
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
rows = []
|
||||
|
||||
def add(name, configured, wired, detail=""):
|
||||
# A behaviour rolled at 0% is OFF, whatever its call sites look like.
|
||||
# Reporting it as "wired" is the same misleading-status problem this
|
||||
# manifest exists to eliminate.
|
||||
if str(configured).strip() in ("0", "0.0", "0%", "0.0%"):
|
||||
rows.append((name, "off", "disabled by config"))
|
||||
return
|
||||
if wired is None:
|
||||
status = detail or "not enabled"
|
||||
elif wired:
|
||||
status = f"wired{(' - ' + detail) if detail else ''}"
|
||||
else:
|
||||
status = f"UNREACHABLE{(' - ' + detail) if detail else ''}"
|
||||
rows.append((name, configured, status))
|
||||
|
||||
# afk_break: must roll on every way home, not just the teleport path.
|
||||
# "maybe_afk_break()" with parens never matches the import line, so no adjustment.
|
||||
afk = _count_calls("bot", "maybe_afk_break()")
|
||||
add("afk_break", f"{cfg['afk_break_chance']}%", afk > 0, f"{afk} call sites")
|
||||
|
||||
skip = _count_calls("bot", "should_skip_run()")
|
||||
add("skip_run", f"{cfg['skip_run_chance']}%", skip > 0, f"{skip} call sites")
|
||||
|
||||
# vary_kill_time reaches a boss only via IChar.atk_len().
|
||||
try:
|
||||
import importlib, inspect, pkgutil, char as char_pkg
|
||||
adopted, total = [], []
|
||||
for m in pkgutil.walk_packages(char_pkg.__path__, "char."):
|
||||
try:
|
||||
src = inspect.getsource(importlib.import_module(m.name))
|
||||
except Exception:
|
||||
continue
|
||||
if 'Config().char["atk_len' in src or "self.atk_len(" in src:
|
||||
total.append(m.name)
|
||||
if "self.atk_len(" in src:
|
||||
adopted.append(m.name)
|
||||
detail = f"{len(adopted)}/{len(total)} char modules on IChar.atk_len()"
|
||||
add("vary_kill_time", "on" if cfg["vary_kill_time"] else "off",
|
||||
bool(adopted) if cfg["vary_kill_time"] else None, detail)
|
||||
except Exception:
|
||||
add("vary_kill_time", "on" if cfg["vary_kill_time"] else "off", None)
|
||||
|
||||
gated = _count_calls("input_layer", "_is_skill_key(key)") > 0
|
||||
add("skill_hesitation", f"{cfg['skill_hesitation_min_ms']}-{cfg['skill_hesitation_max_ms']}ms",
|
||||
gated, "skill keys only" if gated else "gate missing")
|
||||
add("skill_mistake", f"{cfg['skill_mistake_chance']}", gated,
|
||||
"skill keys only" if gated else "gate missing")
|
||||
|
||||
wp = _count_calls("ui.waypoint", "should_wrong_waypoint()")
|
||||
add("wrong_waypoint", f"{cfg['wrong_waypoint_chance']}", wp > 0, f"{wp} call sites")
|
||||
|
||||
add("click_delay",
|
||||
f"{cfg['click_delay_min_ms']}-{cfg['click_delay_max_ms']}ms" if cfg["click_delay_enabled"] else "off",
|
||||
True if cfg["click_delay_enabled"] else None, "" if cfg["click_delay_enabled"] else "opt-in")
|
||||
|
||||
add("wait_jitter", f"{cfg['wait_jitter_min']}-{cfg['wait_jitter_max']}", True, "global")
|
||||
|
||||
budget_h = cfg.get("session_budget_h", 0)
|
||||
add("session_budget", f"{budget_h}h" if budget_h else "0",
|
||||
True if budget_h else None,
|
||||
f"this run {session_budget_seconds()/3600:.1f}h" if budget_h else "unlimited")
|
||||
|
||||
# Report the call site, not just the config — the lesson from chicken_variance.
|
||||
daily_h = cfg.get("daily_budget_h", 0)
|
||||
daily_wired = _count_calls("bot", "daily_budget_exceeded(") > 0
|
||||
if daily_h:
|
||||
_b = daily_budget_seconds() / 3600
|
||||
_u = _daily_read().get("seconds", 0.0) / 3600
|
||||
detail = f"today {_u:.1f}/{_b:.1f}h" if daily_wired else "no call site in bot.py"
|
||||
else:
|
||||
detail = "unlimited"
|
||||
add("daily_budget", f"{daily_h}h" if daily_h else "0",
|
||||
(daily_wired if daily_h else None), detail)
|
||||
|
||||
run_s, brk_s = break_schedule()
|
||||
add("scheduled_break", f"{run_s/60:.0f}m/{brk_s/60:.0f}m" if run_s else "0",
|
||||
run_s > 0, "re-rolled each break" if run_s else "disabled in [general]")
|
||||
|
||||
add("idle_drift", "on" if cfg.get("idle_drift_enabled") else "0",
|
||||
bool(cfg.get("idle_drift_enabled")), "bot thread only")
|
||||
# Check the CALL SITE, not just the config value. Reporting this as "wired"
|
||||
# from the config alone hid the fact that the roll was never wired at all —
|
||||
# the precise failure this manifest exists to catch, committed by the
|
||||
# manifest itself.
|
||||
chick = _count_calls("bot", "set_game_chicken_threshold(") > 0
|
||||
add("chicken_variance", f"+0-{cfg.get('chicken_variance', 0)}",
|
||||
chick and float(cfg.get("chicken_variance", 0)) > 0,
|
||||
"raises only, never lowers" if chick else "no call site in bot.py")
|
||||
add("town_browse", f"{cfg.get('town_browse_chance', 0)}",
|
||||
_count_calls("bot", "should_browse_town()") > 0, "town only")
|
||||
add("pickup_skip", f"{cfg.get('pickup_skip_chance', 0)}",
|
||||
_count_calls("item.pickit", "should_skip_pickup()") > 0, "2 decision points")
|
||||
add("session_bias", f"{get_session_bias():.3f}x", True, "constant this run")
|
||||
return rows
|
||||
|
||||
|
||||
def log_manifest(once: bool = True):
|
||||
"""One grep-able line per behaviour: grep "STEALTH>".
|
||||
|
||||
`=== BOT START ===` is emitted per GAME, not per process, so default to
|
||||
logging this only the first time or it becomes nine lines of noise per run.
|
||||
"""
|
||||
seed = get_personality_seed(char_name)
|
||||
# Don't reseed global random (breaks other things),
|
||||
# but each stealth function uses its own local random call
|
||||
# which gets the benefit of global state entropy.
|
||||
# Personality is encoded in config ranges, not seeds.
|
||||
pass
|
||||
global _manifest_logged
|
||||
if once and _manifest_logged:
|
||||
return
|
||||
_manifest_logged = True
|
||||
rows = manifest()
|
||||
if not rows:
|
||||
Logger.warning("STEALTH> manifest unavailable — config not loaded")
|
||||
return
|
||||
for name, configured, status in rows:
|
||||
line = f"STEALTH> {name:<18} {configured:<14} {status}"
|
||||
if "UNREACHABLE" in status:
|
||||
Logger.warning(line)
|
||||
else:
|
||||
Logger.info(line)
|
||||
|
||||
|
||||
# ─── Tier 5: session rhythm and idle behaviour ────────────────────────────────
|
||||
# The strongest remaining signal is not per-click timing. Averaged over a
|
||||
# session, jitter converges; what does NOT converge is a player who starts at
|
||||
# the same hour, plays the same length, and never does anything unproductive.
|
||||
|
||||
_session_budget_s = None
|
||||
|
||||
|
||||
def break_schedule() -> tuple:
|
||||
"""(run_seconds, break_seconds) for the NEXT scheduled break.
|
||||
|
||||
Re-rolled each time, because a break at exactly 120 minutes every time is
|
||||
still a pattern — just a slower one than no break at all.
|
||||
"""
|
||||
try:
|
||||
cfg = Config().general
|
||||
run_m = float(cfg.get("max_runtime_before_break_m") or 0)
|
||||
brk_m = float(cfg.get("break_length_m") or 0)
|
||||
except Exception:
|
||||
return (0, 0)
|
||||
if run_m <= 0 or brk_m <= 0:
|
||||
return (0, 0)
|
||||
return (random.uniform(run_m * 0.7, run_m * 1.3) * 60,
|
||||
random.uniform(brk_m * 0.6, brk_m * 1.6) * 60)
|
||||
|
||||
|
||||
def session_budget_seconds() -> float:
|
||||
"""How long this session should run before stopping. 0 = unlimited.
|
||||
|
||||
Fixed for the process, varied per run, so consecutive days differ.
|
||||
"""
|
||||
global _session_budget_s
|
||||
if _session_budget_s is None:
|
||||
try:
|
||||
hours = float(Config().stealth.get("session_budget_h", 0) or 0)
|
||||
except Exception:
|
||||
hours = 0
|
||||
if hours <= 0:
|
||||
_session_budget_s = 0.0
|
||||
else:
|
||||
_session_budget_s = random.uniform(hours * 0.65, hours * 1.35) * 3600
|
||||
Logger.info(f"[Stealth] Session budget: {_session_budget_s / 3600:.1f}h")
|
||||
return _session_budget_s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Daily runtime cap
|
||||
#
|
||||
# session_budget_h is per PROCESS. A restart re-rolls it, so a bot that is
|
||||
# restarted (by the user, or by the restart-on-crash path) can run all day and
|
||||
# never trip it — which is exactly the signal a daily cap is meant to remove.
|
||||
# This tracker persists to disk and is keyed on the calendar date.
|
||||
#
|
||||
# The rolled target is stored WITH the date. Re-rolling it on every process
|
||||
# start would let a restart hand the bot a fresh, possibly larger budget; once
|
||||
# a day's target is chosen it is fixed until the date changes.
|
||||
# --------------------------------------------------------------------------
|
||||
_DAILY_STATE_PATH = _os.path.join("log", ".daily_runtime.json")
|
||||
_daily_last_tick: float | None = None
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
import datetime
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
def _daily_read() -> dict:
|
||||
import json
|
||||
try:
|
||||
with open(_DAILY_STATE_PATH, "r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
if state.get("date") == _today():
|
||||
return state
|
||||
except Exception:
|
||||
pass
|
||||
return {"date": _today(), "seconds": 0.0, "budget_s": None}
|
||||
|
||||
|
||||
def _daily_write(state: dict) -> None:
|
||||
import json
|
||||
try:
|
||||
_os.makedirs(_os.path.dirname(_DAILY_STATE_PATH), exist_ok=True)
|
||||
with open(_DAILY_STATE_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f)
|
||||
except Exception as e:
|
||||
Logger.warning(f"[Stealth] could not persist daily runtime: {e}")
|
||||
|
||||
|
||||
def daily_budget_seconds() -> float:
|
||||
"""Today's runtime allowance in seconds. 0 = unlimited.
|
||||
|
||||
Rolled once per calendar day and persisted, so restarts cannot extend it.
|
||||
"""
|
||||
try:
|
||||
hours = float(Config().stealth.get("daily_budget_h", 0) or 0)
|
||||
except Exception:
|
||||
hours = 0
|
||||
if hours <= 0:
|
||||
return 0.0
|
||||
state = _daily_read()
|
||||
if not state.get("budget_s"):
|
||||
try:
|
||||
jitter = float(Config().stealth.get("daily_budget_jitter", 0.12) or 0)
|
||||
except Exception:
|
||||
jitter = 0.12
|
||||
# Jitter DOWNWARD only. daily_budget_h is a ceiling, not an average: a
|
||||
# two-sided roll on 8h could hand out 9h, which is not what "cap it at 8
|
||||
# hours" means. Varying 7-8h still breaks the identical-length-day signal
|
||||
# this exists for, without ever exceeding the configured limit.
|
||||
state["budget_s"] = random.uniform(hours * (1 - jitter), hours) * 3600
|
||||
_daily_write(state)
|
||||
Logger.info(f"[Stealth] Daily budget for {state['date']}: {state['budget_s'] / 3600:.1f}h")
|
||||
return float(state["budget_s"])
|
||||
|
||||
|
||||
def daily_runtime_tick() -> float:
|
||||
"""Add elapsed time since the last tick to today's total; return the total.
|
||||
|
||||
Call periodically (end of each game). Time before the first tick in a
|
||||
process is not counted — a crash therefore under-counts rather than
|
||||
over-counts, which is the safe direction for a cap.
|
||||
"""
|
||||
global _daily_last_tick
|
||||
now = time.time()
|
||||
if _daily_last_tick is None:
|
||||
_daily_last_tick = now
|
||||
return float(_daily_read().get("seconds", 0.0))
|
||||
state = _daily_read()
|
||||
# A date rollover resets seconds AND clears the budget so a new one is rolled.
|
||||
state["seconds"] = float(state.get("seconds", 0.0)) + (now - _daily_last_tick)
|
||||
_daily_last_tick = now
|
||||
_daily_write(state)
|
||||
return state["seconds"]
|
||||
|
||||
|
||||
def daily_budget_exceeded() -> tuple[bool, float, float]:
|
||||
"""(exceeded, seconds_used_today, budget_seconds). budget 0 = unlimited."""
|
||||
budget = daily_budget_seconds()
|
||||
used = daily_runtime_tick()
|
||||
if not budget:
|
||||
return False, used, 0.0
|
||||
return used >= budget, used, budget
|
||||
|
||||
|
||||
def idle_drift():
|
||||
"""Nudge the cursor a little, the way a resting hand does.
|
||||
|
||||
BOT THREAD ONLY — never call from health_manager/death_manager (see the
|
||||
threading rules in CLAUDE.md). Between actions the cursor otherwise sits
|
||||
exactly where the last click left it, indefinitely.
|
||||
"""
|
||||
try:
|
||||
if not Config().stealth.get("idle_drift_enabled", True):
|
||||
return
|
||||
from input_layer import mouse
|
||||
x, y = mouse.get_position()
|
||||
rx = x + int(random.gauss(0, 18))
|
||||
ry = y + int(random.gauss(0, 14))
|
||||
rx = max(20, min(1260, rx))
|
||||
ry = max(20, min(700, ry))
|
||||
mouse.move(rx, ry, randomize=6, delay_factor=[0.6, 1.1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def chicken_threshold(base: float) -> float:
|
||||
"""Per-game chicken threshold. Only ever RAISED (safer), never lowered.
|
||||
|
||||
A fixed 0.40 every single game is a precise tell. The asymmetry is
|
||||
deliberate: a higher threshold bails earlier, so the worst case of this
|
||||
randomisation is a slightly cautious run — never a death.
|
||||
"""
|
||||
try:
|
||||
spread = float(Config().stealth.get("chicken_variance", 0.08))
|
||||
except Exception:
|
||||
spread = 0.08
|
||||
if spread <= 0:
|
||||
return base
|
||||
# Capped at base+spread. An uncapped Gaussian tail reached 0.55 on a 0.40
|
||||
# base — chickening at 55% HP throws away healthy games, which is its own
|
||||
# kind of cost. The band is meant to be a few points wide, not open-ended.
|
||||
bump = min(spread, abs(random.gauss(0, spread / 2)))
|
||||
return round(base + bump, 4)
|
||||
|
||||
|
||||
def should_browse_town() -> bool:
|
||||
"""Chance of an unproductive town action — opening the inventory and
|
||||
closing it, the way a player checks something and moves on."""
|
||||
try:
|
||||
chance = float(Config().stealth.get("town_browse_chance", 0.0))
|
||||
except Exception:
|
||||
return False
|
||||
if chance > 0 and random.random() < chance:
|
||||
_tl("town_browse", "ok", f"idle inventory check (chance {chance})")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_skip_pickup() -> bool:
|
||||
"""Occasionally walk past something the filter wanted.
|
||||
|
||||
The bot otherwise picks up exactly what the filter says, instantly, every
|
||||
time. Kept small — this costs real loot.
|
||||
"""
|
||||
try:
|
||||
chance = float(Config().stealth.get("pickup_skip_chance", 0.0))
|
||||
except Exception:
|
||||
return False
|
||||
if chance > 0 and random.random() < chance:
|
||||
_tl("pickup_skip", "ok", f"walked past a wanted item (chance {chance})")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = '0.8.4'
|
||||
__version__ = '0.10.0'
|
||||