Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
47e9344fd5 | ||
|
|
0176f66a1c | ||
|
|
38c2bbdea6 | ||
|
|
682580e44c | ||
|
|
34cc75c9e4 | ||
|
|
e62272b743 | ||
|
|
e797445cb9 | ||
|
|
5d5b03ca76 | ||
|
|
f5ea29489e | ||
|
|
abb06066d5 | ||
|
|
05df847933 | ||
|
|
0eca544d2e | ||
|
|
737636b644 | ||
|
|
17bcb95c0a | ||
|
|
59b851b107 | ||
|
|
0e90b36e14 | ||
|
|
71109e9532 | ||
|
|
6012934e66 | ||
|
|
48d8445c05 | ||
|
|
44794fba67 | ||
|
|
aa78bab638 | ||
|
|
3d11947bf2 | ||
|
|
f31c30f388 | ||
|
|
b9f6635f8f | ||
|
|
73c4601e2b | ||
|
|
5647f323a7 | ||
|
|
3db44fe4e6 | ||
|
|
57073946ae | ||
|
|
5c687773b1 | ||
|
|
c1997b438e | ||
|
|
aa2f52f702 | ||
|
|
0fbe480f38 | ||
|
|
de2943fe7a | ||
|
|
41c230ad9c | ||
|
|
a4e3ff0e2f | ||
|
|
6625c1e1d5 | ||
|
|
fc8ef97a7a | ||
|
|
1a58006188 | ||
|
|
e18b81200f | ||
|
|
03da8783b6 | ||
|
|
26ecb826ec | ||
|
|
3ed837e1ee | ||
|
|
175f8e342b | ||
|
|
c566519cd8 | ||
|
|
e712076f08 | ||
|
|
63d0a334cb | ||
|
|
0facc84661 | ||
|
|
c3a2edb23c | ||
|
|
a4498d2244 | ||
|
|
0facc38613 | ||
|
|
4bb460db2a | ||
|
|
23f9488853 | ||
|
|
e79cf0470d | ||
|
|
fffc0a4b56 | ||
|
|
0e9c18e3e7 | ||
|
|
87c8dc5e16 | ||
|
|
b84bdc8f4b | ||
|
|
8ca96241bf | ||
|
|
f42637a5d8 | ||
|
|
cbc797c14a | ||
|
|
3522dedcee | ||
|
|
6a8f1ed5d9 | ||
|
|
ebe2e354dc | ||
|
|
868d117530 | ||
|
|
9244570e22 | ||
|
|
b06c77d2e4 | ||
|
|
0834831220 | ||
|
|
40e4dd8a4f | ||
|
|
1b4773b42e | ||
|
|
77b35df26b | ||
|
|
63248a9437 | ||
|
|
2862591492 | ||
|
|
ddb7008419 | ||
|
|
43c77deede | ||
|
|
b77347542d | ||
|
|
1b7721779c | ||
|
|
6822a7e495 | ||
|
|
66a71b4ad3 | ||
|
|
b8479c2300 | ||
|
|
2bfcf0ff02 | ||
|
|
c80e002ab9 | ||
|
|
1fe95aae08 | ||
|
|
f1c5b6cdf4 | ||
|
|
19011c1a12 | ||
|
|
78bc195bd9 | ||
|
|
2784b50675 | ||
|
|
4e7c517764 | ||
|
|
3093e04517 | ||
|
|
b1bec0cecc | ||
|
|
54e63197c2 | ||
|
|
3372b91ed7 | ||
|
|
9d0ae9322a | ||
|
|
06c73e7cbf | ||
|
|
f410ea21f1 | ||
|
|
d9a6e7ce40 | ||
|
|
c92b7730ee | ||
|
|
a7b12bc8a3 | ||
|
|
0b8d67b4e4 | ||
|
|
3479a74503 | ||
|
|
5727a81cb8 | ||
|
|
95bd5a76ec | ||
|
|
a05a13be67 | ||
|
|
a06e46df09 | ||
|
|
8a58b44866 | ||
|
|
d99e3461fe | ||
|
|
a695252614 | ||
|
|
7ef62161ca | ||
|
|
6301490cc4 | ||
|
|
83951db876 | ||
|
|
793d7ed967 | ||
|
|
5fbac05e4e | ||
|
|
594d633787 | ||
|
|
9f45539210 | ||
|
|
83539ed9c3 | ||
|
|
f1d6c6bd1c | ||
|
|
86e3cccbc9 | ||
|
|
d0ecf3afdc | ||
|
|
8ccdca7946 | ||
|
|
7bd9d01023 | ||
|
|
45fa775a2e | ||
|
|
b781dd56b1 | ||
|
|
fda747a1bf | ||
|
|
5254b7ee75 | ||
|
|
f705091991 | ||
|
|
8cf152cfb4 | ||
|
|
af43051b22 | ||
|
|
a611765e42 | ||
|
|
21e63fc423 | ||
|
|
02d113ea0c | ||
|
|
17c97a61b1 | ||
|
|
307b021352 | ||
|
|
3410a82895 | ||
|
|
35fa4ba442 | ||
|
|
e729dc0c0e | ||
|
|
7e39119f33 | ||
|
|
d69f066da5 | ||
|
|
88bd369bd0 | ||
|
|
dc48a05d61 | ||
|
|
6f4d6880e4 | ||
|
|
97f4e55643 | ||
|
|
a91e128a1e | ||
|
|
01a0351530 | ||
|
|
5ac00d16d0 | ||
|
|
4db549cd85 | ||
|
|
95f520e968 | ||
|
|
7dcbb5284b | ||
|
|
dc61367a6e | ||
|
|
6c8da72bf4 | ||
|
|
77f2092572 | ||
|
|
6d42216fd1 | ||
|
|
d1502a008a | ||
|
|
b94147a9c9 | ||
|
|
75a35821e4 | ||
|
|
d4ccca8496 | ||
|
|
26f5faf977 | ||
|
|
3f46ccbe23 | ||
|
|
08a79b73c1 | ||
|
|
22b7ac5b49 | ||
|
|
3d7163df16 | ||
|
|
4c88c7204f | ||
|
|
4b96155a37 | ||
|
|
2baf51c717 | ||
|
|
2cd683ac7b | ||
|
|
30bf163ee3 | ||
|
|
cb3d49fa22 | ||
|
|
5244a50ea3 | ||
|
|
f59ffd5d1f | ||
|
|
8eafd91f0a | ||
|
|
a83dc15b62 | ||
|
|
78bb963654 | ||
|
|
7d1f03beb7 | ||
|
|
7d6314b575 | ||
|
|
21c33a3dc9 | ||
|
|
b9f3253e24 | ||
|
|
fefcfa013f | ||
|
|
4b339228fe | ||
|
|
43f973ebc2 | ||
|
|
16a4d121c0 | ||
|
|
be874f3275 | ||
|
|
719b0b1dea | ||
|
|
c228fa9661 | ||
|
|
8853620786 | ||
|
|
e4c201bf2d | ||
|
|
9491169549 | ||
|
|
3f50a6529c | ||
|
|
ed65691257 | ||
|
|
14b4835702 | ||
|
|
ecf91683b7 | ||
|
|
29e6cc3c99 | ||
|
|
9c00ca5de7 | ||
|
|
dbee47e152 | ||
|
|
512acb02b9 | ||
|
|
69dc33706a | ||
|
|
93771198f1 | ||
|
|
5cfa91fe64 | ||
|
|
8574eedc16 | ||
|
|
bc649ed863 | ||
|
|
ea4a753e1c | ||
|
|
635ccb0dea | ||
|
|
fd49d9700d | ||
|
|
ef29136e16 | ||
|
|
51319fd11f | ||
|
|
e3d6605595 | ||
|
|
19f4b8a553 | ||
|
|
3888cc9666 | ||
|
|
055cd4fdf1 | ||
|
|
377b2803e3 | ||
|
|
7620bd4ef7 | ||
|
|
9b5ba463c2 | ||
|
|
64349a988d | ||
|
|
cdc13d1c6c | ||
|
|
a3ac31463e | ||
|
|
e54bb64d03 | ||
|
|
179eac8b80 | ||
|
|
b7a7280964 | ||
|
|
909aa88977 | ||
|
|
254c167cbe | ||
|
|
b3fd60c26d | ||
|
|
55def980cd | ||
|
|
265326a471 | ||
|
|
af6ab38939 | ||
|
|
5ee0061936 | ||
|
|
0bd8e7e7bc | ||
|
|
25c5810778 | ||
|
|
f20ac02720 | ||
|
|
c96fcdf545 | ||
|
|
b77d62144a | ||
|
|
e573a28e8a | ||
|
|
d79f472c33 | ||
|
|
3bf1c3d539 | ||
|
|
a691d4bdfc | ||
|
|
b84b745dbd | ||
|
|
a70302f44d | ||
|
|
0b1af05841 | ||
|
|
d96cf544bf | ||
|
|
fc3ac30431 | ||
|
|
a39acf510d | ||
|
|
c9ffcf1e4f | ||
|
|
0ccf8ae5ea | ||
|
|
d4e1758d97 | ||
|
|
40d46bb592 | ||
|
|
c5519ff290 | ||
|
|
dd45386579 | ||
|
|
54f0e3ebcc | ||
|
|
7eb961476e | ||
|
|
d44acf8bf2 | ||
|
|
f597a0108c | ||
|
|
af06d8aff4 | ||
|
|
9ee102a0f1 | ||
|
|
5e320f0eef | ||
|
|
2634619b18 | ||
|
|
dc224bec5c | ||
|
|
a77e87e138 | ||
|
|
080b7d1679 | ||
|
|
d4fec70950 | ||
|
|
d3dbf2bf9c | ||
|
|
887a97c93b | ||
|
|
d6c8d8d5f7 | ||
|
|
e1c18faf26 | ||
|
|
bdd6247342 | ||
|
|
77ad97022e | ||
|
|
d9383cc164 | ||
|
|
a77dafaf08 | ||
|
|
18c08a4c65 | ||
|
|
f8380af3bd | ||
|
|
ee940b7e3d | ||
|
|
91403e49d3 | ||
|
|
8ee31d9691 | ||
|
|
40a207be9a | ||
|
|
dec3269a0c | ||
|
|
27573bf362 | ||
|
|
5451357b86 | ||
|
|
94f3e6bca8 | ||
|
|
6c261c3cbb | ||
|
|
6ace0a7970 | ||
|
|
ed4aaf77f8 | ||
|
|
416b6714dd | ||
|
|
ab29fa0a46 | ||
|
|
77d84680c6 | ||
|
|
d0e6eb7d21 | ||
|
|
75f64351ee | ||
|
|
8dc677d2e6 | ||
|
|
8d1a2f20c8 | ||
|
|
e9d88f9763 | ||
|
|
776a09da5d | ||
|
|
b3052e50b4 | ||
|
|
8575bc4446 | ||
|
|
3f6363aec6 | ||
|
|
bab6e24438 | ||
|
|
9bbc753a16 | ||
|
|
df501d3ea9 | ||
|
|
5ea1c17902 | ||
|
|
0af8a54116 |
@@ -1,8 +1,21 @@
|
||||
# .coveragerc to control coverage.py
|
||||
[run]
|
||||
branch = True
|
||||
source = src
|
||||
# Exclude conda/xonsh internal shims that leak into coverage data. The phantom
|
||||
# is an absolute path like D:\a\...\config-3.py, so a bare "config-3.py" omit
|
||||
# never matches — use a glob that matches any path ending in config-<n>.py.
|
||||
omit =
|
||||
*config-*.py
|
||||
*/site-packages/*
|
||||
*/conda-meta/*
|
||||
|
||||
[xml]
|
||||
output = coverage.xml
|
||||
|
||||
[report]
|
||||
# Ignore missing source files (conda shims reference files not in tree)
|
||||
ignore_errors = True
|
||||
# Regexes for lines to exclude from consideration
|
||||
exclude_lines =
|
||||
# Have to re-enable the standard pragma
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Botty personal environment overrides.
|
||||
# Copy this file to ".env" in the repo root and edit your values there.
|
||||
# ".env" is ignored by git and should never be committed.
|
||||
#
|
||||
# Format: BOTTY_<CONFIG_KEY>=<value>
|
||||
# These override values from config/params.ini and config/custom.ini at runtime.
|
||||
#
|
||||
# Common personal settings:
|
||||
|
||||
# Bot display/account name used in logs/Discord usernames.
|
||||
# BOTTY_NAME=zapzap
|
||||
|
||||
# Main Discord webhook (status/death/chicken/general messages).
|
||||
# BOTTY_CUSTOM_MESSAGE_HOOK=https://discord.com/api/webhooks/...
|
||||
|
||||
# Optional dedicated loot webhook for item drops.
|
||||
# BOTTY_CUSTOM_LOOT_MESSAGE_HOOK=https://discord.com/api/webhooks/...
|
||||
|
||||
# Optional auto-login credentials (keep private).
|
||||
# BOTTY_BNET_NAME=your-battlenet-email-or-name
|
||||
# BOTTY_BNET_PASS=your-battlenet-password
|
||||
# BOTTY_CHAR_NAME=your-character-name
|
||||
|
||||
# Optional path override if you reference it in params.ini:
|
||||
# BOTTY_SAVED_GAMES_FOLDER=C:\Users\you\Saved Games\Diablo II Resurrected
|
||||
@@ -1,52 +1,207 @@
|
||||
name: Botty - CI
|
||||
on: [pull_request]
|
||||
name: CI
|
||||
|
||||
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:
|
||||
tests:
|
||||
install-and-test:
|
||||
name: Install & Test (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: actions/setup-python@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
# TODO: The below setp only chaches conda packages, need to cache pip seperatly
|
||||
- name: Cache conda
|
||||
uses: actions/cache@v2
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-win11.txt
|
||||
shell: bash
|
||||
|
||||
- name: Install Tesseract OCR
|
||||
run: choco install tesseract --no-progress -y
|
||||
|
||||
- name: Python version
|
||||
run: python -c "import sys; print(sys.version)"
|
||||
|
||||
- name: Syntax check
|
||||
env:
|
||||
# Increase this value to reset cache if environment.yml has not changed
|
||||
CACHE_NUMBER: 1
|
||||
with:
|
||||
path: ~/conda_pkgs_dir
|
||||
key: conda-${{ env.CACHE_NUMBER }}-${{hashFiles('environment.yml') }}
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v2
|
||||
PYTHONPATH: ./src
|
||||
run: python -m compileall -q src tools test scripts
|
||||
|
||||
- name: Verify core imports
|
||||
env:
|
||||
# Increase this value to reset cache if environment.yml has not changed
|
||||
CACHE_NUMBER: 1
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
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: 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 = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
|
||||
|
||||
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: C:\Program Files\Tesseract-OCR\tesseract.exe
|
||||
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: |
|
||||
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
|
||||
|
||||
- name: Tests with coverage
|
||||
env:
|
||||
PYTHONPATH: ./src:.
|
||||
RUN_ENV: test
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
path: ~/pip
|
||||
key: pip-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }}
|
||||
- name: Install Miniconda
|
||||
uses: conda-incubator/setup-miniconda@v2
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment.yml
|
||||
use-only-tar-bz2: true # Needed for caching
|
||||
- name: Activate conda
|
||||
shell: powershell
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat init powershell
|
||||
set PYTHONPATH=./src
|
||||
- name: Pytest & Coverage
|
||||
shell: powershell
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -c "import sys; print(sys.version)"
|
||||
coverage run --source=./src -m pytest -v -s
|
||||
- name: Coverage Report Generation
|
||||
shell: powershell
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
coverage xml
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
@@ -15,13 +15,54 @@ botty_v*/
|
||||
custom.ini
|
||||
config/custom.ini
|
||||
config/custom.*.ini
|
||||
config/*.local.ini
|
||||
.vscode
|
||||
.vs/
|
||||
test/assets/
|
||||
# Logs and run reports
|
||||
log/
|
||||
log/archive/
|
||||
log/runs/
|
||||
log/stats/
|
||||
*.log
|
||||
|
||||
# Scraped data (cached web pages, URLs)
|
||||
data/d2jsp_pages/
|
||||
data/d2jsp_topic_urls.txt
|
||||
|
||||
# Debug files
|
||||
debug_forum.html
|
||||
|
||||
# Generated/runtime price data (updated automatically by bot)
|
||||
config/fg_daily_estimates.json
|
||||
config/fg_prices.json
|
||||
config/fg_prices.db
|
||||
config/daily_prices.json
|
||||
config/daily_prices_history.json
|
||||
config/traderie_prices.json
|
||||
|
||||
# Screenshots (debug/test captures)
|
||||
screenshots/
|
||||
|
||||
# Tools dev cache
|
||||
tools/__pycache__/
|
||||
|
||||
# Build artifacts
|
||||
botty_v*/
|
||||
botty_v*.zip
|
||||
|
||||
.venv
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.bak
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Secrets / account-specific — never commit
|
||||
cookies.txt
|
||||
cookies_temp*
|
||||
*.cookies
|
||||
config/custom.ini
|
||||
coverage.xml
|
||||
utils/live-view/
|
||||
config/bnip/*
|
||||
@@ -33,5 +74,32 @@ loot_screenshots/
|
||||
pickit_screenshots/
|
||||
*info_log_parsed.txt
|
||||
*info_*.png
|
||||
*.log
|
||||
log/
|
||||
blizzpw.txt
|
||||
|
||||
# Local user/workspace files
|
||||
.claude/
|
||||
GEMINI.md
|
||||
test_config_load.py
|
||||
|
||||
# Debug directory
|
||||
botty_debug/
|
||||
|
||||
# per-user character profiles (survive git pulls)
|
||||
config/profiles/
|
||||
config/active_profile.txt
|
||||
|
||||
# Docker / dev-only files (not production)
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-*.bat
|
||||
DOCKER.md
|
||||
botty_next/
|
||||
src/bridge_server.py
|
||||
src/input_layer/bridge_input.py
|
||||
.hermes/
|
||||
fixtures/
|
||||
test/run/
|
||||
|
||||
# Installer output (generated by run_install_capture.bat)
|
||||
install_log.txt
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Botty - Open Issues & TODO Plan
|
||||
|
||||
Generated: 2026-06-01
|
||||
|
||||
---
|
||||
|
||||
## HIGH PRIORITY (affects botting reliability)
|
||||
|
||||
### 1. BNIP transpiler broken code (`src/bnip/transpile.py:369`)
|
||||
- **Status:** `# TODO FIX THIS SHIT` — `remove_quantity()` function is hacky
|
||||
- **Problem:** Expression splitting by `#` is fragile; can corrupt BNIP expressions with multiple `#` delimiters
|
||||
- **Impact:** Pickit rules may silently misparse quantity operators
|
||||
- **Fix:** Rewrite `remove_quantity()` to properly handle `#`-delimited expressions with edge cases
|
||||
- **File:** `src/bnip/transpile.py`
|
||||
|
||||
### 2. FOH cast delay missing (`src/char/paladin/fohdin.py:99`)
|
||||
- **Status:** `# TODO: add delay between FOH casts--doesn't properly cast each FOH in sequence`
|
||||
- **Problem:** FOH casts fire too fast; some casts don't land in sequence
|
||||
- **Impact:** Reduced DPS, wasted FOH rotations
|
||||
- **Fix:** Add `wait()` between FOH casts to ensure each cast completes before next
|
||||
- **File:** `src/char/paladin/fohdin.py`
|
||||
|
||||
### 3. Chest opening telekinesis workaround (`src/chest.py:51`)
|
||||
- **Status:** `# TODO: Act as picking up a potion to support telekinesis`
|
||||
- **Problem:** Chest open simulates potion pickup to work around telekinesis skill
|
||||
- **Impact:** Fragile interaction; may break with game updates
|
||||
- **Fix:** Implement proper chest interaction that accounts for telekinesis
|
||||
- **File:** `src/chest.py`
|
||||
|
||||
### 4. Inventory full handling (`src/item/pickit.py:236`)
|
||||
- **Status:** `#TODO Create logic to handle inventory full`
|
||||
- **Problem:** When inventory fills, pickit just stops — doesn't try to stash, sell, or prioritize
|
||||
- **Impact:** Bot stops picking up items mid-run; lost gold/runes
|
||||
- **Fix:** Add fallback logic: stop picking, trigger town run to stash/sell
|
||||
- **File:** `src/item/pickit.py`
|
||||
|
||||
### 5. Overburdened handling (`src/ui/view.py:109`)
|
||||
- **Status:** `#TODO: handle "Overburdened"`
|
||||
- **Problem:** `pickup_corpse()` doesn't detect "Overburdened" state after clicking
|
||||
- **Impact:** Bot may get stuck trying to pickup corpse when overweight
|
||||
- **Fix:** Add template detection for "Overburdened" UI and handle gracefully
|
||||
- **File:** `src/ui/view.py`
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM PRIORITY (code quality / edge cases)
|
||||
|
||||
### 6. BNIP parenthesis cross-section check (`src/bnip/transpile.py:199`)
|
||||
- **Status:** `# TODO Backtrace until the last opening to make sure it wasn't from the past section.`
|
||||
- **Problem:** Parenthesis validation doesn't catch `(` in one BNIP section and `)` in another
|
||||
- **Impact:** Silent BNIP syntax errors that pass validation
|
||||
- **Fix:** Implement backtrace to reject cross-section parentheses
|
||||
- **File:** `src/bnip/transpile.py`
|
||||
|
||||
### 7. BNIP lexer misplaced checks (`src/bnip/lexer.py:319`)
|
||||
- **Status:** `# TODO: The second checks seem a little misplaced`
|
||||
- **Problem:** `NTIPAliasClass` and `TokenType.CLASS:` checks should be in transpiler validation, not lexer
|
||||
- **Impact:** Code organization; potential missed validation
|
||||
- **Fix:** Move validation logic to `transpile.py` and emit warnings
|
||||
- **File:** `src/bnip/lexer.py`, `src/bnip/transpile.py`
|
||||
|
||||
### 8. BNIP actions error handling (`src/bnip/actions.py:209`)
|
||||
- **Status:** `# TODO look at these errors`
|
||||
- **Problem:** BNIP load errors are printed but not properly logged or categorized
|
||||
- **Impact:** Hard to diagnose BNIP parse failures
|
||||
- **Fix:** Replace `print()` with proper `Logger.error()` and structured error reporting
|
||||
- **File:** `src/bnip/actions.py`
|
||||
|
||||
### 9. Pickit return type (`src/item/pickit.py:165`)
|
||||
- **Status:** `TODO :return: return a list of the items that were picked up`
|
||||
- **Problem:** Docstring says it should return a list, but function returns `bool`
|
||||
- **Impact:** Inconsistent API; callers can't know what was actually picked
|
||||
- **Fix:** Return `list[Item]` of picked items instead of `bool`
|
||||
- **File:** `src/item/pickit.py`
|
||||
|
||||
### 10. Consumable auto-belt (`src/inventory/personal.py:350`)
|
||||
- **Status:** `# TODO: logic for trying to add potion to belt if there are needs`
|
||||
- **Problem:** Consumables found during inventory management aren't auto-added to belt
|
||||
- **Impact:** Bot doesn't restock belt potions from inventory during runs
|
||||
- **Fix:** Add logic to detect belt needs and move potions from inventory
|
||||
- **File:** `src/inventory/personal.py`
|
||||
|
||||
### 11. Merc blocking templates (`src/run/nihlathak.py:45`)
|
||||
- **Status:** `# TODO: We might need a second template for each option as merc might run into the template`
|
||||
- **Problem:** Merc can stand on template match location, causing detection failure
|
||||
- **Impact:** Nihlathak run fails to detect layout variant
|
||||
- **Fix:** Add backup templates with offset ROIs for each layout variant
|
||||
- **File:** `src/run/nihlathak.py`
|
||||
|
||||
---
|
||||
|
||||
## LOW PRIORITY (cleanup / refactoring)
|
||||
|
||||
### 12. Character select cleanup (`src/ui/character_select.py:109`)
|
||||
- **Status:** `# TODO: can cleanup logic here, can we utilize a generic ScreenObject or use custom locator?`
|
||||
- **Problem:** Character selection uses ad-hoc template search instead of reusable ScreenObject
|
||||
- **Fix:** Refactor to use `ScreenObjects` pattern
|
||||
- **File:** `src/ui/character_select.py`
|
||||
|
||||
### 13. Screen utility functions (`src/screen.py:104`)
|
||||
- **Status:** `# TODO: Move the below funcs to utils(?)`
|
||||
- **Problem:** `convert_monitor_to_screen()` and related functions live in `screen.py` but could be in utils
|
||||
- **Fix:** Move coordinate conversion functions to `src/utils/`
|
||||
- **File:** `src/screen.py`
|
||||
|
||||
### 14. Graphic debugger re-init (`src/utils/graphic_debugger.py:60`)
|
||||
- **Status:** `# TODO: these two layers variable needs to be reassigned because F10 will not re-init`
|
||||
- **Problem:** Debugger layers don't reinitialize properly on F10 toggle
|
||||
- **Fix:** Move layer state into controller class; reinit on stop/start
|
||||
- **File:** `src/utils/graphic_debugger.py`
|
||||
|
||||
### 15. mttkinter logging (`src/utils/mttkinter.py:62-63`)
|
||||
- **Status:** `# TODO: Replace custom logging functionality with standard logging.Logger`
|
||||
- **Problem:** Custom logging in tkinter utils instead of standard library
|
||||
- **Fix:** Replace with `logging.Logger`
|
||||
- **File:** `src/utils/mttkinter.py`
|
||||
|
||||
### 16. Pickit test note (`test/nip/keep_item_test_cases.py:978`)
|
||||
- **Status:** `# TODO: I had to change from [defense] >= 47 to [plusdefense] >= 47`
|
||||
- **Problem:** `[defense]` is a calculated property; `[plusdefense]` is the raw value. Note for future reference.
|
||||
- **Fix:** Document in BNIP docs that `[defense]` is calculated; `[plusdefense]` is the raw modifier
|
||||
- **File:** `test/nip/keep_item_test_cases.py`
|
||||
|
||||
### 17. New route scaffolding (`src/utils/new_route.py`)
|
||||
- **Status:** Multiple TODO placeholders (by design — it's a code generator template)
|
||||
- **Problem:** Template placeholders are intentional; not bugs
|
||||
- **Fix:** No action needed — these are scaffolding placeholders
|
||||
- **File:** `src/utils/new_route.py`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Priority | Count | Files |
|
||||
|----------|-------|-------|
|
||||
| HIGH | 5 | transpile.py, fohdin.py, chest.py, pickit.py, view.py |
|
||||
| MEDIUM | 6 | transpile.py, lexer.py, actions.py, pickit.py, personal.py, nihlathak.py |
|
||||
| LOW | 6 | character_select.py, screen.py, graphic_debugger.py, mttkinter.py, keep_item_test_cases.py, new_route.py |
|
||||
|
||||
**Total: 17 items across 13 files**
|
||||
@@ -0,0 +1,114 @@
|
||||
# Dia-run test #2 — session state & plan (2026-06-11 ~20:45)
|
||||
|
||||
## OUTCOME (21:29) — ALL FIXES VERIFIED ✅
|
||||
Full Diablo run completed with every fix live: game 1 (21:14 session) ran Pindle (45s, clean,
|
||||
no BC double-swap... BC retried once — template marginal, see below) then run_diablo SUCCEEDED
|
||||
(21:17:28→21:29:04): A5 WP found FIRST TRY at new 0.62 threshold (was 0-for-5 before),
|
||||
vendor trip skipped by gating, CS layout matched 91.5%, all seals, Diablo killed, game ended
|
||||
clean at 872s. Implemented beyond the original plan: quick-mode for open_wp (failure 1 → direct
|
||||
path only on next call; failure 2 → instant fail), sweep 10→6 steps, select timeout 4s, and
|
||||
A5 threshold drops (stash 0.60/0.45, WP 0.62 — safe because success_func gates every click).
|
||||
Remaining known marginals (non-blocking): BC/BO skill icon template (1 extra swap ~2s, 2/3 games),
|
||||
A5_TOWN_0/1 town markers (detect_current_act warns but soft-falls-back), npc body templates
|
||||
(Larzuk/Cain flaky, fallbacks work), numpy truthiness bug in missing-template debug screenshot
|
||||
helper, inventory-full pickit skips until next successful stash. Char left at char-select/lobby,
|
||||
D2R running, no bot processes.
|
||||
|
||||
## Goal
|
||||
User asked: "trigger a full dia run and monitor it for loops and mistakes". A full Diablo run
|
||||
(WP → ROF → CS → 3 seals → kill) must complete while logging all loops/mistakes, then deliver
|
||||
an analysis report.
|
||||
|
||||
## Current state
|
||||
- Bot was F12-stopped at 20:37 (was stuck in A5_WP search loop, game 3, char wandered to town wall).
|
||||
- D2R is OPEN, character "fistman" is IN the stuck game with the **Options→Video menu open**.
|
||||
- Next immediate steps: Esc out of options, click SAVE AND EXIT at **physical (650, 424)**,
|
||||
relaunch bot, F11, re-arm monitor, wait for a full dia run (stealth may randomly skip runs).
|
||||
- After run completes: F12 stop, kill leftover `cmd`/`pwsh` with `run_botty` in commandline,
|
||||
delete `log/_*.png` and `log/_run2_out.txt` scratch files, deliver findings report.
|
||||
|
||||
## How to drive (hard-won specifics)
|
||||
- Display is 1920x1200 physical, 125% scaling (1536x960 logical). **Use the botty env python**
|
||||
(`C:\ProgramData\miniforge3\envs\botty\python.exe`) with `ctypes.windll.user32.SetProcessDPIAware()`
|
||||
+ `src/input_layer/win_input.py` `mouse_move/mouse_click/mouse_wheel` for clicks (physical coords;
|
||||
cwd must be C:\Users\alex\my-botty with sys.path.insert(0,"src")).
|
||||
PowerShell `SetCursorPos`/`mouse_event` are DPI-virtualized → clicks land 1.25x off — do NOT use.
|
||||
- Click into D2R twice (first click only activates the window).
|
||||
- F11/F12 hotkeys work via `keybd_event` from anywhere (GetAsyncKeyState polling).
|
||||
- Screenshots: PIL `ImageGrab.grab(all_screens=True)` in the DPI-aware python = physical pixels.
|
||||
- Launch: `cmd /c C:\Users\alex\my-botty\run_botty.bat *> log\_run2_out.txt` (PowerShell bg task).
|
||||
- Watch `log/stats/events_*.jsonl` (newest) + `log/log.txt`.
|
||||
|
||||
## Findings so far for the final report (test #2, started 20:23)
|
||||
1. **A5_WP selection loop (CRITICAL, 3/3 occurrences after Pindle returns)**: every A5 WP open
|
||||
after a Pindle run fails first try ("Wanted to select A5_WP"); anchor retries (qual_kehk, malah)
|
||||
sometimes recover (~25s cost), but in game 3 (~20:30) ALL anchors + directed sweep failed,
|
||||
char wandered to the town wall off all pather nodes, looped 15+ times until manual intervention.
|
||||
Hypothesis: after Pindle TP return, pather position estimate is wrong; traverses compound the error.
|
||||
2. **NPC detection failures**: Cain (A4) timed out → fell back to A5 Cain (worked); Tyrael resurrect
|
||||
timed out once, retry worked. Town maintenance took ~3 min in game 1 due to these.
|
||||
3. **Battle Command prebuff retry fired 3/3 games** ("Failed to find Battle Command, swapping
|
||||
weapons again") — CTA buff icon detection systematically needs a second swap.
|
||||
4. **Mouse misses (relative mode)**: ~6 occurrences, 12-80px off, all self-corrected via SetCursorPos
|
||||
retry (win_input fallback working as designed).
|
||||
5. **Player chicken at Pindle** game 2 (HP 37.2%, 59s game) — survivability, not logic.
|
||||
6. **Stealth random skip** skipped Diablo in game 1 — by design but reduces dia throughput.
|
||||
7. **D2R settings now verified correct** (in-game screenshots 20:42): DLSS OFF, 1280x720 windowed,
|
||||
texture HIGH, details LOW, AA/AO off → matches assets/d2r_settings.json (startup warning gone).
|
||||
Previous session's CS template failures should be fixed; pentagram matched 95% last session.
|
||||
8. Earlier fixes this session (all verified live): hotkey.wait() no-arg blocking bug, edge-triggered
|
||||
hotkeys, OCR tesseract_cmd wiring, NipSyntaxError→BNipSyntaxError + Schaefershammer typo.
|
||||
|
||||
## PERMANENT FIX PLAN (user-approved direction: fix properly, prefer smarter designs)
|
||||
|
||||
### Fix 1 — A5_WP loop: fail-fast + fresh game (CRITICAL, the 10-min wander)
|
||||
`a5.py:open_wp` already has 3 escalation layers (direct path → 3 anchors → directed sweep,
|
||||
~3.5 min total). The death loop is the OUTER chain: `bot.on_maintenance` retry sites call
|
||||
`buy_consumables`/`go_to_act` again → `town_manager.open_wp` again → full 3.5-min escalation
|
||||
again, from an ever-worse position estimate. Each failed cycle compounds.
|
||||
**Smart fix:** position estimates can't be trusted after a failure, but a NEW GAME gives a
|
||||
guaranteed-known spawn in ~40s. Add a per-game WP-failure budget on the `Bot` instance:
|
||||
- `self._wp_fail_count` reset in `on_init`; `town_manager.open_wp` failure increments it
|
||||
(thread the signal via return or a callback).
|
||||
- In `on_maintenance`/`on_end_run`: if `_wp_fail_count >= 2` → `_save_error_screenshot` +
|
||||
`trigger_or_stop("end_game", failed=True)` immediately. No more wandering retries.
|
||||
- Also cap `a5.open_wp` layer 3 (sweep) to run only on the FIRST failure per game; subsequent
|
||||
calls in the same game go straight to fail (the sweep from an unknown spot is what walked the
|
||||
char onto the town wall).
|
||||
|
||||
### Fix 2 — same family: A5_RED_PORTAL first-click miss (Pindle approach, seen 20:47)
|
||||
Same position-estimate root cause, already has a "retry from town start" recovery that works.
|
||||
Include its failure in the same per-game budget rather than new mechanisms.
|
||||
|
||||
### Fix 3 — reduce A5→A4 Jamella trips (exposure reduction, smarter)
|
||||
`buy_consumables: in A5 — traveling to A4 Jamella (Malah unreliable)` runs every game even when
|
||||
only selling 1-2 junk items. Gate the trip: only travel to A4 if (pots needed below threshold)
|
||||
OR (tp/id tomes low) OR (inventory has >N sell items). Selling junk can wait; stash is in A5.
|
||||
Fewer WP trips = fewer chances to hit Fix-1 territory.
|
||||
|
||||
### Fix 4 — Battle Command prebuff double-swap (3/3 games)
|
||||
`Failed to find Battle Command, swapping weapons again` every game. The buff check runs too
|
||||
soon after weapon swap (buff icons fade in). In the prebuff code (char/hammerdin.py or
|
||||
i_char.pre_buff): add ~0.4-0.6s wait after CTA casts before checking the buff bar, and lower
|
||||
the BC icon threshold slightly (capture shows icons render fine). Saves a full swap cycle/game.
|
||||
|
||||
### Fix 5 — Cain ID: sticky act preference
|
||||
A4 Cain timed out (20s wasted) then A5 Cain worked. Cache `self._last_good_cain_act` on Bot;
|
||||
try that act first next game. One-line behavioral memory, halves ID time after first game.
|
||||
|
||||
### Fix 6 — leave as-is (verified fine)
|
||||
- Mouse misses: ~6/session, all self-corrected by SetCursorPos retry (stealth Bezier primary
|
||||
path is intentional). No change.
|
||||
- Stealth random run skip: by design.
|
||||
- Pindle chicken @ 37% HP: gear/survivability, not code. Mention to user only.
|
||||
|
||||
### Verification after implementing
|
||||
- Unit-light: run 3+ games (`run_pindle`+`run_diablo`), grep log for: no second consecutive
|
||||
`Wanted to select A5_WP` burst per game; `Battle Command` retry absent; Jamella trip skipped
|
||||
when nothing needed; failed-WP game ends < 90s instead of 900s timeout.
|
||||
|
||||
## Stats so far (test #2)
|
||||
- Game 1: Pindle OK (46s) + Diablo stealth-skipped. Maintenance ~3min (Cain/Tyrael/A5_WP issues).
|
||||
- Game 2: Pindle chicken @ HP 37% (59s, failed).
|
||||
- Game 3: Pindle OK (43s), then A5_WP loop before Diablo → manually stopped 20:37.
|
||||
- Diablo run not yet completed in test #2.
|
||||
@@ -34,14 +34,14 @@ main.py (main thread)
|
||||
|
||||
### Input Layer (`src/input_layer/`)
|
||||
|
||||
Native Windows API input replacement (no kernel drivers):
|
||||
Native Windows API input replacement designed to evade kernel-level anti-cheat (Warden). It bypasses common Python libraries like `pyautogui` or `pynput` which can be easily detected.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `win_input.py` | ctypes wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos` |
|
||||
| `mouse_impl.py` | Humanized mouse with Bezier curves, Gaussian distortion, endpoint wobble |
|
||||
| `hotkey.py` | Polling-based hotkey manager (replaces `keyboard.add_hotkey`) |
|
||||
| `__init__.py` | Drop-in API: `from input_layer import keyboard, mouse` |
|
||||
| `win_input.py` | Low-level `ctypes` wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos`. Uses standard Windows user-mode APIs. |
|
||||
| `mouse_impl.py` | Humanized mouse controller. Features include: Bezier curve trajectories, Gaussian noise (hand tremor), endpoint wobble, and randomized arrival-to-click delays. |
|
||||
| `hotkey.py` | Polling-based hotkey manager that avoids global hooks. All polling intervals include micro-jitter. |
|
||||
| `__init__.py` | Drop-in API that shims standard input calls with stealth timing and variable duration automatically. |
|
||||
|
||||
All key presses include stealth micro-pauses and variable press duration automatically.
|
||||
|
||||
@@ -162,12 +162,15 @@ Singleton that merges config files in priority order:
|
||||
|
||||
Supports variable substitution via `[variables]` sections.
|
||||
|
||||
### Stealth (`src/utils/stealth.py`)
|
||||
### Stealth System (`src/utils/stealth.py`)
|
||||
|
||||
Three-tier stealth system:
|
||||
- **Tier 1 (Input)**: Micro-pauses, click variance, key press duration, endpoint wobble
|
||||
- **Tier 2 (Behavior)**: Wrong waypoint chance, skill mistake chance, skill hesitation
|
||||
- **Tier 3 (Session)**: AFK breaks, run skipping, personality seed per character
|
||||
A multi-tiered approach to mimicking human behavior and evading detection:
|
||||
|
||||
- **Tier 1: Input Stealth**: Automatic micro-pauses (20-120ms), variable key press durations (20-200ms), and non-linear mouse paths via `input_layer`.
|
||||
- **Tier 2: Behavioral Stealth**: Probabilistic "mistakes" such as clicking the wrong waypoint (2.5% chance) or skill hesitation (80-300ms) before casting.
|
||||
- **Tier 3: Session Stealth**: Randomized run durations (+/-15%), AFK breaks (2-12 mins), and shuffling of farming routes between rotations.
|
||||
|
||||
All timing across the bot is routed through `utils.misc.wait()`, which applies Gaussian jitter to every sleep call.
|
||||
|
||||
### Utilities (`src/utils/`)
|
||||
|
||||
@@ -181,6 +184,17 @@ Three-tier stealth system:
|
||||
| `node_recorder.py` | Record new path nodes |
|
||||
| `stealth.py` | Stealth behavior randomization |
|
||||
|
||||
### Auxiliary Tools
|
||||
|
||||
Standalone tools located in the root directory for project maintenance and development:
|
||||
|
||||
- `asset_manager.py`: Unified interface for auditing, searching, and optimizing template assets.
|
||||
- `asset_extractor.py`: Screenshot capture and AI-assisted entity cropping workflow.
|
||||
- `build.py`: PyInstaller wrapper for building production executables.
|
||||
- `desktop_snap.py`: Lightweight tool for capturing full desktop screenshots.
|
||||
- `quest_debug.py`: Debugging interface for the questing system.
|
||||
- `screenshot_tool.py`: Simple utility for taking D2R client area screenshots.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
@@ -219,6 +233,16 @@ D2R Game Window
|
||||
|
||||
At startup, `key_detector.py` reads the character's `.key`/`.keyo` file from `Saved Games/Diablo II Resurrected/` to auto-detect skill bindings and non-skill keys (inventory, show items, etc.). This eliminates manual key configuration.
|
||||
|
||||
Slot-to-key mapping (`CHAR_BINDING_SLOTS`):
|
||||
| Slot | Config key |
|
||||
|---|---|
|
||||
| 41 | `show_belt` |
|
||||
| 36 | `stand_still` |
|
||||
| 44 | `weapon_switch` |
|
||||
| 43 | `force_move` |
|
||||
|
||||
If params.ini and the `.keyo` file disagree, params.ini wins and a `"Keeping configured key binding"` line is logged. No disagreement = no log line.
|
||||
|
||||
## Coordinate Systems
|
||||
|
||||
| System | Origin | Used By |
|
||||
@@ -229,3 +253,72 @@ At startup, `key_detector.py` reads the character's `.key`/`.keyo` file from `Sa
|
||||
| Relative | Template match position | Inventory grid, NPC interaction |
|
||||
|
||||
Conversion functions in `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()`, etc.
|
||||
|
||||
## State Machine
|
||||
|
||||
`bot.py` uses the `transitions` library. States and transitions are defined at `Bot.__init__` time.
|
||||
|
||||
**States**: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
|
||||
|
||||
**Key transitions**:
|
||||
|
||||
| Trigger | Source | Dest | Notes |
|
||||
|---|---|---|---|
|
||||
| `init` | initialization | initialization | Screen detection, routes to create_game or start_from_town |
|
||||
| `select_character` | initialization | hero_selection | |
|
||||
| `start_from_town` | initialization/hero_selection | town | |
|
||||
| `maintenance` | town | town | Heal, buy pots, stash, repair, resurrect merc |
|
||||
| `run_pindle` | town | pindle | |
|
||||
| `run_arcane` | town | arcane | |
|
||||
| `end_run` | any run state | town | TP back; calls `on_end_run` which TPs to town |
|
||||
| `end_game` | town/any run state | initialization | Save & exit; use when no TP scrolls or unrecoverable |
|
||||
|
||||
`end_run` requires working TP scrolls. If charges = 0, trigger `end_game` instead — otherwise the bot loops trying to TP back indefinitely.
|
||||
|
||||
## HealthManager — Internal Timing
|
||||
|
||||
The background health monitor (`src/health_manager.py`) polls at:
|
||||
|
||||
```
|
||||
interval = max(0, (3/25 - fn_elapsed) * jitter(0.8–1.2))
|
||||
```
|
||||
|
||||
That's approximately every 3 game frames (96–144ms at 25 FPS). The jitter prevents perfectly regular polling patterns from being detectable.
|
||||
|
||||
**Rejuv logic**:
|
||||
1. Minimum 0.60s between rejuv drinks (hit recovery guard).
|
||||
2. Drinks rejuv if `health ≤ take_rejuv_potion_health` OR `mana ≤ take_rejuv_potion_mana`.
|
||||
3. "Double rejuv" chicken fires only if `last_drink < 8s` **AND** `health ≤ take_rejuv_potion_health`.
|
||||
- The HP check is critical: mana-triggered rejuvs can legitimately fire back-to-back at full HP (Hammerdin spending mana fast). Without the HP check, false chickens occur at 99.9% HP.
|
||||
|
||||
## PickIt — Item Identity
|
||||
|
||||
`GroundItem` has two identity fields:
|
||||
|
||||
| Field | Formula | Purpose |
|
||||
|---|---|---|
|
||||
| `ID` | `slugify(Name + all as_dict() values including Amount)` | Pickit cache key; different gold amounts = different IDs |
|
||||
| `UID` | `ID + screen center position` | Deduplication within a single items list; same pile at same coords = same UID |
|
||||
|
||||
The fail-detection in `_pick_up_item` uses `item.ID == prev.ID` for gold and `item.UID == prev.UID` for everything else. Two nearby gold piles with different amounts bypass the ID check since they produce different IDs.
|
||||
|
||||
`_yoink_item()` always returns `PickedUpResult.PickedUp` regardless of actual success. True pickup failures surface only through `_pick_up_item`'s same-ID/UID repeat detection. On confirmed failure, the item's `ID` is blacklisted in `_cached_pickit_items` so it isn't retried in the same session.
|
||||
|
||||
## Configuration Priority
|
||||
|
||||
Merge order (highest priority first):
|
||||
|
||||
```
|
||||
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
|
||||
```
|
||||
|
||||
`Config` is a singleton (`__new__` + `data_loaded` class variable). First instantiation loads all files; subsequent calls return the same instance. Key detection runs during `__init__` after `self.char` is populated.
|
||||
|
||||
## Known Architectural Issues
|
||||
|
||||
See `IMPROVEMENTS.md` for the full list. Highest-priority unresolved items:
|
||||
|
||||
- **C10**: `kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API). Can corrupt locks/GIL. Replace with `threading.Event` cooperative shutdown.
|
||||
- **H12/H14**: Health/death managers use module-level globals for state. HealthManager now has a `_state_lock`; death manager does not yet.
|
||||
- **M14**: `PickedUpResult` enum has a gap (values 0,1,3,4,5 — missing 2).
|
||||
- **H10/H11**: `pather.py` (750 lines) and `config.py` are oversized and should be split.
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
# Claude Code Working Guide — my-botty
|
||||
|
||||
This file is auto-read by Claude Code at session start. It covers what you need to debug failures, extend the bot, and avoid known pitfalls. For static architecture see `ARCHITECTURE.md`; for Gemini conventions see `GEMINI.md`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Orientation
|
||||
|
||||
`my-botty` is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only `SendInput`, `GetCursorPos`, `SetCursorPos` via `ctypes`.
|
||||
|
||||
**Active setup (as of 2026-06-08):**
|
||||
- Character: Hammerdin (`fistman`), Hell difficulty
|
||||
- Runs: `run_diablo`, `run_pindle` (see `config/params.ini`)
|
||||
- OS: Windows 11, D2R 1280×720 windowed
|
||||
- Start bot: `run_botty.bat` (runs `python src/main.py` from the conda env — always current with source; no exe build exists/needed)
|
||||
|
||||
**Always start a session by reading logs first:**
|
||||
```
|
||||
log/log.txt ← current session (DEBUG level)
|
||||
log/stats/events_*.jsonl ← per-run event stream (JSON lines)
|
||||
log/stats/stats_*.log ← human-readable summary
|
||||
```
|
||||
|
||||
Use `/check-logs` slash command to get an instant summary.
|
||||
|
||||
---
|
||||
|
||||
## How to Read a Failure
|
||||
|
||||
### Startup line (every session)
|
||||
```
|
||||
=== BOT START === char=hammerdin | difficulty=hell | routes=['run_diablo', 'run_pindle']
|
||||
```
|
||||
If this is missing, the bot crashed before `on_init()`.
|
||||
|
||||
### Approach failure (run couldn't get to the boss area)
|
||||
```
|
||||
ERROR Approach failed for run_diablo [step: use_wp_rof]
|
||||
```
|
||||
The `[step: X]` tells you exactly which sub-step failed. Also sent to Discord via `_save_error_screenshot`. Step names per run:
|
||||
|
||||
| Run | Step names (in order) |
|
||||
|-----|----------------------|
|
||||
| `diablo` | `open_wp` → `use_wp_rof` → `verify_rof_first` → `retry_open_wp` → `retry_use_wp_rof` → `verify_rof_retry` |
|
||||
| `vizier` | `open_wp` → `use_wp_rof` |
|
||||
| `arcane` | `open_wp` → `use_wp_arcane` |
|
||||
| `shenk` | `open_wp` → `use_wp_frigid` |
|
||||
| `trav` | `open_wp` → `use_wp_travincal` |
|
||||
| `nihlathak` | `open_wp` → `use_wp_halls_of_pain` → `verify_halls_of_pain` |
|
||||
| `pindle` | `go_to_act5` → `traverse_to_portal` → `retry_traverse_to_portal` → `click_red_portal` |
|
||||
| `andariel` | `go_to_act1` → `traverse_to_wp` → `open_wp` → `use_wp_catacombs` |
|
||||
| `countess` | `go_to_act1` → `traverse_to_wp` → `open_wp` → `use_wp_black_marsh` |
|
||||
| `mephisto` | `go_to_act3` → `traverse_to_wp` → `open_wp` → `use_wp_durance` |
|
||||
| `baal` | `go_to_act5` → `traverse_to_wp` → `open_wp` → `use_wp_worldstone` |
|
||||
|
||||
**Implementation:** Each run class has `self.approach_fail_step: str | None = None`. Set to step name just before `return False`. Read in `bot.py` `_run_wrapper()` via `getattr(run_obj, "approach_fail_step", None)`.
|
||||
|
||||
### Maintenance failure (town routine broke)
|
||||
```
|
||||
ERROR Maintenance failed [step: buy_consumables] — vendor NPC not found after retry
|
||||
```
|
||||
Also sent to Discord. Maintenance steps in execution order:
|
||||
`town_heal` → `inspect_inventory` → `identify_items` → `buy_consumables` / `heal` → `stash_items` → `repair` → `resurrect_merc` → `gamble`
|
||||
|
||||
Tracked in `self._maintenance_step` on the `Bot` instance (`bot.py`). Non-fatal failures (repair, resurrect) just log a warning with the step name. Fatal failures (buy_consumables, stash_items) call `_save_error_screenshot("maintenance", reason)` then `trigger_or_stop("end_game", failed=True)`.
|
||||
|
||||
### Common error patterns to grep for
|
||||
|
||||
```
|
||||
ERROR|WARNING|failed|Approach failed|starting from True|DAMAGED|SendInput.*missed
|
||||
```
|
||||
|
||||
| Pattern | Cause |
|
||||
|---------|-------|
|
||||
| `starting from True` | `_curr_loc` became Python `True` instead of a `Location` enum — see `TownManager.identify()` bug below |
|
||||
| `failed on item.*DAMAGED` | `NTIP_ALIAS_QUALITY_MAP` missing `ItemQualityKeyword.Damaged.value` — fixed in `bnip_data.py` |
|
||||
| `SendInput.*missed target` | Win11 pointer acceleration amplifying relative mouse moves — fixed in `win_input.py` |
|
||||
| `Repair/vendor interaction failed` | Flaky NPC detection, best-effort — bot continues |
|
||||
| `Could not identify act from location` | `_curr_loc` is `None` or `True`, not a `Location` string |
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs and Fixes (permanent reference)
|
||||
|
||||
### Bug 1: `_curr_loc = True` propagation
|
||||
**File:** `src/town/town_manager.py` — `identify()` method (~line 236)
|
||||
|
||||
The act-level `identify()` returns a `Location` enum on success. The wrapper previously returned `curr_loc` (the passed-in value) unchanged. If `curr_loc` was somehow `True` (Python bool), it propagated. `True == 1` in Python, so pather's `traverse_nodes((True, A5_QUAL_KEHK))` could match `A5_TOWN_START` (enum value 1), masking the bug.
|
||||
|
||||
**Fix:** Both return sites in `identify()` now return `success if isinstance(success, Location) else curr_loc` (primary) and `success if isinstance(success, Location) else new_loc` (A5 fallback).
|
||||
|
||||
**Defensive guard in bot.py** (~line 438):
|
||||
```python
|
||||
self._curr_loc = self._town_manager.identify(self._curr_loc)
|
||||
if self._curr_loc is True:
|
||||
Logger.warning("identify() returned True — resetting to A5_TOWN_START")
|
||||
self._curr_loc = Location.A5_TOWN_START
|
||||
```
|
||||
|
||||
### Bug 2: `DAMAGED` quality KeyError
|
||||
**File:** `src/d2r_image/bnip_data.py` — `NTIP_ALIAS_QUALITY_MAP`
|
||||
|
||||
`ItemQualityKeyword.Damaged = 'DAMAGED'` exists but the map was missing an entry for it. Caused `KeyError: 'DAMAGED'` on 20+ items per session whenever Charsi repair triggered ground-item reads.
|
||||
|
||||
**Fix:** Added `ItemQualityKeyword.Damaged.value: 1` to the map alongside `LowQuality`, `Crude`, `Cracked`.
|
||||
|
||||
### Bug 3: NPC name tag templates stale → `open_npc_menu` always times out
|
||||
**File:** `src/npc_manager.py` — `open_npc_menu()`
|
||||
|
||||
NPC body templates find the NPC correctly (Akara consistently at screen ~(649, 407)), but the name tag "AKARA"/"MALAH" templates score only ~0.28 on hover, just below the original 0.35 threshold. This caused a 20-second full-screen search that never clicked anyone.
|
||||
|
||||
**Root causes:**
|
||||
1. Name tag templates are slightly stale vs. current D2R rendering
|
||||
2. Name tag search used the full screen ROI, so false-positive text anywhere on screen could also score 0.28+ — raising threshold didn't help
|
||||
3. After click, the old code checked gold name tag template (also stale) instead of the NPC dialogue UI element
|
||||
|
||||
**Fix:** Three changes in `open_npc_menu()`:
|
||||
1. **Small ROI for name tag check**: after hovering, search for the name tag only in a 240×140px box directly above the cursor (where D2R always renders name tags), preventing distant false positives
|
||||
2. **Lower name tag threshold 0.35 → 0.26**: Akara at 0.28 now passes; with the small ROI false-positive risk is contained
|
||||
3. **NPCDialogue confirmation instead of gold tag**: after clicking, check `is_visible(ScreenObjects.NPCDialogue)` first — more reliable than matching a stale gold name tag template
|
||||
4. **Timeout 20 s → 8 s**: fails faster when the NPC is genuinely off-screen
|
||||
|
||||
**Symptom to grep for (before fix):** `NPC akara hover - White score: 0.28x` spinning for 20 s
|
||||
|
||||
---
|
||||
|
||||
### Bug 4: `open_npc_menu` body+pose scoping bug → false positive Akara/Malah clicks
|
||||
**File:** `src/npc_manager.py` — `open_npc_menu()`, hover loop
|
||||
|
||||
User-added body+pose fallback click path had a scoping bug: `min_dist` was computed per template in the build loop but the variable was NOT stored in the result dict, so the hover loop's `pose_confirmed = ... and min_dist < 100` used the stale value from the last template processed — not the current result's distance to a known pose. This caused false positives (e.g. Akara body template scoring 0.531 at screen `(589, 560)`, far outside her ROI and 211 px from any pose) to pass `pose_confirmed` and be clicked, while the real Akara at `(979, 443)` (body 0.42–0.47, 102 px from pose) never got clicked.
|
||||
|
||||
**Fix** (`open_npc_menu` build + hover loops):
|
||||
1. Renamed `min_dist` → `min_dist_val`, stored in result dict: `results.append({..., "min_dist": min_dist_val})`
|
||||
2. Hover loop uses `result["min_dist"]` instead of stale `min_dist`
|
||||
3. Body threshold 0.50 → 0.40 (real Akara at 0.42–0.47)
|
||||
4. Pose tolerance 100 → 150 px (real Akara at 102 px from nearest pose; false positive at 211 px, correctly rejected)
|
||||
5. Removed `elif body_confident and attempts == 0` blind-first-attempt path (was the direct enabler of false positive clicks)
|
||||
|
||||
**Symptom to grep for (before fix):** `Clicking on akara at (589, 559) (body+pose confirmed, name tag score 0.225)` followed by `dialogue not open`
|
||||
|
||||
---
|
||||
|
||||
### Bug 5: `_curr_loc` becomes `A1_TOWN_START` when Cain ID fails → A1 pathing in A5 environment
|
||||
**Files:** `src/bot.py` — `on_maintenance()` lines 473–475 and 502–510
|
||||
|
||||
When Cain identification fails, `identify()` returns False → `_curr_loc` was reset to `Location.A1_TOWN_START` as a fallback. But the character is physically still in A5 Harrogath (game just spawned there). The subsequent `buy_consumables(A1_TOWN_START)` call runs A1 pather navigation in the A5 environment: pather can't find A1 reference templates → moves character to a random A5 spot → Akara body templates fire false positives → clicks fail → fatal.
|
||||
|
||||
The A1 retry at line 505 also hardcoded `Location.A1_TOWN_START` without calling `go_to_act` first, so the character was never actually navigated to A1 before running A1 pathing.
|
||||
|
||||
**Fix (both sites in `bot.py` `on_maintenance()`):**
|
||||
1. Identify fallback: `A1_TOWN_START` → `A5_TOWN_START` (character IS in A5 when Cain fails)
|
||||
2. A1 retry: add `go_to_act(1, retry_start)` before `buy_consumables` so the bot actually opens the waypoint and travels to A1 first:
|
||||
```python
|
||||
retry_start = self._curr_loc or Location.A5_TOWN_START
|
||||
a1_loc = self._town_manager.go_to_act(1, retry_start)
|
||||
self._curr_loc, result_items = self._town_manager.buy_consumables(a1_loc or Location.A1_TOWN_START, items=items)
|
||||
```
|
||||
|
||||
**Symptom to grep for (before fix):** `TownManager buy_consumables: starting from a1_town_start` immediately after `Could not identify items (Cain not available)`
|
||||
|
||||
---
|
||||
|
||||
### Bug 6: `open_npc_menu` dialogue confirmation always False → retry click closes dialogue → loop
|
||||
**File:** `src/npc_manager.py` — `open_npc_menu()` post-click confirmation block
|
||||
|
||||
After clicking Akara/Malah the bot checked `is_visible(ScreenObjects.NPCDialogue)`. The `npc_dialogue.png` template is a narrow (~15px) gold vertical border strip from an old D2R rendering; the ROI `456,0,30,150` points at the top-center of the screen where no dialogue border renders. Result: `NPCDialogue` is **always False** even when the dialogue IS open.
|
||||
|
||||
Fallback was `name_tag_gold` template — also stale. Both checks failing triggered a retry click on the NPC body, which **closes** the already-open dialogue, creating an open→can't detect→close→retry loop until timeout.
|
||||
|
||||
Also: `press_npc_btn` had `wait_until_visible(ScreenObjects.NPCDialogue, timeout=3.0)` which wasted 3 seconds on every NPC button press.
|
||||
|
||||
**Fix:** Replace NPCDialogue + gold-tag checks with action button detection (`_action_btns_visible`). The TRADE/RESURRECT/IDENTIFY buttons appear when the dialogue opens and are reliably matched by the same templates used in `press_npc_btn`. Added helper `_action_btns_visible(npc_key, img)` that mirrors `press_npc_btn`'s white→blue→grayscale search. Also added a **fast path** at the top of the hover loop: if action buttons are already visible at the start of an iteration, return True immediately without hovering.
|
||||
|
||||
**Symptom to grep for (before fix):** `NPC akara - dialogue not open, retrying click on body` repeating 2–3 times then `NPC akara - clicked but neither dialogue nor gold tag found`
|
||||
|
||||
---
|
||||
|
||||
### Bug 7: High-score false-positive name tags at positions outside NPC ROI bypass pose check
|
||||
**File:** `src/npc_manager.py` — `open_npc_menu()` click decision (was line 327)
|
||||
|
||||
White text in the game world (ground items, skill effects, UI elements) could produce name-tag white-template scores of 0.98+ at positions far outside the NPC's known ROI. Because `name_tag_confirmed = res_w.valid or res_g.valid` was unconditional, these positions bypassed the `pose_confirmed` check entirely and got clicked (e.g. (200, 539) while Akara's ROI is x=605–1004).
|
||||
|
||||
**Fix:** Gate `name_tag_confirmed` on an ROI boundary check when `attempts == 0` and the NPC has a defined ROI:
|
||||
```python
|
||||
if "roi" in npcs[npc_key] and attempts == 0:
|
||||
npc_roi = npcs[npc_key]["roi"]
|
||||
in_npc_roi = (npc_roi[0] <= hover_screen[0] <= npc_roi[0] + npc_roi[2] and
|
||||
npc_roi[1] <= hover_screen[1] <= npc_roi[1] + npc_roi[3])
|
||||
else:
|
||||
in_npc_roi = True
|
||||
name_tag_confirmed = (res_w.valid or res_g.valid) and in_npc_roi
|
||||
```
|
||||
After the first pass (`attempts > 0`), ROI restriction is lifted so the wide fallback search can still find the NPC if it wandered.
|
||||
|
||||
**Symptom to grep for (before fix):** `Clicking on akara at (200, 539) (name tag confirmed)` with x < 605
|
||||
|
||||
---
|
||||
|
||||
### Bug 8: Win11 mouse 3–6× overshoot
|
||||
**File:** `src/input_layer/win_input.py` — `mouse_move()` (~line 270)
|
||||
|
||||
Win11 relative `SendInput` deltas are amplified by Windows Enhanced Pointer Precision (pointer acceleration). A 209px delta became 678px. Affected every NPC click, waypoint click, and template interaction.
|
||||
|
||||
**Fix:** On Win11 (`_USE_ABSOLUTE_MOUSE == False`), use `SetCursorPos(x, y)` for accurate positioning, then send a zero-delta `MOUSEEVENTF_MOVE` event so D2R's hover/cursor pipeline fires at the new position.
|
||||
|
||||
```python
|
||||
# Win11 path in mouse_move():
|
||||
user32.SetCursorPos(target_x, target_y)
|
||||
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE, 0, 0))
|
||||
```
|
||||
|
||||
The OS mode is detected at import time via `utils.os_detect.detect_os()` → `_USE_ABSOLUTE_MOUSE`.
|
||||
|
||||
---
|
||||
|
||||
### Bug 9: Act-state desync → char respawns in wrong act → every subsequent game fails (2026-06-10)
|
||||
**Files:** `src/bot.py`, `src/town/town_manager.py`
|
||||
|
||||
The bot's believed location (`_curr_loc`) and the character's PHYSICAL act diverge after any town
|
||||
failure: retries hardcoded acts without traveling (`buy_consumables(A1_TOWN_START)` even when
|
||||
go_to_act(1) FAILED, `repair(A4_TOWN_START)`, `heal(A1_TOWN_START)`) and failure fallbacks blindly
|
||||
set `_curr_loc = A5_TOWN_START`/`A1_TOWN_START`. D2R respawns the char in the act it save+exited
|
||||
from, so one desync poisons EVERY following game: A5 pathing runs in A1/A4 town → `A5_WP` never
|
||||
found → 42 consecutive `open_wp` approach failures in the 2026-06-09 session.
|
||||
|
||||
**Fix:**
|
||||
1. `TownManager.detect_current_act()` — searches `TOWN_MARKERS` to find the physical act town.
|
||||
2. `TownManager.open_wp()` / `go_to_act()` — on failure/early-return, verify the physical act and
|
||||
retry with the detected act's pather.
|
||||
3. `Bot._verify_town_location(assumed)` — used at EVERY retry/fallback site in `on_maintenance()`
|
||||
and `on_end_run()` instead of hardcoded town starts. Never run act-X pathing without confirmed
|
||||
presence in act X.
|
||||
|
||||
**Symptom to grep for:** `Wanted to select A5_WP, but could not find it` repeated across games;
|
||||
`A1 open_trade_menu: navigating from a1_town_start` + `Pather: taking a random guess` while
|
||||
physically elsewhere.
|
||||
|
||||
### Bug 10: Duplicate `log_end_game` → phantom 0s "successful" games reset the fail circuit breaker (2026-06-10)
|
||||
**File:** `src/game_stats.py`
|
||||
|
||||
`bot.on_end_game()` and `game_controller.run_bot()` can BOTH call `log_end_game` for the same game.
|
||||
The second call emitted a phantom `game_ended failed:false elapsed 0` event and reset
|
||||
`_consecutive_runs_failed` to 0 — so `max_consecutive_fails=5` never triggered during the 3-hour
|
||||
death spiral. **Fix:** `log_end_game` returns early if `self._timer is None` (already logged).
|
||||
Also: `_last_failure_reason` is now cleared in `log_start_game` so events can't inherit a stale
|
||||
reason from a previous game (games 43–50 were blamed on `open_wp` when they actually failed in
|
||||
maintenance).
|
||||
|
||||
### Bug 11: Chickens mislabeled "Bot stopped (F12 or crash)" (2026-06-10)
|
||||
**Files:** `src/health_manager.py`, `src/game_controller.py`
|
||||
|
||||
`_do_chicken()` called `bot.stop()` (callback) FIRST and only set `_did_chicken = True` several
|
||||
seconds later (after save/exit + screenshot). The controller poll loop saw `_stopping` before the
|
||||
chicken flag and labeled the failure "Bot stopped (F12 or crash)". **Fix:** set `_did_chicken =
|
||||
True` at the top of `_do_chicken()` before the callback; controller re-checks the flag before
|
||||
resetting it.
|
||||
|
||||
### Bug 12: A4 Halbu repair trip = act-desync trigger (2026-06-10)
|
||||
**File:** `config/params.ini`
|
||||
|
||||
`repair_npc=a4_halbu` sent the bot A5→A4 via WP every 5 runs. Halbu detection failed 100% in the
|
||||
2026-06-09 session (body score ~0.39, name tag ~0.19), wasting ~60s per attempt and leaving the
|
||||
char in A4 (see Bug 9). **Fix:** `repair_npc=a5_larzuk` — stays in-act; the Larzuk flow has a
|
||||
direct-template fallback and still falls back to Halbu (with proper travel) if Larzuk fails.
|
||||
|
||||
### Bug 13: hotkey `wait()` no-arg returned on ANY key → silent process exit (2026-06-11)
|
||||
**File:** `src/input_layer/hotkey.py`
|
||||
The reimplemented `keyboard.wait()` (no key) returned on any keypress; `main.py` relies on it
|
||||
blocking forever to keep the process alive (all bot threads are daemons). Pressing F11 both
|
||||
started the bot AND killed the process moments later, with no traceback. **Fix:** no-arg `wait()`
|
||||
now sleeps forever; the poll loop is also edge-triggered (one fire per physical press — held
|
||||
keys no longer refire every ~20ms) and callback exceptions print instead of being swallowed.
|
||||
|
||||
### Bug 14: OCR dead — pytesseract never configured (2026-06-11)
|
||||
**File:** `src/d2r_image/ocr.py`
|
||||
pytesseract only looks for plain `tesseract` on PATH; the `PYTESSERACT_TESSERACT_CMD` env var
|
||||
set by run_botty.bat is NOT a pytesseract feature and was never read. **Fix:** ocr.py applies
|
||||
that env var (fallback: PATH, then `C:\Program Files\Tesseract-OCR\tesseract.exe`) to
|
||||
`pytesseract.pytesseract.tesseract_cmd` at import.
|
||||
|
||||
### Bug 15: NIP loader NameError + dropped rule (2026-06-11)
|
||||
**Files:** `src/bnip/utils.py`, `config/default.bnip:1714`
|
||||
`find_unique_or_set_base` raised undefined `NipSyntaxError` (→ `BNipSyntaxError`), and the
|
||||
underlying trigger was a typo `Shaefershammer` → `Schaefershammer`. 474 expressions now load.
|
||||
|
||||
### Bug 16: A5 WP death loop after Pindle returns (2026-06-11)
|
||||
**Files:** `src/town/town_manager.py`, `src/town/a5.py`, `src/bot.py`
|
||||
After a Pindle return the believed location (`a5_town_start`) is wrong (char is at the stash);
|
||||
the direct WP node path failed 5/5, and outer maintenance retries re-ran the full anchor/sweep
|
||||
escalation from ever-worse positions (one 10-min wander onto the town wall). A5 templates also
|
||||
score marginally low at current D2R settings (stash ~0.51, WP needs ≤0.62).
|
||||
**Fix (layered):** per-game WP budget on TownManager (reset in `bot.on_init`): failure 1 = full
|
||||
escalation allowed; failure 2 = `quick=True` direct path only; failure 3+ = instant False so the
|
||||
caller's fatal path ends the game (~40s fresh spawn beats wandering). Sweep trimmed 10→6 steps,
|
||||
WP select timeout 4s, thresholds dropped (WP 0.62; stash 0.60→0.45 retry) — safe because every
|
||||
select is gated by a success_func (panel-open / WP-label check).
|
||||
|
||||
### Bug 17: kill_diablo fought with Conviction + mid-fight Redemption (2026-06-11)
|
||||
**File:** `src/char/paladin/hammerdin.py` `kill_diablo()`
|
||||
Conviction doesn't boost magic-damage hammers, and the interleaved 0.8s Redemption casts were
|
||||
pure downtime vs a solo boss with no corpses — long fights got the merc killed. **Fix:** attack
|
||||
with Concentration (also buffs the merc via party aura), Redemption only once post-kill.
|
||||
|
||||
### Bug 18: stash only used personal + 3 shared (2026-06-11)
|
||||
**Files:** `src/inventory/personal.py`, `src/inventory/stash.py`
|
||||
Gold used raw `select_tab()` clicks with a 4-tab rotation (`% 4`, `> 3`); items already paged.
|
||||
D2R 2.7+ has 5 shared pages. **Fix:** gold now navigates via `select_stash_page()` (OCR-verified
|
||||
page arrows, layout-proof), rotation `% 6` / stop at `> 5`, shared-first starts at page 5.
|
||||
Also fixed a leftover `> 3` bound in the item-stash loop (`personal.py` ~line 185) → `> 5`.
|
||||
|
||||
### Bug 19: failed item transfer mistaken for "stash full" → taskkill D2R (2026-06-12)
|
||||
**File:** `src/inventory/personal.py` — `stash_all_items()` stash loop
|
||||
When `transfer_items("stash")` fails for any reason OTHER than fullness (e.g. the equipped-area
|
||||
click guard, a transient UI hiccup), keep items stay in inventory. The loop interpreted ANY
|
||||
remaining keep item as "this tab is full", paged through all stash tabs, and on the last page
|
||||
called `stash.stash_full()` → `taskkill /f /im D2R.exe` + a false Discord "stash full" alert.
|
||||
Surfaced by `tools/testbed.py stash all` (charms in cols 4–9 hit the equipped-area guard, so every
|
||||
transfer was cancelled and the loop nuked the game).
|
||||
**Fix:** before declaring the tab full, check `is_visible(ScreenObjects.EmptyStashSlot)`. If a slot
|
||||
IS free but the transfer still failed, count it as a transfer failure (cap 2) and bail out,
|
||||
leaving items in inventory — never advance tabs / call `stash_full()` on a non-full page.
|
||||
**Symptom to grep for (before fix):** repeated `transfer_items: inventory unchanged after
|
||||
attempting to stash` followed by `Wanted to stash item ... Assumes full stash` across rising page
|
||||
numbers, ending in `All stash is full, quitting`.
|
||||
**Test tip:** `tools/testbed.py stash` exercises the gold + open-stash path live; add `all` to scan
|
||||
all 10 inventory columns and exercise the keep-item transfer branch on existing charms.
|
||||
|
||||
### Bug 20: unescaped `)` in an echo killed the conda direct-download path (2026-08-05)
|
||||
**File:** `install.bat` — Miniforge install error branch
|
||||
`echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).` sat inside a parenthesised
|
||||
`if (...)` block. An unescaped `)` inside a block **terminates the block**, leaving `.` as a stray
|
||||
token. cmd parses the whole block when it reaches it, so this aborted the script **even when the
|
||||
installer succeeded and the branch was never taken** — verified with a minimal repro (unescaped form
|
||||
exits 255 on a false condition; escaped form exits 0).
|
||||
Effect: the direct-download fallback — the only path on a machine without winget — installed conda
|
||||
and then died before creating the `botty` env, leaving the bot unusable.
|
||||
**Fix:** escape as `^(exit code %errorlevel%^)`, the convention already used elsewhere in the file
|
||||
(`^(fast path^)`).
|
||||
**Symptom to grep for:** `. was unexpected at this time.` right after `Installing Miniforge3`.
|
||||
|
||||
**Two batch pitfalls that keep recurring in `install.bat` — check both when editing it:**
|
||||
1. A `::` comment line **inside** a `( )` block is a parse error. Put comments above the block.
|
||||
2. Any unescaped `(` or `)` in an `echo` inside a block breaks it. Escape as `^(` / `^)`.
|
||||
|
||||
Audit both across every `.bat` with:
|
||||
```
|
||||
awk '{ if ($0 ~ /^[ \t]*::/) { if (d>0) print FILENAME": "NR": "$0; next }
|
||||
t=$0; gsub(/\^[()]/,"",t); d += gsub(/\(/,"(",t) - gsub(/\)/,")",t); if (d<0) d=0 }' *.bat
|
||||
```
|
||||
|
||||
### Bug 21: installer needed admin, so it silently failed on a normal double-click (2026-08-05)
|
||||
**Files:** `install.bat`, `src/d2r_image/ocr.py`, `run_botty.bat`
|
||||
`winget install` defaulted to **machine scope**, putting conda in `%ProgramData%\miniforge3` — which
|
||||
requires elevation. Double-clicking `install.bat` without admin failed silently and conda never
|
||||
installed. The same bug applied to the Tesseract install.
|
||||
**Fix:** `--scope user` (conda now lands in `%USERPROFILE%\miniforge3`, no admin), plus re-scanning
|
||||
for `conda.exe`/`tesseract.exe` after winget instead of trusting its exit code — winget returns
|
||||
non-zero when a package is *already installed*. Tesseract additionally falls back to a direct NSIS
|
||||
download. `ocr.py` and `run_botty.bat` now also look in `%LOCALAPPDATA%\Programs\Tesseract-OCR`.
|
||||
**Known limitation:** the official Tesseract installer self-elevates and discards `/D=`, so it
|
||||
always installs machine-wide and **does require admin/UAC**. There is no per-user Tesseract install.
|
||||
Conda has no such limitation.
|
||||
**Note:** deleting a conda folder without running its uninstaller leaves stale Add/Remove-Programs
|
||||
entries, which make winget treat the next install as an *upgrade* instead of a fresh install.
|
||||
|
||||
### Bug 22: tesserocr never loaded → bot silently ran on the slow OCR fallback (2026-08-06)
|
||||
**File:** `install.bat` — OCR backend 1 section
|
||||
`install.bat` always printed `tesserocr: not available (DLL issue)` and the bot logged
|
||||
`OCR backend: pytesseract (fallback)`. pytesseract spawns `tesseract.exe` as a **subprocess per OCR
|
||||
call**; tesserocr uses the in-process C++ API, so this was a permanent, silent performance loss on
|
||||
every item hover.
|
||||
|
||||
**Root cause** (found by walking the PE import table with `pefile`, not by guessing):
|
||||
```
|
||||
tesserocr.pyd → tesseract52.dll → leptonica-1.78.0.dll → tiff.dll → libdeflate.dll ← MISSING
|
||||
```
|
||||
Current conda-forge `libdeflate` (>=1.20) installs the library as **`deflate.dll`**, but the older
|
||||
`tiff.dll` from the `tesseract=4.*` stack still imports the previous name **`libdeflate.dll`**.
|
||||
Nothing provided that name, so `tiff.dll` failed to load and every DLL above it failed with
|
||||
**WinError 126 — "The specified module could not be found"**. That message made it look like a
|
||||
missing *module*, which is why earlier fixes chased `os.add_dll_directory` / PATH instead. Adding
|
||||
every DLL directory does NOT help; the file genuinely does not exist under that name.
|
||||
|
||||
**Fix:** install `libdeflate` explicitly next to `tesseract=4.*`, then copy `deflate.dll` to the
|
||||
legacy name when it is absent:
|
||||
```bat
|
||||
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
|
||||
copy /y "...\deflate.dll" "...\libdeflate.dll" >nul
|
||||
)
|
||||
)
|
||||
```
|
||||
**Verify:** `install.bat` should print `tesserocr: OK (fast path)` and the bot should log
|
||||
`OCR backend: tesserocr (primary)`. Deleting `libdeflate.dll` reproduces the failure exactly.
|
||||
|
||||
**Debugging tip for any future "DLL load failed" error:** don't assume it's a search-path problem.
|
||||
Walk the real import chain and try loading each DLL directly — WinError 126 names the *importer*,
|
||||
never the missing dependency:
|
||||
```python
|
||||
import pefile, ctypes
|
||||
pe = pefile.PE(r"...\some.dll", fast_load=True)
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]])
|
||||
print([e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Run
|
||||
|
||||
1. Create `src/run/my_run.py` — class `MyRun` with `name = "run_my_run"`
|
||||
2. Add `self.approach_fail_step: str | None = None` in `__init__`
|
||||
3. In `approach()`: set `self.approach_fail_step = None` at top, then set step name before every `return False`
|
||||
4. Register in `bot.py`: instantiate in `__init__`, add to `self._do_runs`, add state + transition, add `on_run_my_run()` handler
|
||||
5. Add route toggle to `config/params.ini` under `[routes]`
|
||||
6. Add to `Config().routes_order` list
|
||||
|
||||
The `_run_wrapper()` in `bot.py` handles approach failure reporting automatically — it calls `getattr(run_obj, "approach_fail_step", None)` so no changes needed there.
|
||||
|
||||
---
|
||||
|
||||
## How Town Maintenance Works
|
||||
|
||||
Sequence in `bot.py` `on_maintenance()` (called between every run):
|
||||
|
||||
1. **town_heal** — drink belt potions if HP < 95%
|
||||
2. **inspect_inventory** — open inventory, count items, update TP/ID/key needs
|
||||
3. **identify_items** — go to Cain if any `item.need_id` is True
|
||||
4. **buy_consumables** — visit vendor for HP/mana pots, TP scrolls, ID scrolls, keys; sell flagged items (fatal if fails twice)
|
||||
5. **heal** — visit healer NPC if HP/mana below threshold (alternative to buy_consumables branch)
|
||||
6. **stash_items** — put kept items / gold in stash; run transmutes after (fatal if fails twice)
|
||||
7. **repair** — repair gear + sell via Halbu (A4) or Larzuk (A5); non-fatal, bot continues
|
||||
8. **resurrect_merc** — revive dead merc; non-fatal
|
||||
9. **gamble** — buy gamble items if Jamella has stock; non-fatal
|
||||
|
||||
`TownManager` methods (`buy_consumables`, `stash`, `repair`, etc.) return `(new_loc, items)` tuples or `(False, False)` on failure. On failure, bot.py retries once from a fallback location before giving up.
|
||||
|
||||
---
|
||||
|
||||
## Coordinate Systems (quick ref)
|
||||
|
||||
| Name | Origin | Notes |
|
||||
|------|--------|-------|
|
||||
| Monitor | Top-left of first monitor | `screen.grab()` output |
|
||||
| Screen | D2R client area top-left | UI detection |
|
||||
| Absolute | Character at screen center | Pathing targets |
|
||||
| Relative | Template match position | NPC interaction |
|
||||
|
||||
Convert via `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()`, `convert_abs_to_monitor()`, `convert_screen_to_monitor()`.
|
||||
|
||||
---
|
||||
|
||||
## Config System
|
||||
|
||||
Singleton `Config()` merges in priority order: `custom.ini` > `params.ini` > `game.ini` > `shop.ini` > `transmute.ini`. First instantiation loads; subsequent calls return the same object. User overrides go in `custom.ini` (not tracked in git).
|
||||
|
||||
Key sections in `params.ini`:
|
||||
- `[char]` — character type, keybinds, difficulty, runs_per_repair, etc.
|
||||
- `[general]` — discord webhook, auto_login, info_screenshots, difficulty
|
||||
- `[routes]` — boolean flags for each run (run_diablo=1, run_pindle=1, etc.)
|
||||
- `[routes_order]` — execution order
|
||||
|
||||
---
|
||||
|
||||
## Discord Notifications
|
||||
|
||||
`src/messages/messenger.py` wraps a Discord webhook. Error events are sent via `_save_error_screenshot(run_name, reason)` in `bot.py`, which:
|
||||
1. Saves a timestamped screenshot to `log/screenshots/error/`
|
||||
2. Calls `messenger.send_error(run_name, reason, screenshot_path)` if `discord_log_errors=1`
|
||||
|
||||
Enable in `params.ini`:
|
||||
```ini
|
||||
discord_log_errors=1
|
||||
discord_hook_url=https://discord.com/api/webhooks/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threading Safety Rules
|
||||
|
||||
- **Never call `input_layer` from the health_manager or death_manager threads.** Those threads only read screen state. All input goes through the bot thread.
|
||||
- **`set_panel_check_paused(True)`** must be called before opening any UI panel (vendor, stash, WP). Forgetting it causes health_manager to misread HP through the panel overlay and false-chicken.
|
||||
- **`_stash_mutex`** on `Bot` must be held during transmutes (stash is open). Acquired in `on_maintenance()` after stash opens.
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
**Template match failures** — Add `save_debug=True` to `template_finder.search_and_wait(...)` to dump the failed match image to `log/screenshots/debug/`.
|
||||
|
||||
**Path node failures** — `pather.py` traverse failures usually mean the bot is in the wrong position. Check the log for the node sequence — the last successful node before the failure shows where the bot got lost.
|
||||
|
||||
**Verify D2R window** — `screen.find_and_set_window_position(force=True)` re-detects the window. Call this before template searches in flaky areas (Pindle portal approach does this).
|
||||
|
||||
**Adding step tracking to a new failure point** — just set `self.approach_fail_step = "descriptive_name"` before `return False`. No other wiring needed.
|
||||
|
||||
---
|
||||
|
||||
## File Quick Reference
|
||||
|
||||
```
|
||||
src/bot.py main state machine, maintenance loop
|
||||
src/run/*.py one file per boss run
|
||||
src/town/town_manager.py orchestrates all town NPC interactions
|
||||
src/town/a1.py .. a5.py per-act NPC/WP/stash implementations
|
||||
src/input_layer/win_input.py mouse_move(), key_press(), SendInput wrappers
|
||||
src/input_layer/mouse_impl.py humanized Bezier mouse paths
|
||||
src/pather.py node-based pathfinding
|
||||
src/template_finder.py OpenCV template matching
|
||||
src/screen.py window detection, screenshot, coord conversion
|
||||
src/d2r_image/bnip_data.py NTIP alias maps (quality, stat, flag)
|
||||
src/item/pickit.py item pickup decision logic
|
||||
src/health_manager.py background HP/mana potion auto-drinker
|
||||
src/death_manager.py death detection + recovery
|
||||
src/config.py Config singleton
|
||||
config/params.ini user config (routes, difficulty, Discord)
|
||||
config/game.ini D2R UI coordinates, template ROIs
|
||||
scripts/stash_inventory.py scan all 6 stash pages → log/stash_inventory.json
|
||||
scripts/make_stash_csv.py convert stash_inventory.json → stash_list.csv (trade list)
|
||||
stash_list.csv deduplicated item list (name, page, stats); auto-updated by bot
|
||||
log/log.txt current session log
|
||||
log/stats/events_*.jsonl per-run event stream
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Botty working-state dependencies (verified 2026-06-11)
|
||||
|
||||
Everything required for the bot to run as well as it did during the verified
|
||||
full Diablo runs on 2026-06-11. If a future setup misbehaves, diff against this.
|
||||
|
||||
## Runtime stack
|
||||
| Layer | Requirement | Verified value |
|
||||
|---|---|---|
|
||||
| Python env | conda env `botty` | `C:\ProgramData\miniforge3\envs\botty` (python 3.10.14) — NOTE: `C:\Users\alex\miniforge3\envs\botty` is a broken leftover (no python.exe); `find_python.bat` skips it correctly |
|
||||
| OCR binary | Tesseract 5.5.0 (winget) | `C:\Program Files\Tesseract-OCR\tesseract.exe` — wired in `src/d2r_image/ocr.py` (env var `PYTESSERACT_TESSERACT_CMD` → PATH → this default). Conda tesseract must stay UNINSTALLED (access violations) |
|
||||
| OCR backend | pytesseract fallback | tesserocr wheel is DLL-broken (needs Tesseract 4.x libs) — pytesseract is the working path; startup logs `OCR backend: pytesseract (fallback)` |
|
||||
| Launcher | `run_botty.bat` | sets conda-like PATH, UTF-8, TESSDATA. No exe build exists — bot always runs current source |
|
||||
|
||||
## Key python packages (installed, working)
|
||||
```
|
||||
opencv-python==4.5.5.64 numpy==1.26.4 pytesseract==0.3.13
|
||||
mss==7.0.1 beautifultable==1.1.0 colorama==0.4.6
|
||||
discord.py==2.7.1 aiohttp==3.14.1 certifi==2026.5.20
|
||||
pillow==12.2.0 rapidfuzz==2.15.1 pywin32==312
|
||||
psutil==7.2.2 cryptography==48.0.1
|
||||
```
|
||||
|
||||
## D2R requirements (template matching breaks without these)
|
||||
- Settings must match `assets/d2r_settings.json` — startup warns loudly if not.
|
||||
Verified in-game 2026-06-11: 1280x720 windowed, resolution scale 100, DLSS OFF,
|
||||
AA OFF, AO OFF, texture HIGH, character/environment/transparency/shadow LOW.
|
||||
(DLSS ON was the root cause of the CS template failures earlier that day.)
|
||||
- Window: bot enforces client area at (5, 98) size 1280x720 (`enforce_d2r_window`).
|
||||
- Keybinds file: `C:\Users\alex\Saved Games\Diablo II Resurrected\Fistman*.keyo`
|
||||
(auto-parsed at startup). Skill hotkeys in `config/params.ini` must match the
|
||||
in-game skill assignments: blessed_hammer=f1, holy_shield=f2, redemption=f3,
|
||||
vigor=f4, conviction=f5, concentration=f8, teleport=b, BO=7, BC=8.
|
||||
Use `tools/capture_skill_hotkeys.py` to verify/capture them from the live game.
|
||||
- Char: hammerdin "fistman", Hell, CTA swap on weapon slot 2.
|
||||
|
||||
## Host specifics
|
||||
- Windows 11 build 26200, display 1920x1200 physical at 125% scaling (1536x960
|
||||
logical). D2R renders 1:1 physical. Any DPI-unaware automation (PowerShell
|
||||
SetCursorPos/mouse_event) lands 1.25x off — use the bot's input_layer from the
|
||||
conda env with `SetProcessDPIAware()` instead.
|
||||
- Mouse mode: relative (Win11) with SetCursorPos retry fallback (`win_input.py`).
|
||||
- `max_consecutive_fails=5`, `max_game_length_s=900`, maintenance timeout ~280s,
|
||||
per-game WP failure budget = 2 (town_manager) are the safety nets that keep a
|
||||
bad game cheap.
|
||||
@@ -24,20 +24,9 @@ Character: FOH Paladin | Priority: Anti-cheat stealth > everything else
|
||||
|
||||
## CRITICAL - DO FIRST
|
||||
|
||||
### C1. Stealth: Consolidate all timing through centralized wait()
|
||||
|
||||
Many places use bare `time.sleep()` instead of `utils.misc.wait()` (which has Gaussian jitter).
|
||||
Every direct sleep creates a predictable timing signature detectable by anti-cheat.
|
||||
|
||||
Files with bare `time.sleep()`:
|
||||
- health_manager.py line 14
|
||||
- chest.py
|
||||
- pather.py
|
||||
- game_recovery.py
|
||||
- npc_manager.py
|
||||
- bot.py
|
||||
|
||||
Fix: Replace all bare `time.sleep(n)` with `wait(n, n*1.2)` for human-like jitter.
|
||||
- [x] **C1. Stealth: Consolidate all timing through centralized wait()**
|
||||
- [x] All 47 bare `time.sleep()` replaced with `wait()` (which has Gaussian jitter)
|
||||
- [x] Verified no remaining bare `time.sleep()` in `src/` (except inside `utils.misc.wait`)
|
||||
|
||||
### C2. Stealth: Add variable typing rhythm
|
||||
|
||||
@@ -83,11 +72,12 @@ Fix: Add per-segment timing variation in `HumanCurve` execution loop.
|
||||
|
||||
### C9. ~~Bug: Fix FoHdin missing PickIt (bot.py line 72)~~ ~~(DONE)~~
|
||||
|
||||
### C10. ~~Bug: Replace thread killing with cooperative shutdown~~
|
||||
~~`utils.misc.kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API).~~
|
||||
~~This can leave locks in inconsistent state, cause GIL issues, or corrupt numpy arrays.~~
|
||||
~~Fix: Replace with threading.Event flags for cooperative shutdown.~~
|
||||
(Still needs doing - this is the most dangerous remaining bug.)
|
||||
- [x] **C10. Bug: Replace thread killing with cooperative shutdown**
|
||||
- [x] `utils.misc.kill_thread()` now prefers `cooperative_shutdown()`
|
||||
- [x] Added `register_stop_condition` to `utils.misc`
|
||||
- [x] Centralized `wait()` and `search_and_wait()` now check for shutdown signals
|
||||
- [x] `Bot`, `HealthManager`, and `DeathManager` register their stop conditions
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,13 +4,49 @@
|
||||
|
||||
Pixelbot for Diablo 2 Resurrected. This project is for informational and educational purposes only.
|
||||
|
||||
## Installation (first time)
|
||||
|
||||
**Step 1 — Install Miniforge** (only needed once, skip if you already have conda/Miniconda)
|
||||
Download and run the installer from: https://github.com/conda-forge/miniforge/releases/latest
|
||||
Pick the Windows x86_64 `.exe`. Keep defaults; tick "Add to PATH" if asked.
|
||||
|
||||
**Step 2 — Download Botty**
|
||||
Click the green **Code** button on this GitHub page → **Download ZIP**. Extract the ZIP anywhere (e.g. `C:\botty`).
|
||||
|
||||
**Step 3 — Install dependencies**
|
||||
Double-click **`install.bat`** inside the extracted folder. It will create the `botty` conda environment and install everything. This takes a few minutes the first time.
|
||||
|
||||
`install.bat` detects Windows 10 vs Windows 11 automatically:
|
||||
- Windows 10 uses `environment-win10.yml`, `requirements-win10.txt`, and absolute mouse input.
|
||||
- Windows 11 uses `environment-win11.yml`, `requirements-win11.txt`, and relative mouse input.
|
||||
|
||||
Botty also runs OS detection at startup and logs the selected requirements profile and mouse mode.
|
||||
|
||||
**Step 4 — Configure**
|
||||
Open `config\params.ini` in Notepad and set at minimum:
|
||||
- `[char] type=` — your build (`sorceress`, `hammerdin`, `paladin`, `trapsin`, …)
|
||||
- `[routes] order=` — which bosses to farm (e.g. `run_pindle`)
|
||||
- Hotkeys under your build's section to match your D2R keybinds
|
||||
|
||||
**Step 5 — Start**
|
||||
Double-click **`run_botty.bat`**. Switch to D2R, go to the hero selection screen, then press **F11** to start. Press **F12** to stop.
|
||||
|
||||
> D2R must be in **English** and running at **720p** window mode.
|
||||
|
||||
Optional personal setup:
|
||||
- Copy `.env.example` to `.env` in the repo root and set personal values there.
|
||||
- `.env` is git-ignored so multiple testers can use different local values without git conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Getting started & Prerequisites
|
||||
|
||||
- D2R needs to be in English Language,
|
||||
- Botty currently works with 720p D2R window (will be adjusted automatically on auto settings)
|
||||
|
||||
### 1) Graphics and Gameplay Settings
|
||||
|
||||
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default f9). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
|
||||
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default **ctrl+f9**). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
|
||||
**Note**: Make sure that no other external programs adapt your graphics settings (HDR, Geforce Experience, etc.)
|
||||
|
||||
### 2) Supported builds
|
||||
@@ -23,13 +59,20 @@ Open up D2R and wait till you are at the hero selection screen. Make sure the ch
|
||||
|
||||
### 4) Start Botty
|
||||
|
||||
Refer to [development.md](development.md) for setup instructions. Once the conda environment is created:
|
||||
|
||||
- **Quick start**: Double-click `run_botty.bat` (auto-detects your conda env)
|
||||
- **Manual**: `conda activate botty` then `python src\main.py`
|
||||
|
||||
After starting, focus your D2R window and press the start key (default f11). You can always force stop botty with f12. Note: Botty will use the /nopickup command in the first game to avoid pickup up trash while traversing. This command will only allow item pickup when "show items" is active.
|
||||
|
||||
### Stability and safety updates
|
||||
|
||||
- XP OCR parsing now tolerates common OCR mistakes (`I/l/| -> 1`, `O/o -> 0`, mixed-case "experience").
|
||||
- XP status math no longer throws on early-session edge cases; unavailable projections show as `n/a`.
|
||||
- Routine repair is best-effort for native-teleport builds to reduce fail spirals when NPC detection is flaky.
|
||||
- Repair fallback from A5 now attempts A4 from Larzuk location for better path reliability.
|
||||
- Discord message sending now guards invalid embed payloads and has a plain-text fallback.
|
||||
- Selling now logs item names (not just positions) and shields are protected by default.
|
||||
|
||||
## Development
|
||||
|
||||
Check out the [development.md](development.md) docu for infos on how to build from source and details of the project structure and code.
|
||||
@@ -99,7 +142,10 @@ order=run_pindle, run_eldritch
|
||||
|
||||
| [routes] | Descriptions |
|
||||
| ------------ | ------------------------------------------------------------------------ |
|
||||
| order | List of runs botty should do. These will be run in the the order listed unless `randomize_runs` is set to 1. Possible runs: </br> run_trav, run_pindle, run_eldritch, run_eldritch_shenk, run_nihlathak (requires teleport), run_arcane (requires teleport), run_diablo (requires teleport, only hammardin)
|
||||
| order | Comma-delimited run list. If `randomize_runs=0`, Botty executes left-to-right. If `randomize_runs=1`, enabled runs are shuffled each game. Possible runs: </br> run_trav, run_pindle, run_eldritch, run_eldritch_shenk, run_nihlathak (teleport strongly recommended), run_arcane (teleport strongly recommended), run_diablo (teleport recommended), run_vizier, run_andariel, run_countess, run_mephisto, run_baal |
|
||||
|
||||
Hammerdin keyrun example (stable-first):
|
||||
`order=run_countess, run_arcane, run_nihlathak`
|
||||
|
||||
| [char] | Descriptions |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------|
|
||||
@@ -198,6 +244,32 @@ python tools/click_recorder.py playback # replay with human-like timing
|
||||
|
||||
Records mouse clicks with timestamps and replays them with configurable speed and repeat count.
|
||||
|
||||
### Asset Manager
|
||||
|
||||
A comprehensive tool for managing bot assets (templates). It helps with inventorying, auditing, searching, and optimizing template images.
|
||||
|
||||
```bash
|
||||
python asset_manager.py inventory # List all templates
|
||||
python asset_manager.py audit # Check for missing/low-quality templates
|
||||
python asset_manager.py search <name> # Search for a specific template
|
||||
python asset_manager.py quality # Analyze template quality (SNR, contrast)
|
||||
python asset_manager.py batch resize 64x64 # Batch process templates
|
||||
```
|
||||
|
||||
Features include similarity checking (to find duplicates), automatic cropping, and validation of template paths.
|
||||
|
||||
### Asset Extractor
|
||||
|
||||
A workflow tool for capturing and cropping new templates from D2R screenshots. Designed to work alongside an AI agent for rapid asset generation.
|
||||
|
||||
```bash
|
||||
python asset_extractor.py
|
||||
```
|
||||
|
||||
- **F1**: Capture D2R screen to `screenshots/debug/latest.png`.
|
||||
- **F2**: Crop entities using an AI-generated `latest_annotations.json` file.
|
||||
- **F3**: List all currently extracted assets.
|
||||
|
||||
### Builds
|
||||
|| [sorceress] | Descriptions |
|
||||
|| ------------- | ----------------------------------------------------------------------------- |
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"Screen Resolution (Windowed)": "1280x720",
|
||||
"Resolution Scale": 100,
|
||||
"Sharpening": 6,
|
||||
"Game Resolution": 1,
|
||||
"Game Resolution": 2,
|
||||
"Light Quality": 2,
|
||||
"Blended Shadows": 0,
|
||||
"Perspective": 0,
|
||||
"VSync": 1,
|
||||
"Framerate Cap": 60,
|
||||
"Framerate Target": 0,
|
||||
"Window Mode": 0,
|
||||
"Window Mode": 1,
|
||||
"Graphic Presets": 4,
|
||||
"Texture Quality": 4,
|
||||
"Texture Anisotropy": 0,
|
||||
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1 +0,0 @@
|
||||
3LONE*dkbcy^v0I8
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Botty Next offline-first visual QA harness."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Capture backends for offline fixtures and live observer mode."""
|
||||
|
||||
from botty_next.capture.mss_backend import MssCaptureBackend
|
||||
from botty_next.capture.window import WindowRegion, find_window_region
|
||||
|
||||
__all__ = ["MssCaptureBackend", "WindowRegion", "find_window_region"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
from botty_next.capture.window import WindowRegion
|
||||
|
||||
|
||||
class MssCaptureBackend:
|
||||
def grab(self, region: WindowRegion | None = None) -> np.ndarray:
|
||||
with mss.mss() as screen_capture:
|
||||
monitor = region.as_mss_monitor() if region else screen_capture.monitors[1]
|
||||
shot = screen_capture.grab(monitor)
|
||||
|
||||
bgra = np.asarray(shot)
|
||||
return cv2.cvtColor(bgra, cv2.COLOR_BGRA2BGR)
|
||||
|
||||
|
||||
def save_frame(frame: np.ndarray, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cv2.imwrite(str(output), frame):
|
||||
raise RuntimeError(f"failed to write screenshot: {output}")
|
||||
return output
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowRegion:
|
||||
left: int
|
||||
top: int
|
||||
width: int
|
||||
height: int
|
||||
title: str
|
||||
|
||||
def as_mss_monitor(self) -> dict[str, int]:
|
||||
return {
|
||||
"left": self.left,
|
||||
"top": self.top,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
|
||||
def find_window_region(title_contains: str) -> WindowRegion:
|
||||
import win32gui
|
||||
|
||||
matches: list[WindowRegion] = []
|
||||
|
||||
def collect(hwnd: int, _extra) -> bool:
|
||||
if not win32gui.IsWindowVisible(hwnd):
|
||||
return True
|
||||
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
if title_contains.lower() not in title.lower():
|
||||
return True
|
||||
|
||||
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
if width > 0 and height > 0:
|
||||
matches.append(WindowRegion(left, top, width, height, title))
|
||||
return True
|
||||
|
||||
win32gui.EnumWindows(collect, None)
|
||||
if not matches:
|
||||
raise RuntimeError(f"no visible window found containing title: {title_contains}")
|
||||
|
||||
return max(matches, key=lambda region: region.width * region.height)
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from botty_next.capture.mss_backend import MssCaptureBackend, save_frame
|
||||
from botty_next.capture.window import find_window_region
|
||||
from botty_next.config import load_config
|
||||
from botty_next.vision.fixtures import load_image
|
||||
from botty_next.vision.ocr import run_tesseract_ocr, save_ocr_preprocess_debug
|
||||
from botty_next.vision.template_matching import match_template, save_match_debug
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="botty-next")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
config_parser = subparsers.add_parser("config")
|
||||
config_subparsers = config_parser.add_subparsers(dest="config_command", required=True)
|
||||
validate_parser = config_subparsers.add_parser("validate")
|
||||
validate_parser.add_argument("-c", "--config", required=True, type=Path)
|
||||
validate_parser.set_defaults(handler=validate_config)
|
||||
|
||||
detect_parser = subparsers.add_parser("detect")
|
||||
detect_parser.add_argument("detector", choices=["template"], help="detector to run")
|
||||
detect_parser.add_argument("--image", required=True, type=Path)
|
||||
detect_parser.add_argument("--template", required=True, type=Path)
|
||||
detect_parser.add_argument("--threshold", type=float, default=0.85)
|
||||
detect_parser.add_argument("--debug-output", type=Path)
|
||||
detect_parser.set_defaults(handler=detect)
|
||||
|
||||
capture_parser = subparsers.add_parser("capture")
|
||||
capture_parser.add_argument("--output", required=True, type=Path)
|
||||
capture_parser.add_argument("--window-title", type=str)
|
||||
capture_parser.set_defaults(handler=capture)
|
||||
|
||||
ocr_parser = subparsers.add_parser("ocr")
|
||||
ocr_parser.add_argument("--image", required=True, type=Path)
|
||||
ocr_parser.add_argument("--lang", default="eng")
|
||||
ocr_parser.add_argument("--psm", type=int, default=6)
|
||||
ocr_parser.add_argument("--tesseract-cmd")
|
||||
ocr_parser.add_argument("--debug-output", type=Path)
|
||||
ocr_parser.set_defaults(handler=ocr)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def validate_config(args: argparse.Namespace) -> int:
|
||||
config = load_config(args.config)
|
||||
print(json.dumps(config.model_dump(mode="json"), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def detect(args: argparse.Namespace) -> int:
|
||||
image = load_image(args.image)
|
||||
template = load_image(args.template)
|
||||
result = match_template(image, template, threshold=args.threshold)
|
||||
|
||||
if args.debug_output:
|
||||
save_match_debug(image, result, args.debug_output)
|
||||
|
||||
print(json.dumps(_result_to_dict(result), indent=2))
|
||||
return 0 if result.passed else 1
|
||||
|
||||
|
||||
def capture(args: argparse.Namespace) -> int:
|
||||
region = find_window_region(args.window_title) if args.window_title else None
|
||||
frame = MssCaptureBackend().grab(region)
|
||||
output = save_frame(frame, args.output)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"output": str(output),
|
||||
"shape": tuple(map(int, frame.shape)),
|
||||
"window": region.title if region else None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def ocr(args: argparse.Namespace) -> int:
|
||||
image = load_image(args.image)
|
||||
if args.debug_output:
|
||||
save_ocr_preprocess_debug(image, args.debug_output)
|
||||
|
||||
try:
|
||||
result = run_tesseract_ocr(
|
||||
image,
|
||||
lang=args.lang,
|
||||
psm=args.psm,
|
||||
tesseract_cmd=args.tesseract_cmd,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"error": str(exc),
|
||||
"debug_output": str(args.debug_output) if args.debug_output else None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 2
|
||||
|
||||
print(json.dumps(_ocr_result_to_dict(result), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _result_to_dict(result) -> dict:
|
||||
return {
|
||||
"confidence": result.confidence,
|
||||
"bbox": result.bbox,
|
||||
"passed": result.passed,
|
||||
"method": result.method,
|
||||
"debug": result.debug,
|
||||
}
|
||||
|
||||
|
||||
def _ocr_result_to_dict(result) -> dict:
|
||||
return {
|
||||
"text": result.text,
|
||||
"confidence": result.confidence,
|
||||
"bbox": result.bbox,
|
||||
"debug": result.debug,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Configuration loading and validation."""
|
||||
|
||||
from botty_next.config.models import BottyNextConfig, load_config
|
||||
|
||||
__all__ = ["BottyNextConfig", "load_config"]
|
||||
@@ -0,0 +1,13 @@
|
||||
profile_name: local
|
||||
capture:
|
||||
backend: fixture
|
||||
monitor: 1
|
||||
fps_limit: 10
|
||||
window_title: null
|
||||
vision:
|
||||
template_threshold: 0.85
|
||||
debug_output_dir: botty_next/debug/output
|
||||
input:
|
||||
enabled: false
|
||||
dry_run: true
|
||||
emergency_stop_key: f12
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class CaptureConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
backend: Literal["fixture", "mss", "dxcam"] = "fixture"
|
||||
monitor: int = 1
|
||||
fps_limit: int = Field(default=10, ge=1, le=240)
|
||||
window_title: str | None = None
|
||||
|
||||
|
||||
class VisionConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
template_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
|
||||
debug_output_dir: Path = Path("botty_next/debug/output")
|
||||
|
||||
|
||||
class InputConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
dry_run: bool = True
|
||||
emergency_stop_key: str = "f12"
|
||||
|
||||
@field_validator("dry_run")
|
||||
@classmethod
|
||||
def dry_run_required_when_disabled(cls, value: bool) -> bool:
|
||||
return value
|
||||
|
||||
|
||||
class BottyNextConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profile_name: str = "local"
|
||||
capture: CaptureConfig = Field(default_factory=CaptureConfig)
|
||||
vision: VisionConfig = Field(default_factory=VisionConfig)
|
||||
input: InputConfig = Field(default_factory=InputConfig)
|
||||
|
||||
@field_validator("input")
|
||||
@classmethod
|
||||
def input_must_be_explicit_and_dry_run_by_default(cls, value: InputConfig) -> InputConfig:
|
||||
if value.enabled and value.dry_run is False:
|
||||
raise ValueError("live input cannot be enabled without a future explicit safety gate")
|
||||
return value
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> BottyNextConfig:
|
||||
config_path = Path(path)
|
||||
with config_path.open("r", encoding="utf-8") as handle:
|
||||
raw = yaml.safe_load(handle) or {}
|
||||
return BottyNextConfig.model_validate(raw)
|
||||
@@ -0,0 +1 @@
|
||||
"""Debug image and report output helpers."""
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Input abstraction layer.
|
||||
|
||||
Live input is intentionally not implemented in the bootstrap harness.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Offline/private routine replay harness."""
|
||||
@@ -0,0 +1 @@
|
||||
"""State detection and transition logic."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Botty Next harness."""
|
||||
@@ -0,0 +1,12 @@
|
||||
from botty_next.capture.window import WindowRegion
|
||||
|
||||
|
||||
def test_window_region_converts_to_mss_monitor() -> None:
|
||||
region = WindowRegion(left=10, top=20, width=640, height=480, title="Example")
|
||||
|
||||
assert region.as_mss_monitor() == {
|
||||
"left": 10,
|
||||
"top": 20,
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
from botty_next.cli import main
|
||||
|
||||
|
||||
def test_config_validate_cli_starts(capsys) -> None:
|
||||
exit_code = main(["config", "validate", "-c", "botty_next/config/default.yaml"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert '"profile_name": "local"' in captured.out
|
||||
|
||||
|
||||
def test_detect_cli_runs_template_detector(capsys) -> None:
|
||||
exit_code = main(
|
||||
[
|
||||
"detect",
|
||||
"template",
|
||||
"--image",
|
||||
"fixtures/screenshots/sample_scene.ppm",
|
||||
"--template",
|
||||
"fixtures/templates/sample_marker.ppm",
|
||||
"--threshold",
|
||||
"0.99",
|
||||
]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert '"passed": true' in captured.out
|
||||
@@ -0,0 +1,10 @@
|
||||
from botty_next.config import load_config
|
||||
|
||||
|
||||
def test_load_default_config() -> None:
|
||||
config = load_config("botty_next/config/default.yaml")
|
||||
|
||||
assert config.profile_name == "local"
|
||||
assert config.capture.backend == "fixture"
|
||||
assert config.input.enabled is False
|
||||
assert config.input.dry_run is True
|
||||
@@ -0,0 +1,13 @@
|
||||
from botty_next.vision.fixtures import load_screenshot, load_template
|
||||
|
||||
|
||||
def test_load_sample_screenshot_fixture() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
assert image.shape == (8, 8, 3)
|
||||
|
||||
|
||||
def test_load_sample_template_fixture() -> None:
|
||||
template = load_template("sample_marker.ppm")
|
||||
|
||||
assert template.shape == (3, 3, 3)
|
||||
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from botty_next.vision.fixtures import load_screenshot
|
||||
from botty_next.vision.ocr import preprocess_for_ocr, run_tesseract_ocr, save_ocr_preprocess_debug
|
||||
|
||||
|
||||
def test_preprocess_for_ocr_returns_thresholded_image() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
processed = preprocess_for_ocr(image)
|
||||
|
||||
assert processed.ndim == 2
|
||||
assert processed.shape == (16, 16)
|
||||
|
||||
|
||||
def test_save_ocr_preprocess_debug(tmp_path) -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
output = save_ocr_preprocess_debug(image, tmp_path / "ocr.png")
|
||||
|
||||
assert output.exists()
|
||||
|
||||
|
||||
def test_run_tesseract_ocr_uses_pytesseract_adapter(monkeypatch) -> None:
|
||||
fake = SimpleNamespace(
|
||||
Output=SimpleNamespace(DICT="dict"),
|
||||
pytesseract=SimpleNamespace(tesseract_cmd=None),
|
||||
image_to_string=lambda *_args, **_kwargs: "Short Sword\n",
|
||||
image_to_data=lambda *_args, **_kwargs: {"conf": ["95", "-1", "85"]},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "pytesseract", fake)
|
||||
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
result = run_tesseract_ocr(image, tesseract_cmd="C:/Tesseract/tesseract.exe")
|
||||
|
||||
assert result.text == "Short Sword"
|
||||
assert result.confidence == pytest.approx(0.9)
|
||||
assert result.debug["backend"] == "pytesseract"
|
||||
assert fake.pytesseract.tesseract_cmd == "C:/Tesseract/tesseract.exe"
|
||||
@@ -0,0 +1,24 @@
|
||||
from botty_next.vision.fixtures import load_screenshot, load_template
|
||||
from botty_next.vision.template_matching import match_template, save_match_debug
|
||||
|
||||
|
||||
def test_template_match_finds_sample_marker() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
template = load_template("sample_marker.ppm")
|
||||
|
||||
result = match_template(image, template, threshold=0.99)
|
||||
|
||||
assert result.passed is True
|
||||
assert result.confidence >= 0.99
|
||||
assert result.bbox == (3, 2, 3, 3)
|
||||
assert "image_shape" in result.debug
|
||||
|
||||
|
||||
def test_template_match_can_save_debug_image(tmp_path) -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
template = load_template("sample_marker.ppm")
|
||||
result = match_template(image, template, threshold=0.99)
|
||||
|
||||
output = save_match_debug(image, result, tmp_path / "marked.png")
|
||||
|
||||
assert output.exists()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Vision helpers and detectors."""
|
||||
|
||||
from botty_next.vision.ocr import OcrResult, preprocess_for_ocr, run_tesseract_ocr
|
||||
from botty_next.vision.template_matching import MatchResult, match_template
|
||||
|
||||
__all__ = ["MatchResult", "OcrResult", "match_template", "preprocess_for_ocr", "run_tesseract_ocr"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_image(path: str | Path, *, grayscale: bool = False) -> np.ndarray:
|
||||
image_path = Path(path)
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"image fixture does not exist: {image_path}")
|
||||
|
||||
flag = cv2.IMREAD_GRAYSCALE if grayscale else cv2.IMREAD_COLOR
|
||||
image = cv2.imread(str(image_path), flag)
|
||||
if image is None:
|
||||
raise ValueError(f"OpenCV could not read image fixture: {image_path}")
|
||||
return image
|
||||
|
||||
|
||||
def load_screenshot(name: str, root: str | Path = "fixtures/screenshots") -> np.ndarray:
|
||||
return load_image(Path(root) / name)
|
||||
|
||||
|
||||
def load_template(name: str, root: str | Path = "fixtures/templates") -> np.ndarray:
|
||||
return load_image(Path(root) / name)
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OcrResult:
|
||||
text: str
|
||||
confidence: float
|
||||
bbox: tuple[int, int, int, int] | None
|
||||
debug: dict[str, Any]
|
||||
|
||||
|
||||
def preprocess_for_ocr(image: np.ndarray, *, scale: float = 2.0) -> np.ndarray:
|
||||
if image.size == 0:
|
||||
raise ValueError("image is empty")
|
||||
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
||||
if scale != 1.0:
|
||||
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
denoised = cv2.GaussianBlur(gray, (3, 3), 0)
|
||||
return cv2.adaptiveThreshold(
|
||||
denoised,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY,
|
||||
31,
|
||||
7,
|
||||
)
|
||||
|
||||
|
||||
def run_tesseract_ocr(
|
||||
image: np.ndarray,
|
||||
*,
|
||||
lang: str = "eng",
|
||||
psm: int = 6,
|
||||
tesseract_cmd: str | None = None,
|
||||
) -> OcrResult:
|
||||
try:
|
||||
pytesseract = import_module("pytesseract")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"pytesseract is not installed; run install.bat or install requirements.txt"
|
||||
) from exc
|
||||
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
|
||||
processed = preprocess_for_ocr(image)
|
||||
config = f"--psm {psm}"
|
||||
text = pytesseract.image_to_string(processed, lang=lang, config=config).strip()
|
||||
confidences = _read_confidences(
|
||||
pytesseract.image_to_data(
|
||||
processed,
|
||||
lang=lang,
|
||||
config=config,
|
||||
output_type=pytesseract.Output.DICT,
|
||||
)
|
||||
)
|
||||
confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||
return OcrResult(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=None,
|
||||
debug={
|
||||
"backend": "pytesseract",
|
||||
"lang": lang,
|
||||
"psm": psm,
|
||||
"preprocessed_shape": tuple(map(int, processed.shape)),
|
||||
"word_confidences": confidences,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_ocr_preprocess_debug(image: np.ndarray, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cv2.imwrite(str(output), preprocess_for_ocr(image)):
|
||||
raise RuntimeError(f"failed to write OCR debug image: {output}")
|
||||
return output
|
||||
|
||||
|
||||
def _read_confidences(data: dict[str, list[Any]]) -> list[float]:
|
||||
values: list[float] = []
|
||||
for raw in data.get("conf", []):
|
||||
try:
|
||||
confidence = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if confidence >= 0:
|
||||
values.append(confidence / 100.0)
|
||||
return values
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchResult:
|
||||
confidence: float
|
||||
bbox: tuple[int, int, int, int]
|
||||
passed: bool
|
||||
method: str
|
||||
debug: dict[str, Any]
|
||||
|
||||
|
||||
def _as_gray(image: np.ndarray) -> np.ndarray:
|
||||
if image.ndim == 2:
|
||||
return image
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
def match_template(
|
||||
image: np.ndarray,
|
||||
template: np.ndarray,
|
||||
*,
|
||||
threshold: float = 0.85,
|
||||
method: int = cv2.TM_CCOEFF_NORMED,
|
||||
) -> MatchResult:
|
||||
if image.size == 0:
|
||||
raise ValueError("image is empty")
|
||||
if template.size == 0:
|
||||
raise ValueError("template is empty")
|
||||
if template.shape[0] > image.shape[0] or template.shape[1] > image.shape[1]:
|
||||
raise ValueError("template cannot be larger than image")
|
||||
|
||||
image_gray = _as_gray(image)
|
||||
template_gray = _as_gray(template)
|
||||
response = cv2.matchTemplate(image_gray, template_gray, method)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(response)
|
||||
|
||||
if method in (cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED):
|
||||
top_left = min_loc
|
||||
confidence = 1.0 - float(min_val)
|
||||
else:
|
||||
top_left = max_loc
|
||||
confidence = float(max_val)
|
||||
|
||||
width = int(template.shape[1])
|
||||
height = int(template.shape[0])
|
||||
bbox = (int(top_left[0]), int(top_left[1]), width, height)
|
||||
return MatchResult(
|
||||
confidence=confidence,
|
||||
bbox=bbox,
|
||||
passed=confidence >= threshold,
|
||||
method=_method_name(method),
|
||||
debug={
|
||||
"threshold": threshold,
|
||||
"min_value": float(min_val),
|
||||
"max_value": float(max_val),
|
||||
"min_location": tuple(map(int, min_loc)),
|
||||
"max_location": tuple(map(int, max_loc)),
|
||||
"image_shape": tuple(map(int, image.shape)),
|
||||
"template_shape": tuple(map(int, template.shape)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_match_debug(image: np.ndarray, result: MatchResult, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
marked = image.copy()
|
||||
x, y, width, height = result.bbox
|
||||
color = (0, 255, 0) if result.passed else (0, 0, 255)
|
||||
cv2.rectangle(marked, (x, y), (x + width, y + height), color, 2)
|
||||
cv2.imwrite(str(output), marked)
|
||||
return output
|
||||
|
||||
|
||||
def _method_name(method: int) -> str:
|
||||
names = {
|
||||
cv2.TM_CCOEFF: "TM_CCOEFF",
|
||||
cv2.TM_CCOEFF_NORMED: "TM_CCOEFF_NORMED",
|
||||
cv2.TM_CCORR: "TM_CCORR",
|
||||
cv2.TM_CCORR_NORMED: "TM_CCORR_NORMED",
|
||||
cv2.TM_SQDIFF: "TM_SQDIFF",
|
||||
cv2.TM_SQDIFF_NORMED: "TM_SQDIFF_NORMED",
|
||||
}
|
||||
return names.get(method, str(method))
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from src.version import __version__
|
||||
import argparse
|
||||
@@ -9,6 +10,28 @@ from cryptography.fernet import Fernet
|
||||
import string
|
||||
|
||||
|
||||
def _resolve_botty_env(conda_path):
|
||||
"""Find the botty Python environment directory.
|
||||
|
||||
Tries (in order):
|
||||
1. Explicit conda path (conda_path/envs/botty)
|
||||
2. Current sys.prefix if it looks like a conda env
|
||||
3. Fallback to sys.prefix (pip/virtualenv installs)
|
||||
"""
|
||||
# 1. Explicit conda path
|
||||
botty_env = os.path.join(conda_path, "envs", "botty")
|
||||
if os.path.isdir(botty_env):
|
||||
return botty_env
|
||||
|
||||
# 2. Current prefix is a conda env
|
||||
if os.path.isfile(os.path.join(sys.prefix, "conda-meta", "history")) or \
|
||||
os.path.isdir(os.path.join(sys.prefix, "Library")):
|
||||
return sys.prefix
|
||||
|
||||
# 3. Plain pip / virtualenv — sys.prefix is the site
|
||||
return sys.prefix
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description="Build Botty")
|
||||
parser.add_argument(
|
||||
"-v" , "--version",
|
||||
@@ -71,15 +94,34 @@ if __name__ == "__main__":
|
||||
shutil.rmtree(path)
|
||||
shutil.rmtree(botty_dir)
|
||||
|
||||
botty_env = _resolve_botty_env(args.conda_path)
|
||||
pyinstaller_exe = os.path.join(botty_env, "Scripts", "pyinstaller.exe")
|
||||
if not os.path.isfile(pyinstaller_exe):
|
||||
raise RuntimeError(f"PyInstaller not found at {pyinstaller_exe}. "
|
||||
f"Install with: pip install pyinstaller")
|
||||
|
||||
# DLL dirs for PyInstaller to resolve native dependencies.
|
||||
# Conda: Library\bin, Library\lib, DLLs
|
||||
# pip/virtualenv: just the system DLLs under sys.prefix
|
||||
dll_dirs = []
|
||||
for d in ["Library/bin", "Library/lib", "DLLs"]:
|
||||
p = os.path.join(botty_env, d)
|
||||
if os.path.isdir(p):
|
||||
dll_dirs.append(p)
|
||||
if dll_dirs:
|
||||
os.environ["PATH"] = os.pathsep.join(dll_dirs) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
for exe in ["main.py", "shopper.py"]:
|
||||
key_cmd = " "
|
||||
if args.use_key:
|
||||
key = Fernet.generate_key().decode("utf-8")
|
||||
key_cmd = " --key " + key
|
||||
installer_cmd = f"pyinstaller --onefile --noconsole --distpath {botty_dir}{key_cmd} --exclude-module graphviz --exclude-module keyboard --exclude-module mouse --exclude-module pyclick --exclude-module mouseinfo --paths .\\src --paths {args.conda_path}\\envs\\botty\\Lib\\site-packages src\\{exe}"
|
||||
os.system(installer_cmd)
|
||||
installer_cmd = f'{pyinstaller_exe} --onefile --noconsole --distpath {botty_dir}{key_cmd} --exclude-module graphviz --exclude-module keyboard --exclude-module mouse --exclude-module pyclick --exclude-module mouseinfo --paths .\\src --paths "{botty_env}\\Lib\\site-packages" src\\{exe}'
|
||||
ret = os.system(installer_cmd)
|
||||
if ret != 0:
|
||||
raise RuntimeError(f"PyInstaller failed for {exe} (exit {ret})")
|
||||
|
||||
os.system(f"cd {botty_dir} && mkdir config && cd ..")
|
||||
os.makedirs(f"{botty_dir}/config", exist_ok=True)
|
||||
|
||||
with open(f"{botty_dir}/config/custom.ini", "w") as f:
|
||||
f.write("; Add parameters you want to overwrite from param.ini here")
|
||||
@@ -90,6 +132,34 @@ if __name__ == "__main__":
|
||||
os.makedirs(f"{botty_dir}/config/bnip", exist_ok=True)
|
||||
shutil.copy("README.md", f"{botty_dir}/")
|
||||
shutil.copytree("assets", f"{botty_dir}/assets")
|
||||
shutil.copytree("src", f"{botty_dir}/src")
|
||||
shutil.copy("environment.yml", f"{botty_dir}/")
|
||||
shutil.copy("install.bat", f"{botty_dir}/")
|
||||
shutil.copy("find_python.bat", f"{botty_dir}/")
|
||||
shutil.copy("run_botty.bat", f"{botty_dir}/")
|
||||
shutil.copy("run.bat", f"{botty_dir}/")
|
||||
if os.path.exists("dependencies"):
|
||||
shutil.copytree("dependencies", f"{botty_dir}/dependencies")
|
||||
|
||||
# Bundle a portable Tesseract so the standalone exe is click-and-run with
|
||||
# working OCR and no separate install. ocr.py prefers <exe_dir>/tesseract/
|
||||
# tesseract.exe. Source: TESSERACT_DIR env or the default UB Mannheim path.
|
||||
# Skipped (with a warning) if not present — the bot still works once the
|
||||
# user runs install.bat, which sets OCR up the conda way.
|
||||
tesseract_src = os.environ.get("TESSERACT_DIR", r"C:\Program Files\Tesseract-OCR")
|
||||
tess_exe = os.path.join(tesseract_src, "tesseract.exe")
|
||||
if os.path.isfile(tess_exe):
|
||||
print(f"Bundling Tesseract from {tesseract_src}")
|
||||
# Copy the exe + DLLs; skip their tessdata (we ship our own trained
|
||||
# models in assets/tessdata and pass --tessdata-dir to point at them).
|
||||
os.makedirs(f"{botty_dir}/tesseract", exist_ok=True)
|
||||
for entry in os.listdir(tesseract_src):
|
||||
src = os.path.join(tesseract_src, entry)
|
||||
if os.path.isfile(src) and entry.lower().endswith((".exe", ".dll")):
|
||||
shutil.copy(src, f"{botty_dir}/tesseract/")
|
||||
else:
|
||||
print(f"WARNING: Tesseract not found at {tesseract_src} — release will "
|
||||
f"rely on install.bat for OCR setup. Set TESSERACT_DIR to bundle it.")
|
||||
clean_up()
|
||||
|
||||
if args.random_name:
|
||||
@@ -97,12 +167,13 @@ if __name__ == "__main__":
|
||||
new_name = ''.join(random.choices(string.ascii_letters, k=random.randint(6, 14)))
|
||||
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
|
||||
|
||||
# Always rename main.exe to avoid Warden flagging the obvious name
|
||||
if not args.random_name:
|
||||
# Rename main.exe to avoid Warden flagging the obvious name
|
||||
# In CI/production builds (env BOTTY_NO_RENAME=1) keep main.exe as-is
|
||||
if not args.random_name and not os.environ.get("BOTTY_NO_RENAME"):
|
||||
new_name = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
|
||||
print(f"Renamed main.exe -> {new_name}.exe")
|
||||
|
||||
if new_version_code is not None:
|
||||
os.system(f'git add .')
|
||||
os.system(f'git commit -m "Bump version to v{args.version}"')
|
||||
os.system(f'git commit -m "Bump version to v{args.version}"')
|
||||
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "BOTTY_DIR=%~dp0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%BOTTY_DIR%tools\check_dependencies.ps1"
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
if not "%RC%"=="0" (
|
||||
echo.
|
||||
echo Dependency check reported issues.
|
||||
)
|
||||
|
||||
pause
|
||||
exit /b %RC%
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
; Personal overrides for your local machine/user.
|
||||
; This file is tracked as an example.
|
||||
; Copy to config/custom.ini and edit values there.
|
||||
;
|
||||
; Botty auto-loads config/custom.ini when present.
|
||||
|
||||
[general]
|
||||
; name=botty
|
||||
; custom_message_hook=
|
||||
; custom_loot_message_hook=
|
||||
; discord_status_runs=10
|
||||
|
||||
[char]
|
||||
; type=blizz_sorc
|
||||
; atk_len_pindle=3.0
|
||||
; show_items=alt
|
||||
|
||||
[sorceress]
|
||||
; teleport=t
|
||||
|
||||
[blizz_sorc]
|
||||
; blizzard=f1
|
||||
; ice_blast=f2
|
||||
@@ -0,0 +1,800 @@
|
||||
{
|
||||
"generated_at": "2026-06-17T07:19:33.572415+00:00",
|
||||
"mode": "offline",
|
||||
"offline_dir": "data\\d2jsp_pages",
|
||||
"ladder_start_date": "2026-05-12",
|
||||
"days": 60,
|
||||
"topics_scanned": 853,
|
||||
"estimates": {
|
||||
"day_1": {},
|
||||
"day_2": {},
|
||||
"day_3": {},
|
||||
"day_4": {},
|
||||
"day_5": {},
|
||||
"day_6": {},
|
||||
"day_7": {},
|
||||
"day_8": {},
|
||||
"day_9": {},
|
||||
"day_10": {},
|
||||
"day_11": {},
|
||||
"day_12": {},
|
||||
"day_13": {},
|
||||
"day_14": {},
|
||||
"day_15": {},
|
||||
"day_16": {},
|
||||
"day_17": {},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"notes": "Estimated Day 1-14 ladder prices derived from Day 3 snapshot using decay multipliers. Use as planning guidance, not exact market truth.",
|
||||
"source_day": 3,
|
||||
"day_multipliers": {
|
||||
"day_1": 1.45,
|
||||
"day_2": 1.2,
|
||||
"day_3": 1.0,
|
||||
"day_4": 0.93,
|
||||
"day_5": 0.88,
|
||||
"day_6": 0.84,
|
||||
"day_7": 0.8,
|
||||
"day_8": 0.77,
|
||||
"day_9": 0.74,
|
||||
"day_10": 0.72,
|
||||
"day_11": 0.7,
|
||||
"day_12": 0.68,
|
||||
"day_13": 0.66,
|
||||
"day_14": 0.64
|
||||
},
|
||||
"base_day_3_fg": {
|
||||
"Cham Rune": 800,
|
||||
"Lo Rune": 900,
|
||||
"Ohm Rune": 600,
|
||||
"Vex Rune": 400,
|
||||
"Gul Rune": 200,
|
||||
"Ist Rune": 200,
|
||||
"Mal Rune": 120,
|
||||
"Um Rune": 90,
|
||||
"Pul Rune": 50,
|
||||
"Lem Rune": 70,
|
||||
"Unid Anni": 400,
|
||||
"Unid Torch": 1500,
|
||||
"Unid Griffon": 2000,
|
||||
"Unid Eth Andy": 2000,
|
||||
"Shako": 400,
|
||||
"Mara 30": 1200,
|
||||
"Mara Mid": 750,
|
||||
"BK 5": 600,
|
||||
"War Traveler": 500,
|
||||
"Death's Fathom": 800,
|
||||
"5/5 Facet": 500,
|
||||
"5@res SC": 300,
|
||||
"20life SC": 100,
|
||||
"7mf SC": 100,
|
||||
"Pcomb SK": 200,
|
||||
"Cold SK": 250,
|
||||
"Java SK": 200,
|
||||
"Light SK": 200
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ rejuv_potion=140,50,40,160,255,255
|
||||
skill_charges=70,30,25,150,163,255
|
||||
health_globe_red=178,110,20,183,255,255
|
||||
health_globe_green=47,90,20,54,255,255
|
||||
mana_globe=117,120,20,121,255,255
|
||||
mana_globe=110,50,15,125,255,255
|
||||
blue_slot=102,194,18,138,230,54
|
||||
green_slot=33,181,18,87,258,69
|
||||
red_slot=161,204,28,197,240,64
|
||||
@@ -51,8 +51,8 @@ potion1_y=695
|
||||
potion_width=30
|
||||
potion_height=30
|
||||
potion_next=41
|
||||
merc_health_top=14
|
||||
merc_health_left=15
|
||||
merc_health_top=675
|
||||
merc_health_left=305
|
||||
merc_health_width=40
|
||||
; skills
|
||||
skill_y=693
|
||||
@@ -88,7 +88,7 @@ play_btn=426,616,320,71
|
||||
difficulty_select=536,236,210,320
|
||||
gold_btn=997,521,20,18
|
||||
inventory_gold=980,510,150,40
|
||||
gold_btn_stash=160,520,25,25
|
||||
gold_btn_stash=158,518,30,30
|
||||
vendor_gold_digits=186,509,97,16
|
||||
stash_gold_digits=184,527,100,16
|
||||
inventory_gold_digits=1017,523,100,16
|
||||
@@ -97,16 +97,16 @@ health_globe=160,580,240,140
|
||||
mana_globe=887,580,240,140
|
||||
health_slice=309,610,7,101
|
||||
mana_slice=961,610,7,101
|
||||
cut_skill_bar=0,0,1280,653
|
||||
cut_skill_bar=0,0,1284,653
|
||||
reduce_to_center=120,60,1040,540
|
||||
search_npcs=120,0,1040,620
|
||||
merc_icon=0,0,100,100
|
||||
search_npcs=120,0,1042,620
|
||||
merc_icon=10,9,56,56
|
||||
loading_left_black=0,0,350,720
|
||||
death=444,198,397,71
|
||||
tp_search=353,120,547,400
|
||||
repair_btn=318,473,90,80
|
||||
left_inventory=35,86,378,378
|
||||
right_inventory=866,348,379,152
|
||||
left_inventory=33,84,382,382
|
||||
right_inventory=868,348,379,152
|
||||
transmute_third_slot=242,342,38,38
|
||||
skill_right=664,673,41,41
|
||||
skill_right_expanded=655,375,385,255
|
||||
@@ -128,12 +128,12 @@ cube_btn_roi=160,368,125,57
|
||||
xp_bar_text=369,630,554,34
|
||||
corpse=459,195,414,213
|
||||
chat_icon=7,555,43,41
|
||||
left_panel_header=0,0,450,54
|
||||
right_panel_header=830,0,450,54
|
||||
npc_dialogue=458,4,21,140
|
||||
left_panel_header=0,0,455,56
|
||||
right_panel_header=830,0,455,56
|
||||
npc_dialogue=456,0,30,150
|
||||
bind_skill=516,619,251,29
|
||||
quest_skill_btn=284,455,710,121
|
||||
left_inventory_tabs=31,62,385,28
|
||||
left_inventory_tabs=29,60,389,30
|
||||
tab_indicator=31,71,385,14
|
||||
stash_page_select_left=155,475,20,20
|
||||
stash_page_select_right=270,475,20,20
|
||||
|
||||
@@ -1,45 +1,136 @@
|
||||
; There is detailed documentation for each parameter in the README.md
|
||||
[general]
|
||||
difficulty=normal
|
||||
name=bigfont
|
||||
; Personal values can reference env keys from repo-root .env:
|
||||
; Example syntax:
|
||||
; custom_message_hook=${BOTTY_CUSTOM_MESSAGE_HOOK}
|
||||
; custom_loot_message_hook=${ENV:BOTTY_CUSTOM_LOOT_MESSAGE_HOOK}
|
||||
; bnet_name=${BOTTY_BNET_NAME}
|
||||
; bnet_pass=${BOTTY_BNET_PASS}
|
||||
; char_name=${BOTTY_CHAR_NAME}
|
||||
; difficulty: game difficulty to create ("normal", "nightmare", "hell")
|
||||
;
|
||||
; Hammerdin difficulty guide (CTA build with Conviction aura):
|
||||
; NORMAL - Very easy. 1-2h Hammer kills most packs. No CTA needed.
|
||||
; NIGHTMARE - Recommended starting point. Bosses hit ~100-200 dmg.
|
||||
; CTA + Conviction drops resists. Need decent armor (ED/HR).
|
||||
; Gear targets: ~200 AR, ~15 ED, ~30% HR, ~50% FCR on hammer.
|
||||
; HELL - Bosses hit 400-800+ dmg per swing. Conviction is mandatory.
|
||||
; Gear targets: ~350+ AR, ~20+ ED, ~50%+ HR, ~60%+ FCR,
|
||||
; ~150% IAS on weapon. Full Rejuv belt recommended.
|
||||
; 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=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)
|
||||
target_tz=1
|
||||
saved_games_folder=C:\Users\alex\Saved Games\Diablo II Resurrected
|
||||
; saved_games_folder: optional override path to D2R Saved Games folder (blank = auto)
|
||||
saved_games_folder=
|
||||
; level_max_steps: max pathing steps for leveling-style routines
|
||||
level_max_steps=20
|
||||
|
||||
; Battle.net credentials (for auto-login at launch)
|
||||
; Leave empty to log in manually
|
||||
bnet_name=
|
||||
bnet_pass=
|
||||
; Set to 1 to enable auto-login and auto-launch of D2R on startup.
|
||||
; Credentials below are ONLY used when auto_login=1.
|
||||
auto_login=0
|
||||
bnet_name=${BOTTY_BNET_NAME}
|
||||
bnet_pass=${BOTTY_BNET_PASS}
|
||||
|
||||
; Character name to auto-select from the character selection screen
|
||||
; If empty, bot relies on the saved character template from previous sessions
|
||||
char_name=Burr
|
||||
char_name=${BOTTY_CHAR_NAME}
|
||||
|
||||
; messaging
|
||||
custom_loot_message_hook=
|
||||
custom_message_hook=
|
||||
; custom_loot_message_hook: optional separate webhook for loot notifications
|
||||
custom_loot_message_hook=${BOTTY_CUSTOM_LOOT_MESSAGE_HOOK}
|
||||
; custom_message_hook: main webhook for status/death/chicken messages
|
||||
custom_message_hook=${BOTTY_CUSTOM_MESSAGE_HOOK}
|
||||
; discord_log_chicken: 1 = send chicken/death style notifications
|
||||
discord_log_chicken=1
|
||||
; discord_log_errors: 1 = send a Discord message + error screenshot every time a
|
||||
; run fails (approach/battle/exception). Set to 0 to keep error screenshots on
|
||||
; disk only. Can also be toggled via [discord_events] error=0.
|
||||
discord_log_errors=1
|
||||
; discord_status_runs: send periodic status every X completed runs (blank/0 disables)
|
||||
discord_status_runs=10
|
||||
; discord_status_count: legacy fallback, send periodic status every X games (blank/0 disables)
|
||||
discord_status_count=20
|
||||
; 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
|
||||
; easily go 10 games without a keep-worthy drop).
|
||||
pickup_drought_window=10
|
||||
; message_api_type: "" disables messaging, "discord" or "generic_api"
|
||||
message_api_type=discord
|
||||
|
||||
; breaks
|
||||
; break_length_m: scheduled break duration in minutes (0 = disabled)
|
||||
break_length_m=0
|
||||
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
|
||||
max_runtime_before_break_m=0
|
||||
|
||||
; timers / fail handling
|
||||
; d2r_path: Diablo II: Resurrected install path
|
||||
d2r_path=C:\Program Files (x86)\Diablo II Resurrected
|
||||
; max_consecutive_fails: stop bot after this many failed runs in a row
|
||||
max_consecutive_fails=5
|
||||
max_game_length_s=160
|
||||
; max_game_length_s: emergency timeout per run/game
|
||||
; 900: a full Chaos Sanctuary clear (3 seals + bosses + loot) takes ~10 min;
|
||||
; 600 force-quit a game while literally waiting for Diablo to spawn (2026-06-10).
|
||||
; Genuinely stuck games are caught much earlier by max_maintenance_time_s and
|
||||
; approach step timeouts.
|
||||
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
|
||||
; 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)
|
||||
auto_downgrade_threshold=0
|
||||
|
||||
; if you set this field to 1, botty will attempt to restart d2 after a crash or failure
|
||||
restart_d2r_when_stuck=0
|
||||
restart_d2r_when_stuck=1
|
||||
; hardcore: 1 enables hardcore-safe assumptions in some routines
|
||||
hardcore=0
|
||||
|
||||
; screenshots
|
||||
; info_screenshots: save screenshots for info/chicken/death events
|
||||
info_screenshots=1
|
||||
; error_screenshots: save a screenshot to log/screenshots/error/ every time a run
|
||||
; fails (approach failure, battle failure, or an exception) so logs and visuals can
|
||||
; be reviewed side by side. Falls back to info_screenshots if unset.
|
||||
error_screenshots=1
|
||||
; loot_screenshots: save screenshots for picked loot
|
||||
loot_screenshots=0
|
||||
pickit_screenshots=0
|
||||
; pickit_screenshots: save screenshots for pickit debugging
|
||||
pickit_screenshots=1
|
||||
|
||||
; recovery
|
||||
; disable_run_after_failures: after this many CONSECUTIVE failures of the same run
|
||||
; (e.g. run_vizier), the bot disables just that run for the rest of the session and
|
||||
; keeps doing the other runs instead of stopping. A single success resets the count.
|
||||
; If every run gets disabled the bot stops for investigation. Set high to effectively
|
||||
; disable this behaviour.
|
||||
disable_run_after_failures=5
|
||||
; stash_scan_interval: scan all stash tabs and export stash_list.csv every X runs (0 disables)
|
||||
stash_scan_interval=0
|
||||
|
||||
[discord_events]
|
||||
; Fine-grained Discord event toggles (1=send, 0=disable)
|
||||
; status: periodic status + generic bot messages (breaks, shop notifications, etc.)
|
||||
status=1
|
||||
; item_keep: send kept item notifications (loot webhooks/embeds)
|
||||
item_keep=1
|
||||
; death: send death notifications
|
||||
death=1
|
||||
; chicken: send chicken (emergency leave) notifications
|
||||
chicken=1
|
||||
; stash_full: send stash full notifications
|
||||
stash_full=1
|
||||
; gold_full: send gold full notifications
|
||||
gold_full=1
|
||||
; error: send run-failure notifications (message + error screenshot)
|
||||
error=1
|
||||
|
||||
[stealth]
|
||||
; Multiplies all wait() calls by a random value in this range each call
|
||||
@@ -52,7 +143,10 @@ wait_jitter_max = 1.20
|
||||
click_variance = 8
|
||||
|
||||
; Re-shuffle run order after completing a full rotation (vs only at session start)
|
||||
reshuffle_each_rotation = 1
|
||||
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
|
||||
; there) — the next game then spawns at A4 where Jamella/Cain/Tyrael work,
|
||||
; avoiding the A5 Malah vendor entirely (stale templates in current patch).
|
||||
reshuffle_each_rotation = 0
|
||||
|
||||
; Probability (0-100) of randomly skipping a run each game
|
||||
; 0 = never skip, 20 = skip ~1 in 5 runs
|
||||
@@ -102,31 +196,45 @@ wrong_waypoint_chance = 0.025
|
||||
skill_mistake_chance = 0.015
|
||||
|
||||
[routes]
|
||||
; Add these possible routes to "order" as a comma delimited list to run them:
|
||||
; run_trav
|
||||
; run_pindle
|
||||
; run_eldritch
|
||||
; run_eldritch_shenk
|
||||
; run_nihlathak
|
||||
; run_arcane
|
||||
; run_diablo
|
||||
; run_vizier
|
||||
; Controls which farm runs Botty performs each game.
|
||||
; "order" is a comma-delimited list and runs left-to-right when randomize_runs=0.
|
||||
; If randomize_runs=1 (in [general]), Botty shuffles enabled runs each game.
|
||||
;
|
||||
; Hammerdin keyrun recommendation (stable-first):
|
||||
; order=run_countess, run_arcane, run_nihlathak
|
||||
;
|
||||
; Route quick notes:
|
||||
; run_trav (Act 3 Travincal council farm; short/high-density run)
|
||||
; run_pindle (Act 5 Nihlathak temple red portal boss farm)
|
||||
; run_eldritch (Act 5 Frigid Highlands Eldritch-only run)
|
||||
; run_eldritch_shenk (Act 5 Eldritch then Shenk in same game)
|
||||
; run_nihlathak (Act 5 Halls of Vaught, teleport strongly recommended)
|
||||
; run_arcane (Act 2 Arcane Sanctuary / Summoner, teleport strongly recommended)
|
||||
; run_diablo (Act 4 Chaos Sanctuary, teleport recommended)
|
||||
; run_vizier (Act 4 Chaos Vizier-only route; lighter/faster than full Diablo run)
|
||||
; run_andariel (Act 1 Catacombs)
|
||||
; run_countess (Act 1 Forgotten Tower)
|
||||
; run_mephisto (Act 3 Durance of Hate)
|
||||
; run_baal (Act 5 Throne of Destruction)
|
||||
order=run_pindle
|
||||
order=run_pindle, run_diablo
|
||||
|
||||
[char]
|
||||
; ==========================
|
||||
; ==== Mandatory Fields ====
|
||||
; ==========================
|
||||
; These configs have to be alligned with your d2r settings and char build
|
||||
type=fohdin
|
||||
; type: character build profile to use (must match one section below)
|
||||
; examples: blizz_sorc, hammerdin, fohdin
|
||||
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
|
||||
; cta_casting_frames: cast breakpoint on CTA swap (if used)
|
||||
cta_casting_frames=8
|
||||
; attack_frames: base attack animation timing for non-cast attacks
|
||||
attack_frames=15
|
||||
; cta_available: 1 if Call to Arms swap exists, else 0
|
||||
cta_available=1
|
||||
;Do we want to cast non-cta buffs (ex energy shield) with cta
|
||||
buff_with_cta=1
|
||||
@@ -139,6 +247,7 @@ safer_routines=1
|
||||
num_loot_columns=4
|
||||
|
||||
; game hotkeys:
|
||||
; NOTE: each key must match your in-game binding exactly
|
||||
force_move=e
|
||||
inventory_screen=i
|
||||
potion1=1
|
||||
@@ -151,9 +260,10 @@ 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=5
|
||||
teleport=b
|
||||
town_portal=6
|
||||
; 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
|
||||
@@ -165,13 +275,43 @@ stash_gold=1
|
||||
use_merc=1
|
||||
|
||||
; Attack length for barbarians should be as high as 8-10 and even 10-12 for trav/shenk
|
||||
;
|
||||
; Hammerdin attack lengths (seconds of hammer spam per boss):
|
||||
; atk_len_trav = 4.0 (Council of 3 - 3 council members, easy)
|
||||
; atk_len_pindle = 8.0 (Pindle — Hell: 13094-16070 HP, 75% fire, 100% poison)
|
||||
; Pindle stats by difficulty:
|
||||
; Normal: 1064-1588 HP, 75% fire, 70% poison
|
||||
; Nightmare: 4773-5859 HP, 100% poison
|
||||
; Hell: 13094-16070 HP, 75% fire, 50% cold, 33% light, 100% poison
|
||||
; With Conviction (-33% res), Hell Pindle = 50% fire, 17% cold, 0% light, 67% poison
|
||||
; CTA adds +100% dmg to hammer. Need ~60%+ FCR and good IAS to kill in 8s.
|
||||
; atk_len_nihlathak = 4.0 (Nihlathak - single boss)
|
||||
; atk_len_eldritch = 3.0 (Eldritch only - single boss)
|
||||
; atk_len_shenk = 4.0 (Shenk only - single boss)
|
||||
; atk_len_arc = 2.5 (Summoner in Arcane - single boss)
|
||||
; atk_len_diablo = 10.0 (Diablo in CS - longest single boss)
|
||||
; atk_len_countess = 3.0 (Countess - single boss, easy)
|
||||
; atk_len_andariel = 4.0 (Andariel - single boss)
|
||||
; atk_len_mephisto = 12.0 (Mephisto - single boss, high HP)
|
||||
; atk_len_baal = 10.0 (Baal - single boss)
|
||||
; atk_len_baal_waves = 30.0 (Baal's spawn waves before boss)
|
||||
;
|
||||
; Chaos Sanctuary (Diablo run) individual fights:
|
||||
; atk_len_cs_trashmobs = 2.0 (Trash packs in CS)
|
||||
; atk_len_diablo_vizier = 2.0 (Vizier of Chaos - seal boss A)
|
||||
; atk_len_diablo_infector = 4.0 (Infector of Souls - seal boss C)
|
||||
; atk_len_diablo_deseis = 5.0 (Lord De Seis - seal boss B, hardest seal)
|
||||
;
|
||||
; Increase these if your hammer kills slower (low IAS/FCR).
|
||||
; Decrease if your hammer kills faster (high IAS/FCR, good gear).
|
||||
; If you die during a fight, the attack length is too long for your defense.
|
||||
atk_len_arc=2.5
|
||||
atk_len_eldritch=3.0
|
||||
atk_len_nihlathak=4.0
|
||||
atk_len_pindle=9.0
|
||||
atk_len_pindle=8.0
|
||||
atk_len_shenk=4.0
|
||||
atk_len_diablo=3.0
|
||||
atk_len_trav=3.0
|
||||
atk_len_trav=4.0
|
||||
|
||||
; Boss run attack lengths (per-character defaults in kill_* methods)
|
||||
; Adjust these if your build is faster/slower against these bosses
|
||||
@@ -197,42 +337,72 @@ belt_mp_columns=1
|
||||
belt_rejuv_columns=2
|
||||
|
||||
; Potion/chicken settings
|
||||
take_health_potion=0.90
|
||||
take_mana_potion=0.80
|
||||
take_rejuv_potion_health=0.75
|
||||
take_rejuv_potion_mana=0.50
|
||||
heal_merc=0.7
|
||||
heal_rejuv_merc=0.25
|
||||
chicken=0.75
|
||||
merc_chicken=0.25
|
||||
take_health_potion=0.60
|
||||
take_mana_potion=0.40
|
||||
take_rejuv_potion_health=0.45
|
||||
take_rejuv_potion_mana=0.10
|
||||
heal_merc=0.70
|
||||
heal_rejuv_merc=0.45
|
||||
chicken=0.40
|
||||
merc_chicken=0.20
|
||||
|
||||
; Misc.
|
||||
; helps reduce accidental pickups when enabled especially on walking characters
|
||||
enable_no_pickup=1
|
||||
enable_no_pickup=0
|
||||
; fill_shared_stash_first: 1 = prefer shared stash tabs before personal stash
|
||||
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
|
||||
pre_buff_every_run=0
|
||||
runs_per_repair=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
|
||||
; 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
|
||||
; runs_per_stash: stash/sell every X runs (blank/0 disables)
|
||||
runs_per_stash=4
|
||||
sell_junk=0
|
||||
; 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
|
||||
; 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
|
||||
; 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
|
||||
; pick_gold: 1 = pick up ground gold piles, 0 = ignore all ground gold
|
||||
pick_gold=1
|
||||
|
||||
[transmute]
|
||||
;stash tabs by priority where to put transmuted gems
|
||||
stash_destination=3,2,1,0
|
||||
stash_destination=0,1,2,3
|
||||
; Add these possible gems to "transmute" to transmute them:
|
||||
; chipped, flawed, standard, flawless
|
||||
transmute=flawless
|
||||
;how often we want to run transmute routine(e.g. every 100 games)
|
||||
transmute_every_x_game=2000
|
||||
transmute_every_x_game=60
|
||||
; number of stash tabs (ROTW has more than 6, adjust as needed)
|
||||
stash_tabs=6
|
||||
; potion transmute settings
|
||||
; convert_rejuv: 1 = convert regular Rejuv Potions to Full Rejuv via cube (3 -> 1)
|
||||
convert_rejuv=1
|
||||
; min_rejuv_to_convert: minimum regular rejuv potions in inventory before starting conversion
|
||||
min_rejuv_to_convert=6
|
||||
|
||||
; ===========================
|
||||
; ==== Builds: Sorceress ====
|
||||
; ===========================
|
||||
[sorceress]
|
||||
energy_shield=f3
|
||||
frozen_armor=f4
|
||||
energy_shield=f4
|
||||
frozen_armor=f7
|
||||
static_field=f5
|
||||
telekinesis=f6
|
||||
thunder_storm=
|
||||
@@ -249,14 +419,14 @@ frozen_orb=
|
||||
; blizzard must be right skill, hotkey required
|
||||
blizzard=f1
|
||||
; ice_blast must be left skill (hotkey optional as it shouldnt change)
|
||||
ice_blast=
|
||||
ice_blast=f2
|
||||
|
||||
[blizzorb_sorc]
|
||||
; frozen orb must be left skill and preselected (no hotkey required)
|
||||
|
||||
;All others must be right skill and hotkey required!
|
||||
blizzard=f1
|
||||
glacial_spike=f2
|
||||
glacial_spike=f7
|
||||
|
||||
[nova_sorc]
|
||||
; nova must be right skill, hotkey required
|
||||
@@ -273,22 +443,23 @@ hydra=f1
|
||||
; ==== Builds: Paladin ====
|
||||
; =========================
|
||||
[paladin]
|
||||
cleansing=
|
||||
cleansing=f9
|
||||
holy_shield=f2
|
||||
redemption=f3
|
||||
vigor=f4
|
||||
|
||||
[fohdin]
|
||||
; foh must be left skill, hotkey required
|
||||
blessed_hammer=
|
||||
concentration=
|
||||
blessed_hammer=f1
|
||||
concentration=f8
|
||||
conviction=f5
|
||||
foh=f6
|
||||
holy_bolt=f7
|
||||
|
||||
[hammerdin]
|
||||
blessed_hammer=f4
|
||||
concentration=f5
|
||||
blessed_hammer=f1
|
||||
concentration=f8
|
||||
conviction=f5
|
||||
|
||||
; =========================
|
||||
; ==== Builds: Warlock ====
|
||||
@@ -443,25 +614,78 @@ buff_2=
|
||||
|
||||
[advanced_options]
|
||||
; startup hotkeys
|
||||
restore_settings_from_backup_key=f7
|
||||
settings_backup_key=f8
|
||||
auto_settings_key=f9
|
||||
graphic_debugger_key=f10
|
||||
; select_runs_key: open run selector UI
|
||||
select_runs_key=pagedown
|
||||
; restore_settings_from_backup_key: restore backed-up D2R settings
|
||||
restore_settings_from_backup_key=insert
|
||||
; settings_backup_key: create backup of current D2R settings
|
||||
settings_backup_key=pause
|
||||
; auto_settings_key: auto-apply required D2R settings
|
||||
auto_settings_key=pageup
|
||||
; graphic_debugger_key: toggle on-screen debug layers
|
||||
graphic_debugger_key=delete
|
||||
; resume_key: start/pause bot loop
|
||||
resume_key=f11
|
||||
; exit_key: hard stop bot
|
||||
exit_key=f12
|
||||
; cycle_pickit_profile_key: cycle through pickit profiles in config/pickit_profiles/
|
||||
cycle_pickit_profile_key=f10
|
||||
|
||||
; etc.
|
||||
; graphic_debugger_layer_creator: 1 = enable interactive layer creator tooling
|
||||
graphic_debugger_layer_creator=0
|
||||
; hwnd_window_process: process regex used to locate D2R window handle
|
||||
hwnd_window_process=D2R\.exe
|
||||
; hwnd_window_title: optional title regex override for window lookup
|
||||
hwnd_window_title=
|
||||
; launch_options: will replace <name> with setting for [general] "name" above
|
||||
launch_options=-mod <name> -txt
|
||||
; logg_lvl: logging verbosity (debug/info/warning/error)
|
||||
logg_lvl=debug
|
||||
; message_body_template: payload template for generic_api mode
|
||||
message_body_template={{"content": "{msg}"}}
|
||||
; message_headers: optional JSON headers for generic_api mode
|
||||
message_headers=
|
||||
; ocr_during_pickit: 1 = run OCR while looting (slower, more diagnostics)
|
||||
ocr_during_pickit=0
|
||||
;use "can_teleport_natively" or "can_teleport_with_charges" if you want to force certain behavior in case autodetection isn't working properly
|
||||
override_capabilities=
|
||||
override_capabilities=can_teleport_natively
|
||||
; pathing_delay_factor: movement/click delay multiplier (1 fast .. 10 slow)
|
||||
pathing_delay_factor=2
|
||||
; If you want to control Hyper-V window from host use 0,51 here
|
||||
; window_client_area_offset: x,y pixel offset for captured game client area
|
||||
window_client_area_offset=0,0
|
||||
|
||||
[log_rotation]
|
||||
; Log rotation prevents screenshot directories from filling the disk.
|
||||
; Each managed directory has a max file count and max total size.
|
||||
; When limits are exceeded, oldest files are deleted automatically.
|
||||
; Checked every 60 seconds (to avoid I/O overhead during runs).
|
||||
;
|
||||
; The text log (log/log.txt) is size-capped separately: it rotates at 50 MB,
|
||||
; keeps 5 zipped backups in log/archive/ (max 30 archives). Override the per-file
|
||||
; cap with the BOTTY_LOG_MAX_MB environment variable (e.g. set 100 for 100 MB).
|
||||
|
||||
; pickit directory (log/screenshots/pickit/):
|
||||
; Can grow very fast — every item scan writes a PNG + JSON.
|
||||
; pickit_max_files=500 ; max files before rotation kicks in
|
||||
; pickit_max_mb=500 ; max total size in MB
|
||||
pickit_max_files=300
|
||||
pickit_max_mb=200
|
||||
|
||||
; info directory (log/screenshots/info/):
|
||||
; Debug screenshots for deaths, chickens, errors, etc.
|
||||
; info_max_files=200 ; max files before rotation kicks in
|
||||
; info_max_mb=500 ; max total size in MB
|
||||
info_max_files=100
|
||||
info_max_mb=200
|
||||
|
||||
; items directory (log/screenshots/items/):
|
||||
; Loot screenshots sent to Discord.
|
||||
; items_max_files=100
|
||||
; items_max_mb=100
|
||||
items_max_files=50
|
||||
items_max_mb=50
|
||||
|
||||
; discord_notify_rotation: 1 = send Discord message when log rotation deletes files
|
||||
discord_notify_rotation=0
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Shared pickit profiles (git-tracked)
|
||||
|
||||
One folder per pickit set, e.g. for season phases:
|
||||
|
||||
```
|
||||
config/pickit_profiles/
|
||||
season_start/ *.bnip (leveling: keep bases, gems, chipped...)
|
||||
mid_season/ *.bnip
|
||||
endgame/ *.bnip (GG-only filter)
|
||||
```
|
||||
|
||||
Drop `.bnip` (or `.nip`) files in a folder; a `.nipignore` works like in
|
||||
`config/bnip`. These folders ARE committed — build them once, everyone
|
||||
gets them via git pull.
|
||||
|
||||
## Selecting a set
|
||||
Per user, in your gitignored `config/profiles/<you>/profile.ini`:
|
||||
|
||||
```ini
|
||||
[general]
|
||||
pickit_profile=season_start
|
||||
```
|
||||
|
||||
Priority: `config/profiles/<you>/pickit/` (personal set, gitignored)
|
||||
> `config/pickit_profiles/<pickit_profile>/` (shared, from this folder)
|
||||
> `config/bnip/` > `config/default.bnip`.
|
||||
@@ -1,4 +1,5 @@
|
||||
[claws]
|
||||
; shop_trap_claws: 1 = enable claw-shopping routine for trap claws from Anya/Drognan flow
|
||||
; Current scoring for trap claws
|
||||
; 3 traps: +12
|
||||
; 2/1 traps: +8
|
||||
@@ -7,17 +8,22 @@
|
||||
; x weapon block: +1
|
||||
; x death sentrey: +4
|
||||
shop_trap_claws=0
|
||||
; trap_min_score: minimum combined score required to keep a trap claw
|
||||
trap_min_score=13
|
||||
|
||||
; shop_melee_claws: 1 = enable shopping melee claws (venom/block focused)
|
||||
; Current scoring for melee claws
|
||||
; 2 assa: +10
|
||||
; x venom: +6
|
||||
; x weapon block: +2
|
||||
shop_melee_claws=0
|
||||
; melee_min_score: minimum combined score required to keep a melee claw
|
||||
melee_min_score=13
|
||||
|
||||
[gloves]
|
||||
; shop_3_skills_ias_gloves: 1 = shop +3 skill tree + IAS gloves
|
||||
shop_3_skills_ias_gloves=1
|
||||
; shop_2_skills_ias_gloves: 1 = shop +2 skill tree + IAS gloves
|
||||
shop_2_skills_ias_gloves=0
|
||||
|
||||
;
|
||||
@@ -29,6 +35,9 @@ shop_2_skills_ias_gloves=0
|
||||
; apply_pather_adjustment - alternative option that applies an adjustment to movements.
|
||||
; Should not need this. Try it if you have trouble when it is off.
|
||||
[scepters]
|
||||
; shop_hammerdin_scepters: 1 = enable Hammerdin scepter shopping route
|
||||
shop_hammerdin_scepters=1
|
||||
; speed_factor: movement compensation for FRW and path timing during shopping loop
|
||||
speed_factor=0.25
|
||||
apply_pather_adjustment=0
|
||||
; apply_pather_adjustment: optional alternate node adjustment if default route misses NPC/shop spots
|
||||
apply_pather_adjustment=0
|
||||
|
||||
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -11,13 +11,29 @@ git clone https://github.com/Hoblirm/botty.git
|
||||
cd botty
|
||||
|
||||
# Create the conda environment (installs all Python deps + tesserocr with tesseract)
|
||||
conda env create -f environment.yml
|
||||
# Windows 10:
|
||||
conda env create -f environment-win10.yml
|
||||
# Windows 11:
|
||||
conda env create -f environment-win11.yml
|
||||
|
||||
# Activate and run
|
||||
# Activate
|
||||
conda activate botty
|
||||
|
||||
# Optional pip profile after the conda env exists:
|
||||
# Windows 10:
|
||||
python -m pip install -r requirements-win10.txt
|
||||
# Windows 11:
|
||||
python -m pip install -r requirements-win11.txt
|
||||
|
||||
# Run
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
`install.bat` detects Windows 10 vs Windows 11 and installs the matching
|
||||
environment and requirements profiles automatically. Botty runtime detection
|
||||
lives in `src/utils/os_detect.py`; startup logs show the selected profile and
|
||||
mouse mode.
|
||||
|
||||
### PowerShell users
|
||||
```powershell
|
||||
conda init powershell # One time setup
|
||||
|
||||
@@ -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,74 @@
|
||||
# BNIP Guide
|
||||
|
||||
This guide explains how to edit `config/default.bnip` safely and predictably.
|
||||
|
||||
## What BNIP Does
|
||||
|
||||
BNIP rules decide which items Botty keeps.
|
||||
Each line is a filter expression evaluated against detected item data.
|
||||
|
||||
## Rule Shape
|
||||
|
||||
Typical rule format:
|
||||
|
||||
```text
|
||||
[Name] == Ring && [Quality] == Rare # [Fcr] >= 10 && [Allres] >= 15
|
||||
```
|
||||
|
||||
- Left side (`[Name]`, `[Type]`, `[Quality]`, etc.) narrows item identity.
|
||||
- Right side after `#` checks stats/rolls.
|
||||
|
||||
## Enable or Disable Rules
|
||||
|
||||
- Enabled: line starts with `[...`
|
||||
- Disabled: line starts with `//`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
//[Name] == Lemrune
|
||||
[Name] == Pulrune
|
||||
```
|
||||
|
||||
## Safe Editing Workflow
|
||||
|
||||
1. Copy an existing nearby rule.
|
||||
2. Keep your new rule commented out initially (`//`).
|
||||
3. Enable one new rule at a time.
|
||||
4. Run a few games and verify behavior before adding more.
|
||||
|
||||
## Rule Ordering
|
||||
|
||||
BNIP files are easier to maintain when ordered from specific to broad.
|
||||
|
||||
- Put strict high-value rules first.
|
||||
- Put broad catch-all rules later.
|
||||
- Avoid duplicated broad rules in multiple sections.
|
||||
|
||||
## Common Fields You Will Use
|
||||
|
||||
- `[Name]`
|
||||
- `[Type]`
|
||||
- `[Quality]`
|
||||
- `[Flag]` (for ethereal/sockets behavior)
|
||||
- Stat aliases like `[Fcr]`, `[Allres]`, `[Enhanceddefense]`, `[Enhanceddamage]`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If a desired item is not kept:
|
||||
|
||||
1. Confirm the rule is enabled (no `//`).
|
||||
2. Relax one condition at a time.
|
||||
3. Check for typos in stat aliases.
|
||||
4. Ensure no local BNIP file in `config/bnip/` is overriding expectations.
|
||||
|
||||
If too much junk is kept:
|
||||
|
||||
1. Tighten broad rules.
|
||||
2. Disable catch-all rules first.
|
||||
3. Add stricter stat thresholds.
|
||||
|
||||
## Recommended Local Customization
|
||||
|
||||
Keep `config/default.bnip` as the team baseline.
|
||||
Put personal experiments in separate local `.bnip` files under `config/bnip/` and test there first.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Broken Runs - Setup Guide
|
||||
|
||||
## Current Status
|
||||
|
||||
All 4 boss runs (Countess, Andariel, Mephisto, Baal) have **code structure in place** but require **path recording** before they can run.
|
||||
|
||||
| Run | Code | Guards | Path Coords | Templates | Walking Fallback |
|
||||
|---|---|---|---|---|---|
|
||||
| Countess | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1000-1004 |
|
||||
| Andariel | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1010-1012 |
|
||||
| Mephisto | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1020-1021 |
|
||||
| Baal | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1030-1031 |
|
||||
|
||||
## What's Done
|
||||
|
||||
- **Run classes**: `countess.py`, `andariel.py`, `mephisto.py`, `baal.py` - all wired in `bot.py`
|
||||
- **Kill methods**: `kill_countess()`, `kill_andariel()`, `kill_mephisto()`, `kill_baal()`, `kill_baal_waves()` - implemented in Hammerdin/FoHdin/Warlock
|
||||
- **Path guards**: All 4 runs check for `(0,0)` paths and refuse to run with a clear error message
|
||||
- **Walking fallback**: `pather.py` has node definitions (1000-1031) and path routes for walking fallback
|
||||
- **Template dirs**: `assets/templates/countess/`, `andariel/`, `mephisto/`, `baal/` created (empty)
|
||||
|
||||
## What's Needed to Enable Each Run
|
||||
|
||||
### Option A: Teleport (faster, recommended)
|
||||
|
||||
Record path coordinates in `config/game.ini` using `node_recorder.py`:
|
||||
|
||||
```bash
|
||||
cd C:\Users\alex\Downloads\my-botty
|
||||
python src/utils/node_recorder.py
|
||||
```
|
||||
|
||||
Then follow the on-screen instructions:
|
||||
- **F8**: Capture template ROI (top-left, then bottom-right) -> saves PNG
|
||||
- **F9**: Record node position at cursor -> generates path coordinates
|
||||
- **F10**: Update all nodes with visible templates
|
||||
|
||||
### Option B: Walking (slower, needs templates + nodes)
|
||||
|
||||
Same as above, but also requires creating template PNGs for each landmark.
|
||||
|
||||
---
|
||||
|
||||
## 1. COUNTESS (Act 1 Forgotten Tower)
|
||||
|
||||
**game.ini keys to record (5):**
|
||||
|
||||
| Key | What to Record |
|
||||
|---|---|
|
||||
| `a1_tower_level2_enter` | First click inside tower after entering from Black Marsh |
|
||||
| `a1_tower_level3_enter` | Top of stairs from L2 to L3 |
|
||||
| `a1_tower_level4_enter` | Top of stairs from L3 to L4 |
|
||||
| `a1_tower_level5_enter` | Top of stairs from L4 to L5 |
|
||||
| `a1_countess_safe_dist` | Position near Countess at safe hammer range |
|
||||
|
||||
**Templates for walking fallback (in `assets/templates/countess/`):**
|
||||
|
||||
| Template File | Template Key | Purpose |
|
||||
|---|---|---|
|
||||
| `countess_tower_l2.png` | COUNTESS_TOWER_L2 | Tower L2 entrance |
|
||||
| `countess_tower_l3.png` | COUNTESS_TOWER_L3 | Stairs L2->L3 |
|
||||
| `countess_tower_l4.png` | COUNTESS_TOWER_L4 | Stairs L3->L4 |
|
||||
| `countess_tower_l5.png` | COUNTESS_TOWER_L5 | Stairs L4->L5 |
|
||||
| `countess_boss.png` | COUNTESS_BOSS | Countess boss area |
|
||||
|
||||
**Nodes in pather.py:** 1000-1004 (placeholder coords `(0,0)` - update after recording)
|
||||
|
||||
---
|
||||
|
||||
## 2. ANDARIEL (Act 1 Catacombs)
|
||||
|
||||
**game.ini keys to record (3):**
|
||||
|
||||
| Key | What to Record |
|
||||
|---|---|
|
||||
| `a1_andy_level3_enter` | Catacombs L3 entrance (from L2) |
|
||||
| `a1_andy_level4_enter` | Catacombs L4 entrance (from L3) |
|
||||
| `a1_andy_safe_dist` | Position near Andariel's cage at safe hammer range |
|
||||
|
||||
**Templates for walking fallback (in `assets/templates/andariel/`):**
|
||||
|
||||
| Template File | Template Key | Purpose |
|
||||
|---|---|---|
|
||||
| `andy_l3_stairs.png` | ANDY_L3_STAIRS | Stairs from L2 to L3 |
|
||||
| `andy_l4_stairs.png` | ANDY_L4_STAIRS | Stairs from L3 to L4 |
|
||||
| `andy_cage.png` | ANDY_CAGE | Andariel cage area |
|
||||
|
||||
**Nodes in pather.py:** 1010-1012 (placeholder coords `(0,0)` - update after recording)
|
||||
|
||||
---
|
||||
|
||||
## 3. MEPHISTO (Act 3 Durance of Hate)
|
||||
|
||||
**game.ini keys to record (2):**
|
||||
|
||||
| Key | What to Record |
|
||||
|---|---|
|
||||
| `a3_meph_level3_enter` | Durance of Hate L3 entrance (from L2) |
|
||||
| `a3_meph_safe_dist` | Position near Mephisto's cage at safe hammer range |
|
||||
|
||||
**Templates for walking fallback (in `assets/templates/mephisto/`):**
|
||||
|
||||
| Template File | Template Key | Purpose |
|
||||
|---|---|---|
|
||||
| `meph_l3_stairs.png` | MEPH_L3_STAIRS | Stairs from L2 to L3 |
|
||||
| `meph_cage.png` | MEPH_CAGE | Mephisto cage area |
|
||||
|
||||
**Nodes in pather.py:** 1020-1021 (placeholder coords `(0,0)` - update after recording)
|
||||
|
||||
---
|
||||
|
||||
## 4. BAAL (Act 5 Throne of Destruction)
|
||||
|
||||
**game.ini keys to record (2):**
|
||||
|
||||
| Key | What to Record |
|
||||
|---|---|
|
||||
| `a5_baal_throne_entry` | Throne of Destruction entrance (from Worldstone Keep L2) |
|
||||
| `a5_baal_safe_dist` | Position near Baal at safe hammer range (after wave clear) |
|
||||
|
||||
**Templates for walking fallback (in `assets/templates/baal/`):**
|
||||
|
||||
| Template File | Template Key | Purpose |
|
||||
|---|---|---|
|
||||
| `baal_throne_entry.png` | BAAL_THRONE_ENTRY | Entrance to Throne area |
|
||||
| `baal_arena.png` | BAAL_ARENA | Baal arena / boss position |
|
||||
|
||||
**Nodes in pather.py:** 1030-1031 (placeholder coords `(0,0)` - update after recording)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start: Record Paths for One Run
|
||||
|
||||
1. Launch D2R and navigate to the boss area
|
||||
2. Run `python src/utils/node_recorder.py` from the botty directory
|
||||
3. Enter the run name when prompted (e.g., `countess`)
|
||||
4. Position your cursor at the target location in-game
|
||||
5. Press F8 to capture a template, F9 to record a node
|
||||
6. Press F10 to update all nodes with visible templates
|
||||
7. Copy the generated path coordinates to `config/game.ini`
|
||||
8. Copy the generated template PNGs to `assets/templates/<run>/`
|
||||
9. Update `pather.py` node definitions with real coordinates from the recorder output
|
||||
@@ -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,18 @@
|
||||
# D2R Window and Input Troubleshooting
|
||||
|
||||
Botty expects Diablo II: Resurrected to expose a stable 1280x720 client area. If
|
||||
the live D2R window reports a slightly different client size, template matching
|
||||
can still succeed while mouse clicks land at the wrong monitor coordinate.
|
||||
|
||||
At startup and before creating a game, Botty now resizes and positions the D2R
|
||||
client area to the configured `config/game.ini` dimensions. The expected size is
|
||||
`1280x720`, matching `assets/d2r_settings.json` and the template assets.
|
||||
|
||||
If a UI click is detected but the cursor does not visibly move, the native input
|
||||
layer first tries `SendInput` and then verifies the cursor position. When
|
||||
`SendInput` misses the target coordinate, Botty falls back to `SetCursorPos` and
|
||||
logs the fallback.
|
||||
|
||||
If movement or clicks still do not reach D2R, check that D2R and the bot process
|
||||
are running at the same privilege level. Windows can block input from a
|
||||
non-admin process into an elevated game window.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Diablo Waypoint Recovery Notes
|
||||
|
||||
Date: 2026-06-07
|
||||
|
||||
## What changed
|
||||
|
||||
- Diablo is now first in the configured run order, followed by Pindle only.
|
||||
- After using the Act 4 River of Flame waypoint, the Diablo approach now closes the waypoint panel, resets stale health-manager panel detections, and verifies that River of Flame templates are visible.
|
||||
- If the first River of Flame waypoint attempt leaves the bot in town, the approach retries from the Act 4 town start before failing the run.
|
||||
- River of Flame and Pentagram traversal now include template-based verification before continuing into Chaos Sanctuary logic.
|
||||
- Diablo now has a reusable `_search_and_log()` wrapper for template searches that can report match confidence during route debugging.
|
||||
- Health manager reset now clears `_count_panel_detects` so detections from a previous game do not immediately chicken the next game.
|
||||
- `config/fg_daily_estimates.json` was regenerated with the improved offline estimator output, including skipped-topic counts and trimmed price statistics.
|
||||
|
||||
## Why
|
||||
|
||||
Same-act waypoint use can leave the waypoint panel open without a loading screen. That stale panel could combine with the CTA weapon-swap panel during pre-buffing and trigger the health manager panel-detection chicken path. The bot could also silently remain in town after a missed waypoint click and continue as if it had reached River of Flame.
|
||||
|
||||
The new checks make Diablo startup state-based: the bot confirms River of Flame and Pentagram markers before continuing. Failed waypoint transitions are retried once, then reported as approach failures instead of drifting into later pathing.
|
||||
|
||||
## Validation
|
||||
|
||||
Run a focused route order:
|
||||
|
||||
```ini
|
||||
order=run_diablo, run_pindle
|
||||
```
|
||||
|
||||
Expected log behavior:
|
||||
|
||||
- `_verify_in_rof: confirmed in River of Flame ...` after the Act 4 waypoint.
|
||||
- `CS: Calibrated at PENTAGRAM` after Pentagram traversal.
|
||||
- No immediate chicken caused by the waypoint panel plus CTA weapon-swap panel sequence.
|
||||
|
||||
If River of Flame or Pentagram verification fails repeatedly, refresh the affected templates:
|
||||
|
||||
- `DIABLO_ROF_WP_0`
|
||||
- `DIABLO_ROF_WP_1`
|
||||
- `DIABLO_ENTRANCE_50` through `DIABLO_ENTRANCE_55`
|
||||
- `DIA_NEW_PENT_TP`
|
||||
- `DIA_NEW_PENT_0` through `DIA_NEW_PENT_2`
|
||||
@@ -0,0 +1,44 @@
|
||||
# FG Market Scraper (Day 1-14)
|
||||
|
||||
This tool estimates FG prices per ladder day by scraping public trade topics from:
|
||||
- https://forums.d2jsp.org/forum.php?f=271
|
||||
|
||||
## Script
|
||||
- `tools/fg_market_scraper.py`
|
||||
|
||||
## What it does
|
||||
1. Scans forum listing pages for topic links.
|
||||
2. Fetches topic pages (rate-limited).
|
||||
3. Detects known item/rune keywords.
|
||||
4. Extracts `fg` prices from post text.
|
||||
5. Buckets prices by ladder day index (Day 1..Day N).
|
||||
6. Writes median estimates + sample counts.
|
||||
|
||||
## Usage
|
||||
From repo root:
|
||||
|
||||
```powershell
|
||||
python tools/fg_market_scraper.py --ladder-start-date 2026-05-23 --days 14
|
||||
```
|
||||
|
||||
Output:
|
||||
- `config/fg_daily_estimates.json`
|
||||
|
||||
## Tuning
|
||||
- `--max-forum-pages`: listing pages to scan (default 40)
|
||||
- `--max-topics`: hard cap on fetched topics (default 800)
|
||||
- `--delay-s`: delay between requests (default 0.35s)
|
||||
|
||||
Example heavier run:
|
||||
|
||||
```powershell
|
||||
python tools/fg_market_scraper.py --ladder-start-date 2026-05-23 --days 14 --max-forum-pages 120 --max-topics 2400 --delay-s 0.5
|
||||
```
|
||||
|
||||
## Notes
|
||||
- This is a heuristic estimator, not a full market engine.
|
||||
- Accuracy depends on post format quality and keyword matches.
|
||||
- Keep request rate polite to avoid stressing the forum.
|
||||
- Some environments/IPs will receive HTTP `403` from d2jsp. In that case use:
|
||||
- `config/fg_day_estimates.json` (Day 1-14 estimated multiplier model from Day 3 snapshot),
|
||||
- and update `config/fg_prices.json` manually from your current market sample.
|
||||
@@ -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,87 @@
|
||||
# GEMS-tab transmute flow (rewritten in commit 48d8445)
|
||||
|
||||
`src/transmute/transmute.py` — `Transmute.convert_all_gems()` and helpers.
|
||||
|
||||
## What changed and why
|
||||
|
||||
The old flow drove gem upgrades through the **Horadric Cube UI**: it required the cube to live in
|
||||
the PERSONAL stash tab, opened the cube, ctrl+shift+right-clicked 3 gems into it, clicked the cube's
|
||||
Transmute button, then pulled the result back out. This was fragile — it depended on cube placement,
|
||||
cube-open detection (`ScreenObjects.CubeOpened` via `wait_until_visible`), and a pre-flight
|
||||
"empty the cube" pass.
|
||||
|
||||
The rewrite uses D2R's **native GEMS-tab convert panel** instead of the cube. Gems are moved
|
||||
directly between the GEMS stack grid and the convert panel, and conversion uses the GEMS tab's own
|
||||
convert button. The cube is no longer required to be in the stash and, if present in the character
|
||||
inventory, is left untouched.
|
||||
|
||||
## Fixed stack-coordinate grid
|
||||
|
||||
`GEMS_TAB_STACK_COORDS` is a new module-level dict mapping every gem template
|
||||
(`INVENTORY_<FAMILY>_<TIER>`) to a fixed `(x, y)` **screen** coordinate at 1280×720. The grid is:
|
||||
|
||||
- **Columns by family** (x): diamond 85, emerald 132, ruby 180, topaz 227, amethyst 275,
|
||||
sapphire 322, skull 370.
|
||||
- **Rows by tier** (y): chipped 154, flawed 195, standard 234, flawless 273, perfect 311.
|
||||
|
||||
Because gem stacks always render at these fixed slots on the GEMS tab, the flow can click them by
|
||||
coordinate instead of template-searching for them each time. `_gems_stack_monitor_for(template)`
|
||||
converts the screen coord to monitor coords (returns `None` if the template isn't in the grid, so
|
||||
the caller can fall back to a template search).
|
||||
|
||||
## Panel constants (1280×720)
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|----------|-------|---------|
|
||||
| `GEMS_TAB_X` | `223` | GEMS tab click X (was `200`) |
|
||||
| `GEMS_CONVERT_BUTTON` | `(225, 500)` | native convert button in the GEMS panel |
|
||||
| `GEMS_CONVERT_PANEL_ROI` | `(160, 296, 128, 160)` | ROI to search for the converted result |
|
||||
| `GEMS_CONVERT_FIRST_SLOT` | `(181, 318)` | fallback slot to grab the result if the search misses |
|
||||
|
||||
## Per-transmute flow (new)
|
||||
|
||||
For each gem type/tier with enough gems to convert:
|
||||
|
||||
1. **Switch to GEMS tab.**
|
||||
2. **Load 3 gems** — `_ctrl_shift_left_click_monitor` the stack slot ×3 to move 3 gems into the
|
||||
convert panel. If exactly 3 weren't loaded, abort that batch.
|
||||
3. **Convert** — `_click_gems_tab_convert_button()` clicks the panel's native convert button.
|
||||
4. **Recover the result** — template-search `gems_out` within `GEMS_CONVERT_PANEL_ROI`; if found,
|
||||
ctrl+shift+left-click it back onto the GEMS tab. If not found, fall back to ctrl+shift+left-click
|
||||
on `GEMS_CONVERT_FIRST_SLOT` (instead of the old behavior of clearing the cube and bailing).
|
||||
5. **Leave the inventory cube untouched** and return to the GEMS tab for the next iteration.
|
||||
|
||||
The old pre-flight "open cube → empty 12 cube slots → reopen stash" pass and the per-iteration
|
||||
"close cube / reopen stash" steps are **removed**.
|
||||
|
||||
## Helper changes
|
||||
|
||||
- `_open_cube_from_stash()` / `_open_cube_from_gems_tab()` / `_open_available_cube_for_gems_tab()`
|
||||
now operate from the **GEMS** tab rather than PERSONAL, and can open the cube from character
|
||||
inventory (`_open_cube_from_inventory`) when the GEMS tab is active. These remain for older
|
||||
inventory flows; the new gem flow doesn't need them.
|
||||
- `_locate_cube()` can now return `'opened'` (cube UI already open on the GEMS tab) in addition to
|
||||
`'stash'` / `'inventory'`.
|
||||
- `_ensure_cube_available()` now treats `inventory` as usable ("it will stay there") instead of an
|
||||
error — the cube no longer has to be moved to PERSONAL.
|
||||
- `_ensure_cube_in_stash()` dropped the loot-column safety guard: it now ctrl+clicks any positive
|
||||
`CubeInventory` match, so the cube can be moved from reserved inventory columns too.
|
||||
- **Keyboard modifier handling** switched from the low-level
|
||||
`win_input.key_down/key_up(_get_vk(...))` to `keyboard.send(..., do_release=False)` /
|
||||
`keyboard.release(...)` wrapped in `try/finally`, so ctrl+shift are always released even if the
|
||||
click raises. Applies to both `_ctrl_shift_click_monitor` (right) and
|
||||
`_ctrl_shift_left_click_monitor` (left).
|
||||
- `_empty_cube_to_gems_tab()` now uses `_ctrl_click_monitor` (plain ctrl) rather than ctrl+shift.
|
||||
|
||||
## Gem counting (`_count_gems_by_ocr`)
|
||||
|
||||
Counting no longer template-searches the left-inventory ROI for each gem. It now reads each fixed
|
||||
slot from `GEMS_TAB_STACK_COORDS`: crop the slot icon, and **skip empty slots** by checking the
|
||||
95th-percentile grayscale brightness (`< 45` ⇒ no gem present). Occupied slots still OCR the count
|
||||
badge in the bottom-right quadrant. This is faster and immune to template-match drift, at the cost
|
||||
of depending on the fixed 1280×720 grid.
|
||||
|
||||
## Resolution assumption
|
||||
|
||||
The whole flow is hard-coded to **1280×720**. All coordinates above are screen coordinates at that
|
||||
resolution, converted to monitor coordinates at click time via `convert_screen_to_monitor`.
|
||||
@@ -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,112 @@
|
||||
# Recovery & Error Logging
|
||||
|
||||
This document describes the failure-recovery and diagnostics features that let the
|
||||
bot survive broken runs and makes every failure reviewable after the fact.
|
||||
|
||||
## 1. Comprehensive failure logging
|
||||
|
||||
Every town action (`src/town/a1.py` … `a5.py`) and every run `approach()` method
|
||||
(`src/run/*.py`) now logs the **specific step that failed** via `Logger.error`
|
||||
instead of silently returning `False`. Examples of what now gets logged:
|
||||
|
||||
- `A1 resurrect: open_npc_menu(KASHYA) failed — NPC not found or menu did not open`
|
||||
- `A4 open_wp: waypoint template A4_WP/A4_WP_2 not found — WP may be obscured or template mismatch`
|
||||
- `Vizier approach: use_wp('River of Flame') failed`
|
||||
- `Mephisto approach: go_to_act(3) failed`
|
||||
- `gamble: go_to_act(4) failed from A1_TOWN_START — cannot reach Jamella`
|
||||
|
||||
Town-manager fallbacks (`resurrect`, `identify`, `open_stash`, `gamble`, `stash`,
|
||||
`heal`) also log when their `go_to_act` recovery hop fails, so you can tell whether
|
||||
the problem was reaching the NPC, opening the menu, pressing the button, or the
|
||||
panel never opening.
|
||||
|
||||
This means that "if Cain is missing, if the WP is not enabled, if some other game
|
||||
factor is not in place" the reason ends up in the log.
|
||||
|
||||
## 2. Error screenshots
|
||||
|
||||
When a run fails, the bot saves a screenshot so logs and visuals can be compared.
|
||||
|
||||
- **Where:** `log/screenshots/error/`
|
||||
- **Filename:**
|
||||
`error_<run_name>_<reason>_g<game#>_r<run#>_<YYYYMMDD_HHMMSS>.png`
|
||||
e.g. `error_run_vizier_approach_failed_g12_r47_20260607_143501.png`
|
||||
- **When:** captured on three failure types inside `Bot._run_wrapper`:
|
||||
- `approach_failed` — couldn't reach the boss
|
||||
- `battle_failed` — boss fight didn't complete
|
||||
- `exception_<ExceptionType>` — an uncaught exception during battle
|
||||
- The `g<game#>_r<run#>` in the filename matches the `game`/`run` fields in the
|
||||
structured event log (`log/stats/events_*.jsonl`) and the `Logger` output, so a
|
||||
screenshot can be lined up with its log lines.
|
||||
|
||||
### Discord delivery
|
||||
|
||||
When a run fails the bot also sends the **error message and the screenshot** to
|
||||
Discord (as a red embed with the screenshot attached). For the generic webhook API
|
||||
a text-only message is sent (the screenshot still lands on disk). This is on by
|
||||
default and can be turned off two ways:
|
||||
|
||||
- `[general] discord_log_errors=0` — master switch for error → Discord
|
||||
- `[discord_events] error=0` — fine-grained event toggle (Discord API only)
|
||||
|
||||
### Config
|
||||
|
||||
`config/params.ini` → `[general]`:
|
||||
|
||||
```ini
|
||||
; save a screenshot every time a run fails (falls back to info_screenshots if unset)
|
||||
error_screenshots=1
|
||||
; also send the error message + screenshot to Discord on each run failure
|
||||
discord_log_errors=1
|
||||
```
|
||||
|
||||
`config/params.ini` → `[discord_events]`:
|
||||
|
||||
```ini
|
||||
; send run-failure notifications (message + error screenshot)
|
||||
error=1
|
||||
```
|
||||
|
||||
Set `error_screenshots=0` to turn off error screenshots entirely. Old screenshots
|
||||
are size-rotated using the `info_*` limits in `[log_rotation]` (or optional
|
||||
`error_max_files` / `error_max_mb` if you add them).
|
||||
|
||||
## 3. Auto-disable broken runs (recovery)
|
||||
|
||||
Previously, repeated failures of one run could stall the whole bot. Now each run
|
||||
has its own **consecutive failure counter**:
|
||||
|
||||
- Every failed attempt of a run increments its counter; a single success resets it.
|
||||
- When a run reaches the threshold of consecutive failures, it is **disabled for
|
||||
the rest of the session**. The bot keeps doing the other runs instead of stopping.
|
||||
- Disabling is logged and sent to Discord (if enabled):
|
||||
`DISABLING run_vizier for this session: it failed 5 consecutive times. …`
|
||||
- The disable is **session-only / in-memory** — it does not edit `params.ini`.
|
||||
Restarting the bot re-enables the run. This is intentional so a transient problem
|
||||
(e.g. a game hiccup) doesn't permanently change your config.
|
||||
- If **all** runs end up disabled there are no routes left, so the bot saves a
|
||||
session report and stops cleanly (kills the game and exits) rather than looping
|
||||
over empty games or pointlessly restarting D2R.
|
||||
|
||||
### Config
|
||||
|
||||
`config/params.ini` → `[general]`:
|
||||
|
||||
```ini
|
||||
; after this many CONSECUTIVE failures of the same run, disable just that run
|
||||
; for the rest of the session and keep doing the others. A success resets the count.
|
||||
disable_run_after_failures=5
|
||||
```
|
||||
|
||||
Set this high (e.g. `9999`) to effectively disable the auto-disable behaviour.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `src/bot.py`
|
||||
- `__init__`: `_run_failure_counts`, `_disabled_runs`, `_max_run_failures`
|
||||
- `_record_run_result(run_name, failed)`: counts failures, disables runs, resets on success
|
||||
- `_save_error_screenshot(run_name, reason)`: writes the diagnostic screenshot
|
||||
- `_run_wrapper(...)`: calls both on each failure path (approach / battle / exception)
|
||||
- `src/config.py`: parses `error_screenshots` and `disable_run_after_failures`
|
||||
(both via `_select_optional`, so existing configs keep working).
|
||||
- `src/utils/log_rotation.py`: routes the `error/` directory through rotation.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Run Countess Route Plan
|
||||
|
||||
## Goal
|
||||
Record path data and templates for the Countess run (Act 1 Forgotten Tower) so it executes reliably with Hammerdin.
|
||||
|
||||
## Current State
|
||||
- `src/run/countess.py` exists and is wired into bot.py
|
||||
- `Location` constants defined in `pather.py`: A1_TOWER_LEVEL2_ENTER through A1_TOWER_LEVEL5_ENTER, A1_COUNTESS_SAFE_DIST
|
||||
- `config/game.ini` has 5 path entries — all set to `0,0` (placeholders)
|
||||
- Run is disabled in `params.ini` (`order=run_arcane, run_pindle`)
|
||||
|
||||
## Paths Needed (5 entries in game.ini)
|
||||
|
||||
| Config Key | Description | How to Record |
|
||||
|---|---|---|
|
||||
| `a1_tower_level2_enter` | First click point inside tower after entering from Black Marsh | Walk into tower, record first position on L2 |
|
||||
| `a1_tower_level3_enter` | Click point at top of stairs to L3 | Walk up stairs, record position at L3 entrance |
|
||||
| `a1_tower_level4_enter` | Click point at top of stairs to L4 | Walk up stairs, record position at L4 entrance |
|
||||
| `a1_tower_level5_enter` | Click point at top of stairs to L5 | Walk up stairs, record position at L5 entrance |
|
||||
| `a1_countess_safe_dist` | Click point near Countess (safe hammer range) | Walk to Countess, step back to hammer range |
|
||||
|
||||
## Recording Steps
|
||||
|
||||
### 1. Record path coordinates
|
||||
1. Start botty, let it waypoint to Black Marsh
|
||||
2. Enter the tower manually (walk through the door)
|
||||
3. Run `static_run_recorder.py`:
|
||||
```
|
||||
conda activate botty
|
||||
cd src/utils
|
||||
python static_run_recorder.py
|
||||
```
|
||||
4. Right-click each target position in game order (L2 enter → L3 enter → L4 enter → L5 enter → Countess safe distance)
|
||||
5. Copy the printed coordinate list
|
||||
|
||||
### 2. Update game.ini
|
||||
Replace the `0,0` values with recorded coordinates. Format: `x,y` per line, or comma-separated for multi-point paths.
|
||||
|
||||
Example:
|
||||
```ini
|
||||
a1_tower_level2_enter=400,300
|
||||
a1_tower_level3_enter=410,280
|
||||
a1_tower_level4_enter=420,260
|
||||
a1_tower_level5_enter=430,240
|
||||
a1_countess_safe_dist=450,200
|
||||
```
|
||||
|
||||
### 3. Test the run
|
||||
1. Add `run_countess` back to `order=` in `params.ini`
|
||||
2. Start botty and verify it navigates through all tower levels
|
||||
3. Adjust coordinates if it misses stairs or clicks in wrong spots
|
||||
|
||||
## Hammerdin-Specific Notes
|
||||
- `atk_len_countess=3.0` is already set in `params.ini`
|
||||
- Hammerdin uses `kill_countess()` method — ensure conviction aura is active before engaging
|
||||
- Safe distance should account for hammer swing range (~3-4 game units)
|
||||
|
||||
## Path Recorder Alternative
|
||||
If `static_run_recorder.py` is inconvenient, use `node_recorder.py`:
|
||||
```
|
||||
cd src/utils
|
||||
python node_recorder.py
|
||||
```
|
||||
Follow on-screen prompts to record nodes, then copy the generated path coordinates.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Botty enters Black Marsh via waypoint
|
||||
- Navigates L2 → L3 → L4 → L5 without getting stuck
|
||||
- Reaches Countess and engages
|
||||
- Completes kill and returns to town
|
||||
- No false template matches on tower stairs
|
||||
@@ -0,0 +1,225 @@
|
||||
# Run Cows Route Plan
|
||||
|
||||
## Goal
|
||||
Add a stable `run_cows` route that can:
|
||||
- create/open Secret Cow Level portal in Act 1,
|
||||
- clear cows with safe movement and loot handling,
|
||||
- exit cleanly and continue normal run rotation.
|
||||
|
||||
## Scope
|
||||
This plan is for implementation and testing in this repo only.
|
||||
No behavior changes to existing routes unless needed for shared helpers.
|
||||
|
||||
## Constraints
|
||||
- Keep it compatible with current config style (`[routes]`, `[char]`, per-build sections).
|
||||
- Prioritize stability over speed for first release.
|
||||
- Avoid forcing hard dependencies on OCR for core route progression.
|
||||
|
||||
## Route Design
|
||||
1. Town prep in Act 1:
|
||||
- go to stash/cube workflow position.
|
||||
- ensure cube available.
|
||||
2. Cow portal creation:
|
||||
- verify Wirt's Leg + Tome of Town Portal availability.
|
||||
- transmute in Act 1 town.
|
||||
- detect red portal reliably.
|
||||
3. Entry and combat:
|
||||
- enter portal.
|
||||
- execute deterministic clear pattern (clockwise + center sweep).
|
||||
- run character-specific attack loop with timeout guard.
|
||||
4. Loot and exit:
|
||||
- reuse existing pickit flow.
|
||||
- return through TP or save/exit based on safety state.
|
||||
|
||||
## Code Changes (Planned)
|
||||
1. Add new run module:
|
||||
- `src/run/cows.py` (`name = "run_cows"`)
|
||||
- methods: `approach()`, `battle()`
|
||||
2. Wire route registration:
|
||||
- include `run_cows` in route loader/dispatcher used by `bot.py`.
|
||||
3. Town/cube helpers:
|
||||
- add minimal helper(s) for cube transmute flow if missing.
|
||||
4. Pathing/templates:
|
||||
- add cow portal/town waypoint templates only as needed.
|
||||
- add robust fallback checks for portal detect/select.
|
||||
5. Config/docs:
|
||||
- document `run_cows` in `config/params.ini` route comments.
|
||||
- add usage notes to `README.md`.
|
||||
|
||||
## Config Additions (Planned)
|
||||
In `[char]` or dedicated route section:
|
||||
- `cows_clear_timeout_s` (default safe value, e.g. 90-150)
|
||||
- `cows_use_tp_exit` (1/0)
|
||||
- `cows_repair_before_run` (1/0, default 1)
|
||||
|
||||
Keep defaults conservative.
|
||||
|
||||
## Safety & Recovery
|
||||
- If portal creation fails: log once, abort current cow attempt, continue next route.
|
||||
- If portal click fails: retry from known anchor position once.
|
||||
- If in-combat timeout reached: force exit sequence and mark run failed.
|
||||
- If inventory full during cows: run pickit stop logic and exit route early.
|
||||
|
||||
## Character Compatibility Strategy
|
||||
Phase 1 support:
|
||||
- Blizz Sorc
|
||||
- Hammerdin
|
||||
- FoHdin
|
||||
|
||||
Phase 2 support:
|
||||
- remaining builds with route-specific combat tuning.
|
||||
|
||||
## Telemetry / Stats
|
||||
Track in `game_stats`:
|
||||
- `run_cows` attempts
|
||||
- success/fail count
|
||||
- elapsed duration
|
||||
- rune/valuable item outcomes (already covered by global item tracking)
|
||||
|
||||
## Test Plan
|
||||
1. Unit-ish tests:
|
||||
- route registration includes `run_cows`
|
||||
- config parsing accepts route key without crash
|
||||
2. Integration smoke (manual):
|
||||
- portal creation success path
|
||||
- missing Wirt’s Leg failure path
|
||||
- missing tome failure path
|
||||
- portal click retry path
|
||||
3. Stability run:
|
||||
- 25+ games mixed with existing routes
|
||||
- verify no regression in Pindle/Trav/Diablo flows
|
||||
|
||||
## Assets / Inputs Needed
|
||||
- Screenshot set for:
|
||||
- Act 1 stash/cube interaction points
|
||||
- successful cow portal in town
|
||||
- entry location inside cows
|
||||
- Optional:
|
||||
- map movement recording for an efficient clear loop.
|
||||
|
||||
## Screenshot + Coordinates SOP (Detailed)
|
||||
|
||||
### Why this matters
|
||||
Cow route reliability depends on visual matching and deterministic interaction points.
|
||||
Bad captures (cursor overlap, wrong UI state, mismatched lighting) are the #1 reason for unstable routes.
|
||||
|
||||
### Environment lock before capture
|
||||
1. Use the same D2R setup that bot runtime uses:
|
||||
- same resolution,
|
||||
- same UI scale,
|
||||
- same graphics preset,
|
||||
- same window mode.
|
||||
2. Apply Botty auto settings before capture.
|
||||
3. Keep panel state consistent:
|
||||
- inventory closed unless the step explicitly needs it,
|
||||
- item labels in known state,
|
||||
- no obstructing chat/quest panels.
|
||||
|
||||
### Capture tools in this repo
|
||||
- `screenshot_tool.py`
|
||||
- `quest_screenshot_tool.py`
|
||||
- `quest_debug.py`
|
||||
- `desktop_snap.py`
|
||||
- Graphic debugger hotkey from `config/params.ini` (`advanced_options.graphic_debugger_key`)
|
||||
|
||||
### Capture types required per target
|
||||
For every actionable object, collect both:
|
||||
1. Full-context screenshot (for debugging and reproducibility)
|
||||
2. Tight template crop (for matching)
|
||||
|
||||
Template crop rules:
|
||||
- include only the unique visual feature + small margin,
|
||||
- avoid cursor overlap,
|
||||
- avoid tooltip overlap unless tooltip itself is the detection target,
|
||||
- avoid over-cropping edges that change with animation.
|
||||
|
||||
### Coordinate types to record
|
||||
For each target interaction, record:
|
||||
1. Screen/monitor click point used when interaction succeeds.
|
||||
2. Whether template-center click worked.
|
||||
3. Optional fallback click point if template-center is unreliable.
|
||||
4. ROI used (if narrowed in code/tests).
|
||||
5. Confidence range seen in quick manual validation.
|
||||
|
||||
### Mandatory cows capture checklist (in order)
|
||||
1. Act 1 town anchor (post-spawn)
|
||||
- full screenshot
|
||||
- possible anchor templates
|
||||
2. Stash interaction
|
||||
- full screenshot near stash
|
||||
- stash template crop(s)
|
||||
- successful click coordinate(s)
|
||||
3. Cube interaction/transmute context
|
||||
- inventory+cube state screenshot
|
||||
- transmute UI screenshot references
|
||||
4. Cow portal in town (opened)
|
||||
- full screenshot with portal visible
|
||||
- portal template crops (2-3 variants)
|
||||
- portal click success coordinates
|
||||
5. Cow level entry area
|
||||
- immediate post-load screenshot(s)
|
||||
- safe first-move anchor capture
|
||||
6. Exit path reference
|
||||
- screenshot where exit action is normally triggered
|
||||
- any portal/escape interaction target needed
|
||||
|
||||
### Variants to capture for robustness
|
||||
Per critical template (stash, portal, entry anchor), capture:
|
||||
- 3-5 samples with slight camera/position variation,
|
||||
- at least one “busy background” sample,
|
||||
- one sample with nearby NPC/player clutter if possible.
|
||||
|
||||
### Validation pass before route coding
|
||||
For each template candidate:
|
||||
1. Verify it matches across the sample set at intended threshold.
|
||||
2. Verify it does not false-match to nearby unrelated objects.
|
||||
3. Verify click-at-center triggers the intended interaction.
|
||||
4. Record accepted threshold and failure notes.
|
||||
|
||||
Reject templates that:
|
||||
- only match at very low confidence,
|
||||
- require exact pixel-perfect camera alignment,
|
||||
- frequently collide with non-target objects.
|
||||
|
||||
### File naming convention
|
||||
Use deterministic names aligned with route semantics, e.g.:
|
||||
- `A1_COW_PORTAL_0`
|
||||
- `A1_COW_PORTAL_1`
|
||||
- `A1_STASH_REF_0`
|
||||
- `COW_ENTRY_ANCHOR_0`
|
||||
|
||||
Keep source full screenshots in a dated debug folder and final template crops in the templates location used by the project.
|
||||
|
||||
### Capture log format (what testers should write down)
|
||||
For each target:
|
||||
- `template_name`
|
||||
- capture timestamp
|
||||
- location/context
|
||||
- successful click point(s)
|
||||
- confidence range observed
|
||||
- notes on failed attempts
|
||||
|
||||
### Inputs needed from testers (you + friend)
|
||||
Provide:
|
||||
1. ZIP/folder of full screenshots + template crops.
|
||||
2. Short markdown/text capture log using format above.
|
||||
3. Character/build used during capture (Blizz Sorc / FoH / Hammerdin).
|
||||
4. Any observed instability notes (miss-clicks, wrong target matches, etc.).
|
||||
|
||||
### Acceptance criteria for capture package
|
||||
Capture package is ready for implementation when:
|
||||
- all mandatory checklist targets are present,
|
||||
- each critical target has multi-sample variants,
|
||||
- at least one tester validated successful interaction per target,
|
||||
- naming is consistent and unambiguous.
|
||||
|
||||
## Rollout
|
||||
1. Land Phase 1 behind route opt-in (`order=... ,run_cows`).
|
||||
2. Keep disabled by default in examples until 10+ stable sessions.
|
||||
3. Promote to documented standard route after stability threshold.
|
||||
|
||||
## Definition of Done
|
||||
- `run_cows` can run repeatedly without manual intervention.
|
||||
- Failure modes recover without crashing whole bot loop.
|
||||
- Route is documented in `params.ini` and `README.md`.
|
||||
- CI/lint/tests pass for changed files.
|
||||
@@ -0,0 +1,11 @@
|
||||
name: botty
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python=3.10
|
||||
- pywin32
|
||||
- pip
|
||||
- pkg-config
|
||||
- rapidfuzz=2.15.1
|
||||
- pip:
|
||||
- -r requirements-win10.txt
|
||||
@@ -0,0 +1,11 @@
|
||||
name: botty
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python=3.10
|
||||
- pywin32
|
||||
- pip
|
||||
- pkg-config
|
||||
- rapidfuzz=2.15.1
|
||||
- pip:
|
||||
- -r requirements-win11.txt
|
||||
@@ -5,8 +5,6 @@ dependencies:
|
||||
- python=3.10
|
||||
- pywin32
|
||||
- pip
|
||||
- leptonica
|
||||
- tesseract=5.1.0
|
||||
- pkg-config
|
||||
- rapidfuzz=2.15.1
|
||||
- pip:
|
||||
@@ -23,7 +21,6 @@ dependencies:
|
||||
- coverage
|
||||
- pytest
|
||||
- pytest-env
|
||||
- pytest-pythonpath
|
||||
- pytest-mock
|
||||
- pyparsing
|
||||
- graphviz
|
||||
@@ -32,7 +29,6 @@ dependencies:
|
||||
- pillow
|
||||
- discord.py
|
||||
- parse
|
||||
- dependencies/tesserocr-2.5.2-cp310-cp310-win_amd64.whl
|
||||
- typing_extensions
|
||||
- colorama
|
||||
- dataclasses-json
|
||||
- dataclasses-json
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
:: Shared conda environment detection for Botty
|
||||
:: Source this file from your .bat scripts: call find_python.bat
|
||||
:: On return, %PYTHON% will be set to the botty env python.exe or the script will exit.
|
||||
|
||||
if defined PYTHON goto :_find_python_done
|
||||
|
||||
for %%C in (
|
||||
"%LOCALAPPDATA%\miniforge3\envs\botty\python.exe"
|
||||
"%LOCALAPPDATA%\miniconda3\envs\botty\python.exe"
|
||||
"%USERPROFILE%\miniforge3\envs\botty\python.exe"
|
||||
"%USERPROFILE%\miniconda3\envs\botty\python.exe"
|
||||
"%USERPROFILE%\anaconda3\envs\botty\python.exe"
|
||||
"%USERPROFILE%\.conda\envs\botty\python.exe"
|
||||
"C:\ProgramData\miniforge3\envs\botty\python.exe"
|
||||
"C:\ProgramData\miniconda3\envs\botty\python.exe"
|
||||
"C:\ProgramData\anaconda3\envs\botty\python.exe"
|
||||
) do (
|
||||
if exist %%C (
|
||||
set "PYTHON=%%~C"
|
||||
goto :_find_python_done
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
:_find_python_done
|
||||
@@ -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
|
||||