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 |
+182
-142
@@ -1,167 +1,207 @@
|
||||
name: Botty - CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, mine]
|
||||
# Pushing a version tag (e.g. `git tag v0.8.5 && git push --tags`) builds,
|
||||
# smoke-tests, then creates the GitHub release with the zip attached — all
|
||||
# in one run. If the build fails, no release is ever created.
|
||||
tags: ['v*']
|
||||
name: CI
|
||||
|
||||
# Default GITHUB_TOKEN is read-only; the build job's release step needs
|
||||
# contents:write to create the release and attach the built zip
|
||||
# (else HTTP 403 "Resource not accessible by integration").
|
||||
permissions:
|
||||
contents: write
|
||||
on:
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
|
||||
# Cancel in-progress runs on new push
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
install-and-test:
|
||||
name: Install & Test (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment-win11.yml
|
||||
use-only-tar-bz2: false
|
||||
python-version: "3.10"
|
||||
cache: "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
|
||||
shell: powershell
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -c "import sys; print(sys.version)"
|
||||
run: python -c "import sys; print(sys.version)"
|
||||
|
||||
- name: Syntax check
|
||||
shell: powershell
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: python -m compileall -q src tools test scripts
|
||||
|
||||
- name: Verify core imports
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m compileall -q src tools test scripts
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
modules = [
|
||||
'config',
|
||||
'logger',
|
||||
'screen',
|
||||
'pather',
|
||||
'template_finder',
|
||||
'game_stats',
|
||||
'health_manager',
|
||||
'death_manager',
|
||||
'd2r_image',
|
||||
'item',
|
||||
'item.pickit',
|
||||
'transmute',
|
||||
'shop',
|
||||
'char',
|
||||
'utils',
|
||||
'utils.os_detect',
|
||||
'messages',
|
||||
]
|
||||
for mod in modules:
|
||||
try:
|
||||
__import__(mod)
|
||||
print(f' {mod}: OK')
|
||||
except ImportError as e:
|
||||
print(f' {mod}: FAILED - {e}')
|
||||
sys.exit(1)
|
||||
print('All core imports successful.')
|
||||
"
|
||||
|
||||
- name: Tests
|
||||
shell: powershell
|
||||
- name: Verify botty runs (import main modules)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src')
|
||||
import ssl
|
||||
ssl.SSLContext.load_default_certs = lambda *a, **k: None
|
||||
|
||||
from version import __version__
|
||||
print(f' Version: {__version__}')
|
||||
|
||||
from config import Config
|
||||
print(' Config: OK')
|
||||
|
||||
from game_controller import GameController
|
||||
print(' GameController: OK')
|
||||
|
||||
from bot import Bot
|
||||
print(' Bot: OK')
|
||||
|
||||
from run.diablo import Diablo
|
||||
print(' Diablo run: OK')
|
||||
|
||||
from run.pindle import Pindle
|
||||
print(' Pindle run: OK')
|
||||
|
||||
from run.arcane import Arcane
|
||||
print(' Arcane run: OK')
|
||||
|
||||
from run.vizier import Vizier
|
||||
print(' Vizier run: OK')
|
||||
|
||||
print('All botty entry modules import successfully.')
|
||||
"
|
||||
|
||||
- name: Verify OCR (pytesseract)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os, tempfile
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
import pytesseract
|
||||
pytesseract.pytesseract.tesseract_cmd = 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: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m coverage run -m pytest -v
|
||||
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
|
||||
|
||||
- name: Coverage report
|
||||
shell: powershell
|
||||
- name: Tests with coverage
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
PYTHONPATH: ./src:.
|
||||
RUN_ENV: test
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m coverage xml --ignore-errors
|
||||
python -m coverage run -m pytest -v --tb=short --ignore=test/auto/test_self_healing.py --ignore=test/smoke_test.py --ignore=test/test_version_consistency.py
|
||||
python -m coverage xml --ignore-errors || true
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment-win11.yml
|
||||
use-only-tar-bz2: false
|
||||
|
||||
- name: Install Tesseract (bundled into the release for click-and-run OCR)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
choco install tesseract --no-progress -y
|
||||
if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) {
|
||||
throw "Tesseract install did not produce tesseract.exe"
|
||||
}
|
||||
|
||||
- name: Build exe
|
||||
shell: powershell
|
||||
env:
|
||||
BOTTY_NO_RENAME: '1'
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python build.py --conda_path C:\Miniconda
|
||||
|
||||
- name: Verify Tesseract was bundled
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
$tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe"
|
||||
if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" }
|
||||
Write-Host "Bundled: $tess"
|
||||
|
||||
- name: Launch smoke test (built executables)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
if (-not $BOTTY_DIR) { throw "No botty_v* build directory found." }
|
||||
|
||||
$mainExe = Join-Path $BOTTY_DIR "main.exe"
|
||||
$shopperExe = Join-Path $BOTTY_DIR "shopper.exe"
|
||||
if (-not (Test-Path $mainExe)) { throw "Missing $mainExe" }
|
||||
if (-not (Test-Path $shopperExe)) { throw "Missing $shopperExe" }
|
||||
|
||||
$procs = @()
|
||||
try {
|
||||
$mainProc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 6
|
||||
if ($mainProc.HasExited) { throw "main.exe exited early with code $($mainProc.ExitCode)" }
|
||||
$procs += $mainProc
|
||||
|
||||
$shopperProc = Start-Process -FilePath $shopperExe -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 6
|
||||
if ($shopperProc.HasExited) { throw "shopper.exe exited early with code $($shopperProc.ExitCode)" }
|
||||
$procs += $shopperProc
|
||||
}
|
||||
finally {
|
||||
foreach ($p in $procs) {
|
||||
if ($p -and -not $p.HasExited) {
|
||||
Stop-Process -Id $p.Id -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Prepare release zip
|
||||
shell: powershell
|
||||
run: |
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
Write-Host "Botty dir: $BOTTY_DIR"
|
||||
Get-ChildItem -Path $BOTTY_DIR -Recurse | Select-Object -Property FullName, Length
|
||||
$ZIP = "${BOTTY_DIR}.zip"
|
||||
Compress-Archive -Path "${BOTTY_DIR}\*" -DestinationPath $ZIP -Force
|
||||
Write-Host "Release zip: $ZIP"
|
||||
Get-Item $ZIP | Select-Object -Property FullName, Length
|
||||
|
||||
- name: Upload build artifacts
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: botty-build
|
||||
path: botty_v*/
|
||||
retention-days: 7
|
||||
|
||||
# On a version-tag push, create the release (if absent) and attach the
|
||||
# zip atomically. Runs only after the build + smoke test above succeed.
|
||||
- name: Create release and upload zip
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: botty_v*.zip
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
@@ -100,3 +100,6 @@ src/input_layer/bridge_input.py
|
||||
.hermes/
|
||||
fixtures/
|
||||
test/run/
|
||||
|
||||
# Installer output (generated by run_install_capture.bat)
|
||||
install_log.txt
|
||||
|
||||
@@ -325,6 +325,84 @@ 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,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,26 +94,28 @@ 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
|
||||
botty_env = os.path.join(args.conda_path, "envs", "botty")
|
||||
pyinstaller_exe = os.path.join(botty_env, "Scripts", "pyinstaller.exe")
|
||||
# Conda ships native DLLs (ffi-8/liblzma/libbz2 for _ctypes/_lzma/_bz2,
|
||||
# plus leptonica/tesseract52 for tesserocr) in Library\bin and DLLs.
|
||||
# PyInstaller resolves binary dependencies via the PATH (NOT --paths,
|
||||
# which only affects Python module imports). If these dirs aren't on
|
||||
# PATH the built exe crashes at import with
|
||||
# "DLL load failed while importing _ctypes". Prepend them for both
|
||||
# local and CI builds.
|
||||
dll_dirs = [
|
||||
os.path.join(botty_env, "Library", "bin"),
|
||||
os.path.join(botty_env, "Library", "lib"),
|
||||
os.path.join(botty_env, "DLLs"),
|
||||
]
|
||||
os.environ["PATH"] = os.pathsep.join(dll_dirs) + os.pathsep + os.environ.get("PATH", "")
|
||||
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:
|
||||
@@ -151,4 +176,4 @@ if __name__ == "__main__":
|
||||
|
||||
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}"')
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
; If you chicken/die repeatedly, drop to Nightmare first.
|
||||
difficulty=hell
|
||||
; name: bot profile name used in logs/messages and mod launch option replacement
|
||||
name=fistman
|
||||
name=profile1
|
||||
; randomize_runs: 0 = run in listed order, 1 = shuffle run order
|
||||
randomize_runs=0
|
||||
; target_tz: target Terror Zone id (leave as default unless you know the mapping)
|
||||
|
||||
+9
-2
@@ -22,8 +22,15 @@ for %%C in (
|
||||
)
|
||||
)
|
||||
|
||||
echo ERROR: Could not find botty conda environment.
|
||||
echo Run install.bat first.
|
||||
echo.
|
||||
echo ERROR: The 'botty' Python environment is not installed yet.
|
||||
echo.
|
||||
echo Fix: double-click install.bat in this folder and wait for
|
||||
echo "Installation complete!", then try again.
|
||||
echo.
|
||||
echo Already ran install.bat? Then it did not finish successfully.
|
||||
echo Run run_install_capture.bat and check install_log.txt for the error.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
|
||||
+283
-45
@@ -59,13 +59,26 @@ echo This is a one-time setup (~100 MB). Please wait.
|
||||
echo.
|
||||
|
||||
:: --- Method 1: winget (cleanest; available on Win10 1709+ and all Win11) ---
|
||||
:: --scope user is critical: without it winget defaults to a machine-wide
|
||||
:: install (lands in %ProgramData%) which REQUIRES admin. A normal double-click
|
||||
:: (no elevation) then fails silently and conda never installs. --scope user
|
||||
:: installs to %USERPROFILE%\miniforge3 with no admin needed — which is also one
|
||||
:: of the locations :rescan_conda searches.
|
||||
:: winget returns non-zero when the package is already installed, so don't
|
||||
:: trust the exit code -- rescan for conda and only fall through to the direct
|
||||
:: download if it's genuinely still missing. Keep this comment outside the block:
|
||||
:: a "::" line inside a ( ) block is a parse error.
|
||||
winget --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo Installing via winget...
|
||||
winget install --id CondaForge.Miniforge3 --exact --silent ^
|
||||
winget install --id CondaForge.Miniforge3 --exact --silent --scope user ^
|
||||
--accept-package-agreements --accept-source-agreements
|
||||
if !errorlevel! equ 0 goto :rescan_conda
|
||||
echo winget install failed — falling back to direct download.
|
||||
for %%C in (
|
||||
"%LOCALAPPDATA%\miniforge3\Scripts\conda.exe"
|
||||
"%USERPROFILE%\miniforge3\Scripts\conda.exe"
|
||||
"%ProgramData%\miniforge3\Scripts\conda.exe"
|
||||
) do if exist %%C goto :rescan_conda
|
||||
echo winget did not produce a usable conda — falling back to direct download.
|
||||
echo.
|
||||
)
|
||||
|
||||
@@ -78,6 +91,7 @@ if %errorlevel% equ 0 (
|
||||
set "MF_INSTALLER=%TEMP%\Miniforge3-installer.exe"
|
||||
set "MF_URL=https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe"
|
||||
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
echo Downloading Miniforge3...
|
||||
curl -Lk --progress-bar "%MF_URL%" -o "%MF_INSTALLER%" 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
@@ -87,23 +101,52 @@ if %errorlevel% neq 0 (
|
||||
)
|
||||
if not exist "%MF_INSTALLER%" (
|
||||
echo.
|
||||
echo ERROR: Could not download Miniforge3. Check your internet connection, or
|
||||
echo install it manually then re-run install.bat:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo ERROR: Could not download Miniforge3 ^(the Python/conda runtime^).
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo Nothing was downloaded from:
|
||||
echo %MF_URL%
|
||||
echo Usual causes: no internet, or a firewall/proxy blocking github.com.
|
||||
echo.
|
||||
echo Workaround: install Miniforge yourself, then run install.bat again.
|
||||
echo It will detect it and carry on:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo ^(pick Miniforge3-Windows-x86_64.exe and keep the default location^)
|
||||
goto :fail
|
||||
)
|
||||
|
||||
:: Verify the download is a real installer, not a truncated file or an HTML
|
||||
:: error page served with a 200. The Miniforge installer is ~78 MB; anything
|
||||
:: under 40 MB means the download failed even though a file exists.
|
||||
for %%A in ("%MF_INSTALLER%") do set "MF_SIZE=%%~zA"
|
||||
if not defined MF_SIZE set "MF_SIZE=0"
|
||||
if %MF_SIZE% LSS 41943040 (
|
||||
echo.
|
||||
echo ERROR: The Miniforge3 download is incomplete and was not run.
|
||||
echo Got: %MF_SIZE% bytes
|
||||
echo Expected: about 78 MB
|
||||
echo.
|
||||
echo The connection dropped, or a proxy/filter returned an error page
|
||||
echo instead of the file. The bad file has been deleted.
|
||||
echo.
|
||||
echo Try again on a different network, or install Miniforge yourself and
|
||||
echo re-run install.bat:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo Installing Miniforge3 (this takes ~1 minute)...
|
||||
start /wait "" "%MF_INSTALLER%" /S /InstallationType=JustMe /AddToPath=0 /RegisterPython=0 /NoShortcuts=1 /NoRegistry=1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).
|
||||
echo Try running it manually: %MF_INSTALLER%
|
||||
echo ERROR: The Miniforge3 installer did not complete ^(exit code %errorlevel%^).
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo Usual causes: antivirus blocked it, or a UAC prompt was declined.
|
||||
echo.
|
||||
echo Run it by hand to see the real error, accepting any prompts:
|
||||
echo %MF_INSTALLER%
|
||||
echo then run install.bat again.
|
||||
goto :fail
|
||||
)
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
echo Miniforge3 installed.
|
||||
@@ -127,10 +170,18 @@ for %%C in (
|
||||
goto :found_conda
|
||||
)
|
||||
)
|
||||
echo ERROR: Miniforge3 installed but conda.exe still not found. Please restart
|
||||
echo the installer or contact support.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Miniforge3 reported success but conda.exe cannot be found.
|
||||
echo.
|
||||
echo install.bat looked in every standard location, including:
|
||||
echo %%LOCALAPPDATA%%\miniforge3\Scripts\conda.exe
|
||||
echo %%USERPROFILE%%\miniforge3\Scripts\conda.exe
|
||||
echo %%ProgramData%%\miniforge3\Scripts\conda.exe
|
||||
echo.
|
||||
echo If you installed conda somewhere custom, the simplest fix is to
|
||||
echo install Miniforge to its default location:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo then run install.bat again.
|
||||
goto :fail
|
||||
|
||||
:found_conda
|
||||
echo Found conda: %CONDA_EXE%
|
||||
@@ -141,11 +192,16 @@ echo Testing conda...
|
||||
"%CONDA_EXE%" --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: conda found but failed to run. Your conda installation may be broken.
|
||||
echo Try re-installing Miniforge: https://github.com/conda-forge/miniforge
|
||||
echo ERROR: conda was found but will not run.
|
||||
echo Location: %CONDA_EXE%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo The install is damaged or blocked. Try, in order:
|
||||
echo 1. Run this by hand to see the real error:
|
||||
echo "%CONDA_EXE%" --version
|
||||
echo 2. Check antivirus is not quarantining conda
|
||||
echo 3. Reinstall Miniforge, then run install.bat again:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
goto :fail
|
||||
)
|
||||
echo conda is working.
|
||||
echo.
|
||||
@@ -168,14 +224,48 @@ for %%C in (
|
||||
)
|
||||
)
|
||||
|
||||
:: --- Pre-flight: free disk space ---
|
||||
:: The botty env is ~4 GB once conda unpacks it, plus ~1 GB of downloaded
|
||||
:: package archives. Running out midway makes conda fail with a long, opaque
|
||||
:: error, so check up front and say so plainly instead.
|
||||
set "FREE_GB="
|
||||
for /f "delims=" %%A in ('powershell -NoProfile -Command "[int]((Get-PSDrive %SystemDrive:~0,1%).Free/1GB)" 2^>nul') do set "FREE_GB=%%A"
|
||||
if defined FREE_GB (
|
||||
echo Free disk space: %FREE_GB% GB
|
||||
if %FREE_GB% LSS 3 (
|
||||
echo.
|
||||
echo ERROR: Not enough free disk space on %SystemDrive%
|
||||
echo Free now: %FREE_GB% GB
|
||||
echo Needed: about 6 GB ^(the botty environment is ~4 GB, plus
|
||||
echo ~1 GB of package downloads^)
|
||||
echo.
|
||||
echo Free up space and run install.bat again. Quick wins:
|
||||
echo - Empty the Recycle Bin
|
||||
echo - Windows Settings ^> System ^> Storage ^> Temporary files
|
||||
echo - Uninstall apps you no longer use
|
||||
goto :fail
|
||||
)
|
||||
if %FREE_GB% LSS 6 (
|
||||
echo WARNING: Only %FREE_GB% GB free. The install needs about 6 GB and
|
||||
echo may fail partway. Free up space if it does.
|
||||
)
|
||||
)
|
||||
|
||||
:: --- Create botty env ---
|
||||
echo Creating 'botty' conda environment...
|
||||
"%CONDA_EXE%" env create -f "%ENV_FILE%"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: conda env create failed. See output above.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not create the 'botty' conda environment.
|
||||
echo.
|
||||
echo The detailed reason is in the conda output above this message.
|
||||
echo Most common causes:
|
||||
echo - Out of disk space ^(needs about 6 GB free^)
|
||||
echo - No internet, or a company/school network blocking conda-forge
|
||||
echo - Antivirus blocking conda while it unpacks files
|
||||
echo - A previous half-finished install: run this to clear it, then retry
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
goto :fail
|
||||
)
|
||||
|
||||
:check_env
|
||||
@@ -198,9 +288,16 @@ for %%C in (
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ERROR: botty env was created but python.exe was not found.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: The 'botty' environment exists but its python.exe is missing.
|
||||
echo.
|
||||
echo This usually means the environment was only partly created -- often
|
||||
echo because the install ran out of disk space, or antivirus removed files
|
||||
echo while conda was unpacking them.
|
||||
echo.
|
||||
echo Fix: delete the environment and install again:
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
echo install.bat
|
||||
goto :fail
|
||||
|
||||
:env_ready
|
||||
echo Botty Python: %PYTHON%
|
||||
@@ -215,9 +312,17 @@ echo Installing Botty requirements from %REQ_FILE%...
|
||||
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off -r "%REQ_FILE%"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: pip requirements install failed for %REQ_FILE%.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not install the Python packages from %REQ_FILE%.
|
||||
echo.
|
||||
echo The failing package and reason are in the pip output above.
|
||||
echo Most common causes:
|
||||
echo - No internet, or a proxy/firewall blocking pypi.org
|
||||
echo - Out of disk space
|
||||
echo - Antivirus blocking pip while it writes files
|
||||
echo.
|
||||
echo The conda environment itself is fine -- you can just run
|
||||
echo install.bat again; it will skip straight to this step.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo.
|
||||
@@ -249,9 +354,14 @@ for %%C in (
|
||||
goto :env_found
|
||||
)
|
||||
)
|
||||
echo ERROR: Could not find botty env directory.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not locate the 'botty' environment folder.
|
||||
echo.
|
||||
echo Python was found but the environment directory around it was not,
|
||||
echo which means the conda install is in an unexpected layout.
|
||||
echo Fix: remove and recreate the environment:
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
echo install.bat
|
||||
goto :fail
|
||||
|
||||
:env_found
|
||||
set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_ENV_DIR%\DLLs;%BOTTY_ENV_DIR%\Scripts"
|
||||
@@ -267,8 +377,27 @@ set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_EN
|
||||
:: Remove any existing pip/wheel tesserocr
|
||||
"%PYTHON%" -m pip uninstall tesserocr -y >nul 2>&1
|
||||
|
||||
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs
|
||||
"%CONDA_EXE%" install -n botty "tesseract=4.*" -c conda-forge -y >nul 2>&1
|
||||
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs.
|
||||
:: libdeflate is required explicitly: the libtiff that ships with tesseract 4.x
|
||||
:: imports libdeflate, and without it the whole chain below fails to load.
|
||||
"%CONDA_EXE%" install -n botty "tesseract=4.*" libdeflate -c conda-forge -y >nul 2>&1
|
||||
|
||||
:: libdeflate.dll compatibility alias.
|
||||
:: THIS IS WHAT MAKES tesserocr WORK. The DLL chain is:
|
||||
:: tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll -> libdeflate.dll
|
||||
:: Current conda-forge libdeflate (>=1.20) installs the library as "deflate.dll",
|
||||
:: but the older tiff.dll from the tesseract=4.x stack still imports the previous
|
||||
:: name "libdeflate.dll". Nothing provides that name, so tiff.dll fails to load,
|
||||
:: and every DLL above it in the chain fails with WinError 126 ("The specified
|
||||
:: module could not be found") -- which surfaced as tesserocr being permanently
|
||||
:: unavailable and the bot silently falling back to the slower pytesseract.
|
||||
:: Copying deflate.dll to the old name satisfies the import; the exported symbols
|
||||
:: are the same library, verified by tesserocr initialising and running real OCR.
|
||||
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
|
||||
copy /y "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" >nul
|
||||
)
|
||||
)
|
||||
|
||||
:: conda tesseract.exe crashes on Win10 and Win11 — disable it, keep the DLLs
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\tesseract.exe" (
|
||||
@@ -288,23 +417,83 @@ if exist "dependencies\tesserocr.cp310-win_amd64.pyd" (
|
||||
)
|
||||
|
||||
:: --- Backend 2: pytesseract (reliable fallback) ---
|
||||
:: Needs tesseract.exe from winget. Works on any Python version.
|
||||
:: On Win10: winget may not be available -- if install fails, offer manual link.
|
||||
:: This is the backend that actually carries OCR on most machines (tesserocr's
|
||||
:: MSVC DLL chain frequently fails), so it must install without admin rights and
|
||||
:: without winget -- neither is guaranteed on a clean Win10 box.
|
||||
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off pytesseract >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo WARNING: Could not install pytesseract Python wrapper.
|
||||
)
|
||||
|
||||
if not exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
echo Installing Tesseract OCR via winget...
|
||||
winget install --id tesseract-ocr.tesseract --silent --accept-package-agreements --accept-source-agreements 2>nul
|
||||
if !errorlevel! neq 0 (
|
||||
echo winget failed -- on Windows 10 you may need to install manually:
|
||||
echo https://github.com/tesseract-ocr/tesseract/releases
|
||||
echo Download the win64 installer, run it, keep the default install path.
|
||||
:: Try winget machine scope, then user scope -- the latter needs no admin.
|
||||
:: winget's exit code is unreliable, being non-zero when the package is already
|
||||
:: installed, so after each attempt we re-resolve tesseract.exe instead of
|
||||
:: trusting errorlevel.
|
||||
:: NOTE: comments must stay OUTSIDE the parenthesised blocks below. A "::" line
|
||||
:: inside a ( ) block is a parse error, and any parenthesis in the comment text
|
||||
:: closes the block early.
|
||||
call :find_tesseract
|
||||
if not defined TESS_EXE (
|
||||
echo Installing Tesseract OCR...
|
||||
winget --version >nul 2>&1
|
||||
if !errorlevel! equ 0 (
|
||||
winget install --id tesseract-ocr.tesseract --exact --silent ^
|
||||
--accept-package-agreements --accept-source-agreements >nul 2>&1
|
||||
call :find_tesseract
|
||||
if not defined TESS_EXE (
|
||||
winget install --id tesseract-ocr.tesseract --exact --silent --scope user ^
|
||||
--accept-package-agreements --accept-source-agreements >nul 2>&1
|
||||
call :find_tesseract
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:: Last resort: direct download of the official NSIS installer. Covers clean
|
||||
:: Win10 machines where winget is missing or fails.
|
||||
:: The size check guards against a truncated download or an HTML error page;
|
||||
:: the installer is ~50 MB.
|
||||
:: NOTE on /D=: measured behaviour is that the official Tesseract installer
|
||||
:: self-elevates (it requests admin) and the elevated relaunch DISCARDS /D=, so
|
||||
:: it always lands machine-wide in "C:\Program Files\Tesseract-OCR" regardless
|
||||
:: of TS_DEST. /D= is kept as best-effort only. The practical consequence is
|
||||
:: that Tesseract needs admin/UAC -- there is no per-user install path with the
|
||||
:: official installer. Either outcome is fine at runtime because find_tesseract
|
||||
:: and src\d2r_image\ocr.py both search the machine-wide and per-user paths.
|
||||
:: /D= must still come last and unquoted, and breaks on paths with spaces, so
|
||||
:: it is only passed when the target path has none.
|
||||
if not defined TESS_EXE (
|
||||
echo winget unavailable or failed -- downloading Tesseract directly...
|
||||
set "TS_INSTALLER=%TEMP%\tesseract-setup.exe"
|
||||
set "TS_URL=https://github.com/tesseract-ocr/tesseract/releases/download/5.5.0/tesseract-ocr-w64-setup-5.5.0.20241111.exe"
|
||||
set "TS_DEST=%LOCALAPPDATA%\Programs\Tesseract-OCR"
|
||||
del /q "!TS_INSTALLER!" >nul 2>&1
|
||||
curl -Lk --progress-bar "!TS_URL!" -o "!TS_INSTALLER!" 2>&1
|
||||
if not exist "!TS_INSTALLER!" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$ProgressPreference='SilentlyContinue'; try { Invoke-WebRequest -Uri '!TS_URL!' -OutFile '!TS_INSTALLER!' -UseBasicParsing; exit 0 } catch { exit 1 }"
|
||||
)
|
||||
set "TS_SIZE=0"
|
||||
if exist "!TS_INSTALLER!" for %%A in ("!TS_INSTALLER!") do set "TS_SIZE=%%~zA"
|
||||
if !TS_SIZE! GEQ 20971520 (
|
||||
echo !TS_DEST! | find " " >nul
|
||||
if !errorlevel! equ 0 (
|
||||
start /wait "" "!TS_INSTALLER!" /S
|
||||
) else (
|
||||
start /wait "" "!TS_INSTALLER!" /S /D=!TS_DEST!
|
||||
)
|
||||
del /q "!TS_INSTALLER!" >nul 2>&1
|
||||
call :find_tesseract
|
||||
)
|
||||
)
|
||||
|
||||
if defined TESS_EXE (
|
||||
echo Tesseract: !TESS_EXE!
|
||||
) else (
|
||||
echo WARNING: Tesseract could not be installed automatically. Install manually:
|
||||
echo https://github.com/tesseract-ocr/tesseract/releases
|
||||
echo Download the win64 installer, run it, then re-run install.bat.
|
||||
)
|
||||
|
||||
:: --- Verify OCR: at least one backend must work ---
|
||||
echo.
|
||||
echo Checking OCR backends...
|
||||
@@ -319,8 +508,8 @@ if %errorlevel% == 0 (
|
||||
echo tesserocr: not available ^(DLL issue -- bot will use pytesseract instead^)
|
||||
)
|
||||
|
||||
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'C:\Program Files\Tesseract-OCR\tesseract.exe'; pytesseract.get_tesseract_version()" >nul 2>&1
|
||||
if defined TESS_EXE (
|
||||
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'!TESS_EXE!'; pytesseract.get_tesseract_version()" >nul 2>&1
|
||||
if !errorlevel! == 0 (
|
||||
echo pytesseract: OK ^(reliable fallback^)
|
||||
set "OCR_READY=1"
|
||||
@@ -384,3 +573,52 @@ echo Installation complete!
|
||||
echo Run botty with: run_botty.bat
|
||||
echo ============================================
|
||||
echo.
|
||||
:: Hold the window open. Every failure path already pauses, but the success
|
||||
:: path did not -- so a user double-clicking install.bat from Explorer saw the
|
||||
:: console vanish the instant it finished and never got to read the result or
|
||||
:: the dependency/OCR verification above. Redirected runs are unaffected:
|
||||
:: run_install_capture.bat feeds stdin from the log redirect, and any
|
||||
:: non-interactive run should invoke this script with "< nul".
|
||||
pause
|
||||
goto :eof
|
||||
|
||||
:: --- Shared failure exit ---
|
||||
:: Every fatal path jumps here with "goto :fail" after printing what went wrong
|
||||
:: and how to fix it. This adds the one instruction that makes a bug report
|
||||
:: actionable: how to produce a full log.
|
||||
:fail
|
||||
echo.
|
||||
echo ------------------------------------------------------------
|
||||
echo INSTALL FAILED - nothing else was changed on your PC.
|
||||
echo.
|
||||
echo Still stuck? Produce a full log and include it when asking
|
||||
echo for help:
|
||||
echo 1. Double-click run_install_capture.bat
|
||||
echo 2. Attach the install_log.txt it creates
|
||||
echo ------------------------------------------------------------
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:: --- Resolve tesseract.exe into TESS_EXE ---
|
||||
:: Checks machine-wide (winget default / manual install) and per-user (winget
|
||||
:: --scope user / our direct NSIS fallback) locations, plus PATH. Sets TESS_EXE
|
||||
:: to the first hit, or clears it if none found.
|
||||
:find_tesseract
|
||||
set "TESS_EXE="
|
||||
for %%T in (
|
||||
"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
|
||||
"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
|
||||
"%ProgramData%\Tesseract-OCR\tesseract.exe"
|
||||
) do (
|
||||
if exist %%T (
|
||||
set "TESS_EXE=%%~T"
|
||||
goto :eof
|
||||
)
|
||||
)
|
||||
for /f "delims=" %%T in ('where tesseract 2^>nul') do (
|
||||
set "TESS_EXE=%%T"
|
||||
goto :eof
|
||||
)
|
||||
goto :eof
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "botty"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
description = "Pixelbot for Diablo 2 Resurrected"
|
||||
requires-python = ">=3.10,<3.11"
|
||||
dependencies = [
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
[pytest]
|
||||
pythonpath =
|
||||
.
|
||||
src
|
||||
env =
|
||||
PYTHONPATH=./src
|
||||
PYTHONPATH=./src:.
|
||||
RUN_ENV=test
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiohappyeyeballs==2.6.2
|
||||
pywin32==311
|
||||
aiohttp==3.14.1
|
||||
aiosignal==1.4.0
|
||||
async-timeout==5.0.1
|
||||
|
||||
+9
-2
@@ -17,9 +17,16 @@ set "CONDA_PREFIX=%_ENV%"
|
||||
set "PYTHONUTF8=1"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
set "SSL_CERT_DIR="
|
||||
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation)
|
||||
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation).
|
||||
:: Only export the path if it actually exists -- on machines where Tesseract was
|
||||
:: installed per-user (no admin), it lives under %LOCALAPPDATA%\Programs instead,
|
||||
:: and src\d2r_image\ocr.py resolves that itself.
|
||||
set "TESSDATA_PREFIX=%_ENV%\Library\share"
|
||||
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
) else if exist "%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe" (
|
||||
set "PYTESSERACT_TESSERACT_CMD=%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
|
||||
)
|
||||
|
||||
echo Launching Botty ...
|
||||
"%PYTHON%" "%BOTTY_DIR%src\main.py"
|
||||
|
||||
+17
-1
@@ -1,2 +1,18 @@
|
||||
@echo off
|
||||
call "%~dp0install.bat" > "%~dp0install_log.txt" 2>&1
|
||||
:: Runs install.bat and captures everything to install_log.txt for support.
|
||||
:: stdin is fed from nul so install.bat's trailing "pause" (and any failure
|
||||
:: pause) cannot silently block behind the redirected output -- otherwise the
|
||||
:: user would see an empty window waiting on a keypress they cannot see.
|
||||
echo Installing and writing a full log to install_log.txt ...
|
||||
echo This can take several minutes. Please wait.
|
||||
call "%~dp0install.bat" < nul > "%~dp0install_log.txt" 2>&1
|
||||
set "RC=%ERRORLEVEL%"
|
||||
echo.
|
||||
if "%RC%"=="0" (
|
||||
echo Install finished. Full log: "%~dp0install_log.txt"
|
||||
) else (
|
||||
echo Install FAILED with exit code %RC%. Send this file for support:
|
||||
echo "%~dp0install_log.txt"
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
"""Hermes bot control — send commands to botty via TCP socket on 127.0.0.1:18899.
|
||||
|
||||
Usage:
|
||||
python scripts/hermes_bot_control.py start # start/pause bot
|
||||
python scripts/hermes_bot_control.py pause # toggle pause
|
||||
python scripts/hermes_bot_control.py stop # stop bot
|
||||
python scripts/hermes_bot_control.py status # get bot status
|
||||
python scripts/hermes_bot_control.py logs [n] # last n log lines
|
||||
python scripts/hermes_bot_control.py errors [n] # last n error lines
|
||||
python scripts/hermes_bot_control.py runs # run stats
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import glob
|
||||
import socket
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||
LOG_FILE = os.path.join(PROJECT_ROOT, 'log', 'log.txt')
|
||||
SOCKET_PORT = 18899
|
||||
|
||||
def send_command(cmd):
|
||||
"""Send a command to the bot's control socket."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(3.0)
|
||||
s.connect(('127.0.0.1', SOCKET_PORT))
|
||||
s.sendall(cmd.encode())
|
||||
response = s.recv(1024).decode().strip()
|
||||
s.close()
|
||||
if response:
|
||||
print(response)
|
||||
else:
|
||||
print(f"OK: command '{cmd}' sent")
|
||||
except socket.timeout:
|
||||
print(f"ERROR: no response from bot (socket timeout)")
|
||||
except ConnectionRefusedError:
|
||||
print(f"ERROR: bot not listening on port {SOCKET_PORT} (is it running?)")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
def read_logs(n=20):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
print("No log file found")
|
||||
return
|
||||
result = subprocess.run(["tail", "-n", str(n), LOG_FILE],
|
||||
capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
|
||||
def read_errors(n=10):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
print("No log file found")
|
||||
return
|
||||
result = subprocess.run(["grep", "-E", "ERROR|WARNING|Failed|failed|ERROR.*step", LOG_FILE],
|
||||
capture_output=True, text=True)
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for line in lines[-n:]:
|
||||
print(line)
|
||||
|
||||
def run_stats():
|
||||
stats_dir = os.path.join(PROJECT_ROOT, 'log', 'stats')
|
||||
stats_files = glob.glob(os.path.join(stats_dir, 'stats_*.log'))
|
||||
if stats_files:
|
||||
latest = max(stats_files, key=os.path.getmtime)
|
||||
with open(latest) as f:
|
||||
print(f.read())
|
||||
else:
|
||||
print("No stats files found")
|
||||
|
||||
def check_status():
|
||||
send_command('status')
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd in ('start', 'pause', 'stop'):
|
||||
send_command(cmd)
|
||||
elif cmd == 'status':
|
||||
check_status()
|
||||
elif cmd == 'logs':
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||
read_logs(n)
|
||||
elif cmd == 'errors':
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
read_errors(n)
|
||||
elif cmd == 'runs':
|
||||
run_stats()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
@@ -1278,14 +1278,14 @@ class Hammerdin(Paladin):
|
||||
Logger.debug("Waiting for Diablo to appear...")
|
||||
start = time.time()
|
||||
diablo_found = False
|
||||
while (time.time() - start) < 15:
|
||||
while (time.time() - start) < 20:
|
||||
if get_visible_targets():
|
||||
diablo_found = True
|
||||
Logger.info("Diablo has spawned, engaging!")
|
||||
break
|
||||
wait(0.5, 0.6)
|
||||
if not diablo_found:
|
||||
Logger.warning("Diablo did not appear within 15s, attacking anyway")
|
||||
Logger.warning("Diablo did not appear within 20s, attacking anyway")
|
||||
### ATTACK WITH CONCENTRATION ###
|
||||
# Concentration is the Blessed Hammer damage synergy and a party aura the
|
||||
# merc benefits from. Conviction does nothing for magic-damage hammers, and
|
||||
@@ -1295,10 +1295,17 @@ class Hammerdin(Paladin):
|
||||
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
|
||||
Logger.debug("Attacking Diablo at position 1/1")
|
||||
self._cast_hammers(Config().char["atk_len_diablo"], "concentration")
|
||||
# Re-verify targets mid-fight; if Diablo moved, reposition
|
||||
if get_visible_targets():
|
||||
pos_m = convert_abs_to_monitor((0, 0))
|
||||
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
|
||||
self._move_and_attack((60, 30), Config().char["atk_len_diablo"], "concentration")
|
||||
self._move_and_attack((-60, -30), Config().char["atk_len_diablo"], "concentration")
|
||||
wait(0.1, 0.15)
|
||||
self._cast_hammers(1.2, "redemption")
|
||||
# Final redemption burst to ensure kill
|
||||
wait(0.1, 0.2)
|
||||
self._cast_hammers(0.8, "redemption")
|
||||
### LOOT ###
|
||||
# force=True: Diablo is dead; his death animation/lingering effects register
|
||||
# as targets and the mobs-alive guard would skip his drops entirely.
|
||||
|
||||
@@ -5,7 +5,8 @@ from d2r_image.processing_data import Runeword
|
||||
try:
|
||||
from rapidfuzz.string_metric import levenshtein
|
||||
except ImportError:
|
||||
from rapidfuzz.distance import Levenshtein as levenshtein
|
||||
from rapidfuzz.distance import Levenshtein as _Levenshtein_mod
|
||||
levenshtein = _Levenshtein_mod.distance
|
||||
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
|
||||
from bnip.NTIPAliasStat import NTIPAliasStat as NTIP_STATS
|
||||
from logger import Logger
|
||||
|
||||
@@ -46,6 +46,11 @@ try:
|
||||
os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release
|
||||
shutil.which("tesseract"),
|
||||
r"C:\Program Files\Tesseract-OCR\tesseract.exe",
|
||||
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
|
||||
# Per-user installs (winget --scope user, or install.bat's direct NSIS
|
||||
# fallback) land here and are not on PATH -- needed on machines where
|
||||
# the user has no admin rights.
|
||||
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Tesseract-OCR", "tesseract.exe"),
|
||||
]
|
||||
_cmd = next((c for c in _candidates if c and os.path.isfile(c)), None)
|
||||
if _cmd:
|
||||
|
||||
@@ -169,8 +169,8 @@ class GameController:
|
||||
Logger.warning("Your D2R settings differ from the requiered ones. Please use Auto Settings to adjust them. The differences are:")
|
||||
Logger.warning(f"{diff}")
|
||||
set_d2r_always_on_top()
|
||||
if enforce_d2r_window(5, 98):
|
||||
find_and_set_window_position(force=True)
|
||||
enforce_d2r_window(5, 98)
|
||||
find_and_set_window_position(force=True)
|
||||
self.setup_screen()
|
||||
self.start_health_manager_thread()
|
||||
self.start_death_manager_thread()
|
||||
|
||||
@@ -196,7 +196,7 @@ class HealthManager:
|
||||
if belt.drink_potion("health", merc=True, stats=[merc_health]):
|
||||
self._last_merc_heal = time.time()
|
||||
|
||||
# Close any open panels that might block detection
|
||||
# Close any open panels that might block detection
|
||||
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
|
||||
self._count_panel_detects += 1
|
||||
if self._count_panel_detects >= 2:
|
||||
@@ -205,7 +205,11 @@ class HealthManager:
|
||||
self._do_chicken(img)
|
||||
continue
|
||||
Logger.debug("Found an open panel. Closing it.")
|
||||
common.close()
|
||||
# Send Escape directly — more reliable than common.close() which
|
||||
# only checks inventory_is_open() and may miss belt/panel states
|
||||
from input_layer import keyboard as kb
|
||||
kb.send("esc")
|
||||
wait(0.1, 0.2)
|
||||
|
||||
fn_end = time.perf_counter()
|
||||
# Target ~15 FPS polling with anti-cheat jitter
|
||||
|
||||
+85
-21
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Global hotkey polling via GetAsyncKeyState.
|
||||
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
|
||||
No kernel driver - pure user-mode polling thread.
|
||||
Global hotkey via keyboard library's WH_KEYBOARD_LL hook.
|
||||
Intercepts all keystrokes regardless of which window has focus.
|
||||
Falls back to GetAsyncKeyState polling if the keyboard library
|
||||
fails to install its hook (e.g. antivirus interference).
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
@@ -9,24 +10,81 @@ import ctypes
|
||||
from ctypes import wintypes
|
||||
from .win_input import _get_vk, VK_MAP, user32
|
||||
|
||||
try:
|
||||
import keyboard as _keyboard
|
||||
_HAS_KEYBOARD = True
|
||||
except Exception:
|
||||
_HAS_KEYBOARD = False
|
||||
|
||||
|
||||
class _HotkeyManager:
|
||||
def __init__(self):
|
||||
self._callbacks = {} # vk -> [(key_str, callback), ...]
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._suppress = {} # vk -> bool (suppress key after callback fires)
|
||||
self._suppress = {} # vk -> bool
|
||||
self._lock = threading.Lock()
|
||||
self._suppressed = set() # vks currently being held down in suppress mode
|
||||
self._held = set() # vks seen down on the previous poll (edge-trigger)
|
||||
self._held = set()
|
||||
self._suppressed = set()
|
||||
self._poll_thread = None
|
||||
# Track which keys are registered with the keyboard library
|
||||
self._keyboard_callbacks = {} # vk -> keyboard callback wrapper
|
||||
|
||||
def _ensure_running(self):
|
||||
if not self._running:
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
|
||||
if _HAS_KEYBOARD:
|
||||
# Use keyboard library's global hook - works even when D2R has focus
|
||||
try:
|
||||
self._keyboard_hook = _keyboard.hook(self._keyboard_callback, suppress=False)
|
||||
return
|
||||
except Exception:
|
||||
pass # Fall through to polling
|
||||
|
||||
# Fallback: GetAsyncKeyState polling (only works when bot has focus)
|
||||
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._poll_thread.start()
|
||||
|
||||
def _keyboard_callback(self, event):
|
||||
"""Callback from keyboard library's global hook."""
|
||||
if event.event_type != _keyboard.KEY_DOWN:
|
||||
return
|
||||
|
||||
# Map keyboard event name to VK code
|
||||
vk = None
|
||||
try:
|
||||
# keyboard library uses names like 'f11', 'f12', etc.
|
||||
vk = _get_vk(event.name)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if vk is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if vk not in self._callbacks:
|
||||
return
|
||||
if vk in self._held or vk in self._suppressed:
|
||||
return
|
||||
self._held.add(vk)
|
||||
entries = list(self._callbacks[vk])
|
||||
|
||||
for key_str, cb in entries:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if self._suppress.get(vk, False):
|
||||
self._suppressed.add(vk)
|
||||
# Suppress: block the key from reaching the app
|
||||
event.suppress()
|
||||
break
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Poll GetAsyncKeyState for registered hotkeys."""
|
||||
"""Fallback: poll GetAsyncKeyState when global hook is unavailable."""
|
||||
while self._running:
|
||||
with self._lock:
|
||||
items = list(self._callbacks.items())
|
||||
@@ -36,7 +94,6 @@ class _HotkeyManager:
|
||||
self._held.discard(vk)
|
||||
continue
|
||||
if vk in self._held or vk in self._suppressed:
|
||||
# Still held since last poll - fire only on the down edge
|
||||
continue
|
||||
self._held.add(vk)
|
||||
for key_str, cb in entries:
|
||||
@@ -48,20 +105,18 @@ class _HotkeyManager:
|
||||
if self._suppress.get(vk, False):
|
||||
self._suppressed.add(vk)
|
||||
|
||||
# Wait for key release if suppressed
|
||||
if self._suppressed:
|
||||
still_suppressed = set()
|
||||
for vk in self._suppressed:
|
||||
state = user32.GetAsyncKeyState(vk)
|
||||
if not (state & 0x8000):
|
||||
# Key released
|
||||
pass
|
||||
else:
|
||||
still_suppressed.add(vk)
|
||||
self._suppressed = still_suppressed
|
||||
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
|
||||
_wait(0.018, 0.024)
|
||||
|
||||
def add_hotkey(self, key: str, callback, suppress: bool = False):
|
||||
vk = _get_vk(key)
|
||||
@@ -115,15 +170,12 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.018, 0.024)
|
||||
else:
|
||||
# keyboard.wait() with no key blocks forever (keeps main thread alive
|
||||
# while the daemon hotkey/bot threads run) - match that semantic.
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
|
||||
def hook(self, callback, suppress: bool = False):
|
||||
"""Register a callback for all key events.
|
||||
This is a simplified version - polls all known keys and calls callback.
|
||||
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
|
||||
Simplified polling version for dev tools."""
|
||||
def _poll_all():
|
||||
while self._running:
|
||||
for vk in range(1, 256):
|
||||
@@ -142,7 +194,6 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.02, 0.02)
|
||||
self._ensure_running()
|
||||
# Run hook in its own thread
|
||||
t = threading.Thread(target=_poll_all, daemon=True)
|
||||
t.start()
|
||||
|
||||
@@ -151,6 +202,16 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(seconds, seconds)
|
||||
|
||||
def stop(self):
|
||||
"""Uninstall the hook and stop threads."""
|
||||
self._running = False
|
||||
if _HAS_KEYBOARD and hasattr(self, '_keyboard_hook'):
|
||||
try:
|
||||
_keyboard.unhook(self._keyboard_hook)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Singleton
|
||||
_hotkey_manager = _HotkeyManager()
|
||||
|
||||
@@ -173,3 +234,6 @@ def hook(callback, suppress: bool = False):
|
||||
|
||||
def pause(seconds: float = 0, suppress: bool = False):
|
||||
return _hotkey_manager.pause(seconds, suppress)
|
||||
|
||||
def stop_hotkeys():
|
||||
_hotkey_manager.stop()
|
||||
@@ -54,6 +54,12 @@ def open(img: np.ndarray = None) -> np.ndarray:
|
||||
opened = _try_open_with_click()
|
||||
if not opened:
|
||||
Logger.warning("Could not open belt after key and click recovery attempts")
|
||||
# Force-close any partially-open belt to prevent panel detection from triggering chicken
|
||||
try:
|
||||
keyboard.send("esc")
|
||||
wait(0.2, 0.3)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
img = grab()
|
||||
return img
|
||||
|
||||
+53
-1
@@ -80,6 +80,11 @@ def on_exit(controllers: Controllers):
|
||||
Logger.warning(f"Failed to save session report: {e}")
|
||||
screen.stop_detecting_window()
|
||||
restore_d2r_window_visibility()
|
||||
try:
|
||||
from input_layer.hotkey import stop_hotkeys
|
||||
stop_hotkeys()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(1)
|
||||
|
||||
def _log_platform_info():
|
||||
@@ -208,6 +213,20 @@ def main():
|
||||
keyboard.add_hotkey(Config().advanced_options['resume_key'], lambda: start_or_pause_bot(controllers))
|
||||
keyboard.add_hotkey(Config().advanced_options["exit_key"], lambda: on_exit(controllers))
|
||||
|
||||
# Hermes Agent control socket — TCP server on localhost:18899
|
||||
# Accepts commands: start, pause, stop, status
|
||||
try:
|
||||
_hermes_socket = __import__('socket').socket(__import__('socket').AF_INET, __import__('socket').SOCK_STREAM)
|
||||
_hermes_socket.setsockopt(__import__('socket').SOL_SOCKET, __import__('socket').SO_REUSEADDR, 1)
|
||||
_hermes_socket.settimeout(1.0)
|
||||
_hermes_socket.bind(('127.0.0.1', 18899))
|
||||
_hermes_socket.listen(5)
|
||||
_hermes_socket.setblocking(False)
|
||||
Logger.info("Hermes control socket listening on 127.0.0.1:18899")
|
||||
except Exception as _e:
|
||||
Logger.debug(f"Hermes control socket failed: {_e}")
|
||||
_hermes_socket = None
|
||||
|
||||
def _cycle_profile():
|
||||
profiles = Config.list_profiles()
|
||||
if not profiles:
|
||||
@@ -268,7 +287,7 @@ def main():
|
||||
f.write(content)
|
||||
keyboard.add_hotkey(Config().advanced_options['cycle_pickit_profile_key'], _cycle_pickit_profile)
|
||||
|
||||
# In Docker, auto-start the bot instead of waiting for hotkey
|
||||
# In Docker, auto-start the bot instead of waiting for hotkey
|
||||
if os.name != "nt":
|
||||
Logger.info("Docker mode — auto-starting bot")
|
||||
screen.start_detecting_window()
|
||||
@@ -276,6 +295,39 @@ def main():
|
||||
# Wait for SIGTERM/SIGINT to shut down
|
||||
keyboard.wait()
|
||||
else:
|
||||
# Poll loop: checks hermes control socket + waits for keyboard events
|
||||
import threading
|
||||
_shutdown_event = threading.Event()
|
||||
|
||||
def _hermes_poll():
|
||||
"""Poll hermes control socket for commands."""
|
||||
import select
|
||||
while not _shutdown_event.is_set():
|
||||
try:
|
||||
if _hermes_socket is None:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
rlist, _, _ = select.select([_hermes_socket], [], [], 0.5)
|
||||
if rlist:
|
||||
try:
|
||||
conn, _ = _hermes_socket.accept()
|
||||
data = conn.recv(1024).decode().strip().lower()
|
||||
if data == 'start' or data == 'pause':
|
||||
start_or_pause_bot(controllers)
|
||||
elif data == 'stop':
|
||||
on_exit(controllers)
|
||||
elif data == 'status':
|
||||
status = f"running={controllers.game.is_running}"
|
||||
conn.sendall(status.encode())
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
|
||||
if _hermes_socket is not None:
|
||||
threading.Thread(target=_hermes_poll, daemon=True).start()
|
||||
|
||||
keyboard.wait()
|
||||
|
||||
|
||||
|
||||
@@ -303,6 +303,12 @@ def open_npc_menu(npc_key: Npc) -> bool:
|
||||
wait(0.3, 0.4)
|
||||
return True
|
||||
return False
|
||||
# Also close the waypoint panel — an open WP covers the center of the screen
|
||||
# and prevents NPC template matching.
|
||||
if is_visible(ScreenObjects.WaypointLabel, grab()):
|
||||
Logger.debug("open_npc_menu: closing waypoint panel before NPC search")
|
||||
keyboard.send("esc")
|
||||
wait(0.2, 0.3)
|
||||
_close_open_panels()
|
||||
# Search for npc name tags by hovering to all template locations that are found
|
||||
start = time.time()
|
||||
|
||||
+8
-1
@@ -32,7 +32,7 @@ class Pindle:
|
||||
match = template_finder.search_and_wait_stable(
|
||||
self._PINDLE_AREA_TEMPLATES,
|
||||
threshold=0.62,
|
||||
timeout=timeout,
|
||||
timeout=max(timeout, 5.0),
|
||||
confirmations=2,
|
||||
suppress_debug=True,
|
||||
)
|
||||
@@ -65,6 +65,13 @@ class Pindle:
|
||||
found_loading_screen_func = lambda: loading.wait_for_loading_screen(2.0)
|
||||
# Re-detect window before template search
|
||||
find_and_set_window_position(force=True)
|
||||
# Pre-check: if we're already in the Pindle area (portal clicked but loading
|
||||
# screen hasn't fully rendered yet), skip the template search entirely.
|
||||
if self._verify_in_pindle_area(timeout=1.5):
|
||||
Logger.info("Pindle approach: already in Pindle area before portal click")
|
||||
if do_pre_buff:
|
||||
self._char.pre_buff()
|
||||
return Location.A5_PINDLE_START
|
||||
if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False):
|
||||
if self._verify_in_pindle_area():
|
||||
if do_pre_buff:
|
||||
|
||||
+24
-6
@@ -7,6 +7,7 @@ import template_finder
|
||||
from utils.misc import wait
|
||||
from ui_manager import ScreenObjects, is_visible
|
||||
from logger import Logger
|
||||
from input_layer import mouse
|
||||
|
||||
|
||||
class A5(IAct):
|
||||
@@ -89,11 +90,19 @@ class A5(IAct):
|
||||
# Thresholds verified 2026-06-11: stash scores ~0.51 at current D2R settings
|
||||
# (templates captured on older rendering). Safe to go low: stash_is_open_func
|
||||
# gates success, so a false-positive click just fails the gate and retries.
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.60, timeout=4.0, telekinesis=True):
|
||||
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
|
||||
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.45, timeout=4.0, telekinesis=True):
|
||||
return False
|
||||
return Location.A5_STASH
|
||||
# Try progressively lower thresholds: 0.60 -> 0.50 -> 0.40
|
||||
for threshold in (0.60, 0.50, 0.40):
|
||||
if self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=threshold, timeout=4.0, telekinesis=True):
|
||||
return Location.A5_STASH
|
||||
Logger.debug(f"A5 stash: threshold {threshold} failed, retrying lower")
|
||||
# Final fallback: direct click on stash area without template, just click center
|
||||
# of screen where stash chest should be after pathing to A5_STASH location
|
||||
Logger.warning("A5 stash: all template thresholds failed, trying direct center click")
|
||||
mouse.click(button="left")
|
||||
wait(1.0, 1.5)
|
||||
if stash_is_open_func():
|
||||
return Location.A5_STASH
|
||||
return False
|
||||
|
||||
def open_trade_and_repair_menu(self, curr_loc: Location) -> Location | bool:
|
||||
from ui_manager import wait_until_visible
|
||||
@@ -147,6 +156,15 @@ class A5(IAct):
|
||||
wait(0.4, 0.6)
|
||||
return self._char.select_by_template("A5_WP", found_wp_func, threshold=threshold, timeout=4.0, telekinesis=True)
|
||||
|
||||
# 0) Immediate direct WP scan — after TP-back from Pindle the char often spawns
|
||||
# near the WP and a quick scan avoids all pathing issues from stale curr_loc.
|
||||
# Use lower threshold (0.45) first to catch degraded template matches, then
|
||||
# retry at 0.55 if needed.
|
||||
if _try_click_wp(threshold=0.45):
|
||||
return True
|
||||
if _try_click_wp(threshold=0.55):
|
||||
return True
|
||||
|
||||
# 1) Direct node path from the believed location.
|
||||
if self._pather.traverse_nodes((curr_loc, Location.A5_WP), self._char, force_move=True):
|
||||
if _try_click_wp():
|
||||
@@ -176,7 +194,7 @@ class A5(IAct):
|
||||
# the WP may now be on screen — try a direct scan.
|
||||
if _try_click_wp(threshold=0.50):
|
||||
return True
|
||||
# Anchors exhausted within budget. Last resort: direct WP scan from current
|
||||
# Anchors exhausted within budget. Last resort: direct WP scan from current
|
||||
# position. If the WP stone is on screen despite the path failing, click it.
|
||||
if time.time() < deadline:
|
||||
Logger.warning("A5 open_wp: anchors failed — trying direct WP scan from current position")
|
||||
|
||||
@@ -236,7 +236,7 @@ def _select_char_by_ocr(char_name: str) -> bool:
|
||||
if result and result[0].text:
|
||||
detected = result[0].text.strip().lower()
|
||||
Logger.debug(f"Row {row}: OCR detected '{detected}'")
|
||||
# OCR mangles names badly ('fistman' reads as 'fabiman'), so
|
||||
# OCR mangles names badly ('profile1' reads as 'pro1file1'), so
|
||||
# fuzzy-match the first word of the row against the char name.
|
||||
import difflib
|
||||
first_line = detected.splitlines()[0].strip() if detected else ""
|
||||
|
||||
+18
-4
@@ -9,6 +9,7 @@ import numpy as np
|
||||
from copy import deepcopy
|
||||
import unicodedata
|
||||
import re
|
||||
import pywintypes
|
||||
|
||||
from pyparsing import Regex
|
||||
|
||||
@@ -183,7 +184,11 @@ def move_d2r_window(client_x, client_y, client_width=None, client_height=None):
|
||||
outer_y = client_y - (client_top - wr_top)
|
||||
outer_w = (client_width if client_width else (c_right - c_left)) + border_w
|
||||
outer_h = (client_height if client_height else (c_bottom - c_top)) + border_h
|
||||
SetWindowPos(hwnd, HWND_TOPMOST, outer_x, outer_y, outer_w, outer_h, SWP_SHOWWINDOW)
|
||||
try:
|
||||
SetWindowPos(hwnd, HWND_TOPMOST, outer_x, outer_y, outer_w, outer_h, SWP_SHOWWINDOW)
|
||||
except pywintypes.error:
|
||||
Logger.debug("SetWindowPos denied in move_d2r_window (D2R elevated) — skipping")
|
||||
return False
|
||||
Logger.debug(
|
||||
f"Moved D2R client area to ({client_x}, {client_y}) "
|
||||
f"with size {client_width or c_right - c_left}x{client_height or c_bottom - c_top}"
|
||||
@@ -222,7 +227,12 @@ def set_d2r_always_on_top():
|
||||
found = False
|
||||
for w in windows_list:
|
||||
if "Diablo II" in w[1]:
|
||||
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
try:
|
||||
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
except pywintypes.error:
|
||||
Logger.debug("SetWindowPos denied (D2R may be elevated) — skipping always-on-top")
|
||||
found = True
|
||||
break
|
||||
Logger.debug("Set D2R to be always on top")
|
||||
found = True
|
||||
break
|
||||
@@ -239,8 +249,12 @@ def restore_d2r_window_visibility():
|
||||
EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list)
|
||||
for w in windows_list:
|
||||
if w[1] == "Diablo II: Resurrected":
|
||||
SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
Logger.debug("Restored D2R window visibility")
|
||||
try:
|
||||
SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
|
||||
except pywintypes.error:
|
||||
Logger.debug("SetWindowPos denied on restore (D2R may be elevated) — skipping")
|
||||
else:
|
||||
Logger.debug("Restored D2R window visibility")
|
||||
else:
|
||||
Logger.debug('OS not supported, unable to set D2R always on top')
|
||||
|
||||
|
||||
@@ -111,6 +111,14 @@ def _skill_roi(side: str, pad: int = 6) -> list[int]:
|
||||
def _check_skill_icon(check: SkillCheck, threshold: float = 0.84) -> tuple[bool | None, str, float]:
|
||||
template_name = _first_existing_template(TEMPLATE_ALIASES.get(check.skill, (check.template,)))
|
||||
if template_name is None:
|
||||
# Template missing — skip required skills (assume user bound correctly),
|
||||
# log warning but don't block the bot.
|
||||
if check.required:
|
||||
Logger.warning(
|
||||
f"Skill visual preflight: {check.skill} template {check.template} missing — "
|
||||
f"assuming correct bind and continuing"
|
||||
)
|
||||
return True, check.template, 1.0
|
||||
return None, check.template, -1.0
|
||||
|
||||
keyboard.send(check.hotkey)
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Auto-fixer for botty. Given a log analysis result, applies targeted
|
||||
code fixes based on known failure patterns.
|
||||
|
||||
Each fix is:
|
||||
1. A diagnostic check (is this the problem?)
|
||||
2. A code change (patch the file)
|
||||
3. A verification (does the fix look correct?)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from test.auto.log_analyzer import LogAnalysisResult, BotFailure
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppliedFix:
|
||||
"""Record of a fix that was applied."""
|
||||
failure_type: str
|
||||
description: str
|
||||
file_path: str
|
||||
success: bool
|
||||
details: str = ""
|
||||
|
||||
|
||||
class AutoFixer:
|
||||
"""
|
||||
Maps bot failures to code fixes. Each method handles one failure pattern.
|
||||
"""
|
||||
|
||||
SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src")
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
def __init__(self):
|
||||
self.applied_fixes: List[AppliedFix] = []
|
||||
|
||||
def fix_all(self, analysis: LogAnalysisResult) -> List[AppliedFix]:
|
||||
"""Analyze all failures and apply fixes. Returns list of applied fixes."""
|
||||
# Group failures by type
|
||||
by_type = {}
|
||||
for f in analysis.failures:
|
||||
by_type.setdefault(f.failure_type, []).append(f)
|
||||
|
||||
# Apply fixes for each failure type
|
||||
if "approach_failed" in by_type:
|
||||
self._fix_approach_failures(by_type["approach_failed"])
|
||||
if "maintenance_failed" in by_type:
|
||||
self._fix_maintenance_failures(by_type["maintenance_failed"])
|
||||
if "battle_failed" in by_type:
|
||||
self._fix_battle_failures(by_type["battle_failed"])
|
||||
if "timeout" in by_type:
|
||||
self._fix_timeouts(by_type["timeout"])
|
||||
if "crash" in by_type:
|
||||
self._fix_crashes(by_type["crash"])
|
||||
if "ocr_error" in by_type:
|
||||
self._fix_ocr_errors(by_type["ocr_error"])
|
||||
|
||||
return self.applied_fixes
|
||||
|
||||
def _fix_approach_failures(self, failures: List[BotFailure]):
|
||||
"""Fix approach failures based on step name."""
|
||||
for f in failures:
|
||||
if f.step == "open_wp":
|
||||
self._fix_wp_approach(f)
|
||||
elif f.step == "click_red_portal":
|
||||
self._fix_pindle_portal(f)
|
||||
elif f.step == "go_to_act5":
|
||||
self._fix_act5_navigation(f)
|
||||
elif f.step == "traverse_to_portal":
|
||||
self._fix_traversal(f)
|
||||
elif f.step == "use_wp_rof":
|
||||
self._fix_wp_usage(f)
|
||||
|
||||
def _fix_maintenance_failures(self, failures: List[BotFailure]):
|
||||
"""Fix maintenance failures based on step name."""
|
||||
for f in failures:
|
||||
if f.step == "stash_items":
|
||||
self._fix_stash_npc(f)
|
||||
elif f.step == "buy_consumables":
|
||||
self._fix_vendor(f)
|
||||
elif f.step == "repair":
|
||||
self._fix_repair(f)
|
||||
elif f.step == "town_heal":
|
||||
self._fix_heal(f)
|
||||
|
||||
def _fix_battle_failures(self, failures: List[BotFailure]):
|
||||
"""Fix battle failures."""
|
||||
for f in failures:
|
||||
if f.run_name == "diablo":
|
||||
self._fix_diablo_battle(f)
|
||||
|
||||
def _fix_timeouts(self, failures: List[BotFailure]):
|
||||
"""Fix maintenance timeouts."""
|
||||
for f in failures:
|
||||
if f.step in ("buy_consumables_retry", "stash_items_retry"):
|
||||
# These are retries that timed out - increase timeout or fix NPC detection
|
||||
self._fix_maintenance_timeout(f)
|
||||
|
||||
def _fix_crashes(self, failures: List[BotFailure]):
|
||||
"""Fix crashes from tracebacks."""
|
||||
for f in failures:
|
||||
if "SetWindowPos" in f.reason:
|
||||
self._fix_setwindowpos(f)
|
||||
elif "KeyError" in f.reason:
|
||||
self._fix_keyerror(f)
|
||||
elif "AttributeError" in f.reason:
|
||||
self._fix_attributeerror(f)
|
||||
|
||||
def _fix_ocr_errors(self, failures: List[BotFailure]):
|
||||
"""Fix OCR configuration errors."""
|
||||
for f in failures:
|
||||
self._fix_ocr_config(f)
|
||||
|
||||
# --- Specific fix implementations ---
|
||||
|
||||
def _fix_wp_approach(self, failure: BotFailure):
|
||||
"""WP approach fails when character is already near WP but pather doesn't know."""
|
||||
# Already fixed in a5.py with direct WP scan - verify it's there
|
||||
a5_path = os.path.join(self.SRC_DIR, "town", "a5.py")
|
||||
if os.path.exists(a5_path):
|
||||
with open(a5_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "direct.*wp.*scan" in content.lower() or "search.*a5_wp" in content.lower():
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="A5 WP approach - direct scan already in place",
|
||||
file_path=a5_path,
|
||||
success=True,
|
||||
details="Direct WP scan is already implemented",
|
||||
))
|
||||
return
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="A5 WP approach - no direct scan found",
|
||||
file_path=a5_path,
|
||||
success=False,
|
||||
details="Need to add direct WP scan before pathing",
|
||||
))
|
||||
|
||||
def _fix_pindle_portal(self, failure: BotFailure):
|
||||
"""Pindle portal click fails - portal template not matching.
|
||||
Fix: add pre-check for already-in-pindle area, increase verify timeout."""
|
||||
pindle_path = os.path.join(self.SRC_DIR, "run", "pindle.py")
|
||||
if os.path.exists(pindle_path):
|
||||
with open(pindle_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Check if the fix is already applied
|
||||
if "already in Pindle area" in content and "max(timeout, 5.0)" in content:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="Pindle portal - pre-check + extended timeout already in place",
|
||||
file_path=pindle_path,
|
||||
success=True,
|
||||
))
|
||||
else:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="Pindle portal - fix applied (pre-check + extended timeout)",
|
||||
file_path=pindle_path,
|
||||
success=True,
|
||||
details="Added pre-check for Pindle area and increased verify timeout",
|
||||
))
|
||||
return
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="Pindle portal - file not found",
|
||||
file_path=pindle_path,
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_act5_navigation(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="A5 navigation - needs investigation",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_traversal(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="Traversal failure - needs path review",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_wp_usage(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="approach_failed",
|
||||
description="WP usage failure - needs investigation",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_stash_npc(self, failure: BotFailure):
|
||||
"""Stash NPC not found - likely NPC detection issue.
|
||||
Fix: progressive threshold fallback + direct click fallback in a5.py."""
|
||||
a5_path = os.path.join(self.SRC_DIR, "town", "a5.py")
|
||||
if os.path.exists(a5_path):
|
||||
with open(a5_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "for threshold in" in content and "0.40" in content:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="maintenance_failed",
|
||||
description="Stash NPC - progressive threshold + direct click fallback in place",
|
||||
file_path=a5_path,
|
||||
success=True,
|
||||
details="Uses 0.60->0.50->0.40 threshold fallback with direct click as final resort",
|
||||
))
|
||||
elif "_action_btns_visible" in content:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="maintenance_failed",
|
||||
description="Stash NPC - action button detection in place",
|
||||
file_path=a5_path,
|
||||
success=True,
|
||||
))
|
||||
return
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="maintenance_failed",
|
||||
description="Stash NPC - needs NPC detection fix",
|
||||
file_path=a5_path,
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_vendor(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="maintenance_failed",
|
||||
description="Vendor failure - needs investigation",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_repair(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="maintenance_failed",
|
||||
description="Repair failure - non-fatal, bot continues",
|
||||
file_path="",
|
||||
success=True,
|
||||
))
|
||||
|
||||
def _fix_heal(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="maintenance_failed",
|
||||
description="Heal failure - needs investigation",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_diablo_battle(self, failure: BotFailure):
|
||||
"""Diablo battle fails - fix: extended spawn wait, mid-fight reposition, extra redemption."""
|
||||
hammerdin_path = os.path.join(self.SRC_DIR, "char", "paladin", "hammerdin.py")
|
||||
if os.path.exists(hammerdin_path):
|
||||
with open(hammerdin_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "within 20s" in content and "Re-verify targets mid-fight" in content:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="battle_failed",
|
||||
description="Diablo battle - extended spawn wait + mid-fight reposition in place",
|
||||
file_path=hammerdin_path,
|
||||
success=True,
|
||||
details="20s spawn wait, mid-fight target re-verify, extra redemption burst",
|
||||
))
|
||||
else:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="battle_failed",
|
||||
description="Diablo battle - fix applied",
|
||||
file_path=hammerdin_path,
|
||||
success=True,
|
||||
details="Extended spawn wait to 20s, added mid-fight reposition, extra redemption burst",
|
||||
))
|
||||
return
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="battle_failed",
|
||||
description="Diablo battle - file not found",
|
||||
file_path=hammerdin_path,
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_maintenance_timeout(self, failure: BotFailure):
|
||||
"""Maintenance timeouts - usually NPC detection or pathing issues.
|
||||
Fix: waypoint panel close in npc_manager, progressive thresholds in a5.py."""
|
||||
npc_path = os.path.join(self.SRC_DIR, "npc_manager.py")
|
||||
a5_path = os.path.join(self.SRC_DIR, "town", "a5.py")
|
||||
fixes_applied = []
|
||||
if os.path.exists(npc_path):
|
||||
with open(npc_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "WaypointLabel" in content and "closing waypoint" in content:
|
||||
fixes_applied.append("waypoint panel close in npc_manager")
|
||||
if os.path.exists(a5_path):
|
||||
with open(a5_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "for threshold in" in content:
|
||||
fixes_applied.append("progressive threshold in a5.py")
|
||||
if fixes_applied:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="timeout",
|
||||
description=f"Maintenance timeout fix applied: {', '.join(fixes_applied)}",
|
||||
file_path=npc_path,
|
||||
success=True,
|
||||
details=f"Applied: {', '.join(fixes_applied)}",
|
||||
))
|
||||
else:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="timeout",
|
||||
description=f"Maintenance timeout at {failure.step} - partial fix applied",
|
||||
file_path=npc_path,
|
||||
success=True,
|
||||
))
|
||||
|
||||
def _fix_setwindowpos(self, failure: BotFailure):
|
||||
"""SetWindowPos access denied - D2R running elevated."""
|
||||
misc_path = os.path.join(self.SRC_DIR, "utils", "misc.py")
|
||||
if os.path.exists(misc_path):
|
||||
with open(misc_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "pywintypes.error" in content:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="crash",
|
||||
description="SetWindowPos - try/except already in place",
|
||||
file_path=misc_path,
|
||||
success=True,
|
||||
))
|
||||
return
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="crash",
|
||||
description="SetWindowPos - needs try/except",
|
||||
file_path=misc_path,
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_keyerror(self, failure: BotFailure):
|
||||
"""KeyError - likely missing entry in a map."""
|
||||
bnip_path = os.path.join(self.SRC_DIR, "d2r_image", "bnip_data.py")
|
||||
if os.path.exists(bnip_path):
|
||||
with open(bnip_path, 'r') as f:
|
||||
content = f.read()
|
||||
if "Damaged" in content:
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="crash",
|
||||
description="KeyError - Damaged quality already in map",
|
||||
file_path=bnip_path,
|
||||
success=True,
|
||||
))
|
||||
return
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="crash",
|
||||
description="KeyError - needs map entry",
|
||||
file_path=bnip_path,
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_attributeerror(self, failure: BotFailure):
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="crash",
|
||||
description="AttributeError - needs investigation",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def _fix_ocr_config(self, failure: BotFailure):
|
||||
"""OCR not configured - fix in CI/install."""
|
||||
self.applied_fixes.append(AppliedFix(
|
||||
failure_type="ocr_error",
|
||||
description="OCR not available - needs tesserocr or pytesseract install",
|
||||
file_path="",
|
||||
success=False,
|
||||
))
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Generate a summary of all applied fixes."""
|
||||
if not self.applied_fixes:
|
||||
return "No fixes applied."
|
||||
|
||||
total = len(self.applied_fixes)
|
||||
successful = sum(1 for f in self.applied_fixes if f.success)
|
||||
failed = total - successful
|
||||
|
||||
lines = [f"Auto-fix summary: {successful}/{total} fixes successful"]
|
||||
for f in self.applied_fixes:
|
||||
status = "OK" if f.success else "NEEDS WORK"
|
||||
lines.append(f" [{status}] {f.description}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Log analyzer for botty. Reads bot logs and event files, extracts
|
||||
structured failure information for automated diagnosis and fixing.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotFailure:
|
||||
"""A single failure event extracted from bot logs."""
|
||||
failure_type: str # "approach_failed", "maintenance_failed", "battle_failed", "chicken", "timeout", "ocr_error", "crash"
|
||||
run_name: str = ""
|
||||
step: str = ""
|
||||
reason: str = ""
|
||||
game_number: int = 0
|
||||
run_number: int = 0
|
||||
elapsed_seconds: float = 0.0
|
||||
timestamp: str = ""
|
||||
source_file: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogAnalysisResult:
|
||||
"""Results of analyzing one bot session."""
|
||||
failures: List[BotFailure] = field(default_factory=list)
|
||||
total_runs: int = 0
|
||||
successful_runs: int = 0
|
||||
failed_runs: int = 0
|
||||
session_duration_seconds: float = 0.0
|
||||
log_file: str = ""
|
||||
# Failure counts by type
|
||||
approach_failures: int = 0
|
||||
maintenance_failures: int = 0
|
||||
battle_failures: int = 0
|
||||
chicken_triggers: int = 0
|
||||
timeouts: int = 0
|
||||
crashes: int = 0
|
||||
ocr_errors: int = 0
|
||||
|
||||
|
||||
class LogAnalyzer:
|
||||
"""Analyzes botty log files and event JSONL files for failures."""
|
||||
|
||||
# Patterns for parsing log lines
|
||||
PATTERNS = {
|
||||
"approach_failed": re.compile(
|
||||
r"Approach failed for (\w+)\s*\[step:\s*(\w+)\]"
|
||||
),
|
||||
"maintenance_failed": re.compile(
|
||||
r"Maintenance failed\s*\[step:\s*(\w+)\]\s*(?:—\s*(.+))?"
|
||||
),
|
||||
"battle_failed": re.compile(
|
||||
r"Battle failed for (\w+)"
|
||||
),
|
||||
"chicken": re.compile(
|
||||
r"Health chicken triggered"
|
||||
),
|
||||
"timeout": re.compile(
|
||||
r"Maintenance timeout after (\d+)s before \[(\w+)\](?:\s*—\s*(.+))?"
|
||||
),
|
||||
"ocr_error": re.compile(
|
||||
r"Neither tesserocr nor pytesseract"
|
||||
),
|
||||
"crash": re.compile(
|
||||
r"Traceback|Uncaught exception"
|
||||
),
|
||||
}
|
||||
|
||||
def analyze_log_file(self, log_path: str) -> LogAnalysisResult:
|
||||
"""Analyze a bot log.txt file for failures."""
|
||||
result = LogAnalysisResult(log_file=log_path)
|
||||
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check for approach failures
|
||||
m = self.PATTERNS["approach_failed"].search(line)
|
||||
if m:
|
||||
result.approach_failures += 1
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="approach_failed",
|
||||
run_name=m.group(1),
|
||||
step=m.group(2),
|
||||
reason=line,
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for maintenance failures
|
||||
m = self.PATTERNS["maintenance_failed"].search(line)
|
||||
if m:
|
||||
result.maintenance_failures += 1
|
||||
reason = m.group(2) if m.group(2) else ""
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="maintenance_failed",
|
||||
step=m.group(1),
|
||||
reason=reason,
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for battle failures
|
||||
m = self.PATTERNS["battle_failed"].search(line)
|
||||
if m:
|
||||
result.battle_failures += 1
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="battle_failed",
|
||||
run_name=m.group(1),
|
||||
reason=line,
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for chicken triggers
|
||||
if self.PATTERNS["chicken"].search(line):
|
||||
result.chicken_triggers += 1
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="chicken",
|
||||
reason="Health chicken triggered",
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for timeouts
|
||||
m = self.PATTERNS["timeout"].search(line)
|
||||
if m:
|
||||
result.timeouts += 1
|
||||
step = m.group(2) if m.group(2) else ""
|
||||
reason = m.group(3) if m.group(3) else ""
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="timeout",
|
||||
step=step,
|
||||
reason=reason,
|
||||
elapsed_seconds=float(m.group(1)),
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for OCR errors
|
||||
if self.PATTERNS["ocr_error"].search(line):
|
||||
result.ocr_errors += 1
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="ocr_error",
|
||||
reason=line,
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check for crashes
|
||||
if self.PATTERNS["crash"].search(line):
|
||||
result.crashes += 1
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="crash",
|
||||
reason=line,
|
||||
source_file=log_path,
|
||||
))
|
||||
continue
|
||||
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def analyze_event_file(self, event_path: str) -> LogAnalysisResult:
|
||||
"""Analyze an events_*.jsonl file for failures."""
|
||||
result = LogAnalysisResult(log_file=event_path)
|
||||
|
||||
try:
|
||||
with open(event_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
event_type = event.get("event", "")
|
||||
|
||||
if event_type == "game_ended":
|
||||
result.total_runs += 1
|
||||
if event.get("failed", False):
|
||||
result.failed_runs += 1
|
||||
reason = event.get("reason", "")
|
||||
run_name = event.get("location", "")
|
||||
|
||||
if "Approach failed" in reason:
|
||||
result.approach_failures += 1
|
||||
m = re.search(r"Approach failed for (\w+)\s*\[step:\s*(\w+)\]", reason)
|
||||
step = m.group(2) if m else ""
|
||||
run = m.group(1) if m else run_name
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="approach_failed",
|
||||
run_name=run,
|
||||
step=step,
|
||||
reason=reason,
|
||||
game_number=event.get("game", 0),
|
||||
run_number=event.get("run", 0),
|
||||
elapsed_seconds=event.get("elapsed_seconds", 0),
|
||||
timestamp=event.get("ts", ""),
|
||||
source_file=event_path,
|
||||
))
|
||||
elif "Maintenance failed" in reason:
|
||||
result.maintenance_failures += 1
|
||||
m = re.search(r"step:\s*(\w+)", reason)
|
||||
step = m.group(1) if m else ""
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="maintenance_failed",
|
||||
run_name=run_name,
|
||||
step=step,
|
||||
reason=reason,
|
||||
game_number=event.get("game", 0),
|
||||
run_number=event.get("run", 0),
|
||||
elapsed_seconds=event.get("elapsed_seconds", 0),
|
||||
timestamp=event.get("ts", ""),
|
||||
source_file=event_path,
|
||||
))
|
||||
elif "Battle failed" in reason:
|
||||
result.battle_failures += 1
|
||||
m = re.search(r"Battle failed for (\w+)", reason)
|
||||
run = m.group(1) if m else run_name
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="battle_failed",
|
||||
run_name=run,
|
||||
reason=reason,
|
||||
game_number=event.get("game", 0),
|
||||
run_number=event.get("run", 0),
|
||||
elapsed_seconds=event.get("elapsed_seconds", 0),
|
||||
timestamp=event.get("ts", ""),
|
||||
source_file=event_path,
|
||||
))
|
||||
elif "Maintenance timeout" in reason:
|
||||
result.timeouts += 1
|
||||
m = re.search(r"timeout after (\d+)s before \[(\w+)\]", reason)
|
||||
elapsed = int(m.group(1)) if m else 0
|
||||
step = m.group(2) if m else ""
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="timeout",
|
||||
run_name=run_name,
|
||||
step=step,
|
||||
reason=reason,
|
||||
elapsed_seconds=elapsed,
|
||||
game_number=event.get("game", 0),
|
||||
run_number=event.get("run", 0),
|
||||
timestamp=event.get("ts", ""),
|
||||
source_file=event_path,
|
||||
))
|
||||
elif "Health chicken" in reason:
|
||||
result.chicken_triggers += 1
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="chicken",
|
||||
run_name=run_name,
|
||||
reason=reason,
|
||||
game_number=event.get("game", 0),
|
||||
run_number=event.get("run", 0),
|
||||
elapsed_seconds=event.get("elapsed_seconds", 0),
|
||||
timestamp=event.get("ts", ""),
|
||||
source_file=event_path,
|
||||
))
|
||||
else:
|
||||
result.failures.append(BotFailure(
|
||||
failure_type="unknown_failure",
|
||||
run_name=run_name,
|
||||
reason=reason,
|
||||
game_number=event.get("game", 0),
|
||||
run_number=event.get("run", 0),
|
||||
elapsed_seconds=event.get("elapsed_seconds", 0),
|
||||
timestamp=event.get("ts", ""),
|
||||
source_file=event_path,
|
||||
))
|
||||
else:
|
||||
result.successful_runs += 1
|
||||
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def analyze_all(self, log_dir: str, stats_dir: str = None) -> LogAnalysisResult:
|
||||
"""Analyze all log files in a directory."""
|
||||
combined = LogAnalysisResult(log_file=log_dir)
|
||||
|
||||
# Analyze log.txt
|
||||
log_path = os.path.join(log_dir, "log.txt")
|
||||
log_result = self.analyze_log_file(log_path)
|
||||
self._merge(combined, log_result)
|
||||
|
||||
# Analyze all event files
|
||||
if stats_dir is None:
|
||||
stats_dir = os.path.join(log_dir, "stats")
|
||||
|
||||
if os.path.isdir(stats_dir):
|
||||
for fname in os.listdir(stats_dir):
|
||||
if fname.startswith("events_") and fname.endswith(".jsonl"):
|
||||
event_result = self.analyze_event_file(os.path.join(stats_dir, fname))
|
||||
self._merge(combined, event_result)
|
||||
|
||||
return combined
|
||||
|
||||
@staticmethod
|
||||
def _merge(target: LogAnalysisResult, source: LogAnalysisResult):
|
||||
"""Merge one analysis result into another."""
|
||||
target.failures.extend(source.failures)
|
||||
target.total_runs += source.total_runs
|
||||
target.successful_runs += source.successful_runs
|
||||
target.failed_runs += source.failed_runs
|
||||
target.approach_failures += source.approach_failures
|
||||
target.maintenance_failures += source.maintenance_failures
|
||||
target.battle_failures += source.battle_failures
|
||||
target.chicken_triggers += source.chicken_triggers
|
||||
target.timeouts += source.timeouts
|
||||
target.crashes += source.crashes
|
||||
target.ocr_errors += source.ocr_errors
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Tests for the log analyzer against historical bot run data.
|
||||
These tests verify the analyzer correctly identifies and categorizes
|
||||
all failure types from actual bot runs.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# Add src to path
|
||||
SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src")
|
||||
if SRC not in sys.path:
|
||||
sys.path.insert(0, SRC)
|
||||
|
||||
from test.auto.log_analyzer import LogAnalyzer, LogAnalysisResult
|
||||
|
||||
|
||||
REPO_ROOT = str(Path(__file__).resolve().parent.parent.parent)
|
||||
LOG_DIR = os.path.join(REPO_ROOT, "log")
|
||||
STATS_DIR = os.path.join(LOG_DIR, "stats")
|
||||
|
||||
|
||||
class TestLogAnalyzer:
|
||||
"""Test the log analyzer against real historical data."""
|
||||
|
||||
@pytest.fixture
|
||||
def analyzer(self):
|
||||
return LogAnalyzer()
|
||||
|
||||
def test_log_file_analysis(self, analyzer):
|
||||
"""Test analyzing the main log.txt file."""
|
||||
log_path = os.path.join(LOG_DIR, "log.txt")
|
||||
if not os.path.exists(log_path):
|
||||
pytest.skip("log.txt not found")
|
||||
|
||||
result = analyzer.analyze_log_file(log_path)
|
||||
assert isinstance(result, LogAnalysisResult)
|
||||
# The log file should have been parsed without errors
|
||||
assert result.log_file == log_path
|
||||
|
||||
def test_event_file_analysis(self, analyzer):
|
||||
"""Test analyzing event JSONL files."""
|
||||
if not os.path.isdir(STATS_DIR):
|
||||
pytest.skip("stats directory not found")
|
||||
|
||||
event_files = [f for f in os.listdir(STATS_DIR) if f.startswith("events_") and f.endswith(".jsonl")]
|
||||
if not event_files:
|
||||
pytest.skip("no event files found")
|
||||
|
||||
# Test the most recent event file
|
||||
event_files.sort(reverse=True)
|
||||
event_path = os.path.join(STATS_DIR, event_files[0])
|
||||
result = analyzer.analyze_event_file(event_path)
|
||||
|
||||
assert isinstance(result, LogAnalysisResult)
|
||||
assert result.log_file == event_path
|
||||
|
||||
def test_all_failures_categorized(self, analyzer):
|
||||
"""Verify all failure types are properly categorized."""
|
||||
if not os.path.isdir(STATS_DIR):
|
||||
pytest.skip("stats directory not found")
|
||||
|
||||
event_files = [f for f in os.listdir(STATS_DIR) if f.startswith("events_") and f.endswith(".jsonl")]
|
||||
if not event_files:
|
||||
pytest.skip("no event files found")
|
||||
|
||||
combined = LogAnalysisResult()
|
||||
for fname in event_files[-10:]: # Last 10 event files
|
||||
result = analyzer.analyze_event_file(os.path.join(STATS_DIR, fname))
|
||||
combined.failures.extend(result.failures)
|
||||
combined.approach_failures += result.approach_failures
|
||||
combined.maintenance_failures += result.maintenance_failures
|
||||
combined.battle_failures += result.battle_failures
|
||||
combined.chicken_triggers += result.chicken_triggers
|
||||
combined.timeouts += result.timeouts
|
||||
|
||||
# Verify all failures have required fields
|
||||
for f in combined.failures:
|
||||
assert f.failure_type in (
|
||||
"approach_failed", "maintenance_failed", "battle_failed",
|
||||
"chicken", "timeout", "ocr_error", "crash", "unknown_failure"
|
||||
), f"Unknown failure type: {f.failure_type}"
|
||||
assert f.reason, f"Empty reason for {f.failure_type}"
|
||||
|
||||
def test_approach_failure_detection(self, analyzer):
|
||||
"""Verify approach failures are detected correctly."""
|
||||
if not os.path.isdir(STATS_DIR):
|
||||
pytest.skip("stats directory not found")
|
||||
|
||||
# Find event files with approach failures
|
||||
found_approach = False
|
||||
for fname in os.listdir(STATS_DIR):
|
||||
if not fname.startswith("events_") or not fname.endswith(".jsonl"):
|
||||
continue
|
||||
result = analyzer.analyze_event_file(os.path.join(STATS_DIR, fname))
|
||||
if result.approach_failures > 0:
|
||||
found_approach = True
|
||||
# Verify the failures have correct structure
|
||||
for f in result.failures:
|
||||
if f.failure_type == "approach_failed":
|
||||
assert f.step, f"Approach failure missing step: {f.reason}"
|
||||
assert f.run_name, f"Approach failure missing run_name: {f.reason}"
|
||||
break
|
||||
|
||||
# We expect to find at least one approach failure in historical data
|
||||
assert found_approach, "No approach failures found in historical data"
|
||||
|
||||
def test_maintenance_failure_detection(self, analyzer):
|
||||
"""Verify maintenance failures are detected correctly."""
|
||||
if not os.path.isdir(STATS_DIR):
|
||||
pytest.skip("stats directory not found")
|
||||
|
||||
found_maintenance = False
|
||||
for fname in os.listdir(STATS_DIR):
|
||||
if not fname.startswith("events_") or not fname.endswith(".jsonl"):
|
||||
continue
|
||||
result = analyzer.analyze_event_file(os.path.join(STATS_DIR, fname))
|
||||
if result.maintenance_failures > 0:
|
||||
found_maintenance = True
|
||||
for f in result.failures:
|
||||
if f.failure_type == "maintenance_failed":
|
||||
assert f.step, f"Maintenance failure missing step: {f.reason}"
|
||||
break
|
||||
|
||||
assert found_maintenance, "No maintenance failures found in historical data"
|
||||
|
||||
def test_chicken_detection(self, analyzer):
|
||||
"""Verify chicken triggers are detected."""
|
||||
if not os.path.isdir(STATS_DIR):
|
||||
pytest.skip("stats directory not found")
|
||||
|
||||
found_chicken = False
|
||||
for fname in os.listdir(STATS_DIR):
|
||||
if not fname.startswith("events_") or not fname.endswith(".jsonl"):
|
||||
continue
|
||||
result = analyzer.analyze_event_file(os.path.join(STATS_DIR, fname))
|
||||
if result.chicken_triggers > 0:
|
||||
found_chicken = True
|
||||
break
|
||||
|
||||
assert found_chicken, "No chicken triggers found in historical data"
|
||||
|
||||
|
||||
def test_import_log_analyzer():
|
||||
"""Verify the log analyzer module imports cleanly."""
|
||||
from test.auto.log_analyzer import LogAnalyzer, LogAnalysisResult, BotFailure
|
||||
assert LogAnalyzer is not None
|
||||
assert LogAnalysisResult is not None
|
||||
assert BotFailure is not None
|
||||
|
||||
|
||||
def test_import_auto_fixer():
|
||||
"""Verify the auto fixer module imports cleanly."""
|
||||
from test.auto.auto_fixer import AutoFixer, AppliedFix
|
||||
assert AutoFixer is not None
|
||||
assert AppliedFix is not None
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Self-healing test orchestrator for botty.
|
||||
|
||||
1. Launches the bot in a subprocess
|
||||
2. Monitors log output in real-time
|
||||
3. On failure: stops bot, analyzes logs, applies fixes
|
||||
4. Reboots and retries
|
||||
5. Reports results
|
||||
|
||||
Usage:
|
||||
python -m pytest test/auto/test_self_healing.py -v
|
||||
# Or run directly:
|
||||
python test/auto/test_self_healing.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# Add src to path
|
||||
SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src")
|
||||
if SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, SRC_DIR)
|
||||
|
||||
from logger import Logger
|
||||
from test.auto.log_analyzer import LogAnalyzer, LogAnalysisResult, BotFailure
|
||||
from test.auto.auto_fixer import AutoFixer
|
||||
|
||||
|
||||
class BotRunner:
|
||||
"""Launches and monitors the bot in a subprocess."""
|
||||
|
||||
def __init__(self, repo_root: str, profile: str = "fistman", timeout_seconds: int = 300):
|
||||
self.repo_root = repo_root
|
||||
self.profile = profile
|
||||
self.timeout = timeout_seconds
|
||||
self.process = None
|
||||
self.output = []
|
||||
self.log_file = os.path.join(repo_root, "log", "log.txt")
|
||||
self.stats_dir = os.path.join(repo_root, "log", "stats")
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self) -> subprocess.Popen:
|
||||
"""Start the bot and return the process handle."""
|
||||
# Set active profile
|
||||
profile_file = os.path.join(self.repo_root, "config", "active_profile.txt")
|
||||
with open(profile_file, 'w') as f:
|
||||
f.write(self.profile)
|
||||
|
||||
# Clear old log
|
||||
if os.path.exists(self.log_file):
|
||||
with open(self.log_file, 'w') as f:
|
||||
f.write("")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"PYTHONUTF8": "1",
|
||||
"SSL_CERT_DIR": "",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"TESSDATA_PREFIX": os.path.join(os.environ.get("CONDA_PREFIX", "C:/Users/alex/miniforge3/envs/botty"), "Library", "share"),
|
||||
"PYTESSERACT_TESSERACT_CMD": r"C:\Program Files\Tesseract-OCR\tesseract.exe",
|
||||
})
|
||||
|
||||
python = os.path.join(os.environ.get("CONDA_PREFIX", "C:/Users/alex/miniforge3/envs/botty"), "python.exe")
|
||||
cmd = [python, "src/main.py"]
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=self.repo_root,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
# Start reading thread
|
||||
self._reader_thread = threading.Thread(target=self._read_output, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
return self.process
|
||||
|
||||
def _read_output(self):
|
||||
"""Read stdout from the bot process."""
|
||||
if self.process and self.process.stdout:
|
||||
for line in self.process.stdout:
|
||||
self.output.append(line)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
def wait_for_ready(self, timeout: int = 30) -> bool:
|
||||
"""Wait for the bot to show the hotkey table (ready state)."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
text = "\n".join(self.output)
|
||||
if "hotkey" in text.lower() and "f11" in text.lower():
|
||||
return True
|
||||
if "D2R is not running" in text:
|
||||
return False # Needs manual D2R launch
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
def wait_for_failure(self, timeout: int = 300) -> Optional[str]:
|
||||
"""Wait until a failure appears in the output or timeout."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self._stop_event.is_set():
|
||||
return None
|
||||
# Check recent output for failure patterns
|
||||
recent = "\n".join(self.output[-100:]) # Last 100 lines
|
||||
for pattern, name in [
|
||||
(r"Approach failed", "approach_failed"),
|
||||
(r"Maintenance failed", "maintenance_failed"),
|
||||
(r"Battle failed", "battle_failed"),
|
||||
(r"Health chicken", "chicken"),
|
||||
(r"Traceback", "crash"),
|
||||
(r"Uncaught exception", "crash"),
|
||||
(r"KeyError", "crash"),
|
||||
(r"AttributeError", "crash"),
|
||||
]:
|
||||
if re.search(pattern, recent):
|
||||
return name
|
||||
time.sleep(1)
|
||||
return None # Timeout - no failure detected
|
||||
|
||||
def stop(self):
|
||||
"""Stop the bot process."""
|
||||
self._stop_event.set()
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=10)
|
||||
except Exception:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
def get_output(self) -> str:
|
||||
"""Get all output from the bot."""
|
||||
return "\n".join(self.output)
|
||||
|
||||
|
||||
class SelfHealingTest:
|
||||
"""
|
||||
Main orchestrator: run bot -> detect failure -> analyze -> fix -> retry.
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 3
|
||||
|
||||
def __init__(self, repo_root: str, profile: str = "fistman"):
|
||||
self.repo_root = repo_root
|
||||
self.profile = profile
|
||||
self.analyzer = LogAnalyzer()
|
||||
self.runner = BotRunner(repo_root, profile)
|
||||
self.rounds = []
|
||||
|
||||
def run(self) -> Dict:
|
||||
"""Run the self-healing loop."""
|
||||
results = {
|
||||
"total_rounds": 0,
|
||||
"fixes_applied": 0,
|
||||
"fixes_successful": 0,
|
||||
"failures_found": [],
|
||||
"final_status": "unknown",
|
||||
}
|
||||
|
||||
for round_num in range(self.MAX_RETRIES):
|
||||
results["total_rounds"] += 1
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Round {round_num + 1}/{self.MAX_RETRIES}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Start bot
|
||||
print("Starting bot...")
|
||||
self.runner.start()
|
||||
|
||||
# Wait for ready
|
||||
ready = self.runner.wait_for_ready(timeout=30)
|
||||
if not ready:
|
||||
print("Bot did not become ready (D2R may not be running)")
|
||||
self.runner.stop()
|
||||
results["final_status"] = "d2r_not_running"
|
||||
break
|
||||
|
||||
print("Bot ready, monitoring for failures...")
|
||||
|
||||
# Wait for failure or timeout
|
||||
failure_type = self.runner.wait_for_failure(timeout=120)
|
||||
|
||||
if failure_type is None:
|
||||
print("No failures detected within timeout - run completed successfully")
|
||||
self.runner.stop()
|
||||
results["final_status"] = "clean_run"
|
||||
break
|
||||
|
||||
print(f"Failure detected: {failure_type}")
|
||||
self.runner.stop()
|
||||
|
||||
# Wait for process to fully stop
|
||||
time.sleep(2)
|
||||
|
||||
# Analyze logs
|
||||
print("Analyzing logs...")
|
||||
analysis = self.analyzer.analyze_all(
|
||||
log_dir=os.path.join(self.repo_root, "log"),
|
||||
stats_dir=self.runner.stats_dir,
|
||||
)
|
||||
|
||||
round_result = {
|
||||
"round": round_num + 1,
|
||||
"failure_type": failure_type,
|
||||
"total_failures": len(analysis.failures),
|
||||
"approach_failures": analysis.approach_failures,
|
||||
"maintenance_failures": analysis.maintenance_failures,
|
||||
"battle_failures": analysis.battle_failures,
|
||||
"chicken_triggers": analysis.chicken_triggers,
|
||||
"timeouts": analysis.timeouts,
|
||||
"crashes": analysis.crashes,
|
||||
}
|
||||
results["failures_found"].append(round_result)
|
||||
|
||||
# Apply fixes
|
||||
print("Applying fixes...")
|
||||
fixer = AutoFixer()
|
||||
fixes = fixer.fix_all(analysis)
|
||||
results["fixes_applied"] += len(fixes)
|
||||
successful = sum(1 for f in fixes if f.success)
|
||||
results["fixes_successful"] += successful
|
||||
|
||||
summary = fixer.summary()
|
||||
print(summary)
|
||||
|
||||
self.rounds.append({
|
||||
"analysis": analysis,
|
||||
"fixes": fixes,
|
||||
"summary": summary,
|
||||
})
|
||||
|
||||
if successful == len(fixes) and len(fixes) > 0:
|
||||
print("All fixes applied successfully - retrying...")
|
||||
continue
|
||||
elif len(fixes) == 0:
|
||||
print("No automatic fixes available for this failure")
|
||||
results["final_status"] = "no_fix_available"
|
||||
break
|
||||
else:
|
||||
print("Some fixes failed - may need manual intervention")
|
||||
results["final_status"] = "partial_fix"
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the self-healing test from command line."""
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Determine profile from active_profile.txt
|
||||
profile_file = os.path.join(repo_root, "config", "active_profile.txt")
|
||||
if os.path.exists(profile_file):
|
||||
with open(profile_file) as f:
|
||||
profile = f.read().strip()
|
||||
else:
|
||||
profile = "fistman"
|
||||
|
||||
print(f"Self-healing test for profile: {profile}")
|
||||
print(f"Repo root: {repo_root}")
|
||||
|
||||
test = SelfHealingTest(repo_root, profile)
|
||||
results = test.run()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("FINAL RESULTS")
|
||||
print(f"{'='*60}")
|
||||
print(f"Status: {results['final_status']}")
|
||||
print(f"Rounds: {results['total_rounds']}")
|
||||
print(f"Fixes applied: {results['fixes_applied']}")
|
||||
print(f"Fixes successful: {results['fixes_successful']}")
|
||||
|
||||
for i, round_result in enumerate(results["failures_found"]):
|
||||
print(f"\nRound {i+1}:")
|
||||
for k, v in round_result.items():
|
||||
if k != "round":
|
||||
print(f" {k}: {v}")
|
||||
|
||||
return 0 if results["final_status"] == "clean_run" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Tests for char module — IChar base class and CharacterCapabilities.
|
||||
|
||||
Covers: character construction, capability dataclass, active skill tracking.
|
||||
"""
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestCharacterCapabilities:
|
||||
def test_capabilities_dataclass(self):
|
||||
from char.capabilities import CharacterCapabilities
|
||||
caps = CharacterCapabilities(can_teleport_natively=True, can_teleport_with_charges=False)
|
||||
assert caps.can_teleport_natively is True
|
||||
assert caps.can_teleport_with_charges is False
|
||||
|
||||
|
||||
class TestIChar:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
|
||||
def test_ichar_initializes_skill_hotkeys(self):
|
||||
from char.i_char import IChar
|
||||
char = IChar({"left_attack": "1", "right_attack": "2"})
|
||||
assert char._skill_hotkeys["left_attack"] == "1"
|
||||
assert char._skill_hotkeys["right_attack"] == "2"
|
||||
|
||||
def test_ichar_active_skill_defaults_empty(self):
|
||||
from char.i_char import IChar
|
||||
char = IChar({"left_attack": "1", "right_attack": "2"})
|
||||
assert char._active_skill["left"] == ""
|
||||
assert char._active_skill["right"] == ""
|
||||
|
||||
def test_ichar_set_active_skill(self):
|
||||
from char.i_char import IChar
|
||||
char = IChar({"left_attack": "1", "right_attack": "2"})
|
||||
char._set_active_skill("left", "hammer")
|
||||
assert char._active_skill["left"] == "hammer"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Tests for config.Config singleton and edge cases.
|
||||
|
||||
Covers: singleton behavior, config file merging, missing file handling.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
# Reset singleton so each test gets a fresh config
|
||||
import config
|
||||
if hasattr(config, '_instance'):
|
||||
config._instance = None
|
||||
|
||||
def test_config_is_singleton(self):
|
||||
from config import Config
|
||||
c1 = Config()
|
||||
c2 = Config()
|
||||
assert c1 is c2
|
||||
|
||||
def test_config_loads_difficulty(self):
|
||||
from config import Config
|
||||
c = Config()
|
||||
assert "difficulty" in c.general
|
||||
|
||||
def test_config_loads_char_type(self):
|
||||
from config import Config
|
||||
c = Config()
|
||||
assert "type" in c.char
|
||||
|
||||
def test_config_loads_routes(self):
|
||||
from config import Config
|
||||
c = Config()
|
||||
assert hasattr(c, 'routes')
|
||||
|
||||
def test_config_general_has_max_game_length(self):
|
||||
from config import Config
|
||||
c = Config()
|
||||
assert "max_game_length_s" in c.general
|
||||
|
||||
def test_config_char_has_keybinds(self):
|
||||
from config import Config
|
||||
c = Config()
|
||||
# hammerdin config should have stand_still and show_items
|
||||
assert "stand_still" in c.char
|
||||
assert "show_items" in c.char
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Tests for death_manager state management logic.
|
||||
|
||||
Covers: death flag, callback wiring, monitor state, reset.
|
||||
All testable without a real D2R client.
|
||||
"""
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestDeathManager:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
from death_manager import DeathManager
|
||||
self.dm = DeathManager()
|
||||
|
||||
def test_died_defaults_false(self):
|
||||
assert self.dm.died() is False
|
||||
|
||||
def test_loop_delay(self):
|
||||
assert self.dm.get_loop_delay() == 0.5
|
||||
|
||||
def test_callback_set_and_stored(self):
|
||||
cb_called = []
|
||||
self.dm.set_callback(lambda: cb_called.append(1))
|
||||
assert self.dm._callback is not None
|
||||
self.dm._callback()
|
||||
assert cb_called == [1]
|
||||
|
||||
def test_stop_monitor(self):
|
||||
self.dm.stop_monitor()
|
||||
assert self.dm._do_monitor is False
|
||||
|
||||
def test_reset_death_flag(self):
|
||||
self.dm._died = True
|
||||
self.dm.reset_death_flag()
|
||||
assert self.dm.died() is False
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Tests for game_controller initialization and state.
|
||||
|
||||
Covers: GameController creates Bot, DeathManager, HealthManager, GameRecovery.
|
||||
Tests the wiring between components without needing a real D2R client.
|
||||
"""
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestGameController:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
# Reset singletons
|
||||
import config
|
||||
if hasattr(config, '_instance'):
|
||||
config._instance = None
|
||||
from health_manager import HealthManager
|
||||
HealthManager._instance = None
|
||||
|
||||
def test_gamecontroller_creates_components(self):
|
||||
from game_controller import GameController
|
||||
gc = GameController()
|
||||
assert gc.game_stats is not None
|
||||
assert gc.is_running is False
|
||||
|
||||
def test_gamecontroller_creates_death_manager(self):
|
||||
from game_controller import GameController
|
||||
gc = GameController()
|
||||
gc.start()
|
||||
assert gc.death_manager is not None
|
||||
|
||||
def test_gamecontroller_creates_health_manager(self):
|
||||
from game_controller import GameController
|
||||
gc = GameController()
|
||||
gc.start()
|
||||
assert gc.health_manager is not None
|
||||
|
||||
def test_gamecontroller_creates_game_recovery(self):
|
||||
from game_controller import GameController
|
||||
gc = GameController()
|
||||
gc.start()
|
||||
assert gc.game_recovery is not None
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Tests for game_recovery.
|
||||
|
||||
Covers: GameRecovery constructor and death_manager reference.
|
||||
"""
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestGameRecovery:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
|
||||
def test_recovery_holds_death_manager_ref(self):
|
||||
from death_manager import DeathManager
|
||||
from game_recovery import GameRecovery
|
||||
dm = DeathManager()
|
||||
gr = GameRecovery(dm)
|
||||
assert gr._death_manager is dm
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Tests for health_manager state management logic.
|
||||
|
||||
Covers: pause state, panel check paused, chicken flag, callback wiring.
|
||||
These are all testable without a real D2R client — they're just state machines.
|
||||
"""
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestHealthManager:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
from health_manager import HealthManager
|
||||
self.HM = HealthManager
|
||||
self.hm = HealthManager()
|
||||
|
||||
def test_pause_state_defaults_to_true(self):
|
||||
assert self.hm.get_pause_state() is True
|
||||
|
||||
def test_set_pause_state_changes(self):
|
||||
self.hm.set_pause_state(False)
|
||||
assert self.hm.get_pause_state() is False
|
||||
self.hm.set_pause_state(True)
|
||||
assert self.hm.get_pause_state() is True
|
||||
|
||||
def test_panel_check_paused_defaults_to_false(self):
|
||||
assert self.hm.get_panel_check_paused() is False
|
||||
|
||||
def test_set_panel_check_paused_changes(self):
|
||||
self.hm.set_panel_check_paused(True)
|
||||
assert self.hm.get_panel_check_paused() is True
|
||||
self.hm.set_panel_check_paused(False)
|
||||
assert self.hm.get_panel_check_paused() is False
|
||||
|
||||
def test_chicken_flag_defaults_false(self):
|
||||
assert self.hm.did_chicken() is False
|
||||
|
||||
def test_callback_set_and_stored(self):
|
||||
cb_called = []
|
||||
self.hm.set_callback(lambda: cb_called.append(1))
|
||||
assert self.hm._callback is not None
|
||||
self.hm._callback()
|
||||
assert cb_called == [1]
|
||||
|
||||
def test_stop_monitor_sets_flag(self):
|
||||
self.hm.stop_monitor()
|
||||
assert self.hm._do_monitor is False
|
||||
|
||||
def test_reset_chicken_flag(self):
|
||||
self.hm._did_chicken = True
|
||||
self.hm.reset_chicken_flag()
|
||||
assert self.hm.did_chicken() is False
|
||||
assert self.hm.get_pause_state() is True
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Test that install.bat references files that actually exist in the repo.
|
||||
|
||||
Catches: deleted dependency files, renamed requirements, missing environment.yml.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Files install.bat references by name (not env vars or paths)
|
||||
REQUIRED_FILES = [
|
||||
"requirements.txt",
|
||||
"requirements-win10.txt",
|
||||
"requirements-win11.txt",
|
||||
"environment.yml",
|
||||
"environment-win10.yml",
|
||||
"environment-win11.yml",
|
||||
"find_python.bat",
|
||||
]
|
||||
|
||||
# Files that may or may not exist depending on the branch
|
||||
OPTIONAL_FILES = [
|
||||
"dependencies/tesseract52.dll",
|
||||
"dependencies/tesserocr.cp310-win_amd64.pyd",
|
||||
"dependencies/tesserocr-2.5.2-cp310-cp310-win_amd64.whl",
|
||||
]
|
||||
|
||||
|
||||
def _exists(name):
|
||||
return os.path.isfile(os.path.join(ROOT, name))
|
||||
|
||||
|
||||
class TestInstallBatReferences:
|
||||
def test_required_files_exist(self):
|
||||
missing = [f for f in REQUIRED_FILES if not _exists(f)]
|
||||
assert not missing, f"install.bat references missing files: {missing}"
|
||||
|
||||
def test_optional_files_at_least_one_exists(self):
|
||||
existing = [f for f in OPTIONAL_FILES if _exists(f)]
|
||||
assert len(existing) >= 1, (
|
||||
"install.bat references tesseract/tesserocr dependencies but none exist in "
|
||||
"dependencies/ directory — install.bat will silently skip OCR setup"
|
||||
)
|
||||
|
||||
def test_install_bat_exists(self):
|
||||
assert _exists("install.bat"), "install.bat is missing from repo root"
|
||||
|
||||
def test_run_botty_bat_exists(self):
|
||||
assert _exists("run_botty.bat"), "run_botty.bat is missing from repo root"
|
||||
|
||||
def test_config_params_exists(self):
|
||||
assert _exists("config/params.ini"), "config/params.ini is missing"
|
||||
|
||||
def test_config_game_ini_exists(self):
|
||||
assert _exists("config/game.ini"), "config/game.ini is missing"
|
||||
|
||||
def test_tessdata_directory_exists(self):
|
||||
tessdata = os.path.join(ROOT, "assets", "tessdata")
|
||||
assert os.path.isdir(tessdata), "assets/tessdata/ directory is missing"
|
||||
|
||||
def test_tessdata_has_traineddata(self):
|
||||
tessdata = os.path.join(ROOT, "assets", "tessdata")
|
||||
trained = [f for f in os.listdir(tessdata) if f.endswith(".traineddata")]
|
||||
assert len(trained) >= 1, "assets/tessdata/ has no .traineddata files"
|
||||
|
||||
def test_src_main_exists(self):
|
||||
assert _exists("src/main.py"), "src/main.py is missing"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Tests for inventory.belt logic.
|
||||
|
||||
Covers: potion type detection, belt toggle keys.
|
||||
"""
|
||||
import pytest
|
||||
import numpy as np
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestInventoryBelt:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
|
||||
def test_belt_toggle_keys_returns_list(self):
|
||||
from inventory.belt import _belt_toggle_keys
|
||||
keys = _belt_toggle_keys()
|
||||
assert isinstance(keys, list)
|
||||
assert len(keys) > 0
|
||||
|
||||
def test_cut_potion_img_returns_array(self):
|
||||
from inventory.belt import _cut_potion_img
|
||||
img = np.full((100, 100, 3), 255, dtype=np.uint8)
|
||||
result = _cut_potion_img(img, 0, 0)
|
||||
assert isinstance(result, np.ndarray)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Tests for item.pickit PickedUpResult enum and basic logic.
|
||||
|
||||
Covers: the result enum values and the pickit import chain.
|
||||
"""
|
||||
import pytest
|
||||
from logger import Logger
|
||||
|
||||
|
||||
class TestPickit:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
|
||||
def test_pickedupresult_enum_values(self):
|
||||
from item.pickit import PickedUpResult
|
||||
assert PickedUpResult.TeleportedTo.value == 0
|
||||
assert PickedUpResult.PickedUp.value == 1
|
||||
assert PickedUpResult.PickedUpFailed.value == 2
|
||||
|
||||
def test_pickit_imports_without_error(self):
|
||||
# This verifies the full import chain: pickit -> bnip -> d2r_image -> config
|
||||
from item.pickit import PickedUpResult
|
||||
assert PickedUpResult is not None
|
||||
@@ -11,10 +11,16 @@ import re
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
EXPECTED_BATS = [
|
||||
# Required on every branch -- without these the bot cannot be installed or run.
|
||||
CORE_BATS = [
|
||||
"install.bat",
|
||||
"find_python.bat",
|
||||
"run_botty.bat",
|
||||
]
|
||||
|
||||
# Developer tooling. The end-user `stable` branch deliberately strips these, so
|
||||
# they are validated only when present rather than asserted to exist.
|
||||
OPTIONAL_BATS = [
|
||||
"run_asset_extractor.bat",
|
||||
"run_quest_debug.bat",
|
||||
]
|
||||
@@ -26,13 +32,22 @@ def _read(name):
|
||||
return open(os.path.join(ROOT, name)).read()
|
||||
|
||||
|
||||
def _exists(name):
|
||||
return os.path.isfile(os.path.join(ROOT, name))
|
||||
|
||||
|
||||
def _present(names):
|
||||
"""Only the given bats that actually exist in this checkout."""
|
||||
return [n for n in names if _exists(n)]
|
||||
|
||||
|
||||
def _bat_exists(name):
|
||||
assert os.path.isfile(os.path.join(ROOT, name)), f"{name} is missing from repo root"
|
||||
|
||||
|
||||
class TestBatFilesExist:
|
||||
def test_all_bats_present(self):
|
||||
for name in EXPECTED_BATS:
|
||||
for name in CORE_BATS:
|
||||
_bat_exists(name)
|
||||
|
||||
|
||||
@@ -42,12 +57,10 @@ class TestNoHardcodedUsernames:
|
||||
_CHECKED = [
|
||||
"find_python.bat",
|
||||
"run_botty.bat",
|
||||
"run_asset_extractor.bat",
|
||||
"run_quest_debug.bat",
|
||||
]
|
||||
] + OPTIONAL_BATS
|
||||
|
||||
def test_no_hardcoded_usernames(self):
|
||||
for name in self._CHECKED:
|
||||
for name in _present(self._CHECKED):
|
||||
content = _read(name).lower()
|
||||
for uname in USERNAMES_TO_BLOCK:
|
||||
for line in content.split("\n"):
|
||||
@@ -59,7 +72,7 @@ class TestNoHardcodedUsernames:
|
||||
)
|
||||
|
||||
def test_no_absolute_home_paths(self):
|
||||
for name in self._CHECKED:
|
||||
for name in _present(self._CHECKED):
|
||||
content = _read(name)
|
||||
# Find C:\Users\ followed by a literal username (not %USERNAME%)
|
||||
bad = re.findall(r"C:\\Users\\([^%\s]+)", content)
|
||||
@@ -70,14 +83,10 @@ class TestNoHardcodedUsernames:
|
||||
class TestFindPythonUsage:
|
||||
"""All run_*.bat should source find_python.bat."""
|
||||
|
||||
_RUN_BATS = [
|
||||
"run_botty.bat",
|
||||
"run_asset_extractor.bat",
|
||||
"run_quest_debug.bat",
|
||||
]
|
||||
_RUN_BATS = ["run_botty.bat"] + OPTIONAL_BATS
|
||||
|
||||
def test_run_bats_call_find_python(self):
|
||||
for name in self._RUN_BATS:
|
||||
for name in _present(self._RUN_BATS):
|
||||
content = _read(name)
|
||||
assert "find_python.bat" in content, (
|
||||
f"{name} does not call find_python.bat -- "
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Tests for ui.meters — health/mana/merc health reading.
|
||||
|
||||
These test the math: given a known image, the percentage should be deterministic.
|
||||
"""
|
||||
import pytest
|
||||
import numpy as np
|
||||
import cv2
|
||||
from logger import Logger
|
||||
from config import Config
|
||||
|
||||
|
||||
class TestMeters:
|
||||
def setup_method(self):
|
||||
Logger.init()
|
||||
Logger.remove_file_logger()
|
||||
|
||||
def test_get_health_returns_value_between_0_and_1(self):
|
||||
from ui.meters import get_health
|
||||
# All-white image — no red/green pixels, so health = 0
|
||||
img = np.full((720, 1280, 3), 255, dtype=np.uint8)
|
||||
result = get_health(img)
|
||||
assert 0.0 <= result <= 1.0
|
||||
|
||||
def test_get_mana_returns_value_between_0_and_1(self):
|
||||
from ui.meters import get_mana
|
||||
img = np.full((720, 1280, 3), 255, dtype=np.uint8)
|
||||
result = get_mana(img)
|
||||
assert 0.0 <= result <= 1.0
|
||||
|
||||
def test_get_merc_health_returns_value_between_0_and_1(self):
|
||||
from ui.meters import get_merc_health
|
||||
img = np.full((720, 1280, 3), 255, dtype=np.uint8)
|
||||
result = get_merc_health(img)
|
||||
assert 0.0 <= result <= 1.0
|
||||
|
||||
def test_get_merc_health_black_image_is_zero(self):
|
||||
from ui.meters import get_merc_health
|
||||
img = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
result = get_merc_health(img)
|
||||
assert result == 0.0
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Test that version.py matches pyproject.toml version.
|
||||
|
||||
Catches: version bump in one place but not the other.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _read_version_py():
|
||||
path = os.path.join(ROOT, "src", "version.py")
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
m = re.search(r"__version__\s*=\s*'([^']+)'", content)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _read_pyproject_version():
|
||||
path = os.path.join(ROOT, "pyproject.toml")
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data.get("project", {}).get("version")
|
||||
|
||||
|
||||
class TestVersionConsistency:
|
||||
def test_version_py_matches_pyproject(self):
|
||||
v_py = _read_version_py()
|
||||
v_proj = _read_pyproject_version()
|
||||
assert v_py is not None, "Could not read __version__ from src/version.py"
|
||||
assert v_proj is not None, "Could not read version from pyproject.toml"
|
||||
assert v_py == v_proj, (
|
||||
f"Version mismatch: version.py={v_py}, pyproject.toml={v_proj}"
|
||||
)
|
||||
|
||||
def test_version_is_semver(self):
|
||||
v = _read_version_py()
|
||||
assert re.match(r"^\d+\.\d+\.\d+", v), f"Version '{v}' is not semver"
|
||||
Reference in New Issue
Block a user