Compare commits

...
1 Commits
Author SHA1 Message Date
alexpolo1 c2a2d1af8c stable: clean branch for end users — removed dev-only files (botty_next, Docker, debug tools, internal docs)
Botty - CI / test (push) Waiting to run
Botty - CI / build (push) Blocked by required conditions
2026-08-01 10:48:57 +02:00
61 changed files with 16 additions and 6370 deletions
-140
View File
@@ -1,140 +0,0 @@
# Botty - Open Issues & TODO Plan
Generated: 2026-06-01
---
## HIGH PRIORITY (affects botting reliability)
### 1. BNIP transpiler broken code (`src/bnip/transpile.py:369`)
- **Status:** `# TODO FIX THIS SHIT``remove_quantity()` function is hacky
- **Problem:** Expression splitting by `#` is fragile; can corrupt BNIP expressions with multiple `#` delimiters
- **Impact:** Pickit rules may silently misparse quantity operators
- **Fix:** Rewrite `remove_quantity()` to properly handle `#`-delimited expressions with edge cases
- **File:** `src/bnip/transpile.py`
### 2. FOH cast delay missing (`src/char/paladin/fohdin.py:99`)
- **Status:** `# TODO: add delay between FOH casts--doesn't properly cast each FOH in sequence`
- **Problem:** FOH casts fire too fast; some casts don't land in sequence
- **Impact:** Reduced DPS, wasted FOH rotations
- **Fix:** Add `wait()` between FOH casts to ensure each cast completes before next
- **File:** `src/char/paladin/fohdin.py`
### 3. Chest opening telekinesis workaround (`src/chest.py:51`)
- **Status:** `# TODO: Act as picking up a potion to support telekinesis`
- **Problem:** Chest open simulates potion pickup to work around telekinesis skill
- **Impact:** Fragile interaction; may break with game updates
- **Fix:** Implement proper chest interaction that accounts for telekinesis
- **File:** `src/chest.py`
### 4. Inventory full handling (`src/item/pickit.py:236`)
- **Status:** `#TODO Create logic to handle inventory full`
- **Problem:** When inventory fills, pickit just stops — doesn't try to stash, sell, or prioritize
- **Impact:** Bot stops picking up items mid-run; lost gold/runes
- **Fix:** Add fallback logic: stop picking, trigger town run to stash/sell
- **File:** `src/item/pickit.py`
### 5. Overburdened handling (`src/ui/view.py:109`)
- **Status:** `#TODO: handle "Overburdened"`
- **Problem:** `pickup_corpse()` doesn't detect "Overburdened" state after clicking
- **Impact:** Bot may get stuck trying to pickup corpse when overweight
- **Fix:** Add template detection for "Overburdened" UI and handle gracefully
- **File:** `src/ui/view.py`
---
## MEDIUM PRIORITY (code quality / edge cases)
### 6. BNIP parenthesis cross-section check (`src/bnip/transpile.py:199`)
- **Status:** `# TODO Backtrace until the last opening to make sure it wasn't from the past section.`
- **Problem:** Parenthesis validation doesn't catch `(` in one BNIP section and `)` in another
- **Impact:** Silent BNIP syntax errors that pass validation
- **Fix:** Implement backtrace to reject cross-section parentheses
- **File:** `src/bnip/transpile.py`
### 7. BNIP lexer misplaced checks (`src/bnip/lexer.py:319`)
- **Status:** `# TODO: The second checks seem a little misplaced`
- **Problem:** `NTIPAliasClass` and `TokenType.CLASS:` checks should be in transpiler validation, not lexer
- **Impact:** Code organization; potential missed validation
- **Fix:** Move validation logic to `transpile.py` and emit warnings
- **File:** `src/bnip/lexer.py`, `src/bnip/transpile.py`
### 8. BNIP actions error handling (`src/bnip/actions.py:209`)
- **Status:** `# TODO look at these errors`
- **Problem:** BNIP load errors are printed but not properly logged or categorized
- **Impact:** Hard to diagnose BNIP parse failures
- **Fix:** Replace `print()` with proper `Logger.error()` and structured error reporting
- **File:** `src/bnip/actions.py`
### 9. Pickit return type (`src/item/pickit.py:165`)
- **Status:** `TODO :return: return a list of the items that were picked up`
- **Problem:** Docstring says it should return a list, but function returns `bool`
- **Impact:** Inconsistent API; callers can't know what was actually picked
- **Fix:** Return `list[Item]` of picked items instead of `bool`
- **File:** `src/item/pickit.py`
### 10. Consumable auto-belt (`src/inventory/personal.py:350`)
- **Status:** `# TODO: logic for trying to add potion to belt if there are needs`
- **Problem:** Consumables found during inventory management aren't auto-added to belt
- **Impact:** Bot doesn't restock belt potions from inventory during runs
- **Fix:** Add logic to detect belt needs and move potions from inventory
- **File:** `src/inventory/personal.py`
### 11. Merc blocking templates (`src/run/nihlathak.py:45`)
- **Status:** `# TODO: We might need a second template for each option as merc might run into the template`
- **Problem:** Merc can stand on template match location, causing detection failure
- **Impact:** Nihlathak run fails to detect layout variant
- **Fix:** Add backup templates with offset ROIs for each layout variant
- **File:** `src/run/nihlathak.py`
---
## LOW PRIORITY (cleanup / refactoring)
### 12. Character select cleanup (`src/ui/character_select.py:109`)
- **Status:** `# TODO: can cleanup logic here, can we utilize a generic ScreenObject or use custom locator?`
- **Problem:** Character selection uses ad-hoc template search instead of reusable ScreenObject
- **Fix:** Refactor to use `ScreenObjects` pattern
- **File:** `src/ui/character_select.py`
### 13. Screen utility functions (`src/screen.py:104`)
- **Status:** `# TODO: Move the below funcs to utils(?)`
- **Problem:** `convert_monitor_to_screen()` and related functions live in `screen.py` but could be in utils
- **Fix:** Move coordinate conversion functions to `src/utils/`
- **File:** `src/screen.py`
### 14. Graphic debugger re-init (`src/utils/graphic_debugger.py:60`)
- **Status:** `# TODO: these two layers variable needs to be reassigned because F10 will not re-init`
- **Problem:** Debugger layers don't reinitialize properly on F10 toggle
- **Fix:** Move layer state into controller class; reinit on stop/start
- **File:** `src/utils/graphic_debugger.py`
### 15. mttkinter logging (`src/utils/mttkinter.py:62-63`)
- **Status:** `# TODO: Replace custom logging functionality with standard logging.Logger`
- **Problem:** Custom logging in tkinter utils instead of standard library
- **Fix:** Replace with `logging.Logger`
- **File:** `src/utils/mttkinter.py`
### 16. Pickit test note (`test/nip/keep_item_test_cases.py:978`)
- **Status:** `# TODO: I had to change from [defense] >= 47 to [plusdefense] >= 47`
- **Problem:** `[defense]` is a calculated property; `[plusdefense]` is the raw value. Note for future reference.
- **Fix:** Document in BNIP docs that `[defense]` is calculated; `[plusdefense]` is the raw modifier
- **File:** `test/nip/keep_item_test_cases.py`
### 17. New route scaffolding (`src/utils/new_route.py`)
- **Status:** Multiple TODO placeholders (by design — it's a code generator template)
- **Problem:** Template placeholders are intentional; not bugs
- **Fix:** No action needed — these are scaffolding placeholders
- **File:** `src/utils/new_route.py`
---
## Summary
| Priority | Count | Files |
|----------|-------|-------|
| HIGH | 5 | transpile.py, fohdin.py, chest.py, pickit.py, view.py |
| MEDIUM | 6 | transpile.py, lexer.py, actions.py, pickit.py, personal.py, nihlathak.py |
| LOW | 6 | character_select.py, screen.py, graphic_debugger.py, mttkinter.py, keep_item_test_cases.py, new_route.py |
**Total: 17 items across 13 files**
-87
View File
@@ -1,87 +0,0 @@
# Botty Improvements Implementation Plan
## Status Legend
- [ ] Not started
- [~] In progress
- [x] Done
- [-] Cancelled / low priority
---
## Phase 1: Key Auto-Detection (Issue #940)
Read D2R .key file and auto-fill hotkeys.
- [x] Create src/utils/key_detector.py module
- [x] VK code mapping (partial — needs review for accuracy)
- [x] Parse .key file (text format: VK action_type param)
- [x] Auto-fill empty [char] hotkeys from detected bindings
- [x] Auto-fill build-specific skill hotkeys (fohdin, hammerdin, etc.)
- [x] Wire into config.py load_data()
- [ ] REVIEW: Verify VK_MAP accuracy (D2R uses its own VK offset scheme)
- [ ] REVIEW: Skill slot-to-config matching is heuristic — may misassign
- [ ] TEST: Verify against actual D2R .key file on user's machine
## Phase 2: Target Detection False Positives (Issues #959/#964)
Health bars and "immune to X" text mistaken for targets.
- [ ] Analyze current get_visible_targets() in target_detect.py
- [ ] Add shape/size filtering: health bars are thin horizontal strips, immune text is small
- [ ] Add aspect ratio check: real targets (poison/freeze auras) are roughly circular/elliptical
- [ ] Add minimum bounding box height constraint (filter out thin text)
- [ ] Optionally: add color temperature check (immune text is yellow/gold, not blue/green)
- [ ] Test with screenshots of edge cases
## Phase 3: Pickit Timing Fix (Issue #939)
Items skipped because bot teleports away before grabbing.
- [ ] Review pickit.py _yoink_item() for timing issues
- [ ] Add configurable pickup_delay parameter (current: fixed timing)
- [ ] Add retry logic: if item still visible after pickup attempt, re-try
- [ ] Add "slow mode" for large/heavy items (framed/magic items may animate longer)
- [ ] Ensure bot doesn't teleport until pickup animation completes
- [ ] Test: verify no "Attempt to pick xyz" warnings followed by teleport
## Phase 4: Parallel Template Search (Issue #848)
Speed up template_finder.search() with threading.
- [ ] Add ThreadPoolExecutor-based search_all_parallel()
- [ ] Keep existing search() for single-template (no overhead)
- [ ] Only parallelize when searching >3 templates simultaneously
- [ ] Benchmark: measure speedup on typical 1280x720 grab
## Phase 5: Async Mouse Moves (Issue #955)
Non-blocking mouse movement.
- [ ] Add async_move() to utils/custom_mouse.py
- [ ] Run movement in background thread
- [ ] Add is_moving() / wait_for_move() synchronization
- [ ] Integrate into game_controller.py for smoother action chains
## Phase 6: Hardcore Chicken Loop Fix (Issue #942)
Prevent infinite death loops on Hardcore characters.
- [ ] Review death_manager.py chicken logic
- [ ] Add max_chicken_count config parameter (default: 3)
- [ ] If max chicken count exceeded on HC, exit gracefully instead of re-entering
- [ ] Add defensive chicken config option (chicken to TP instead of full chicken)
- [ ] Test: verify HC character exits cleanly after N deaths
## Phase 7: Auto-Label NPCs (Issue #950)
Learn vendor identities automatically during gameplay.
- [ ] During town states, detect NPC name plates via OCR
- [ ] Cross-reference detected names with known NPC list
- [ ] Auto-capture NPC templates when confidence is high
- [ ] Store learned templates in assets/npc/
- [ ] This is a long-term feature — lower priority
---
## Priority Order (implement in this order)
1. **Phase 1** - Key auto-detection (already partially done, needs review + test)
2. **Phase 3** - Pickit timing (high impact on loot collection)
3. **Phase 2** - Target detection (high impact on kill reliability)
4. **Phase 6** - Hardcore chicken fix (safety critical)
5. **Phase 4** - Parallel template search (performance)
6. **Phase 5** - Async mouse moves (quality of life)
7. **Phase 7** - Auto-label NPCs (long-term feature)
-114
View File
@@ -1,114 +0,0 @@
# Dia-run test #2 — session state & plan (2026-06-11 ~20:45)
## OUTCOME (21:29) — ALL FIXES VERIFIED ✅
Full Diablo run completed with every fix live: game 1 (21:14 session) ran Pindle (45s, clean,
no BC double-swap... BC retried once — template marginal, see below) then run_diablo SUCCEEDED
(21:17:28→21:29:04): A5 WP found FIRST TRY at new 0.62 threshold (was 0-for-5 before),
vendor trip skipped by gating, CS layout matched 91.5%, all seals, Diablo killed, game ended
clean at 872s. Implemented beyond the original plan: quick-mode for open_wp (failure 1 → direct
path only on next call; failure 2 → instant fail), sweep 10→6 steps, select timeout 4s, and
A5 threshold drops (stash 0.60/0.45, WP 0.62 — safe because success_func gates every click).
Remaining known marginals (non-blocking): BC/BO skill icon template (1 extra swap ~2s, 2/3 games),
A5_TOWN_0/1 town markers (detect_current_act warns but soft-falls-back), npc body templates
(Larzuk/Cain flaky, fallbacks work), numpy truthiness bug in missing-template debug screenshot
helper, inventory-full pickit skips until next successful stash. Char left at char-select/lobby,
D2R running, no bot processes.
## Goal
User asked: "trigger a full dia run and monitor it for loops and mistakes". A full Diablo run
(WP → ROF → CS → 3 seals → kill) must complete while logging all loops/mistakes, then deliver
an analysis report.
## Current state
- Bot was F12-stopped at 20:37 (was stuck in A5_WP search loop, game 3, char wandered to town wall).
- D2R is OPEN, character "fistman" is IN the stuck game with the **Options→Video menu open**.
- Next immediate steps: Esc out of options, click SAVE AND EXIT at **physical (650, 424)**,
relaunch bot, F11, re-arm monitor, wait for a full dia run (stealth may randomly skip runs).
- After run completes: F12 stop, kill leftover `cmd`/`pwsh` with `run_botty` in commandline,
delete `log/_*.png` and `log/_run2_out.txt` scratch files, deliver findings report.
## How to drive (hard-won specifics)
- Display is 1920x1200 physical, 125% scaling (1536x960 logical). **Use the botty env python**
(`C:\ProgramData\miniforge3\envs\botty\python.exe`) with `ctypes.windll.user32.SetProcessDPIAware()`
+ `src/input_layer/win_input.py` `mouse_move/mouse_click/mouse_wheel` for clicks (physical coords;
cwd must be C:\Users\alex\my-botty with sys.path.insert(0,"src")).
PowerShell `SetCursorPos`/`mouse_event` are DPI-virtualized → clicks land 1.25x off — do NOT use.
- Click into D2R twice (first click only activates the window).
- F11/F12 hotkeys work via `keybd_event` from anywhere (GetAsyncKeyState polling).
- Screenshots: PIL `ImageGrab.grab(all_screens=True)` in the DPI-aware python = physical pixels.
- Launch: `cmd /c C:\Users\alex\my-botty\run_botty.bat *> log\_run2_out.txt` (PowerShell bg task).
- Watch `log/stats/events_*.jsonl` (newest) + `log/log.txt`.
## Findings so far for the final report (test #2, started 20:23)
1. **A5_WP selection loop (CRITICAL, 3/3 occurrences after Pindle returns)**: every A5 WP open
after a Pindle run fails first try ("Wanted to select A5_WP"); anchor retries (qual_kehk, malah)
sometimes recover (~25s cost), but in game 3 (~20:30) ALL anchors + directed sweep failed,
char wandered to the town wall off all pather nodes, looped 15+ times until manual intervention.
Hypothesis: after Pindle TP return, pather position estimate is wrong; traverses compound the error.
2. **NPC detection failures**: Cain (A4) timed out → fell back to A5 Cain (worked); Tyrael resurrect
timed out once, retry worked. Town maintenance took ~3 min in game 1 due to these.
3. **Battle Command prebuff retry fired 3/3 games** ("Failed to find Battle Command, swapping
weapons again") — CTA buff icon detection systematically needs a second swap.
4. **Mouse misses (relative mode)**: ~6 occurrences, 12-80px off, all self-corrected via SetCursorPos
retry (win_input fallback working as designed).
5. **Player chicken at Pindle** game 2 (HP 37.2%, 59s game) — survivability, not logic.
6. **Stealth random skip** skipped Diablo in game 1 — by design but reduces dia throughput.
7. **D2R settings now verified correct** (in-game screenshots 20:42): DLSS OFF, 1280x720 windowed,
texture HIGH, details LOW, AA/AO off → matches assets/d2r_settings.json (startup warning gone).
Previous session's CS template failures should be fixed; pentagram matched 95% last session.
8. Earlier fixes this session (all verified live): hotkey.wait() no-arg blocking bug, edge-triggered
hotkeys, OCR tesseract_cmd wiring, NipSyntaxError→BNipSyntaxError + Schaefershammer typo.
## PERMANENT FIX PLAN (user-approved direction: fix properly, prefer smarter designs)
### Fix 1 — A5_WP loop: fail-fast + fresh game (CRITICAL, the 10-min wander)
`a5.py:open_wp` already has 3 escalation layers (direct path → 3 anchors → directed sweep,
~3.5 min total). The death loop is the OUTER chain: `bot.on_maintenance` retry sites call
`buy_consumables`/`go_to_act` again → `town_manager.open_wp` again → full 3.5-min escalation
again, from an ever-worse position estimate. Each failed cycle compounds.
**Smart fix:** position estimates can't be trusted after a failure, but a NEW GAME gives a
guaranteed-known spawn in ~40s. Add a per-game WP-failure budget on the `Bot` instance:
- `self._wp_fail_count` reset in `on_init`; `town_manager.open_wp` failure increments it
(thread the signal via return or a callback).
- In `on_maintenance`/`on_end_run`: if `_wp_fail_count >= 2``_save_error_screenshot` +
`trigger_or_stop("end_game", failed=True)` immediately. No more wandering retries.
- Also cap `a5.open_wp` layer 3 (sweep) to run only on the FIRST failure per game; subsequent
calls in the same game go straight to fail (the sweep from an unknown spot is what walked the
char onto the town wall).
### Fix 2 — same family: A5_RED_PORTAL first-click miss (Pindle approach, seen 20:47)
Same position-estimate root cause, already has a "retry from town start" recovery that works.
Include its failure in the same per-game budget rather than new mechanisms.
### Fix 3 — reduce A5→A4 Jamella trips (exposure reduction, smarter)
`buy_consumables: in A5 — traveling to A4 Jamella (Malah unreliable)` runs every game even when
only selling 1-2 junk items. Gate the trip: only travel to A4 if (pots needed below threshold)
OR (tp/id tomes low) OR (inventory has >N sell items). Selling junk can wait; stash is in A5.
Fewer WP trips = fewer chances to hit Fix-1 territory.
### Fix 4 — Battle Command prebuff double-swap (3/3 games)
`Failed to find Battle Command, swapping weapons again` every game. The buff check runs too
soon after weapon swap (buff icons fade in). In the prebuff code (char/hammerdin.py or
i_char.pre_buff): add ~0.4-0.6s wait after CTA casts before checking the buff bar, and lower
the BC icon threshold slightly (capture shows icons render fine). Saves a full swap cycle/game.
### Fix 5 — Cain ID: sticky act preference
A4 Cain timed out (20s wasted) then A5 Cain worked. Cache `self._last_good_cain_act` on Bot;
try that act first next game. One-line behavioral memory, halves ID time after first game.
### Fix 6 — leave as-is (verified fine)
- Mouse misses: ~6/session, all self-corrected by SetCursorPos retry (stealth Bezier primary
path is intentional). No change.
- Stealth random run skip: by design.
- Pindle chicken @ 37% HP: gear/survivability, not code. Mention to user only.
### Verification after implementing
- Unit-light: run 3+ games (`run_pindle`+`run_diablo`), grep log for: no second consecutive
`Wanted to select A5_WP` burst per game; `Battle Command` retry absent; Jamella trip skipped
when nothing needed; failed-WP game ends < 90s instead of 900s timeout.
## Stats so far (test #2)
- Game 1: Pindle OK (46s) + Diablo stealth-skipped. Maintenance ~3min (Cain/Tyrael/A5_WP issues).
- Game 2: Pindle chicken @ HP 37% (59s, failed).
- Game 3: Pindle OK (43s), then A5_WP loop before Diablo → manually stopped 20:37.
- Diablo run not yet completed in test #2.
-561
View File
@@ -1,561 +0,0 @@
# Quest Framework + Den of Evil Plan
## Goal
Build a quest automation framework in botty that can interact with D2R NPCs, handle dialogue,
track quest progress, and run Den of Evil as the first quest -- all usable by a low-level FoHdin.
---
## Architecture
The quest framework is a new subsystem that plugs into the existing botty state machine.
It follows the same patterns as existing runs (approach -> battle -> return to town) but adds
NPC dialogue interaction and quest state persistence.
### New files
```
src/quest/
__init__.py # Exports
quest_manager.py # Quest state machine + persistence (JSON)
quest_dialogue.py # OCR-based NPC dialogue interaction
quest_items.py # Quest item detection/pickup
quest_combat.py # Lightweight combat wrapper (killing trash)
a1/
__init__.py
q_den_of_evil.py # Den of Evil run
```
### Modified files
```
src/npc_manager.py # Add TOWN_MAIDEN NPC constant + templates
src/pather.py # Add A1_ROARING_CANYON + DoE entrance locations
src/bot.py # Add quest state, transitions, handler
src/run/__init__.py # Export DenOfEvil
src/town/a1.py # (optional) Add can_do_den_of_evil method
config/params.ini # Add run_doe to [routes]
config/bnip/ # Add town_maiden.png template
```
---
## Phase 1: Foundation
### 1.1 `src/quest/quest_manager.py`
Purpose: Track which quests are done, persist between sessions, dispatch to quest modules.
```python
class QuestManager:
"""Manages quest state: tracks done/available quests per act, persists to JSON."""
# Quest definitions per act
QUESTS = {
"a1": ["den_of_evil"],
"a2": [], # future: radament, horadric_staff, etc.
...
}
def __init__(self):
self._state_file = "config/quest_state.json"
self._state = self._load()
def is_done(self, quest_name: str) -> bool:
return self._state.get(quest_name, False)
def mark_done(self, quest_name: str):
self._state[quest_name] = True
self._save()
def mark_all_done(self, act: str):
for q in self.QUESTS.get(act, []):
self._state[q] = True
self._save()
def next_pending(self, act: str) -> str | None:
for q in self.QUESTS.get(act, []):
if not self.is_done(q):
return q
return None
def all_done(self, act: str) -> bool:
return all(self._state.get(q, False) for q in self.QUESTS.get(act, []))
def _load(self) -> dict:
if os.path.exists(self._state_file):
with open(self._state_file) as f:
return json.load(f)
return {}
def _save(self):
with open(self._state_file, "w") as f:
json.dump(self._state, f, indent=2)
```
JSON format (config/quest_state.json):
```json
{
"den_of_evil": true,
"search_for_smith": true,
...
}
```
### 1.2 `src/quest/quest_dialogue.py`
Purpose: Talk to NPCs, read dialogue options via OCR, click the right branch.
This is the core of quest automation -- it makes the bot "converse" with NPCs.
```python
class QuestDialogue:
"""OCR-based NPC dialogue interaction for quest conversations."""
# ROI at 1280x720
DIALOGUE_TEXT_ROI = (200, 470, 680, 100) # NPC speech text
DIALOGUE_OPTIONS_ROI = (200, 560, 680, 140) # Player response buttons
DIALOGUE_CLOSE_Y = 670 # Close button area
@staticmethod
def open_dialogue(npc_name: str) -> bool:
"""Walk to NPC and open their dialogue menu."""
from npc_manager import Npc, open_npc_menu
return open_npc_menu(getattr(Npc, npc_name.upper()))
@staticmethod
def read_dialogue() -> dict:
"""OCR the current dialogue box. Returns:
{
'npc_text': str, # What the NPC said
'options': [str, ...], # Response options (may be empty if no choice)
'has_continue': bool # True if just need to click continue
}
"""
img = grab()
npc_text = ocr_roi(img, self.DIALOGUE_TEXT_ROI)
options_text = ocr_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Parse options: split by line, filter out empty, return list
options = [line.strip() for line in options_text.split('\n') if line.strip()]
has_continue = len(options) == 0 or "continue" in options_text.lower()
return {
'npc_text': npc_text.strip(),
'options': options,
'has_continue': has_continue
}
@staticmethod
def click_option(option_text: str) -> bool:
"""Find and click a specific dialogue option by matching text via OCR.
Searches the options ROI for a template match of the option text."""
img = grab()
options_img = cut_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Use template_finder or OCR to locate which button matches
# Then click at that position
...
@staticmethod
def continue_dialogue() -> bool:
"""Click the close/continue button to advance dialogue."""
# Click in the close button area
x, y, w, h = self.DIALOGUE_CLOSE_Y
mouse.click at center of close area
...
@staticmethod
def follow_conversation(expected_options: list[str]) -> bool:
"""Follow a multi-step conversation:
- Read NPC text
- If options present, click the expected one
- If no options, click continue
- Repeat until dialogue closes or unexpected text appears
"""
max_steps = 20 # Safety limit
for i in range(max_steps):
dialogue = self.read_dialogue()
if not dialogue['has_continue'] and dialogue['options']:
# We have a choice - click the expected option
for opt in expected_options:
if opt.lower() in ' '.join(dialogue['options']).lower():
if not self.click_option(opt):
return False
break
else:
Logger.warning(f"Unexpected dialogue options: {dialogue['options']}")
return False
else:
# Just continue
if not self.continue_dialogue():
return False
wait(1.0, 1.5)
# Check if dialogue box is still visible
if not is_visible(ScreenObjects.NPCDialogue):
return True # Done
return False # Hit max steps
```
Key design: `follow_conversation()` takes a list of expected response text. It will
match against whatever options the NPC presents and click the right one. This handles
multi-branch dialogues without hardcoding step-by-step clicks.
### 1.3 `src/quest/quest_combat.py`
Purpose: Lightweight combat for clearing trash during quests. Reuses existing char methods.
```python
class QuestCombat:
"""Combat helpers for quest areas -- reuses existing character combat logic."""
@staticmethod
def clear_area(pather: Pather, char: IChar, path_nodes: list[int],
timeout: float = 60) -> bool:
"""Walk a path while killing monsters until timeout or all nodes cleared.
This is the core of DoE: walk down, kill, walk back."""
return pather.traverse_nodes(path_nodes, char, timeout=timeout, do_combat=True)
@staticmethod
def wait_for_clear(char: IChar, timeout: float = 15) -> bool:
"""Wait until no monsters are visible (area is clear)."""
start = time.time()
while time.time() - start < timeout:
targets = get_visible_targets()
if not targets or len(targets) == 0:
return True
# Attack if enemies present
char.attack()
wait(0.5)
return False
```
### 1.4 `src/quest/quest_items.py`
Purpose: Detect and pick up quest items (gold glow detection).
```python
class QuestItems:
"""Quest item detection and management."""
@staticmethod
def detect_quest_items(img: np.ndarray) -> list[tuple[float, float]]:
"""Detect gold-glowing items on screen (quest items).
Returns list of (x, y) positions in monitor coords."""
quest_item_mask, _ = color_filter(img, Config().colors.get("gold_glow", [
(180, 140, 0), (255, 220, 80)
]))
# Find contours, return centers
...
@staticmethod
def pick_up_quest_items(char: IChar, img: np.ndarray = None) -> bool:
"""Find and pick up any quest items currently visible."""
if img is None:
img = grab()
items = self.detect_quest_items(img)
for pos in items:
char.pick_up_item(pos, item_name="Quest Item")
wait(0.5)
return len(items) > 0
```
---
## Phase 2: NPC & Location additions
### 2.1 Add Town_Maiden to `src/npc_manager.py`
```python
# In class Npc:
TOWN_MAIDEN = "town_maiden" # Act 1, Roaring Canyon
# In _build_npcs():
Npc.TOWN_MAIDEN: {
"head": "town_maiden.png", # Need to capture template
"actions": {} # No trade/identify - just dialogue
}
```
The Town Maiden sits in Roaring Canyon (eastern part of Act 1 town). She has a simple
dialogue: you talk to her to "unlock" the Den of Evil entrance, then you talk to her
again after clearing it to get the XP reward and reset it for another run.
### 2.2 Add locations to `src/pather.py`
```python
class Location:
# ... existing locations ...
# Act 1 Roaring Canyon / Den of Evil
A1_ROARING_CANYON = "a1_roaring_canyon" # Town area where Maiden is
A1_DEN_OF_EVIL_ENTRANCE = "a1_doe_entrance" # Stairs down to DoE
A1_DEN_LEVEL_1 = "a1_doe_level_1"
A1_DEN_LEVEL_2 = "a1_doe_level_2"
A1_DEN_LEVEL_3 = "a1_doe_level_3"
A1_DEN_LEVEL_4 = "a1_doe_level_4"
# (DoE has 3-5 levels depending on game version - need to confirm)
```
Path nodes will need to be added for the Roaring Canyon area and each DoE level.
These are captured via quest_debug.py by walking the path and recording waypoints.
---
## Phase 3: Den of Evil run module
### 3.1 `src/quest/a1/q_den_of_evil.py`
```python
class DenOfEvil:
"""Den of Evil run - Act 1 repeatable quest for XP.
Flow:
1. Ensure character is in Act 1
2. Walk to Roaring Canyon (Town Maiden)
3. Talk to Town Maiden (unlock entrance if needed)
4. Enter Den of Evil
5. Pre-buff (FoH + Conviction for FoHdin)
6. Walk through each level, killing trash
7. Exit back to Roaring Canyon
8. Talk to Town Maiden again for reward
9. Return to town center
"""
name = "run_doe"
# Path nodes per level (to be filled in via quest_debug.py)
LEVEL_PATHS = {
1: [], # Entrance to level 1 stairs
2: [], # Level 1 to level 2
3: [], # Level 2 to level 3
4: [], # Level 3 to level 4 (or final area)
}
def __init__(self, pather, town_manager, char, pickit, runs):
self._pather = pather
self._town_manager = town_manager
self._char = char
self._pickit = pickit
self._runs = runs
self._quest_manager = QuestManager()
self._dialogue = QuestDialogue()
def approach(self, curr_loc: Location, do_buff: bool) -> Location | bool:
"""Get to Roaring Canyon and talk to Town Maiden."""
Logger.info("Run Den of Evil")
# Ensure we're in Act 1
if TownManager.get_act_from_location(curr_loc) != Location.A1_TOWN_START:
curr_loc = self._town_manager.go_to_act(1, curr_loc)
if not curr_loc:
return False
# Walk to Roaring Canyon (Town Maiden area)
if not self._pather.traverse_nodes(
(curr_loc, Location.A1_ROARING_CANYON), self._char, force_move=True
):
return False
# Talk to Town Maiden to unlock/open the Den
if not self._dialogue.open_dialogue("town_maiden"):
return False
# Follow the conversation (expect "Oh no, not again" or similar)
if not self._dialogue.follow_conversation(["Tell me more", "I'll help you"]):
return False
# Enter the Den
if not self._pather.traverse_nodes(
(Location.A1_ROARING_CANYON, Location.A1_DEN_OF_EVIL_ENTRANCE),
self._char, force_move=True
):
return False
return Location.A1_DEN_OF_EVIL_ENTRANCE
def battle(self, do_pre_buff: bool) -> bool | tuple[Location, bool]:
"""Fight through the Den of Evil."""
# Pre-buff
if do_pre_buff:
if not self._char.pre_buff():
return False
# Clear each level
for level in sorted(self.LEVEL_PATHS.keys()):
Logger.info(f"Clearing Den of Evil level {level}")
if not self._pather.traverse_nodes(
self.LEVEL_PATHS[level], self._char, timeout=120, do_combat=True
):
Logger.error(f"Failed to clear DoE level {level}")
return False
# Pick up any quest items / loot
self._pickit.pick_up_items(self._char)
QuestItems.pick_up_quest_items(self._char)
# Walk back to Roaring Canyon
if not self._pather.traverse_nodes(
(Location.A1_DEN_OF_EVIL_ENTRANCE, Location.A1_ROARING_CANYON),
self._char, force_move=True
):
return False
# Talk to Town Maiden for reward
if not self._dialogue.open_dialogue("town_maiden"):
return False
if not self._dialogue.follow_conversation(["Yes", "Thank you"]):
Logger.warning("Failed to collect DoE reward from Town Maiden")
# Mark as done (for non-repeatable quests) or just return success
# Note: DoE is repeatable once per real-day, so we DON'T mark permanently done
# self._quest_manager.mark_done("den_of_evil") # Only if non-repeatable
return (Location.A1_ROARING_CANYON, True)
```
---
## Phase 4: Bot integration
### 4.1 `src/bot.py` changes
```python
# Add import
from quest.a1.q_den_of_evil import DenOfEvil
# In __init__:
self._do_runs["run_doe"] = Config().routes.get("run_doe")
self._doe = DenOfEvil(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)
# In _states list:
# (No new state needed - DoE uses the existing pattern: town -> doe -> end_run -> town)
# In _transitions list (add):
{ 'trigger': 'run_doe', 'source': 'town', 'dest': 'doe', 'before': "on_run_doe" },
# Add 'doe' to end_run source list:
{ 'trigger': 'end_run', 'source': [..., 'doe'], 'dest': 'town', 'before': "on_end_run" },
# Add end_game source:
{ 'trigger': 'end_game', 'source': [..., 'doe'], 'dest': 'initialization', 'before': "on_end_game" },
# Add handler method:
def on_run_doe(self):
res = False
self._do_runs["run_doe"] = False
self._game_stats.update_location("DoE")
self._curr_loc = self._doe.approach(self._curr_loc, not self._pre_buffed)
if self._curr_loc:
set_pause_state(False)
res = self._doe.battle(not self._pre_buffed)
self._ending_run_helper(res)
```
### 4.2 `src/run/__init__.py` changes
```python
# No change needed if DoE lives in src/quest/ (not src/run/)
# But if we want consistency, add:
from quest.a1.q_den_of_evil import DenOfEvil
```
### 4.3 `config/params.ini` changes
```ini
[routes]
; ... existing runs ...
; run_doe (Act 1 Den of Evil - repeatable daily XP)
order=run_doe
```
### 4.4 `config/params.ini` FoHdin config
For a lvl 1 Paladin running DoE, the params.ini needs:
```ini
[char]
type=fohdin
...
[fohdin]
; FoHdin-specific config for low-level DoE runs
teleport=
; No teleport at lvl 1-9, so pathing is on foot
```
---
## Phase 5: Testing workflow
### What needs user input (I cannot see D2R):
1. **Capture Town_Maiden template:**
- Go to Roaring Canyon in Act 1
- Stand near the Town Maiden
- Run `quest_debug.py`, press F4 (NPC detection)
- Paste output so I can save the template
2. **Capture DoE path nodes:**
- Enter the Den of Evil
- Run `quest_debug.py`, press F1 at each waypoint
- Walk from entrance through each level
- Paste outputs so I can build the path arrays
3. **Capture dialogue:**
- Talk to Town Maiden (both before and after clearing)
- Run `quest_debug.py`, press F2 (dialogue OCR)
- Paste output so I can code the conversation flow
4. **Test run:**
- After I write the code, you run botty with `run_doe` in the route order
- Report what happens / paste terminal output
- I iterate based on results
### Lvl 1 Paladin specifics:
- **FoHdin requires FOH skill lvl 6 for Feign of Life passive** -- this needs 3 skill points
in FoH, meaning character level 9 minimum (or level 4 with a +1 skill weapon)
- Before reaching lvl 9, the bot can still run DoE but will be much more fragile
- Recommended: manually level Paladin to ~lvl 4-5 (short runs in Area 1 or 2) before
letting the bot solo DoE with FoH
- The bot pathing should handle the walk-through at low speed with heavy FoH spam
---
## Implementation order
1. Write `quest_manager.py` (simple JSON state tracker)
2. Write `quest_dialogue.py` (OCR-based NPC interaction)
3. Write `quest_items.py` + `quest_combat.py` (lightweight helpers)
4. Add Town_Maiden NPC to npc_manager.py
5. Write `q_den_of_evil.py` (skeleton with placeholder paths)
6. Integrate into bot.py (state, transitions, handler)
7. Update params.ini
8. **USER TESTS** -- captures templates, paths, dialogue
9. I fill in the actual path nodes and dialogue based on your captures
10. Full test run and iterate
---
## File tree after implementation
```
my-botty/
├── config/
│ ├── params.ini # Modified: +run_doe in routes
│ ├── quest_state.json # New: auto-created by QuestManager
│ └── bnip/
│ └── town_maiden.png # New: captured template
├── src/
│ ├── quest/ # New directory
│ │ ├── __init__.py
│ │ ├── quest_manager.py
│ │ ├── quest_dialogue.py
│ │ ├── quest_items.py
│ │ ├── quest_combat.py
│ │ └── a1/
│ │ ├── __init__.py
│ │ └── q_den_of_evil.py
│ ├── npc_manager.py # Modified: +TOWN_MAIDEN
│ ├── pather.py # Modified: +A1_ROARING_CANYON, +A1_DEN_* locations
│ ├── bot.py # Modified: +doe state, transitions, handler
│ └── run/__init__.py # Modified: +DenOfEvil export
```
-444
View File
@@ -1,444 +0,0 @@
# Claude Code Working Guide — my-botty
This file is auto-read by Claude Code at session start. It covers what you need to debug failures, extend the bot, and avoid known pitfalls. For static architecture see `ARCHITECTURE.md`; for Gemini conventions see `GEMINI.md`.
---
## Quick Orientation
`my-botty` is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only `SendInput`, `GetCursorPos`, `SetCursorPos` via `ctypes`.
**Active setup (as of 2026-06-08):**
- Character: Hammerdin (`fistman`), Hell difficulty
- Runs: `run_diablo`, `run_pindle` (see `config/params.ini`)
- OS: Windows 11, D2R 1280×720 windowed
- Start bot: `run_botty.bat` (runs `python src/main.py` from the conda env — always current with source; no exe build exists/needed)
**Always start a session by reading logs first:**
```
log/log.txt ← current session (DEBUG level)
log/stats/events_*.jsonl ← per-run event stream (JSON lines)
log/stats/stats_*.log ← human-readable summary
```
Use `/check-logs` slash command to get an instant summary.
---
## How to Read a Failure
### Startup line (every session)
```
=== BOT START === char=hammerdin | difficulty=hell | routes=['run_diablo', 'run_pindle']
```
If this is missing, the bot crashed before `on_init()`.
### Approach failure (run couldn't get to the boss area)
```
ERROR Approach failed for run_diablo [step: use_wp_rof]
```
The `[step: X]` tells you exactly which sub-step failed. Also sent to Discord via `_save_error_screenshot`. Step names per run:
| Run | Step names (in order) |
|-----|----------------------|
| `diablo` | `open_wp``use_wp_rof``verify_rof_first``retry_open_wp``retry_use_wp_rof``verify_rof_retry` |
| `vizier` | `open_wp``use_wp_rof` |
| `arcane` | `open_wp``use_wp_arcane` |
| `shenk` | `open_wp``use_wp_frigid` |
| `trav` | `open_wp``use_wp_travincal` |
| `nihlathak` | `open_wp``use_wp_halls_of_pain``verify_halls_of_pain` |
| `pindle` | `go_to_act5``traverse_to_portal``retry_traverse_to_portal``click_red_portal` |
| `andariel` | `go_to_act1``traverse_to_wp``open_wp``use_wp_catacombs` |
| `countess` | `go_to_act1``traverse_to_wp``open_wp``use_wp_black_marsh` |
| `mephisto` | `go_to_act3``traverse_to_wp``open_wp``use_wp_durance` |
| `baal` | `go_to_act5``traverse_to_wp``open_wp``use_wp_worldstone` |
**Implementation:** Each run class has `self.approach_fail_step: str | None = None`. Set to step name just before `return False`. Read in `bot.py` `_run_wrapper()` via `getattr(run_obj, "approach_fail_step", None)`.
### Maintenance failure (town routine broke)
```
ERROR Maintenance failed [step: buy_consumables] — vendor NPC not found after retry
```
Also sent to Discord. Maintenance steps in execution order:
`town_heal``inspect_inventory``identify_items``buy_consumables` / `heal``stash_items``repair``resurrect_merc``gamble`
Tracked in `self._maintenance_step` on the `Bot` instance (`bot.py`). Non-fatal failures (repair, resurrect) just log a warning with the step name. Fatal failures (buy_consumables, stash_items) call `_save_error_screenshot("maintenance", reason)` then `trigger_or_stop("end_game", failed=True)`.
### Common error patterns to grep for
```
ERROR|WARNING|failed|Approach failed|starting from True|DAMAGED|SendInput.*missed
```
| Pattern | Cause |
|---------|-------|
| `starting from True` | `_curr_loc` became Python `True` instead of a `Location` enum — see `TownManager.identify()` bug below |
| `failed on item.*DAMAGED` | `NTIP_ALIAS_QUALITY_MAP` missing `ItemQualityKeyword.Damaged.value` — fixed in `bnip_data.py` |
| `SendInput.*missed target` | Win11 pointer acceleration amplifying relative mouse moves — fixed in `win_input.py` |
| `Repair/vendor interaction failed` | Flaky NPC detection, best-effort — bot continues |
| `Could not identify act from location` | `_curr_loc` is `None` or `True`, not a `Location` string |
---
## Known Bugs and Fixes (permanent reference)
### Bug 1: `_curr_loc = True` propagation
**File:** `src/town/town_manager.py``identify()` method (~line 236)
The act-level `identify()` returns a `Location` enum on success. The wrapper previously returned `curr_loc` (the passed-in value) unchanged. If `curr_loc` was somehow `True` (Python bool), it propagated. `True == 1` in Python, so pather's `traverse_nodes((True, A5_QUAL_KEHK))` could match `A5_TOWN_START` (enum value 1), masking the bug.
**Fix:** Both return sites in `identify()` now return `success if isinstance(success, Location) else curr_loc` (primary) and `success if isinstance(success, Location) else new_loc` (A5 fallback).
**Defensive guard in bot.py** (~line 438):
```python
self._curr_loc = self._town_manager.identify(self._curr_loc)
if self._curr_loc is True:
Logger.warning("identify() returned True — resetting to A5_TOWN_START")
self._curr_loc = Location.A5_TOWN_START
```
### Bug 2: `DAMAGED` quality KeyError
**File:** `src/d2r_image/bnip_data.py``NTIP_ALIAS_QUALITY_MAP`
`ItemQualityKeyword.Damaged = 'DAMAGED'` exists but the map was missing an entry for it. Caused `KeyError: 'DAMAGED'` on 20+ items per session whenever Charsi repair triggered ground-item reads.
**Fix:** Added `ItemQualityKeyword.Damaged.value: 1` to the map alongside `LowQuality`, `Crude`, `Cracked`.
### Bug 3: NPC name tag templates stale → `open_npc_menu` always times out
**File:** `src/npc_manager.py``open_npc_menu()`
NPC body templates find the NPC correctly (Akara consistently at screen ~(649, 407)), but the name tag "AKARA"/"MALAH" templates score only ~0.28 on hover, just below the original 0.35 threshold. This caused a 20-second full-screen search that never clicked anyone.
**Root causes:**
1. Name tag templates are slightly stale vs. current D2R rendering
2. Name tag search used the full screen ROI, so false-positive text anywhere on screen could also score 0.28+ — raising threshold didn't help
3. After click, the old code checked gold name tag template (also stale) instead of the NPC dialogue UI element
**Fix:** Three changes in `open_npc_menu()`:
1. **Small ROI for name tag check**: after hovering, search for the name tag only in a 240×140px box directly above the cursor (where D2R always renders name tags), preventing distant false positives
2. **Lower name tag threshold 0.35 → 0.26**: Akara at 0.28 now passes; with the small ROI false-positive risk is contained
3. **NPCDialogue confirmation instead of gold tag**: after clicking, check `is_visible(ScreenObjects.NPCDialogue)` first — more reliable than matching a stale gold name tag template
4. **Timeout 20 s → 8 s**: fails faster when the NPC is genuinely off-screen
**Symptom to grep for (before fix):** `NPC akara hover - White score: 0.28x` spinning for 20 s
---
### Bug 4: `open_npc_menu` body+pose scoping bug → false positive Akara/Malah clicks
**File:** `src/npc_manager.py``open_npc_menu()`, hover loop
User-added body+pose fallback click path had a scoping bug: `min_dist` was computed per template in the build loop but the variable was NOT stored in the result dict, so the hover loop's `pose_confirmed = ... and min_dist < 100` used the stale value from the last template processed — not the current result's distance to a known pose. This caused false positives (e.g. Akara body template scoring 0.531 at screen `(589, 560)`, far outside her ROI and 211 px from any pose) to pass `pose_confirmed` and be clicked, while the real Akara at `(979, 443)` (body 0.420.47, 102 px from pose) never got clicked.
**Fix** (`open_npc_menu` build + hover loops):
1. Renamed `min_dist``min_dist_val`, stored in result dict: `results.append({..., "min_dist": min_dist_val})`
2. Hover loop uses `result["min_dist"]` instead of stale `min_dist`
3. Body threshold 0.50 → 0.40 (real Akara at 0.420.47)
4. Pose tolerance 100 → 150 px (real Akara at 102 px from nearest pose; false positive at 211 px, correctly rejected)
5. Removed `elif body_confident and attempts == 0` blind-first-attempt path (was the direct enabler of false positive clicks)
**Symptom to grep for (before fix):** `Clicking on akara at (589, 559) (body+pose confirmed, name tag score 0.225)` followed by `dialogue not open`
---
### Bug 5: `_curr_loc` becomes `A1_TOWN_START` when Cain ID fails → A1 pathing in A5 environment
**Files:** `src/bot.py``on_maintenance()` lines 473475 and 502510
When Cain identification fails, `identify()` returns False → `_curr_loc` was reset to `Location.A1_TOWN_START` as a fallback. But the character is physically still in A5 Harrogath (game just spawned there). The subsequent `buy_consumables(A1_TOWN_START)` call runs A1 pather navigation in the A5 environment: pather can't find A1 reference templates → moves character to a random A5 spot → Akara body templates fire false positives → clicks fail → fatal.
The A1 retry at line 505 also hardcoded `Location.A1_TOWN_START` without calling `go_to_act` first, so the character was never actually navigated to A1 before running A1 pathing.
**Fix (both sites in `bot.py` `on_maintenance()`):**
1. Identify fallback: `A1_TOWN_START``A5_TOWN_START` (character IS in A5 when Cain fails)
2. A1 retry: add `go_to_act(1, retry_start)` before `buy_consumables` so the bot actually opens the waypoint and travels to A1 first:
```python
retry_start = self._curr_loc or Location.A5_TOWN_START
a1_loc = self._town_manager.go_to_act(1, retry_start)
self._curr_loc, result_items = self._town_manager.buy_consumables(a1_loc or Location.A1_TOWN_START, items=items)
```
**Symptom to grep for (before fix):** `TownManager buy_consumables: starting from a1_town_start` immediately after `Could not identify items (Cain not available)`
---
### Bug 6: `open_npc_menu` dialogue confirmation always False → retry click closes dialogue → loop
**File:** `src/npc_manager.py` — `open_npc_menu()` post-click confirmation block
After clicking Akara/Malah the bot checked `is_visible(ScreenObjects.NPCDialogue)`. The `npc_dialogue.png` template is a narrow (~15px) gold vertical border strip from an old D2R rendering; the ROI `456,0,30,150` points at the top-center of the screen where no dialogue border renders. Result: `NPCDialogue` is **always False** even when the dialogue IS open.
Fallback was `name_tag_gold` template — also stale. Both checks failing triggered a retry click on the NPC body, which **closes** the already-open dialogue, creating an open→can't detect→close→retry loop until timeout.
Also: `press_npc_btn` had `wait_until_visible(ScreenObjects.NPCDialogue, timeout=3.0)` which wasted 3 seconds on every NPC button press.
**Fix:** Replace NPCDialogue + gold-tag checks with action button detection (`_action_btns_visible`). The TRADE/RESURRECT/IDENTIFY buttons appear when the dialogue opens and are reliably matched by the same templates used in `press_npc_btn`. Added helper `_action_btns_visible(npc_key, img)` that mirrors `press_npc_btn`'s white→blue→grayscale search. Also added a **fast path** at the top of the hover loop: if action buttons are already visible at the start of an iteration, return True immediately without hovering.
**Symptom to grep for (before fix):** `NPC akara - dialogue not open, retrying click on body` repeating 23 times then `NPC akara - clicked but neither dialogue nor gold tag found`
---
### Bug 7: High-score false-positive name tags at positions outside NPC ROI bypass pose check
**File:** `src/npc_manager.py` — `open_npc_menu()` click decision (was line 327)
White text in the game world (ground items, skill effects, UI elements) could produce name-tag white-template scores of 0.98+ at positions far outside the NPC's known ROI. Because `name_tag_confirmed = res_w.valid or res_g.valid` was unconditional, these positions bypassed the `pose_confirmed` check entirely and got clicked (e.g. (200, 539) while Akara's ROI is x=6051004).
**Fix:** Gate `name_tag_confirmed` on an ROI boundary check when `attempts == 0` and the NPC has a defined ROI:
```python
if "roi" in npcs[npc_key] and attempts == 0:
npc_roi = npcs[npc_key]["roi"]
in_npc_roi = (npc_roi[0] <= hover_screen[0] <= npc_roi[0] + npc_roi[2] and
npc_roi[1] <= hover_screen[1] <= npc_roi[1] + npc_roi[3])
else:
in_npc_roi = True
name_tag_confirmed = (res_w.valid or res_g.valid) and in_npc_roi
```
After the first pass (`attempts > 0`), ROI restriction is lifted so the wide fallback search can still find the NPC if it wandered.
**Symptom to grep for (before fix):** `Clicking on akara at (200, 539) (name tag confirmed)` with x < 605
---
### Bug 8: Win11 mouse 36× overshoot
**File:** `src/input_layer/win_input.py` — `mouse_move()` (~line 270)
Win11 relative `SendInput` deltas are amplified by Windows Enhanced Pointer Precision (pointer acceleration). A 209px delta became 678px. Affected every NPC click, waypoint click, and template interaction.
**Fix:** On Win11 (`_USE_ABSOLUTE_MOUSE == False`), use `SetCursorPos(x, y)` for accurate positioning, then send a zero-delta `MOUSEEVENTF_MOVE` event so D2R's hover/cursor pipeline fires at the new position.
```python
# Win11 path in mouse_move():
user32.SetCursorPos(target_x, target_y)
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE, 0, 0))
```
The OS mode is detected at import time via `utils.os_detect.detect_os()` → `_USE_ABSOLUTE_MOUSE`.
---
### Bug 9: Act-state desync → char respawns in wrong act → every subsequent game fails (2026-06-10)
**Files:** `src/bot.py`, `src/town/town_manager.py`
The bot's believed location (`_curr_loc`) and the character's PHYSICAL act diverge after any town
failure: retries hardcoded acts without traveling (`buy_consumables(A1_TOWN_START)` even when
go_to_act(1) FAILED, `repair(A4_TOWN_START)`, `heal(A1_TOWN_START)`) and failure fallbacks blindly
set `_curr_loc = A5_TOWN_START`/`A1_TOWN_START`. D2R respawns the char in the act it save+exited
from, so one desync poisons EVERY following game: A5 pathing runs in A1/A4 town → `A5_WP` never
found → 42 consecutive `open_wp` approach failures in the 2026-06-09 session.
**Fix:**
1. `TownManager.detect_current_act()` — searches `TOWN_MARKERS` to find the physical act town.
2. `TownManager.open_wp()` / `go_to_act()` — on failure/early-return, verify the physical act and
retry with the detected act's pather.
3. `Bot._verify_town_location(assumed)` — used at EVERY retry/fallback site in `on_maintenance()`
and `on_end_run()` instead of hardcoded town starts. Never run act-X pathing without confirmed
presence in act X.
**Symptom to grep for:** `Wanted to select A5_WP, but could not find it` repeated across games;
`A1 open_trade_menu: navigating from a1_town_start` + `Pather: taking a random guess` while
physically elsewhere.
### Bug 10: Duplicate `log_end_game` → phantom 0s "successful" games reset the fail circuit breaker (2026-06-10)
**File:** `src/game_stats.py`
`bot.on_end_game()` and `game_controller.run_bot()` can BOTH call `log_end_game` for the same game.
The second call emitted a phantom `game_ended failed:false elapsed 0` event and reset
`_consecutive_runs_failed` to 0 — so `max_consecutive_fails=5` never triggered during the 3-hour
death spiral. **Fix:** `log_end_game` returns early if `self._timer is None` (already logged).
Also: `_last_failure_reason` is now cleared in `log_start_game` so events can't inherit a stale
reason from a previous game (games 4350 were blamed on `open_wp` when they actually failed in
maintenance).
### Bug 11: Chickens mislabeled "Bot stopped (F12 or crash)" (2026-06-10)
**Files:** `src/health_manager.py`, `src/game_controller.py`
`_do_chicken()` called `bot.stop()` (callback) FIRST and only set `_did_chicken = True` several
seconds later (after save/exit + screenshot). The controller poll loop saw `_stopping` before the
chicken flag and labeled the failure "Bot stopped (F12 or crash)". **Fix:** set `_did_chicken =
True` at the top of `_do_chicken()` before the callback; controller re-checks the flag before
resetting it.
### Bug 12: A4 Halbu repair trip = act-desync trigger (2026-06-10)
**File:** `config/params.ini`
`repair_npc=a4_halbu` sent the bot A5→A4 via WP every 5 runs. Halbu detection failed 100% in the
2026-06-09 session (body score ~0.39, name tag ~0.19), wasting ~60s per attempt and leaving the
char in A4 (see Bug 9). **Fix:** `repair_npc=a5_larzuk` — stays in-act; the Larzuk flow has a
direct-template fallback and still falls back to Halbu (with proper travel) if Larzuk fails.
### Bug 13: hotkey `wait()` no-arg returned on ANY key → silent process exit (2026-06-11)
**File:** `src/input_layer/hotkey.py`
The reimplemented `keyboard.wait()` (no key) returned on any keypress; `main.py` relies on it
blocking forever to keep the process alive (all bot threads are daemons). Pressing F11 both
started the bot AND killed the process moments later, with no traceback. **Fix:** no-arg `wait()`
now sleeps forever; the poll loop is also edge-triggered (one fire per physical press — held
keys no longer refire every ~20ms) and callback exceptions print instead of being swallowed.
### Bug 14: OCR dead — pytesseract never configured (2026-06-11)
**File:** `src/d2r_image/ocr.py`
pytesseract only looks for plain `tesseract` on PATH; the `PYTESSERACT_TESSERACT_CMD` env var
set by run_botty.bat is NOT a pytesseract feature and was never read. **Fix:** ocr.py applies
that env var (fallback: PATH, then `C:\Program Files\Tesseract-OCR\tesseract.exe`) to
`pytesseract.pytesseract.tesseract_cmd` at import.
### Bug 15: NIP loader NameError + dropped rule (2026-06-11)
**Files:** `src/bnip/utils.py`, `config/default.bnip:1714`
`find_unique_or_set_base` raised undefined `NipSyntaxError` (→ `BNipSyntaxError`), and the
underlying trigger was a typo `Shaefershammer` → `Schaefershammer`. 474 expressions now load.
### Bug 16: A5 WP death loop after Pindle returns (2026-06-11)
**Files:** `src/town/town_manager.py`, `src/town/a5.py`, `src/bot.py`
After a Pindle return the believed location (`a5_town_start`) is wrong (char is at the stash);
the direct WP node path failed 5/5, and outer maintenance retries re-ran the full anchor/sweep
escalation from ever-worse positions (one 10-min wander onto the town wall). A5 templates also
score marginally low at current D2R settings (stash ~0.51, WP needs ≤0.62).
**Fix (layered):** per-game WP budget on TownManager (reset in `bot.on_init`): failure 1 = full
escalation allowed; failure 2 = `quick=True` direct path only; failure 3+ = instant False so the
caller's fatal path ends the game (~40s fresh spawn beats wandering). Sweep trimmed 10→6 steps,
WP select timeout 4s, thresholds dropped (WP 0.62; stash 0.60→0.45 retry) — safe because every
select is gated by a success_func (panel-open / WP-label check).
### Bug 17: kill_diablo fought with Conviction + mid-fight Redemption (2026-06-11)
**File:** `src/char/paladin/hammerdin.py` `kill_diablo()`
Conviction doesn't boost magic-damage hammers, and the interleaved 0.8s Redemption casts were
pure downtime vs a solo boss with no corpses — long fights got the merc killed. **Fix:** attack
with Concentration (also buffs the merc via party aura), Redemption only once post-kill.
### Bug 18: stash only used personal + 3 shared (2026-06-11)
**Files:** `src/inventory/personal.py`, `src/inventory/stash.py`
Gold used raw `select_tab()` clicks with a 4-tab rotation (`% 4`, `> 3`); items already paged.
D2R 2.7+ has 5 shared pages. **Fix:** gold now navigates via `select_stash_page()` (OCR-verified
page arrows, layout-proof), rotation `% 6` / stop at `> 5`, shared-first starts at page 5.
Also fixed a leftover `> 3` bound in the item-stash loop (`personal.py` ~line 185) → `> 5`.
### Bug 19: failed item transfer mistaken for "stash full" → taskkill D2R (2026-06-12)
**File:** `src/inventory/personal.py` — `stash_all_items()` stash loop
When `transfer_items("stash")` fails for any reason OTHER than fullness (e.g. the equipped-area
click guard, a transient UI hiccup), keep items stay in inventory. The loop interpreted ANY
remaining keep item as "this tab is full", paged through all stash tabs, and on the last page
called `stash.stash_full()` → `taskkill /f /im D2R.exe` + a false Discord "stash full" alert.
Surfaced by `tools/testbed.py stash all` (charms in cols 49 hit the equipped-area guard, so every
transfer was cancelled and the loop nuked the game).
**Fix:** before declaring the tab full, check `is_visible(ScreenObjects.EmptyStashSlot)`. If a slot
IS free but the transfer still failed, count it as a transfer failure (cap 2) and bail out,
leaving items in inventory — never advance tabs / call `stash_full()` on a non-full page.
**Symptom to grep for (before fix):** repeated `transfer_items: inventory unchanged after
attempting to stash` followed by `Wanted to stash item ... Assumes full stash` across rising page
numbers, ending in `All stash is full, quitting`.
**Test tip:** `tools/testbed.py stash` exercises the gold + open-stash path live; add `all` to scan
all 10 inventory columns and exercise the keep-item transfer branch on existing charms.
---
## How to Add a New Run
1. Create `src/run/my_run.py` — class `MyRun` with `name = "run_my_run"`
2. Add `self.approach_fail_step: str | None = None` in `__init__`
3. In `approach()`: set `self.approach_fail_step = None` at top, then set step name before every `return False`
4. Register in `bot.py`: instantiate in `__init__`, add to `self._do_runs`, add state + transition, add `on_run_my_run()` handler
5. Add route toggle to `config/params.ini` under `[routes]`
6. Add to `Config().routes_order` list
The `_run_wrapper()` in `bot.py` handles approach failure reporting automatically — it calls `getattr(run_obj, "approach_fail_step", None)` so no changes needed there.
---
## How Town Maintenance Works
Sequence in `bot.py` `on_maintenance()` (called between every run):
1. **town_heal** — drink belt potions if HP < 95%
2. **inspect_inventory** — open inventory, count items, update TP/ID/key needs
3. **identify_items** — go to Cain if any `item.need_id` is True
4. **buy_consumables** — visit vendor for HP/mana pots, TP scrolls, ID scrolls, keys; sell flagged items (fatal if fails twice)
5. **heal** — visit healer NPC if HP/mana below threshold (alternative to buy_consumables branch)
6. **stash_items** — put kept items / gold in stash; run transmutes after (fatal if fails twice)
7. **repair** — repair gear + sell via Halbu (A4) or Larzuk (A5); non-fatal, bot continues
8. **resurrect_merc** — revive dead merc; non-fatal
9. **gamble** — buy gamble items if Jamella has stock; non-fatal
`TownManager` methods (`buy_consumables`, `stash`, `repair`, etc.) return `(new_loc, items)` tuples or `(False, False)` on failure. On failure, bot.py retries once from a fallback location before giving up.
---
## Coordinate Systems (quick ref)
| Name | Origin | Notes |
|------|--------|-------|
| Monitor | Top-left of first monitor | `screen.grab()` output |
| Screen | D2R client area top-left | UI detection |
| Absolute | Character at screen center | Pathing targets |
| Relative | Template match position | NPC interaction |
Convert via `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()`, `convert_abs_to_monitor()`, `convert_screen_to_monitor()`.
---
## Config System
Singleton `Config()` merges in priority order: `custom.ini` > `params.ini` > `game.ini` > `shop.ini` > `transmute.ini`. First instantiation loads; subsequent calls return the same object. User overrides go in `custom.ini` (not tracked in git).
Key sections in `params.ini`:
- `[char]` — character type, keybinds, difficulty, runs_per_repair, etc.
- `[general]` — discord webhook, auto_login, info_screenshots, difficulty
- `[routes]` — boolean flags for each run (run_diablo=1, run_pindle=1, etc.)
- `[routes_order]` — execution order
---
## Discord Notifications
`src/messages/messenger.py` wraps a Discord webhook. Error events are sent via `_save_error_screenshot(run_name, reason)` in `bot.py`, which:
1. Saves a timestamped screenshot to `log/screenshots/error/`
2. Calls `messenger.send_error(run_name, reason, screenshot_path)` if `discord_log_errors=1`
Enable in `params.ini`:
```ini
discord_log_errors=1
discord_hook_url=https://discord.com/api/webhooks/...
```
---
## Threading Safety Rules
- **Never call `input_layer` from the health_manager or death_manager threads.** Those threads only read screen state. All input goes through the bot thread.
- **`set_panel_check_paused(True)`** must be called before opening any UI panel (vendor, stash, WP). Forgetting it causes health_manager to misread HP through the panel overlay and false-chicken.
- **`_stash_mutex`** on `Bot` must be held during transmutes (stash is open). Acquired in `on_maintenance()` after stash opens.
---
## Debugging Tips
**Template match failures** — Add `save_debug=True` to `template_finder.search_and_wait(...)` to dump the failed match image to `log/screenshots/debug/`.
**Path node failures** — `pather.py` traverse failures usually mean the bot is in the wrong position. Check the log for the node sequence — the last successful node before the failure shows where the bot got lost.
**Verify D2R window** — `screen.find_and_set_window_position(force=True)` re-detects the window. Call this before template searches in flaky areas (Pindle portal approach does this).
**Adding step tracking to a new failure point** — just set `self.approach_fail_step = "descriptive_name"` before `return False`. No other wiring needed.
---
## File Quick Reference
```
src/bot.py main state machine, maintenance loop
src/run/*.py one file per boss run
src/town/town_manager.py orchestrates all town NPC interactions
src/town/a1.py .. a5.py per-act NPC/WP/stash implementations
src/input_layer/win_input.py mouse_move(), key_press(), SendInput wrappers
src/input_layer/mouse_impl.py humanized Bezier mouse paths
src/pather.py node-based pathfinding
src/template_finder.py OpenCV template matching
src/screen.py window detection, screenshot, coord conversion
src/d2r_image/bnip_data.py NTIP alias maps (quality, stat, flag)
src/item/pickit.py item pickup decision logic
src/health_manager.py background HP/mana potion auto-drinker
src/death_manager.py death detection + recovery
src/config.py Config singleton
config/params.ini user config (routes, difficulty, Discord)
config/game.ini D2R UI coordinates, template ROIs
scripts/stash_inventory.py scan all 6 stash pages → log/stash_inventory.json
scripts/make_stash_csv.py convert stash_inventory.json → stash_list.csv (trade list)
stash_list.csv deduplicated item list (name, page, stats); auto-updated by bot
log/log.txt current session log
log/stats/events_*.jsonl per-run event stream
```
-198
View File
@@ -1,198 +0,0 @@
"""
D2R Asset Extractor
Runs on your local Windows machine. Captures D2R, saves screenshot.
You then send the screenshot to the AI agent for analysis.
AI returns bounding boxes -> run crop.py to extract PNGs.
Usage:
Run: python asset_extractor.py
F1: Capture D2R screen -> screenshots/debug/latest.png
F2: Crop entities from screenshots/debug/latest_annotations.json
F3: List existing assets
F12: Exit
Workflow:
1. Run this script in the botty conda env
2. F1 to capture
3. Tell your AI agent to analyze screenshots/debug/latest.png
4. AI writes screenshots/debug/latest_annotations.json with bounding boxes
5. F2 to crop entities into assets/enemies/ or assets/npc/
"""
import os, sys, cv2, numpy as np, keyboard, json, ctypes, win32gui
from datetime import datetime
from mss import mss
# DPI awareness - must be first
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except:
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except:
pass
# Fix tesserocr DLLs
if sys.platform == "win32":
_dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin")
if os.path.isdir(_dll):
os.add_dll_directory(_dll)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
BASE = os.path.dirname(os.path.abspath(__file__))
SAVE_DIR = os.path.join(BASE, "screenshots", "debug")
ENEMIES_DIR = os.path.join(BASE, "assets", "enemies")
NPC_DIR = os.path.join(BASE, "assets", "npc")
for d in [SAVE_DIR, ENEMIES_DIR, NPC_DIR]:
os.makedirs(d, exist_ok=True)
LATEST_PATH = os.path.join(SAVE_DIR, "latest.png")
ANNOTATIONS_PATH = os.path.join(SAVE_DIR, "latest_annotations.json")
# Known NPC names for routing
NPC_NAMES = {
'akara', 'charsi', 'kashya', 'cain', 'drognan', 'lysander',
'fara', 'ormus', 'tyrael', 'jamella', 'halbu', 'qual_kehk',
'qual-kehk', 'qualkehk', 'malah', 'larzuk', 'anya'
}
def find_d2r():
hwnds = []
def cb(h, r):
title = win32gui.GetWindowText(h)
if 'diablo' in title.lower() and win32gui.IsWindowVisible(h):
r.append(h)
win32gui.EnumWindows(cb, hwnds)
return hwnds[0] if hwnds else None
def grab():
"""Grab D2R client area. Resizes to 1280x720 if needed."""
hwnd = find_d2r()
if not hwnd:
print(" [ERROR] D2R not found. Is it running and visible?")
return None
client = win32gui.GetClientRect(hwnd)
w, h = client[2] - client[0], client[3] - client[1]
screen_pos = win32gui.ClientToScreen(hwnd, (0, 0))
with mss() as sct:
region = {
'top': screen_pos[1],
'left': screen_pos[0],
'width': w,
'height': h
}
sct_img = sct.grab(region)
img = np.array(sct_img)[:, :, :3] # BGRA -> BGR
if w != 1280 or h != 720:
img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR)
print(f" [RESIZED] {w}x{h} -> 1280x720")
else:
print(f" [CAPTURED] {w}x{h}")
return img
def on_f1():
"""Capture D2R and save."""
print("\n[=== CAPTURING ===]")
img = grab()
if not img:
return
cv2.imwrite(LATEST_PATH, img)
print(f" [SAVED] {LATEST_PATH}")
print(f" Now ask your AI agent to analyze: {LATEST_PATH}")
print(f" AI should write: {ANNOTATIONS_PATH}")
print(' Format: [{"name":"skeleton","x":100,"y":200,"w":60,"h":80}, ...]')
def on_f2():
"""Crop entities from latest capture using annotations JSON."""
print("\n[=== CROPPING ENTITIES ===]")
if not os.path.exists(LATEST_PATH):
print(" [ERROR] No capture found. Press F1 first.")
return
if not os.path.exists(ANNOTATIONS_PATH):
print(" [ERROR] No annotations found.")
print(f" Create: {ANNOTATIONS_PATH}")
print(' [{"name":"skeleton","x":100,"y":200,"w":60,"h":80}, ...]')
return
img = cv2.imread(LATEST_PATH)
with open(ANNOTATIONS_PATH) as f:
entities = json.load(f)
print(f" Image: {img.shape[1]}x{img.shape[0]}, Entities: {len(entities)}")
saved = 0
for ent in entities:
name = ent['name'].lower().replace(' ', '_')
x, y = int(ent['x']), int(ent['y'])
w, h = int(ent['w']), int(ent['h'])
i_w, i_h = img.shape[1], img.shape[0]
# Crop with 5px padding
pad = 5
x1, y1 = max(0, x - pad), max(0, y - pad)
x2, y2 = min(i_w, x + w + pad), min(i_h, y + h + pad)
crop = img[y1:y2, x1:x2]
# Route to npc or enemies folder
if name in NPC_NAMES:
save_dir = NPC_DIR
else:
save_dir = ENEMIES_DIR
# Auto-number duplicates
fname = f"{name}.png"
save_path = os.path.join(save_dir, fname)
variant = 1
while os.path.exists(save_path):
variant += 1
fname = f"{name}_{variant}.png"
save_path = os.path.join(save_dir, fname)
cv2.imwrite(save_path, crop)
print(f" [SAVED] {save_path} ({crop.shape[1]}x{crop.shape[0]})")
saved += 1
print(f"\n Total: {saved} assets cropped.")
def on_f3():
"""List existing assets."""
print("\n[=== ASSETS INVENTORY ===]")
for label, d in [("enemies", ENEMIES_DIR), ("npc", NPC_DIR)]:
if os.path.isdir(d):
files = sorted(os.listdir(d))
print(f"\n assets/{label}/ ({len(files)} files):")
for f in files:
sz = os.path.getsize(os.path.join(d, f))
print(f" {f} ({sz}b)")
else:
print(f"\n assets/{label}/ - EMPTY")
def run():
print("=== D2R Asset Extractor ===")
print(" F1 - Capture D2R screen")
print(" F2 - Crop entities from annotations")
print(" F3 - List assets")
print(" F12 - Exit")
print("Ready.")
keyboard.add_hotkey('f1', on_f1)
keyboard.add_hotkey('f2', on_f2)
keyboard.add_hotkey('f3', on_f3)
keyboard.add_hotkey('f12', lambda: (print("\nBye."), sys.exit(0)))
keyboard.wait()
if __name__ == "__main__":
run()
-1106
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
"""Botty Next offline-first visual QA harness."""
__all__ = ["__version__"]
__version__ = "0.1.0"
-6
View File
@@ -1,6 +0,0 @@
"""Capture backends for offline fixtures and live observer mode."""
from botty_next.capture.mss_backend import MssCaptureBackend
from botty_next.capture.window import WindowRegion, find_window_region
__all__ = ["MssCaptureBackend", "WindowRegion", "find_window_region"]
-27
View File
@@ -1,27 +0,0 @@
from __future__ import annotations
from pathlib import Path
import cv2
import mss
import numpy as np
from botty_next.capture.window import WindowRegion
class MssCaptureBackend:
def grab(self, region: WindowRegion | None = None) -> np.ndarray:
with mss.mss() as screen_capture:
monitor = region.as_mss_monitor() if region else screen_capture.monitors[1]
shot = screen_capture.grab(monitor)
bgra = np.asarray(shot)
return cv2.cvtColor(bgra, cv2.COLOR_BGRA2BGR)
def save_frame(frame: np.ndarray, output_path: str | Path) -> Path:
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(str(output), frame):
raise RuntimeError(f"failed to write screenshot: {output}")
return output
-47
View File
@@ -1,47 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class WindowRegion:
left: int
top: int
width: int
height: int
title: str
def as_mss_monitor(self) -> dict[str, int]:
return {
"left": self.left,
"top": self.top,
"width": self.width,
"height": self.height,
}
def find_window_region(title_contains: str) -> WindowRegion:
import win32gui
matches: list[WindowRegion] = []
def collect(hwnd: int, _extra) -> bool:
if not win32gui.IsWindowVisible(hwnd):
return True
title = win32gui.GetWindowText(hwnd)
if title_contains.lower() not in title.lower():
return True
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
width = right - left
height = bottom - top
if width > 0 and height > 0:
matches.append(WindowRegion(left, top, width, height, title))
return True
win32gui.EnumWindows(collect, None)
if not matches:
raise RuntimeError(f"no visible window found containing title: {title_contains}")
return max(matches, key=lambda region: region.width * region.height)
-139
View File
@@ -1,139 +0,0 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from botty_next.capture.mss_backend import MssCaptureBackend, save_frame
from botty_next.capture.window import find_window_region
from botty_next.config import load_config
from botty_next.vision.fixtures import load_image
from botty_next.vision.ocr import run_tesseract_ocr, save_ocr_preprocess_debug
from botty_next.vision.template_matching import match_template, save_match_debug
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="botty-next")
subparsers = parser.add_subparsers(dest="command", required=True)
config_parser = subparsers.add_parser("config")
config_subparsers = config_parser.add_subparsers(dest="config_command", required=True)
validate_parser = config_subparsers.add_parser("validate")
validate_parser.add_argument("-c", "--config", required=True, type=Path)
validate_parser.set_defaults(handler=validate_config)
detect_parser = subparsers.add_parser("detect")
detect_parser.add_argument("detector", choices=["template"], help="detector to run")
detect_parser.add_argument("--image", required=True, type=Path)
detect_parser.add_argument("--template", required=True, type=Path)
detect_parser.add_argument("--threshold", type=float, default=0.85)
detect_parser.add_argument("--debug-output", type=Path)
detect_parser.set_defaults(handler=detect)
capture_parser = subparsers.add_parser("capture")
capture_parser.add_argument("--output", required=True, type=Path)
capture_parser.add_argument("--window-title", type=str)
capture_parser.set_defaults(handler=capture)
ocr_parser = subparsers.add_parser("ocr")
ocr_parser.add_argument("--image", required=True, type=Path)
ocr_parser.add_argument("--lang", default="eng")
ocr_parser.add_argument("--psm", type=int, default=6)
ocr_parser.add_argument("--tesseract-cmd")
ocr_parser.add_argument("--debug-output", type=Path)
ocr_parser.set_defaults(handler=ocr)
return parser
def validate_config(args: argparse.Namespace) -> int:
config = load_config(args.config)
print(json.dumps(config.model_dump(mode="json"), indent=2))
return 0
def detect(args: argparse.Namespace) -> int:
image = load_image(args.image)
template = load_image(args.template)
result = match_template(image, template, threshold=args.threshold)
if args.debug_output:
save_match_debug(image, result, args.debug_output)
print(json.dumps(_result_to_dict(result), indent=2))
return 0 if result.passed else 1
def capture(args: argparse.Namespace) -> int:
region = find_window_region(args.window_title) if args.window_title else None
frame = MssCaptureBackend().grab(region)
output = save_frame(frame, args.output)
print(
json.dumps(
{
"output": str(output),
"shape": tuple(map(int, frame.shape)),
"window": region.title if region else None,
},
indent=2,
)
)
return 0
def ocr(args: argparse.Namespace) -> int:
image = load_image(args.image)
if args.debug_output:
save_ocr_preprocess_debug(image, args.debug_output)
try:
result = run_tesseract_ocr(
image,
lang=args.lang,
psm=args.psm,
tesseract_cmd=args.tesseract_cmd,
)
except RuntimeError as exc:
print(
json.dumps(
{
"error": str(exc),
"debug_output": str(args.debug_output) if args.debug_output else None,
},
indent=2,
)
)
return 2
print(json.dumps(_ocr_result_to_dict(result), indent=2))
return 0
def _result_to_dict(result) -> dict:
return {
"confidence": result.confidence,
"bbox": result.bbox,
"passed": result.passed,
"method": result.method,
"debug": result.debug,
}
def _ocr_result_to_dict(result) -> dict:
return {
"text": result.text,
"confidence": result.confidence,
"bbox": result.bbox,
"debug": result.debug,
}
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.handler(args)
if __name__ == "__main__":
raise SystemExit(main())
-5
View File
@@ -1,5 +0,0 @@
"""Configuration loading and validation."""
from botty_next.config.models import BottyNextConfig, load_config
__all__ = ["BottyNextConfig", "load_config"]
-13
View File
@@ -1,13 +0,0 @@
profile_name: local
capture:
backend: fixture
monitor: 1
fps_limit: 10
window_title: null
vision:
template_threshold: 0.85
debug_output_dir: botty_next/debug/output
input:
enabled: false
dry_run: true
emergency_stop_key: f12
-59
View File
@@ -1,59 +0,0 @@
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
class CaptureConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
backend: Literal["fixture", "mss", "dxcam"] = "fixture"
monitor: int = 1
fps_limit: int = Field(default=10, ge=1, le=240)
window_title: str | None = None
class VisionConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
template_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
debug_output_dir: Path = Path("botty_next/debug/output")
class InputConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = False
dry_run: bool = True
emergency_stop_key: str = "f12"
@field_validator("dry_run")
@classmethod
def dry_run_required_when_disabled(cls, value: bool) -> bool:
return value
class BottyNextConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
profile_name: str = "local"
capture: CaptureConfig = Field(default_factory=CaptureConfig)
vision: VisionConfig = Field(default_factory=VisionConfig)
input: InputConfig = Field(default_factory=InputConfig)
@field_validator("input")
@classmethod
def input_must_be_explicit_and_dry_run_by_default(cls, value: InputConfig) -> InputConfig:
if value.enabled and value.dry_run is False:
raise ValueError("live input cannot be enabled without a future explicit safety gate")
return value
def load_config(path: str | Path) -> BottyNextConfig:
config_path = Path(path)
with config_path.open("r", encoding="utf-8") as handle:
raw = yaml.safe_load(handle) or {}
return BottyNextConfig.model_validate(raw)
-1
View File
@@ -1 +0,0 @@
"""Debug image and report output helpers."""
-4
View File
@@ -1,4 +0,0 @@
"""Input abstraction layer.
Live input is intentionally not implemented in the bootstrap harness.
"""
-1
View File
@@ -1 +0,0 @@
"""Offline/private routine replay harness."""
-1
View File
@@ -1 +0,0 @@
"""State detection and transition logic."""
-1
View File
@@ -1 +0,0 @@
"""Tests for the Botty Next harness."""
-12
View File
@@ -1,12 +0,0 @@
from botty_next.capture.window import WindowRegion
def test_window_region_converts_to_mss_monitor() -> None:
region = WindowRegion(left=10, top=20, width=640, height=480, title="Example")
assert region.as_mss_monitor() == {
"left": 10,
"top": 20,
"width": 640,
"height": 480,
}
-28
View File
@@ -1,28 +0,0 @@
from botty_next.cli import main
def test_config_validate_cli_starts(capsys) -> None:
exit_code = main(["config", "validate", "-c", "botty_next/config/default.yaml"])
captured = capsys.readouterr()
assert exit_code == 0
assert '"profile_name": "local"' in captured.out
def test_detect_cli_runs_template_detector(capsys) -> None:
exit_code = main(
[
"detect",
"template",
"--image",
"fixtures/screenshots/sample_scene.ppm",
"--template",
"fixtures/templates/sample_marker.ppm",
"--threshold",
"0.99",
]
)
captured = capsys.readouterr()
assert exit_code == 0
assert '"passed": true' in captured.out
-10
View File
@@ -1,10 +0,0 @@
from botty_next.config import load_config
def test_load_default_config() -> None:
config = load_config("botty_next/config/default.yaml")
assert config.profile_name == "local"
assert config.capture.backend == "fixture"
assert config.input.enabled is False
assert config.input.dry_run is True
-13
View File
@@ -1,13 +0,0 @@
from botty_next.vision.fixtures import load_screenshot, load_template
def test_load_sample_screenshot_fixture() -> None:
image = load_screenshot("sample_scene.ppm")
assert image.shape == (8, 8, 3)
def test_load_sample_template_fixture() -> None:
template = load_template("sample_marker.ppm")
assert template.shape == (3, 3, 3)
-42
View File
@@ -1,42 +0,0 @@
import sys
from types import SimpleNamespace
import pytest
from botty_next.vision.fixtures import load_screenshot
from botty_next.vision.ocr import preprocess_for_ocr, run_tesseract_ocr, save_ocr_preprocess_debug
def test_preprocess_for_ocr_returns_thresholded_image() -> None:
image = load_screenshot("sample_scene.ppm")
processed = preprocess_for_ocr(image)
assert processed.ndim == 2
assert processed.shape == (16, 16)
def test_save_ocr_preprocess_debug(tmp_path) -> None:
image = load_screenshot("sample_scene.ppm")
output = save_ocr_preprocess_debug(image, tmp_path / "ocr.png")
assert output.exists()
def test_run_tesseract_ocr_uses_pytesseract_adapter(monkeypatch) -> None:
fake = SimpleNamespace(
Output=SimpleNamespace(DICT="dict"),
pytesseract=SimpleNamespace(tesseract_cmd=None),
image_to_string=lambda *_args, **_kwargs: "Short Sword\n",
image_to_data=lambda *_args, **_kwargs: {"conf": ["95", "-1", "85"]},
)
monkeypatch.setitem(sys.modules, "pytesseract", fake)
image = load_screenshot("sample_scene.ppm")
result = run_tesseract_ocr(image, tesseract_cmd="C:/Tesseract/tesseract.exe")
assert result.text == "Short Sword"
assert result.confidence == pytest.approx(0.9)
assert result.debug["backend"] == "pytesseract"
assert fake.pytesseract.tesseract_cmd == "C:/Tesseract/tesseract.exe"
@@ -1,24 +0,0 @@
from botty_next.vision.fixtures import load_screenshot, load_template
from botty_next.vision.template_matching import match_template, save_match_debug
def test_template_match_finds_sample_marker() -> None:
image = load_screenshot("sample_scene.ppm")
template = load_template("sample_marker.ppm")
result = match_template(image, template, threshold=0.99)
assert result.passed is True
assert result.confidence >= 0.99
assert result.bbox == (3, 2, 3, 3)
assert "image_shape" in result.debug
def test_template_match_can_save_debug_image(tmp_path) -> None:
image = load_screenshot("sample_scene.ppm")
template = load_template("sample_marker.ppm")
result = match_template(image, template, threshold=0.99)
output = save_match_debug(image, result, tmp_path / "marked.png")
assert output.exists()
-6
View File
@@ -1,6 +0,0 @@
"""Vision helpers and detectors."""
from botty_next.vision.ocr import OcrResult, preprocess_for_ocr, run_tesseract_ocr
from botty_next.vision.template_matching import MatchResult, match_template
__all__ = ["MatchResult", "OcrResult", "match_template", "preprocess_for_ocr", "run_tesseract_ocr"]
-26
View File
@@ -1,26 +0,0 @@
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
def load_image(path: str | Path, *, grayscale: bool = False) -> np.ndarray:
image_path = Path(path)
if not image_path.exists():
raise FileNotFoundError(f"image fixture does not exist: {image_path}")
flag = cv2.IMREAD_GRAYSCALE if grayscale else cv2.IMREAD_COLOR
image = cv2.imread(str(image_path), flag)
if image is None:
raise ValueError(f"OpenCV could not read image fixture: {image_path}")
return image
def load_screenshot(name: str, root: str | Path = "fixtures/screenshots") -> np.ndarray:
return load_image(Path(root) / name)
def load_template(name: str, root: str | Path = "fixtures/templates") -> np.ndarray:
return load_image(Path(root) / name)
-98
View File
@@ -1,98 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from importlib import import_module
from pathlib import Path
from typing import Any
import cv2
import numpy as np
@dataclass(frozen=True)
class OcrResult:
text: str
confidence: float
bbox: tuple[int, int, int, int] | None
debug: dict[str, Any]
def preprocess_for_ocr(image: np.ndarray, *, scale: float = 2.0) -> np.ndarray:
if image.size == 0:
raise ValueError("image is empty")
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
if scale != 1.0:
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
denoised = cv2.GaussianBlur(gray, (3, 3), 0)
return cv2.adaptiveThreshold(
denoised,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
31,
7,
)
def run_tesseract_ocr(
image: np.ndarray,
*,
lang: str = "eng",
psm: int = 6,
tesseract_cmd: str | None = None,
) -> OcrResult:
try:
pytesseract = import_module("pytesseract")
except ModuleNotFoundError as exc:
raise RuntimeError(
"pytesseract is not installed; run install.bat or install requirements.txt"
) from exc
if tesseract_cmd:
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
processed = preprocess_for_ocr(image)
config = f"--psm {psm}"
text = pytesseract.image_to_string(processed, lang=lang, config=config).strip()
confidences = _read_confidences(
pytesseract.image_to_data(
processed,
lang=lang,
config=config,
output_type=pytesseract.Output.DICT,
)
)
confidence = sum(confidences) / len(confidences) if confidences else 0.0
return OcrResult(
text=text,
confidence=confidence,
bbox=None,
debug={
"backend": "pytesseract",
"lang": lang,
"psm": psm,
"preprocessed_shape": tuple(map(int, processed.shape)),
"word_confidences": confidences,
},
)
def save_ocr_preprocess_debug(image: np.ndarray, output_path: str | Path) -> Path:
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(str(output), preprocess_for_ocr(image)):
raise RuntimeError(f"failed to write OCR debug image: {output}")
return output
def _read_confidences(data: dict[str, list[Any]]) -> list[float]:
values: list[float] = []
for raw in data.get("conf", []):
try:
confidence = float(raw)
except (TypeError, ValueError):
continue
if confidence >= 0:
values.append(confidence / 100.0)
return values
-93
View File
@@ -1,93 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import cv2
import numpy as np
@dataclass(frozen=True)
class MatchResult:
confidence: float
bbox: tuple[int, int, int, int]
passed: bool
method: str
debug: dict[str, Any]
def _as_gray(image: np.ndarray) -> np.ndarray:
if image.ndim == 2:
return image
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
def match_template(
image: np.ndarray,
template: np.ndarray,
*,
threshold: float = 0.85,
method: int = cv2.TM_CCOEFF_NORMED,
) -> MatchResult:
if image.size == 0:
raise ValueError("image is empty")
if template.size == 0:
raise ValueError("template is empty")
if template.shape[0] > image.shape[0] or template.shape[1] > image.shape[1]:
raise ValueError("template cannot be larger than image")
image_gray = _as_gray(image)
template_gray = _as_gray(template)
response = cv2.matchTemplate(image_gray, template_gray, method)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(response)
if method in (cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED):
top_left = min_loc
confidence = 1.0 - float(min_val)
else:
top_left = max_loc
confidence = float(max_val)
width = int(template.shape[1])
height = int(template.shape[0])
bbox = (int(top_left[0]), int(top_left[1]), width, height)
return MatchResult(
confidence=confidence,
bbox=bbox,
passed=confidence >= threshold,
method=_method_name(method),
debug={
"threshold": threshold,
"min_value": float(min_val),
"max_value": float(max_val),
"min_location": tuple(map(int, min_loc)),
"max_location": tuple(map(int, max_loc)),
"image_shape": tuple(map(int, image.shape)),
"template_shape": tuple(map(int, template.shape)),
},
)
def save_match_debug(image: np.ndarray, result: MatchResult, output_path: str | Path) -> Path:
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
marked = image.copy()
x, y, width, height = result.bbox
color = (0, 255, 0) if result.passed else (0, 0, 255)
cv2.rectangle(marked, (x, y), (x + width, y + height), color, 2)
cv2.imwrite(str(output), marked)
return output
def _method_name(method: int) -> str:
names = {
cv2.TM_CCOEFF: "TM_CCOEFF",
cv2.TM_CCOEFF_NORMED: "TM_CCOEFF_NORMED",
cv2.TM_CCORR: "TM_CCORR",
cv2.TM_CCORR_NORMED: "TM_CCORR_NORMED",
cv2.TM_SQDIFF: "TM_SQDIFF",
cv2.TM_SQDIFF_NORMED: "TM_SQDIFF_NORMED",
}
return names.get(method, str(method))
-154
View File
@@ -1,154 +0,0 @@
import os
import shutil
from pathlib import Path
from src.version import __version__
import argparse
import getpass
import random
from cryptography.fernet import Fernet
import string
parser = argparse.ArgumentParser(description="Build Botty")
parser.add_argument(
"-v" , "--version",
type=str,
help="New release version e.g. 0.4.2",
default=""
)
parser.add_argument(
"-c", "--conda_path",
type=str,
help="Path to local conda e.g. C:\\Users\\USER\\miniconda3",
default=f"C:\\Users\\{getpass.getuser()}\\miniconda3")
parser.add_argument(
"-r", "--random_name",
action='store_true',
help="Will generate a random name for the botty exe")
parser.add_argument(
"-k", "--use_key",
action='store_true',
help="Will build with encryption key")
args = parser.parse_args()
# clean up
def clean_up():
# pyinstaller
if os.path.exists("build"):
shutil.rmtree("build")
if os.path.exists("main.spec"):
os.remove("main.spec")
if os.path.exists("health_manager.spec"):
os.remove("health_manager.spec")
if os.path.exists("shopper.spec"):
os.remove("shopper.spec")
if __name__ == "__main__":
new_version_code = None
if args.version != "":
print(f"Releasing new version: {args.version}")
os.system(f"git checkout -b new-release-v{args.version}")
botty_dir = f"botty_v{args.version}"
version_code = ""
with open('src/version.py', 'r') as f:
version_code = f.read()
version_code = version_code.split("=")
new_version_code = f"{version_code[0]}= '{args.version}'"
with open('src/version.py', 'w') as f:
f.write(new_version_code)
else:
botty_dir = f"botty_v{__version__}"
print(f"Building version: {__version__}")
clean_up()
if os.path.exists(botty_dir):
for path in Path(botty_dir).glob("**/*"):
if path.is_file():
os.remove(path)
elif path.is_dir():
shutil.rmtree(path)
shutil.rmtree(botty_dir)
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:
raise RuntimeError(f"PyInstaller failed for {exe} (exit {ret})")
os.makedirs(f"{botty_dir}/config", exist_ok=True)
with open(f"{botty_dir}/config/custom.ini", "w") as f:
f.write("; Add parameters you want to overwrite from param.ini here")
shutil.copy("config/game.ini", f"{botty_dir}/config/")
shutil.copy("config/params.ini", f"{botty_dir}/config/")
shutil.copy("config/shop.ini", f"{botty_dir}/config/")
shutil.copy("config/default.bnip", f"{botty_dir}/config/")
os.makedirs(f"{botty_dir}/config/bnip", exist_ok=True)
shutil.copy("README.md", f"{botty_dir}/")
shutil.copytree("assets", f"{botty_dir}/assets")
shutil.copytree("src", f"{botty_dir}/src")
shutil.copy("environment.yml", f"{botty_dir}/")
shutil.copy("install.bat", f"{botty_dir}/")
shutil.copy("find_python.bat", f"{botty_dir}/")
shutil.copy("run_botty.bat", f"{botty_dir}/")
shutil.copy("run.bat", f"{botty_dir}/")
if os.path.exists("dependencies"):
shutil.copytree("dependencies", f"{botty_dir}/dependencies")
# Bundle a portable Tesseract so the standalone exe is click-and-run with
# working OCR and no separate install. ocr.py prefers <exe_dir>/tesseract/
# tesseract.exe. Source: TESSERACT_DIR env or the default UB Mannheim path.
# Skipped (with a warning) if not present — the bot still works once the
# user runs install.bat, which sets OCR up the conda way.
tesseract_src = os.environ.get("TESSERACT_DIR", r"C:\Program Files\Tesseract-OCR")
tess_exe = os.path.join(tesseract_src, "tesseract.exe")
if os.path.isfile(tess_exe):
print(f"Bundling Tesseract from {tesseract_src}")
# Copy the exe + DLLs; skip their tessdata (we ship our own trained
# models in assets/tessdata and pass --tessdata-dir to point at them).
os.makedirs(f"{botty_dir}/tesseract", exist_ok=True)
for entry in os.listdir(tesseract_src):
src = os.path.join(tesseract_src, entry)
if os.path.isfile(src) and entry.lower().endswith((".exe", ".dll")):
shutil.copy(src, f"{botty_dir}/tesseract/")
else:
print(f"WARNING: Tesseract not found at {tesseract_src} — release will "
f"rely on install.bat for OCR setup. Set TESSERACT_DIR to bundle it.")
clean_up()
if args.random_name:
print("Generate random names")
new_name = ''.join(random.choices(string.ascii_letters, k=random.randint(6, 14)))
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
# Rename main.exe to avoid Warden flagging the obvious name
# In CI/production builds (env BOTTY_NO_RENAME=1) keep main.exe as-is
if not args.random_name and not os.environ.get("BOTTY_NO_RENAME"):
new_name = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
print(f"Renamed main.exe -> {new_name}.exe")
if new_version_code is not None:
os.system(f'git add .')
os.system(f'git commit -m "Bump version to v{args.version}"')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

-80
View File
@@ -1,80 +0,0 @@
"""
Desktop screenshot tool - captures the full Windows desktop or a specific window.
Usage:
python desktop_snap.py # capture full desktop
python desktop_snap.py D2R # capture D2R window only
Saves to screenshots/desktop_snap.png
"""
import os
import sys
import cv2
from mss import mss
SAVE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots", "desktop_snap.png")
os.makedirs(os.path.dirname(SAVE_PATH), exist_ok=True)
def snap_full_desktop():
"""Capture the full desktop."""
with mss() as sct:
img = sct.grab(sct.monitors[1]) # monitors[1] = primary display
# Convert from BGRA to BGR
img_bgr = img.rgb
cv2.imwrite(SAVE_PATH, img_bgr)
print(f"Saved full desktop to: {SAVE_PATH}")
print(f"Shape: {cv2.imread(SAVE_PATH).shape}")
def snap_d2r_window():
"""Capture the D2R window."""
import numpy as np
import win32gui
import win32ui
import win32con
# Find D2R window
def enum_cb(hwnd, results):
if win32gui.IsWindowVisible(hwnd):
title = win32gui.GetWindowText(hwnd)
if "diablo" in title.lower() or "d2r" in title.lower():
results.append(hwnd)
hwnds = []
win32gui.EnumWindows(enum_cb, hwnds)
if not hwnds:
print("ERROR: D2R window not found. Is it running?")
return
hwnd = hwnds[0]
print(f"Found D2R window: {win32gui.GetWindowText(hwnd)}")
# Get window client area
rect = win32gui.GetClientRect(hwnd)
w, h = rect[2] - rect[0], rect[3] - rect[1]
# Capture client area
hdc = win32gui.GetDC(hwnd)
hdc_mem = win32gui.CreateCompatibleDC(hdc)
bmp = win32gui.CreateCompatibleBitmap(hdc, w, h)
win32gui.SelectObject(hdc_mem, bmp)
win32gui.BitBlt(hdc_mem, 0, 0, w, h, hdc, 0, 0, win32con.SRCCOPY)
# Convert to image
bmp_info = win32ui.CreateBitmapFromHandle(bmp)
bmp_info.SaveBitmapFile(hdc_mem, SAVE_PATH)
win32gui.DeleteObject(bmp)
win32gui.DeleteDC(hdc_mem)
win32gui.ReleaseDC(hwnd, hdc)
img = cv2.imread(SAVE_PATH)
print(f"Saved D2R window to: {SAVE_PATH}")
print(f"Shape: {img.shape}")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "D2R":
snap_d2r_window()
else:
snap_full_desktop()
-117
View File
@@ -1,117 +0,0 @@
# Dev Docu
## Dependencies
- Install [Miniforge](https://github.com/conda-forge/miniforge) (recommended over Miniconda for conda-forge packages). Check "Add to PATH" during installation.
- Alternatively, [Miniconda](https://docs.conda.io/en/latest/miniconda.html) works too.
- Install [git](https://gitforwindows.org/)
## Getting started
```bash
git clone https://github.com/Hoblirm/botty.git
cd botty
# Create the conda environment (installs all Python deps + tesserocr with tesseract)
# Windows 10:
conda env create -f environment-win10.yml
# Windows 11:
conda env create -f environment-win11.yml
# Activate
conda activate botty
# Optional pip profile after the conda env exists:
# Windows 10:
python -m pip install -r requirements-win10.txt
# Windows 11:
python -m pip install -r requirements-win11.txt
# Run
python src/main.py
```
`install.bat` detects Windows 10 vs Windows 11 and installs the matching
environment and requirements profiles automatically. Botty runtime detection
lives in `src/utils/os_detect.py`; startup logs show the selected profile and
mouse mode.
### PowerShell users
```powershell
conda init powershell # One time setup
conda activate botty
python src/main.py
```
### No conda? Quick setup
If you don't want to use conda, you can install dependencies with pip, but `tesserocr`
requires the tesseract C library which is easiest to get via conda. See `environment.yml`
for the full dependency list.
## Running with the launcher
A `run_botty.bat` file is provided in the project root. It auto-detects the conda
environment and launches the bot. You can also run manually:
```cmd
conda activate botty
cd C:\path\to\botty
python src\main.py
```
## Tests
All automated tests can be found within the **/test/*** folder. The file and folder structure is supposed to mimic the src folder.
```bash
conda activate botty
# To run all tests: (-s to see stdout, -v for verbose)
pytest -s -v
# To see std output:
# To run a specific test:
pytest test/smoke_test.py
```
To test single files / routines, most files also can be executed separately. E.g. running `python src/pickit.py` -> going to d2r window -> throw stuff on the groudn -> press f11, will test the pickit.
## Adding Items
To add items you can check the **assets/items** folder. Screenshot whatever you want to pick up in the same way (all settings must be as if you ran the bot). Then add the filename to the param.ini [items] section (e.g. if boots_rare.png add boots_rare=1)
## Folder Structure
**/**</br>
The root contains docu, param files and development specific stuff such as .gitignore</br>
**assets**</br>
Contains all data for the project that is not source code</br>
**assets/docs**</br>
Images you can see in the .md files and logos</br>
**assets/items**</br>
Screenshot of item names that should be picked up. The filename must then be added to the param.ini</br>
**assets/npc**</br>
Templates of npcs in different poses</br>
**assets/templates**</br>
Templates for different UIs and key points. Also contains folders of "pathes" that were generated with the utils/node_creator.py</br>
**src**</br>
All python source files go here</br>
**src/char**</br>
Want to implement a new char or build. Check this folder out. You will have to inherit from IChar and go from there</br>
**src/char**</br>
Utilities functions and scripts e.g. for easily creating templates to traverse nodes and automatically generate code for it</br>
## Code routine
main.py contains the main() function and is the entry point for botty. It will start 3 threads: death monitoring (death_manager.py), health monitoring (health_manager.py) and the bot (bot.py) itself. Whenever the two monitors either detect a player's death or chicken out of the game, the bot thread will be killed and restarted.</br>
In bot.py is a state machine in its core an executes different actions based on the current state. The goal is to remove as much implementation details as possible from bot.py and "hide" them in different manager classes (e.g. pickit.py, pather.py, npc_manager.py, etc.)
## State Diagram
The core logic of the bot is determined by a state machine with these states and transations. The bot.py which contains all of the transitions should have little implementation code which should be hidden as much as possible in the "manager" classes.
<img src="assets/docs/state_diagram.png" width="550"/>
## Coordinate System
There are different coordinate systems used and I tried my best to add these to the variable names.</br>
**Monitor**: It will have the origin at the top left of the first monitor</br>
**Screen**: Same as monitor for single monitor setups, otherwise origin at top left of the screen </br>
**Absolute**: Has its origin at the center of the screen, thus at the footpoint of your char </br>
**Relative**: Relative coordinates as the name suggest are relative to something. It is mostly used to express relative coordinates in relation to a tempalte that is found </br>
<img src="assets/docs/coordinate_systems.png" width="550"/>
## Release process
If you installed your miniconda in another location you will of course have ot change it for that one.
```bash
# Adapt new version with x.x.x, build .exe and bundeling all needed resource into one folder
python build.py x.x.x
```
For changelog run: `git log <PREVIOUS_TAG>..HEAD --oneline --decorate`
-141
View File
@@ -1,141 +0,0 @@
# Auto Skill + Attribute Allocation Plan
## Goal
Add an optional system that automatically assigns:
- skill points
- attribute points
based on:
- active character profile (`blizz_sorc`, `fohdin`, `hammerdin`, etc.)
- current character level
without breaking existing manual setups.
## Scope
- Planning and architecture for Botty repo.
- No forced behavior changes: feature must be opt-in.
## Requirements
1. Determine current level reliably at runtime.
2. Select a build template by character profile.
3. Apply points safely only when unspent points exist.
4. Record every allocation in logs/events for audit/replay.
5. Abort safely on uncertainty (wrong UI state, OCR mismatch, missing templates).
## Current Level Detection Strategy
### Primary path
Use `player_bar.get_experience()` (already used in `game_stats.log_exp`) to derive level from XP table.
### Secondary fallback
Open character panel (`C`) and OCR level/name line directly from upper-left panel region.
### Tertiary fallback
If OCR fails repeatedly:
- keep previous known good level for session,
- do **not** allocate points until confidence is restored.
### Confidence rules
- Require two consistent reads before first allocation in a session.
- Reject impossible jumps (e.g., +5 levels at once).
- Persist `last_known_level` in session stats snapshot.
## Build Template Model
Add config-backed build templates, e.g.:
- `config/auto_builds/blizz_sorc.ini`
- `config/auto_builds/hammerdin.ini`
- `config/auto_builds/fohdin.ini`
Each template defines per-level targets:
- desired skill totals by level milestone
- desired attribute distribution (str/dex/vit/ene)
Example concept:
- Level 1-17: early progression targets
- Level 18-29: mid-game unlock path
- Level 30+: core skill maxing order
## Runtime Flow
1. Enter town and open character/skill UI.
2. Detect level and unspent points.
3. Load template for `Config().char["type"]`.
4. Compute delta between current allocation and target-at-level.
5. Apply points stepwise:
- attributes first (optional toggle),
- skills second.
6. Verify post-apply state.
7. Log allocation summary and persist snapshot.
## Safety Guards
- Only run in town.
- Require stash/vendor windows closed.
- Hard cap per cycle (e.g., max 10 clicks per stat/skill group).
- On mismatch/timeout:
- stop allocation immediately,
- screenshot + structured error event,
- continue bot without crashing.
## Config Additions (Planned)
In `[char]` or new `[auto_build]` section:
- `auto_assign_skills=0/1`
- `auto_assign_attributes=0/1`
- `auto_build_profile=` (defaults to `char.type`)
- `auto_build_check_every_x_games=`
- `auto_build_safe_mode=1` (extra verification)
## Logging / Telemetry
Add structured events:
- `auto_build_check_started`
- `auto_build_level_detected`
- `auto_build_points_detected`
- `auto_build_applied`
- `auto_build_skipped`
- `auto_build_error`
Include:
- profile
- level
- points spent
- before/after snapshots
## UI / Input Dependencies
Need stable template references for:
- character panel level region
- unspent attribute points indicator
- unspent skill points indicator
- individual plus-buttons for stats/skills
## Test Plan
1. Unit tests:
- level-to-target mapping
- delta computation
- guard conditions
2. Integration dry-run mode:
- compute and log planned actions without clicking.
3. Live smoke tests per profile:
- `blizz_sorc`, `hammerdin`, `fohdin`
4. Regression:
- ensure normal runs unaffected with feature disabled.
## Inputs Needed From You
1. Screenshots for each supported class at:
- character panel open,
- skill tree open,
- visible unspent points.
2. Preferred leveling templates:
- exact skill priority order by level range.
- attribute rules (e.g., str to gear breakpoint, then vit).
3. Whether respec-aware logic is needed in v1.
## Rollout Phases
1. Phase 1: Level detection + dry-run planner only.
2. Phase 2: Attribute auto-assign (safer, fewer UI branches).
3. Phase 3: Skill auto-assign with full verification.
4. Phase 4: Expanded profile templates + docs.
## Definition of Done
- Feature is opt-in and stable for `blizz_sorc`, `hammerdin`, `fohdin`.
- Level detection is reliable with fallback behavior.
- No crash on detection/allocation failure.
- Full logs available for every auto-allocation decision.
-539
View File
@@ -1,539 +0,0 @@
# Codex Fix Analysis
Source files read:
- `docs/fix_plan.md`
- `src/run/nihlathak.py`
- `src/town/town_manager.py`
- `src/inventory/vendor.py`
- `src/char/i_char.py` (`src/char.py` does not exist in this repo; CTA is implemented here)
- `config/game.ini`
- Supporting files needed to trace the failures: `src/town/a1.py`, `src/town/a5.py`, `src/town/a4.py`, `src/npc_manager.py`, `src/pather.py`, and the CTA key section in `config/params.ini`
## Priority 1: Nihlathak approach fails
### Finding
The Nihlathak route has three brittle points:
1. `approach()` returns success immediately after clicking the waypoint and never verifies that the Halls of Pain actually loaded.
2. Level 1 layout detection has no fallback if `NI1_A`, `NI1_B`, or `NI1_C` is stale.
3. `traverse_nodes_fixed()` always returns `True`, so a bad static path in `config/game.ini` cannot be detected until the stairs click times out.
The `config/game.ini` Nihlathak path keys are present:
```ini
ni1_a=871,472, 1205,600, 1162,600, 1169,584, 1169,584, 1232,213, 1221,237, 1164,228, 1145,572, 1146,547, 1223,185
ni1_b=23,187, 23,187, 23,187, 23,187, 12,192, 10,192, 10,190, 123,70, 378,120
ni1_c=118,500, 158,602, 187,577, 217,563, 184,551, 70,413, 127,240, 154,493, 197,504, 218,545, 83,246, 45,526, 300,380
```
So the immediate code fix is not "add missing keys"; it is to verify waypoint/area entry and reduce the failure blast radius when stale templates or coordinates are encountered.
### Exact code that needs to change
`src/run/nihlathak.py`:
```python
wait(0.4)
if waypoint.use_wp("Halls of Pain"): # use Halls of Pain Waypoint (5th in A5)
return Location.A5_NIHLATHAK_START
return False
```
```python
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
if not template_match.valid:
return False
```
```python
self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char)
```
### Proposed fix
Replace the waypoint block with a verified load:
```python
wait(0.4)
if not waypoint.use_wp("Halls of Pain"):
return False
if not template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.55, timeout=8).valid:
Logger.error("Nihlathak approach: waypoint click did not land in Halls of Pain")
return False
return Location.A5_NIHLATHAK_START
```
Replace layout detection and static path traversal with a lower-threshold retry and an explicit path result check:
```python
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
if not template_match.valid:
Logger.warning("Nihlathak: strict NI1 layout detection failed, retrying with grayscale/lower threshold")
template_match = template_finder.search_and_wait(
["NI1_A", "NI1_B", "NI1_C"],
threshold=0.55,
best_match=True,
timeout=6,
use_grayscale=True,
)
if not template_match.valid:
return False
```
```python
if not self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char):
Logger.error(f"Nihlathak: failed static route {template_match.name.lower()}")
return False
```
If `search_and_wait()` does not support `use_grayscale` in this repo version, use this compatible form instead:
```python
if not template_match.valid:
start = time.time()
while time.time() - start < 6:
template_match = template_finder.search(
["NI1_A", "NI1_B", "NI1_C"],
grab(),
threshold=0.55,
best_match=True,
use_grayscale=True,
)
if template_match.valid:
break
wait(0.2)
```
That compatible form also needs imports:
```python
import time
from screen import grab, convert_abs_to_monitor
```
### Why it will work
This turns the approach from "clicked the waypoint, assume success" into "clicked the waypoint, confirm an NI1 layout is visible." If the waypoint interaction fails or lands somewhere unexpected, the run fails immediately instead of burning 600+ seconds.
The lower-threshold/grayscale retry handles the likely stale-template case without permanently weakening the first pass. The strict threshold still wins when templates are good; the fallback only runs when the current behavior would fail.
The static route check makes future changes safer. `traverse_nodes_fixed()` currently returns `True`, but guarding the call is still correct because it protects this runner if path traversal later gains real validation.
Fresh templates and re-recorded `ni1_*` coordinates are still required if the fallback logs low-confidence matches or reaches the wrong stairs side. The code fix limits total game loss and gives a useful failure point.
## Priority 2: Vendor trade button not found
### Finding
The failure is not in `src/inventory/vendor.py`; that file buys items after the vendor panel is already open. The failing log comes from `src/npc_manager.py`:
```python
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
```
The current town code also returns a vendor location even if pressing the trade button failed. For A5 Malah:
```python
def open_trade_menu(self, curr_loc: Location) -> Location | bool:
if not self._pather.traverse_nodes((curr_loc, Location.A5_MALAH), self._char, force_move=True): return False
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
```
And `press_npc_btn()` does not return `True` on success:
```python
if res.valid:
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button="left")
wait(0.04, 0.08)
center_mouse()
else:
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
keyboard.send("esc")
```
The template threshold is also hard-coded very high for white and blue text:
```python
filtered_inp_w, 0.85, roi=Config().ui_roi["cut_skill_bar"]
```
### Exact code that needs to change
`src/npc_manager.py`, `press_npc_btn()` needs to return a boolean and use a retry/fallback. A5/A1/A4 trade functions need to check that boolean or verify the vendor panel.
### Proposed fix
Replace `press_npc_btn()` with:
```python
def press_npc_btn(npc_key: Npc, action_btn_key: str) -> bool:
global npcs
for threshold in (0.85, 0.78):
img = grab()
img = escape_dialogue(img)
_, filtered_inp_w = color_filter(img, Config().colors["white"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["white"],
filtered_inp_w,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
)
if not res.valid and "blue" in npcs[npc_key]["action_btns"][action_btn_key]:
_, filtered_inp_b = color_filter(img, Config().colors["blue"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["blue"],
filtered_inp_b,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
)
if not res.valid:
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["white"],
img,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
use_grayscale=True,
)
if res.valid:
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button="left")
wait(0.2, 0.3)
center_mouse()
return True
if "red" in npcs[npc_key]["action_btns"][action_btn_key]:
img = grab()
_, filtered_inp_r = color_filter(img, Config().colors["red"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["red"],
filtered_inp_r,
0.78,
roi=Config().ui_roi["cut_skill_bar"],
)
if res.valid:
Logger.warning(f"Cannot afford {action_btn_key} (red button detected). Skipping...")
keyboard.send("esc")
wait(0.3)
return False
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
keyboard.send("esc")
return False
```
Then change A5 Malah trade from:
```python
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
```
to:
```python
if open_npc_menu(Npc.MALAH):
if press_npc_btn(Npc.MALAH, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A5_MALAH
return False
```
Make the same pattern in `src/town/a1.py`:
```python
if open_npc_menu(Npc.AKARA):
if press_npc_btn(Npc.AKARA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A1_AKARA
return False
```
and in `src/town/a4.py`:
```python
if open_npc_menu(Npc.JAMELLA):
if press_npc_btn(Npc.JAMELLA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A4_JAMELLA
return False
```
### Why it will work
The bot currently proceeds as if trade opened even when the button was not clicked. Returning `False` stops `TownManager.buy_consumables()` at the correct point:
```python
new_loc = self._acts[curr_act].open_trade_menu(curr_loc)
if not (new_loc and common.wait_for_left_inventory()): return False, items
```
The fallback search keeps the current exact template behavior first, then retries with a slightly lower threshold and grayscale. That covers text color/anti-aliasing differences without making every match permissive.
Verifying `ScreenObjects.GoldBtnVendor` makes the action result state-based. Even if the template click returns true, the caller only continues when the vendor panel is actually open.
Fresh `TRADE` / `TRADE_BLUE` templates are still recommended, but this code fix prevents false success and reduces sensitivity to minor UI rendering differences.
## Priority 3: Stash detection fails
### Finding
The stash failures are in act-specific methods, not in `TownManager.stash()` itself. A1 and A5 use default `IChar.select_by_template()` threshold `0.68`:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
```
```python
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
return False
```
The common selector only closes the waypoint menu for A5 stash templates:
```python
if type(template_type) == list and "A5_STASH" in template_type:
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
```
So A1 stash can fail when a waypoint/dialog is left open, and both A1/A5 have no lower-threshold retry.
### Exact code that needs to change
`src/town/a1.py`:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
```
`src/town/a5.py`:
```python
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
return False
```
`src/char/i_char.py`:
```python
if type(template_type) == list and "A5_STASH" in template_type:
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
```
### Proposed fix
Change the selector guard in `src/char/i_char.py` to handle all stash templates:
```python
templates = template_type if isinstance(template_type, list) else [template_type]
if any(template in ["A1_TOWN_0", "A5_STASH", "A5_STASH_2"] for template in templates):
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
wait(0.2, 0.3)
```
Change A1 stash to retry lower after the default threshold fails:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.68, timeout=4.0):
Logger.warning("A1 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.58, timeout=4.0):
return False
```
Change A5 stash similarly:
```python
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.68, timeout=4.0, telekinesis=True):
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.58, timeout=4.0, telekinesis=True):
return False
```
### Why it will work
The default threshold remains unchanged for normal cases. The lower threshold only runs after a specific stash attempt fails, which narrows the risk of false-positive clicks.
Closing the waypoint menu for A1 and A5 prevents stale UI overlays from blocking the stash templates. This directly addresses the logged sequence where town/stash templates are not found after other town interactions.
The success function already checks for stash/inventory gold buttons:
```python
found = is_visible(ScreenObjects.GoldBtnInventory, img)
found |= is_visible(ScreenObjects.GoldBtnStash, img)
```
That means a lower-threshold click must still produce the actual stash UI to count as success.
Fresh `A1_TOWN_0`, `A5_STASH`, and `A5_STASH_2` templates should still be captured if logs continue to show low match confidence. The code change makes the current templates less brittle and prevents open UI overlays from causing avoidable failures.
## Priority 4: CTA weapon switch fails
### Finding
The CTA code is in `src/char/i_char.py`. It depends on `Config().char["weapon_switch"]`, which comes from `config/params.ini`, not `config/game.ini`:
```ini
weapon_switch=w
battle_orders=f6
battle_command=f5
```
The current CTA routine has two reliability problems:
1. It invalidates active-skill cache implicitly by switching weapons but does not reset `_active_skill`.
2. It verifies the switch back by comparing a screenshot of the previous right-skill icon. That can fail when the same skill exists on both swaps, when the icon is visually similar, or when the UI updates slightly late.
Current code:
```python
while time.time() - start < 4:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
wait(0.2, 0.3)
if skills.is_right_skill_selected(["BC", "BO"]):
switch_sucess = True
break
else:
Logger.warning("Failed to find Battle Command, swapping weapons again.")
```
```python
while time.time() - start < 4:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
else:
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
return switch_sucess
```
### Exact code that needs to change
Add a helper inside `IChar` and use it whenever the weapon switch key is sent in `_pre_buff_cta()`.
### Proposed fix
Add this method to `IChar`:
```python
def _weapon_switch(self):
keyboard.send(Config().char["weapon_switch"])
self._set_active_skill("left", "")
self._set_active_skill("right", "")
wait(0.55, 0.65)
```
Change the first CTA-side check from:
```python
if skills.is_right_skill_selected(["BC", "BO"]):
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
```
to:
```python
if skills.is_right_skill_selected(["BC", "BO"]):
self._weapon_switch()
```
Change the switch-to-CTA loop from:
```python
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
```
to:
```python
self._weapon_switch()
keyboard.send(Config().char["battle_command"])
```
Change the switch-back loop from:
```python
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
else:
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
```
to:
```python
self._weapon_switch()
if not skills.is_right_skill_selected(["BC", "BO"]):
switch_sucess = True
break
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
```
Also fix the spelling while touching this code:
```python
switch_success = False
```
instead of:
```python
switch_sucess = False
```
### Why it will work
Resetting `_active_skill` after weapon swap prevents `_select_skill()` from skipping a hotkey press because it thinks the old right skill is still selected. Weapon swap changes the available skill bar, so the cache is no longer trustworthy.
The longer wait gives D2R more time to update the skill icon and weapon state before validation. The current 0.4 second delay is close to the UI transition timing and is likely why the failure is intermittent.
The switch-back validation now accepts the most direct state: the right skill is no longer Battle Command/Battle Orders. The old image comparison remains as a fallback, so this still handles cases where BC/BO detection briefly lags.
The configured key should be checked in `config/params.ini`, not `config/game.ini`. If the user's actual D2R weapon switch key is Caps Lock, then this line must change:
```ini
weapon_switch=w
```
to:
```ini
weapon_switch=capslock
```
Only do that if the in-game key binding is actually Caps Lock; otherwise leave it as `w`.
-57
View File
@@ -1,57 +0,0 @@
# d2jsp Semi-Automated Scraping Guide
Due to Cloudflare's aggressive bot protection, fully automated scraping is currently restricted. This project uses a **Semi-Automated (Offline) Workflow** that leverages your authenticated browser session to safely collect market data.
## Prerequisites
1. **Python Dependencies:** Ensure `keyboard` and `requests` are installed (included in `requirements.txt`).
2. **Browser:** Google Chrome is recommended.
3. **Authentication:** Be logged into [forums.d2jsp.org](https://forums.d2jsp.org/) in your browser.
---
## The Workflow
### 1. Initial Directory Setup
Before running the automation, you must set the default "Save As" path in your browser:
1. Open any topic on d2jsp.
2. Press `Ctrl + S`.
3. Navigate to your bot folder: `data/d2jsp_pages/`.
4. Save the file. Your browser will now remember this location as the default.
### 2. Collect Topic URLs
If you don't have a fresh list of URLs, run the collector on a saved forum listing page:
```powershell
python tools/d2jsp_topic_collector.py --offline-dir data/d2jsp_pages --out data/d2jsp_topic_urls.txt
```
### 3. Run Browser Automation
This script will open the first 20 topics from your list, wait for Cloudflare to pass, and simulate the save command.
```powershell
python tools/browser_auto_save.py
```
**Important:**
* Keep your browser as the active window.
* Do not move the mouse or type while the script is running.
* It will automatically `Ctrl + S` -> `Enter` -> `Ctrl + W` for each tab.
### 4. Generate Price Estimates
Once the HTML files are saved in `data/d2jsp_pages/`, run the offline scraper to update your bot's configuration:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-20 --offline-dir data/d2jsp_pages --out config/fg_daily_estimates.json
```
---
## Troubleshooting
### Cloudflare "Just a Moment" Loop
If the automation is too fast and hits a "Just a Moment" screen that doesn't resolve:
1. Increase the `time.sleep(8)` value in `tools/browser_auto_save.py`.
2. Manually solve one challenge in the browser to "warm up" the IP clearance.
### Files Not Saving to Correct Folder
If files are saving to your "Downloads" folder instead of `data/d2jsp_pages`, the browser's default path was reset. Repeat **Step 1** to fix it.
### Date Parsing Errors
If the scraper reports 0 topics scanned or dates are missing, ensure your browser language isn't translating the page, as the scraper expects English month names (Jan, Feb, Mar, etc.).
-91
View File
@@ -1,91 +0,0 @@
# Botty Fix Plan — 2026-06-05
## Symptoms (from today's logs)
- 4 sessions today: 31 + 1 + 9 + 6 games = 47 games total
- 51 deaths across all sessions
- 0 valuable items found (only charms, jewels, gold)
- Nihlathak run fails every time, ending entire games
- Vendor trade button never found (can't buy potions)
- Stash never opens (can't stash items)
- CTA weapon switch fails occasionally
---
## Priority 1: Nihlathak approach fails (game-ending)
**Error:** `Approach failed for run_nihlathak` — ends game after 600-650s
**Impact:** 3+ games ended per session
**Root cause:** Teleport activation times out, bot can't reach Nihlathak
**Fix:**
- Check `config/game.ini` for Nihlathak coordinates (`a5_nihlathak_*`)
- Likely missing or stale path nodes
- Re-record path with `node_recorder.py`
- May need fresh template images for Nihlathak area
**Files to check:**
- `config/game.ini` (Nihlathak section)
- `src/run/nihlathak.py`
- `assets/templates/nihlathak/`
---
## Priority 2: Vendor trade button not found
**Error:** `Could not find trade btn. Should not happen!`
**Impact:** Can't buy potions every town return
**Root cause:** Trade button template outdated or offset wrong for Hell difficulty UI
**Fix:**
- Take fresh screenshot of vendor trade window
- Update trade button template in `assets/templates/`
- Check if trade button position shifted between Normal/Nightmare/Hell
**Files to check:**
- `assets/templates/` (trade button template)
- `src/` (vendor interaction code — search for "trade btn")
---
## Priority 3: Stash detection fails
**Error:** `select_by_template: could not find ['A1_TOWN_0']` then `['A5_STASH', 'A5_STASH_2']`
**Impact:** Can't stash items, inventory fills with junk
**Root cause:** Stash template confidence threshold too high or template outdated
**Fix:**
- Take fresh screenshot of stash UI in Hell difficulty
- Update A1_TOWN_0, A5_STASH, A5_STASH_2 templates
- Consider lowering confidence threshold (currently 0.68)
**Files to check:**
- `assets/templates/a1_town/`
- `assets/templates/a5_stash/`
- Template matching threshold in code
---
## Priority 4: CTA weapon switch fails
**Error:** `_pre_buff_cta: switch to CTA slot failed — retrying`
**Impact:** Occasional, bot retries
**Root cause:** Weapon switch key or template unreliable
**Fix:**
- Verify weapon switch key binding (capslock per memory)
- Check CTA slot detection template
- May need more robust retry logic
**Files to check:**
- `src/` (search for `_pre_buff_cta`)
- `config/` (weapon_switch key)
---
## Approach
All four issues are template/coordinate problems — stale screenshots or wrong positions.
Fix pattern: screenshot current game UI at those locations, update templates.
Do in order: Nihlathak > vendor > stash > CTA.
-360
View File
@@ -1,360 +0,0 @@
# Hermes Takeover Guide
Practical handoff for continuing Botty development. Read every section before touching code — the trap patterns section alone will save you hours.
---
## 1) Working model
- Treat Botty as a **state machine app** with side effects across D2R UI automation, OCR parsing, inventory/sell/stash routines, and messaging (Discord/webhooks).
- Prefer **small, production-safe patches** over broad refactors.
- Prioritize runtime stability: avoid failing full runs because one subsystem is flaky.
- **Always reproduce from logs first** — most bugs leave a clear trail.
---
## 2) Daily workflow
1. Get fresh logs:
- `log/log.txt`
- `log/stats/events_*.jsonl`
- `log/stats/stats_*.log`
2. Confirm failure path in code with `rg`.
3. Patch narrowly with targeted edits.
4. Validate:
- `python -m compileall <changed files>`
- targeted pytest where available
5. Commit with a clear, single-purpose message.
---
## 3) Core debug commands
From repo root (PowerShell):
```powershell
# Tail errors/warnings
rg -n "ERROR|WARNING|Failed|chicken|exception" log/log.txt | tail -50
# Last 200 log lines
Get-Content log/log.txt | Select-Object -Last 200
# Search source code
rg -n "<keyword>" src/
# Compile check after edits
python -m compileall src/health_manager.py src/item/pickit.py
# Config smoke test (confirm a key's resolved value)
python -c "import sys; sys.path.insert(0,'src'); from config import Config; print(Config().char['show_belt'])"
# Run test suite
python -m pytest -q test/
```
Use `rg` first. It's the fastest way to localize faults.
---
## 4) High-risk areas
### 4.1 Repair/vendor flow
- A5 Larzuk detection is noisy and can fail.
- Current behavior: A5 normal flow → Larzuk direct template click → Act 4 Halbu fallback.
- `repair_npc=a4_halbu` config key skips Larzuk entirely.
Files: `src/town/a5.py`, `src/town/town_manager.py`, `src/bot.py`
### 4.2 Discord messaging
Known issue: `'NoneType' object has no attribute 'to_dict'`
Current fix: `src/messages/discord_embeds.py` `_send_embed` only passes `file=` when attachment exists. Plain text fallback on embed failure.
Config: `config/params.ini``[discord_events]`
### 4.3 Selling safety
Current protections:
- Sell logs include item names (not coordinates only).
- `protect_shields_from_sell=1` blocks selling items whose detected name includes "shield".
Files: `src/inventory/personal.py`, `config/params.ini`, `src/config.py`
### 4.4 XP logging
Two separate concerns:
1. OCR extraction: `src/ui/player_bar.py` — parser handles `I/l/|→1`, `O/o→0`.
2. XP status projection math: `src/game_stats.py` `_create_msg()` — zero denominators guarded.
### 4.5 HealthManager (rejuv / chicken logic) ⚠️
The most subtle source of false positives. See Section 10.2 for full details.
Key thresholds (params.ini `[char]`):
- `take_rejuv_potion_health = 0.80` — drink rejuv if HP ≤ 80%
- `take_rejuv_potion_mana = 0.55` — drink rejuv if mana ≤ 55%
- `chicken = 0.50` — flee if HP ≤ 50%
The "two juvs in 8s → chicken" check **must also verify HP was the trigger**, not just mana. Hammerdins burn mana fast; mana can legitimately trigger back-to-back rejuvs at full HP.
### 4.6 PickIt gold loop ⚠️
`_yoink_item()` **always returns `PickedUpResult.PickedUp`** regardless of actual success (line 129). This means pickup failures are only detectable through `_pick_up_item`'s same-ID or same-UID checks — and different gold pile amounts produce different IDs, bypassing those checks entirely.
Fix in place: `pick_up_items()` now blacklists `item.ID` in `_cached_pickit_items` on `PickedUpFailed`. Do not remove this case.
---
## 5) State machine (bot.py)
States: `initialization → hero_selection → town → [run state] → town → ...`
Full state list: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
Key transitions:
| Trigger | Source | Dest | Handler |
|---|---|---|---|
| `init` | initialization | initialization | `on_init` |
| `select_character` | initialization | hero_selection | `on_select_character` |
| `start_from_town` | initialization/hero_selection | town | `on_start_from_town` |
| `maintenance` | town | town | `on_maintenance` |
| `run_pindle` | town | pindle | `on_run_pindle` |
| `run_arcane` | town | arcane | `on_run_arcane` |
| `end_run` | any run state | town | `on_end_run` |
| `end_game` | town/any run state | initialization | `on_end_game` |
**`on_maintenance` guard**: If `_curr_loc` is None (e.g. after a chicken recovery), defaults to `A1_TOWN_START` with a warning log. Do not remove this guard.
**`end_game` vs `end_run`**: When TP charges run out, trigger `end_game` so the bot restarts and restocks in the next game — not `end_run` which tries to TP back and loops.
---
## 6) Config system
Priority order (highest first):
```
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
```
`custom.ini` is gitignored — user-only overrides. Never edit `game.ini` for user settings.
### Key detection flow
On every `Config()` instantiation:
1. Reads `Saved Games/Diablo II Resurrected/<charname>.keyo` binary file.
2. Parses slot-to-key mapping (`CHAR_BINDING_SLOTS` in `key_detector.py`).
3. Slot 41 = `show_belt`, slot 36 = `stand_still`, slot 44 = `weapon_switch`, etc.
4. Compares each detected key against params.ini value.
5. If they match → use detected key silently.
6. If they differ → logs `"Keeping configured key binding for X: 'params_val' (detected 'keyo_val')"` and keeps the params.ini value.
**What "Keeping..." means**: params.ini disagrees with the in-game binding. Usually indicates a config typo or stale params.ini. If you see `Keeping show_belt: 'k' (detected 'n')`, the fix is `show_belt=n` in params.ini `[char]`.
### Config singleton pattern
`Config` uses `__new__` with a `data_loaded` class variable. Calling `Config()` multiple times in the same process returns the same loaded instance. After editing params.ini at runtime, `Config().reload()` is needed (or restart the bot).
---
## 7) Current bot profile (Fistman — as of 2026-06-05)
- **Character**: Hammerdin (`src/char/paladin/hammerdin.py`)
- **Runs**: Pindle + Arcane Sanctuary
- **Key bindings** (from .keyo + params.ini):
- `show_belt = n` (belt hotkey)
- `stand_still = capslock` (overrides detected 'shift')
- `weapon_switch = w`
- `show_items = alt`
- `potion1..4 = 1,2,3,4`
- **Capabilities**: `can_teleport_natively = True` (set via `override_capabilities` in `[advanced_options]`)
- **Thresholds**: `take_rejuv_potion_health=0.80`, `take_rejuv_potion_mana=0.55`, `chicken=0.50`
---
## 8) Testing strategy by change type
### Messaging changes
```powershell
python -m pytest -q test/test_discord_embeds.py
```
### Config parsing changes
```powershell
python -m compileall src/config.py
python -c "import sys; sys.path.insert(0,'src'); from config import Config; c=Config(); print(c.char['show_belt'], c.char['stand_still'])"
```
### Inventory/sell logic
- Validate no exceptions in `inspect_items` / `transfer_items`.
- Log outputs should include item names (not just coordinates).
- Prefer dry functional checks from logs before gameplay runs.
### HealthManager changes
- Check that chicken thresholds still fire at the right HP%.
- Verify the two-rejuv check only triggers when `health_percentage <= take_rejuv_potion_health`.
### PickIt changes
- Confirm `_cached_pickit_items` is populated on both `PickedUp` (cached True) and `PickedUpFailed` (cached False).
- Confirm `_yoink_item` return value is not relied on for success detection.
---
## 9) Git hygiene
- Keep untracked: `.env`, `config/custom.ini`, `log/`, `log/screenshots/`
- Do not revert unrelated user changes.
- Commit frequently with single-purpose messages.
- `python -m compileall src/` must pass cleanly before committing.
---
## 10) Module internals and trap patterns
### 10.1 Config / key detection traps
**Trap**: `apply_key_bindings` runs *after* `self.char` is built in `config.py`. If you add a new key to `self.char` dict and it doesn't exist in `CHAR_BINDING_SLOTS`, the keyo detector won't touch it — but the user still needs to have it in params.ini.
**Trap**: "Keeping configured key binding" is NOT an error. It means the user explicitly configured something different from the game default. It becomes a problem only if the params.ini value is wrong (e.g., 'k' instead of 'n' for show_belt).
**Trap**: `Config()` is a singleton via `__new__`. The first call loads everything. Subsequent calls within the same process return the cached instance. Do NOT expect param changes at runtime to be visible without `Config().reload()`.
### 10.2 HealthManager rejuv traps
The rejuv logic in `start_monitor()`:
```python
if last_drink > 0.60: # minimum between rejuvs
if health <= take_rejuv_potion_health or mana <= take_rejuv_potion_mana:
drink_rejuv()
self._last_rejuv = time.time()
# Two juvs in 8 seconds → chicken ONLY if HP was the trigger
if last_drink < 8 and health_percentage <= Config().char["take_rejuv_potion_health"]:
self._do_chicken(img)
```
**Critical**: The `last_drink < 8` chicken check MUST also check `health_percentage`. Without it, any mana-triggered second rejuv (common for Hammerdins) will false-chicken at 99.9% HP. The fix is already in place — do not revert it.
**Timing**: The monitor polls every `3/25s * jitter(±20%)` ≈ 96144ms. At 25 FPS that's every 3 frames.
**Thread safety**: `_pause_state` and `_panel_check_paused` are protected by `_state_lock`. Module-level `get_pause_state()` / `set_pause_state()` functions delegate to the singleton. Always use these functions from external code.
### 10.3 PickIt ID/UID system
`GroundItem` has two identifiers:
```python
ID = slugify(f"{Name}_{'_'.join([str(v) for _,v in as_dict().items()])}")
# Includes Amount in the string. Two gold piles with different amounts = different IDs.
UID = f"{ID}_{'_'.join([str(v) for v in center])}"
# ID + screen position. Same pile at same coordinates = same UID.
```
**Trap**: `_pick_up_item`'s gold-fail detection uses `item.ID == prev.ID`. If two nearby gold piles have different amounts (e.g., 338g and 157g), they alternate as the "next" item and each one's ID never matches the previous, so the same-ID fail check never fires. The loop runs until timeout (20s).
**Trap**: `_yoink_item` ALWAYS returns `PickedUpResult.PickedUp`. It never returns `PickedUpFailed`. Pickup failures for teleport builds are silently swallowed.
**Fix in place**: `pick_up_items()` match block now has:
```python
case PickedUpResult.PickedUpFailed:
self._cached_pickit_items[item.ID] = False # blacklist this session
```
This stops the alternating-gold loop by blacklisting the item after the first confirmed failure.
### 10.4 Belt system open() key chain
`belt.open()` tries keys in this order:
```python
[config_val, "n", "k", "`", "~"] # deduplicated
```
If `show_belt = n` in params.ini, the first key tried is 'n'. If it works, no fallback keys appear in logs. If you see `"Trying to open belt with key: k"` it means 'n' failed — check if `show_belt` is actually set to 'n' and if the D2R window is focused.
### 10.5 TownManager location routing
`get_act_from_location(loc)` returns `None` for non-string inputs (e.g., `True`, `False`). The isinstance guard at line 36 (`if not isinstance(loc, str): return None`) prevents `AttributeError: 'bool' object has no attribute 'upper'`. Do not remove it.
All town methods that receive a `Location` return `False` (not `None`) on failure. Callers should check `if not new_loc` not `if new_loc is None`.
### 10.6 State machine: `end_game` vs `end_run`
`end_run` sends a TP, waits in town, does maintenance, then starts another run. If something prevents getting back to town (no TP scrolls, merc dead with no body, disconnected), `end_run` loops.
`end_game` saves and exits, restarts the game fresh. Use it when:
- TP charges = 0 (bot will restock on next game start)
- Unrecoverable in-game state
- Max consecutive failed runs reached
Triggering `end_run` when TP is gone causes an infinite "No TP charges left, trying to walk back" loop (pre-fix behavior).
### 10.7 distance calculation (processing_helpers.py)
The y-center of the screen for distance math is `screen_height / 2`, NOT `screen_width / 2`. Using the wrong dimension skews distance sorting for items on the top/bottom half of the screen. Fix is already applied.
---
## 11) Bugs fixed in 2026-06-04/05 session
All fixes were applied and verified by Python compile/config tests:
| Bug | File | Symptom in logs | Fix |
|---|---|---|---|
| `show_belt` wrong key (`n` instead of `k`) | `config/params.ini` | "Recovered belt hotkey using 'k'" on first game, then silent in-memory mutation | `show_belt=n``show_belt=k` |
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | Crash in `get_act_from_location` when `True`/`False` passed as loc | Added `isinstance(loc, str)` guard |
| No-TP → infinite loop | `src/bot.py` | "No TP charges left, trying to walk back" repeated forever | Trigger `end_game` instead of `end_run` on zero TP |
| Distance y-axis wrong | `src/d2r_image/processing_helpers.py` | Items sorted by wrong distance; far items picked first | `screen_width/2``screen_height/2` for y |
| `on_maintenance` crash with no location | `src/bot.py` | Crash after chicken recovery when `_curr_loc=None` | Guard: default to `A1_TOWN_START` if None |
| False-positive chicken on mana rejuv | `src/health_manager.py:133` | "Two juvs drank within 0.63s. Chicken, HP 99.9%!" | Added `and health_percentage <= take_rejuv_potion_health` |
| Gold pickup infinite loop | `src/item/pickit.py:241` | 338g/157g alternating in logs for 20s | Added `PickedUpFailed` case to blacklist `item.ID` |
| Health pots sold when needed | `src/inventory/personal.py:351` | "Discarding SUPER HEALING POTION." + "Confirmed sell SUPER HEALING POTION" despite health needs | Check `get_needs()` before dropping consumable; `continue` to skip sell/drop when pot is needed |
| No fill_from_inventory after failed buy | `src/bot.py` (after line 438) | Belt empty all game despite pots sitting in inventory; "Out of gold" then nothing fills belt | After buy_consumables block, call `fill_up_belt_from_inventory` + `update_pot_needs` when needs > 0 |
| Wrong weapon in combat after chicken mid-buff | `src/char/i_char.py` `_pre_buff_cta` | Character dies immediately; dies with CTA flail/shield instead of main weapon | Added BC skill-bar template verification after each `weapon_switch`; corrects slot if wrong at game start; retries once on failure |
---
## 12) Known pending issues (as of 2026-06-05)
- **C10** (IMPROVEMENTS.md): `kill_thread()` uses `PyThreadState_SetAsyncExc` — can leave locks inconsistent. Replace with `threading.Event` cooperative shutdown. High risk.
- **optipng pass on assets/**: Pending. Run `asset_manager.py batch` or `optipng -o7` on all PNGs.
- **Thread safety** (H14 in IMPROVEMENTS.md): `health_manager` and `death_manager` shared state — Lock is now present in HealthManager but verify all paths use it.
- **PickedUpResult enum gap** (M14): Values are 0,1,3,4,5. Value 2 is missing. Non-critical but confusing.
- **Gold vicious cycle**: Low gold → can't buy pots → health empty → more chickens/deaths → less gold. Monitor runs after the personal.py + bot.py fix — if the cycle still triggers, also check that `inspect_items` isn't being called with vendor_open=True before `fill_up_belt_from_inventory`.
---
## 13) Fast triage mapping
| Symptom in logs | Where to look | Likely cause |
|---|---|---|
| "Recovered belt hotkey using 'k'" on game 1, then silent | `config/params.ini` | `show_belt=n` should be `show_belt=k` |
| "Two juvs drank... Chicken" at HP > 80% | `src/health_manager.py:133` | Missing HP check on two-rejuv condition |
| Gold pile (XYZg) repeating 5+ times | `src/item/pickit.py:241` | `PickedUpFailed` case missing; item not blacklisted |
| "Failed to pick up X" then same X again immediately | `_yoink_item` / `_cached_pickit_items` | Blacklist not being set on failure |
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | isinstance guard removed or bypassed |
| "No TP charges left, trying to walk back" (repeating) | `src/bot.py` around `end_run` | `end_run` triggered when should be `end_game` |
| "No current location set" | `src/bot.py on_maintenance` | `_curr_loc` was None after chicken/recovery |
| Discord embed errors | `src/messages/discord_embeds.py` | `file=` kwarg passed when attachment is None |
| Repair fail loops | `src/town/a5.py` + `town_manager.py` | Larzuk template noise; check A4 fallback path |
| "Failed to log exp" | `src/ui/player_bar.py` | OCR misread; check for `I/l``1` ambiguity |
| Sell includes wrong items | `src/inventory/personal.py` | `protect_shields_from_sell` or item filter issue |
| "Discarding SUPER HEALING POTION" + "Confirmed sell..." | `src/inventory/personal.py:351` | Consumable sold despite belt need — fixed by get_needs() guard |
| Belt needs stay health=3/mana=3 game after game; pots never drunk | `src/bot.py` after buy_consumables + `personal.py:351` | Health pots sold during inspect; no fill_from_inventory fallback |
| "started on CTA slot" in logs; dies in first seconds of run | `src/char/i_char.py _pre_buff_cta` | Game saved with CTA slot active (interrupted buff). Use `BC` template check at startup to detect and correct |
| Character enters run at partial HP (e.g. 40% after chicken) | `src/bot.py on_maintenance` | Health manager paused in town; no town-heal loop. Check `meters.get_health` and drink belt pots in maintenance before `update_pot_needs` |
---
## 14) "Done" checklist for a fix
- [ ] Reproduced from logs
- [ ] Root cause identified in source
- [ ] Patch applied in smallest reasonable scope
- [ ] `python -m compileall <changed_files>` passes
- [ ] Target tests pass (or explicitly explain why unavailable)
- [ ] Python smoke test confirms the fix (e.g., `Config().char['show_belt']`)
- [ ] Behavior documented here or in README/params if user-visible
---
If you need to continue immediately: start from latest `main`, run a short bot session, then inspect only the newest 200300 log lines before changing anything.
-258
View File
@@ -1,258 +0,0 @@
# Anti-Detection Framework for Botty-Go
## Overview
This document outlines the multi-layered anti-detection system built into botty-go.
Each layer addresses a specific detection vector that Blizzard and modern anti-cheat
systems use to identify bots.
---
## 1. Server-Side Behavior Analysis Countermeasures
### Detection: Session length, timing consistency, pathing patterns, repetition
### Countermeasures:
#### 1a. Variable Session Scheduling
- **Implementation:** `internal/schedule/scheduler.go`
- Randomized session start times using a circadian model
- Simulated human sleep patterns: 6-10 hour breaks between sessions
- Weekend/weekday behavior variance (humans play differently on weekends)
- Random session lengths: 20min to 6hours with exponential distribution
- Occasional "just 5 more minutes" overtime and "I'm tired" early stops
#### 1b. Stochastic Pathing
- **Implementation:** `internal/pather/stochastic.go`
- Add deliberate pathing imperfection: 5-15% deviation from optimal route
- Occasional wrong-way teleports followed by course correction
- Non-optimal waypoint selections (humants don't always take shortest path)
- Variable route ordering with cooldown-dependent choices
- 2-3% chance of "getting lost" and using wrong waypoint first
#### 1c. Skill Rotation Variance
- **Implementation:** `internal/char/behavior.go`
- Variable pre-buff timing (humans rush sometimes, sometimes take time)
- Occasional wrong skill selection followed by correction
- Potion usage with human-like hesitation (check multiple times before drinking)
- Merc healing variance: sometimes forget, sometimes over-heal
#### 1d. Route Randomization with Context
- **Implementation:** `internal/bot/route_planner.go`
- Dynamic route selection based on:
- Time since last run of each type
- Current TP scroll count (humans adapt)
- Gem/transmute urgency
- Occasional "feels like it" switches
- Never perfect round-robin; use weighted probability with drift
#### 1e. Farming Repetition Masking
- Never run the same route more than 8 times consecutively
- Insert "town breaks": stash visit, shrine check, repair, gamble
- 1-2% chance of "I'm bored, switching to different run" mid-session
- Vary kill strategies: sometimes rush, sometimes methodical
---
## 2. Warden / Client Integrity Countermeasures
### Detection: Loaded modules, injected DLLs, memory signatures, debuggers
### Countermeasures:
#### 2a. Pixel-Only Architecture (No Memory Access)
- **Implementation:** entire bot reads game state ONLY via screenshots
- NO memory reading, NO DLL injection, NO process hooking
- Same attack surface as a human with a camera pointed at the screen
- This is the #1 defense: if you only use screen capture + input simulation,
there's nothing to scan in process memory
#### 2b. Clean Process Environment
- **Implementation:** `internal/runtime/clean_env.go`
- Standard Go binary with no suspicious imports
- No debuggers, no memory readers, no process manipulation
- Run as a normal application, not injected
#### 2c. Overlay Avoidance
- Never draw on top of game window
- No window hooking or injection
- Screenshot from a separate thread, not an overlay
---
## 3. Input Pattern Analysis Countermeasures
### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections
### Countermeasures:
#### 3a. Human Motor Model
- **Implementation:** `internal/mouse/human_model.go`
- Full biomechanical mouse model based on Fitts' Law and human motion studies
- Real human mouse data characteristics:
- Multi-segment movement with micro-pauses (1-3 segments per motion)
- Acceleration curve: start slow, peak in middle, decelerate into target
- Endpoint micro-adjustments: 2-5 pixel wobble before click
- Inter-trial variability: each movement is unique even to same target
- Asymmetric error distribution: overshoot more right/down (human bias)
#### 3b. Click Timing Model
- **Implementation:** `internal/mouse/click_model.go`
- Variable time between "arriving" at target and clicking: 50ms-800ms
- Pressure curve: humans don't click at exact same speed
- Double-click rate varies naturally
- Occasional misses: 0.5-1% of clicks land slightly off (1-3px)
#### 3c. Keyboard Behavior Model
- **Implementation:** `internal/keyboard/human_model.go`
- Key press duration variance: not all keypresses are identical
- Typing rhythm for skill hotkeys: natural cadence with micro-pauses
- Occasional key repeat (holding too long = rapid fire)
- Realistic key-up/key-down timing ratios
#### 3d. Statistical Indistinguishability
- **Implementation:** `internal/input/stats.go`
- All input streams modeled from real human motion capture data
- Entropy analysis of output matches human baselines
- Auto-calibration: measure user's own input if they do manual play
- Periodically inject "manual-looking" variance spikes
---
## 4. Economy and Item-Flow Countermeasures
### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior
### Countermeasures:
#### 4a. Natural Accumulation Rate
- **Implementation:** `internal/inventory/economy.go`
- Vary farming intensity: some sessions heavy, some light
- Match accumulation to stated playtime (more sessions = more loot)
- Occasionally "waste" items on gambling/repairs like a real player
#### 4b. Realistic Trading Patterns
- No mass item funneling
- If trading, do it in human-sized batches with natural pauses
- Vary trade partners and timing
#### 4c. Rune Farming Variance
- Don't farm the same runes every session
- Match rune acquisition to character progression
- Occasionally skip rune picks when "full"
---
## 5. Ban Wave Defense
### Detection: Delayed batch bans
### Countermeasures:
#### 5a. Graceful Degradation
- **Implementation:** `internal/runtime/safe_mode.go`
- If one account gets banned, immediately reduce intensity across all
- Auto-pause farming for 48-72 hours (simulating "taking a break")
- Gradual return with reduced session lengths
- Change behavior patterns after any ban event
#### 5b. Account Diversity
- Each account has distinct "personality":
- Different session timing preferences
- Different route preferences
- Different response timing distributions
- Different play styles (rusher vs methodical)
---
## 6. Server Authority Countermeasures
### Detection: Server-side validation of movement, drops, combat, inventory
### Countermeasures:
#### 6a. Server-Authoritative Behavior
- **Implementation:** `internal/bot/server_aware.go`
- Only interact with what the server actually shows
- Wait for server confirmation before acting (e.g., confirm item picked up)
- Respect server-enforced movement limits (no speed hacks)
- Process drops in game-authorized order
#### 6b. No Client Manipulation
- Never try to spoof packets, modify client, or exploit desync
- Purely reactive: see screen -> decide -> act -> wait for response
---
## 7. Social/Reporting System Countermeasures
### Detection: Player reports + telemetry correlation
### Countermeasures:
#### 7a. Social Stealth
- **Implementation:** `internal/social/stealth.go`
- Play during off-peak hours less suspiciously
- Avoid solo-public routes that attract attention
- Occasionally join other players' games (with reduced automation)
- Inherit human-like chat behavior if configured
---
## 8. Hardware/Identity Correlation Countermeasures
### Detection: IP patterns, hardware fingerprints, VMs, account clusters
### Countermeasures:
#### 8a. Clean Deployment
- **Implementation:** `internal/deploy/clean.go`
- Run on real hardware, not VMs
- Use residential IP, not datacenter
- One account per hardware profile
- No VPN/proxy during play sessions
---
## Implementation Architecture
```
internal/
├── input/ # Human-like input generation
│ ├── mouse_model.go # Fitts' Law mouse movement
│ ├── click_model.go # Human click timing
│ ├── keyboard_model.go # Keyboard behavior
│ └── stats.go # Statistical verification
├── behavior/ # High-level human behavior simulation
│ ├── scheduler.go # Session scheduling
│ ├── route_planner.go # Dynamic route selection
│ ├── fatigue.go # Simulated fatigue/boredom
│ └── personality.go # Per-account personality
├── economy/ # Economic behavior masking
│ ├── accumulation.go # Natural loot accumulation
│ └── trading.go # Human-like trading patterns
├── safe_mode/ # Graceful degradation
│ ├── detection.go # Ban wave detection
│ └── cooldown.go # Auto-pause and return
└── deploy/ # Clean deployment helpers
└── check.go # Pre-flight integrity checks
```
## Key Design Principles
1. **Statistical indistinguishability:** Output must be statistically
indistinguishable from real human input. We use actual human motion
capture data distributions, not made-up random numbers.
2. **Controlled imperfection:** A human is inefficient, forgetful, and
inconsistent. The bot should be too — but in a way that matches
real human distributions.
3. **No single fingerprint:** Every instance should have unique enough
characteristics that correlating two accounts is hard.
4. **Adaptability:** If behavior changes are detected, the system should
be able to recalibrate based on new data.
5. **Defense in depth:** No single countermeasure is sufficient. The
combination across all layers is what provides real protection.
-33
View File
@@ -1,33 +0,0 @@
# Botty-Go
D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows).
Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite
in Go that maintains compatibility with the same config files, templates, and run
logic while adding native Linux support.
## Features
- Cross-platform: Linux (X11/Wayland) and Windows
- Same config format as original Botty (params.ini, game.ini, shop.ini)
- Template matching with OpenCV Go bindings
- Tesseract OCR for item identification
- Human-like mouse movement (Bezier curves)
- BNIP pickit language
- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.)
- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo)
## Building
```bash
# Linux
go build -o botty ./cmd/botty
# Windows (from Linux with cross-compile)
GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty
```
## Configuration
Copy `config/` from the original Botty project. Params, routes, and character
config work identically.
-19
View File
@@ -1,19 +0,0 @@
# Legacy: Go Rewrite Design Notes
These docs are archived from an abandoned `~/git/botty-go` directory (May 2026).
That project was a planned ground-up Go rewrite of `johannes-do/botty` for
cross-platform (Linux + Windows) support. Only design docs existed — no `.go`
source was ever written.
The Python `my-botty` project (this repo) is the active path. These docs are
kept here as **reference material**, primarily for Milestone 2 (anti-detection /
stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`.
## Files
- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers
server-side behavior analysis countermeasures (session scheduling, stochastic
pathing, skill rotation variance) and more. Directly applicable as the design
basis for the Python stealth layer.
- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project.
Context only — explains feature scope and what the rewrite was aiming for.
-83
View File
@@ -1,83 +0,0 @@
# Linux Port Plan (Botty)
## Goal
Make Botty runnable on Linux in phased steps, with clear checkpoints and minimal regressions for current Windows users.
## Current status
Botty is currently Windows-first. Full gameplay flow does not run on Linux due to:
- Windows input stack (`win_input`, Win32 hotkey polling).
- Windows process/window management (`taskkill`, Win32 window APIs, `os.startfile`).
- Windows dependency assumptions (`pywin32`, Windows tesserocr wheel guidance).
- Windows path/env assumptions (`APPDATA`, `C:\...`, `D2R.exe`, `.bat` scripts).
## Principles
- Keep Windows behavior unchanged while adding Linux support.
- Introduce platform abstractions before replacing implementations.
- Land small, testable phases.
- Prefer graceful `NotImplemented` behavior over hard crashes on unsupported paths.
## Phase 1: Platform abstraction layer
1. Add a `platform_adapter` module with interfaces for:
- Input (keyboard/mouse send + hotkeys)
- Window management (find game window, set top-most, geometry)
- Process control (start/stop/check D2R/Battle.net)
2. Route existing Windows calls through adapters.
3. Add Linux stub implementations that fail gracefully with actionable logs.
4. Add unit tests for adapter selection and fallback behavior.
## Phase 2: Linux-safe startup and tooling
1. Add Linux entry script (`run_botty.sh`) and dependency checker shell script.
2. Update startup to avoid Windows-only calls unless platform is Windows.
3. Normalize path handling to `pathlib` where feasible.
4. Ensure `main.py` can start on Linux without immediate import/runtime crashes.
## Phase 3: Linux input backend
1. Implement Linux input backend (X11/Wayland-compatible strategy):
- Candidate libs: `pynput`, `python-xlib`, or tool-backed approach (`xdotool` for X11).
2. Match required Botty features:
- Key press/hold/release
- Mouse move/click with jitter and timing controls
- Hotkey registration/polling
3. Add integration tests/mocks for input primitives.
## Phase 4: Linux screen/window backend
1. Validate capture compatibility for `mss` under target Linux desktop/session.
2. Implement Linux window discovery/focus/geometry handling.
3. Rework DPI/coordinate normalization independent of Win32 APIs.
4. Add diagnostics tool to verify coordinates, capture ROI, and template matching on Linux.
## Phase 5: Process and launcher integration
1. Linux-compatible process management (replace `taskkill` paths).
2. Replace `os.startfile` launcher logic with cross-platform process spawning.
3. Add platform-specific config defaults for game executable path conventions.
## Phase 6: Dependency and OCR strategy
1. Split dependencies by platform (base + windows extras + linux extras).
2. Document Linux OCR setup (tesseract/leptonica packages + python bindings).
3. Add CI matrix entries:
- Windows: full current pipeline
- Linux: import/startup + unit/integration subset first, expand later
## Phase 7: Feature parity validation
1. Verify end-to-end flows:
- Start game, run cycle, maintenance, save/exit, restart handling
2. Validate pickit, stash/sell, discord messaging, stats logging.
3. Benchmark timing-sensitive routines and tune Linux defaults.
## Risk register
- Wayland restrictions can block synthetic input/screen capture depending on compositor.
- Template matching thresholds may differ due to capture pipeline differences.
- Hotkey handling behavior can differ across desktop environments.
- OCR reliability can vary based on font rendering stack.
## Suggested delivery milestones
1. **M1**: Linux no-crash startup + stubs + docs.
2. **M2**: Linux input backend functional in sandbox diagnostics.
3. **M3**: Linux screen/window backend and maintenance loop stable.
4. **M4**: End-to-end run support in supported Linux environments.
## Acceptance criteria
- Botty starts on Linux and logs clear capability status.
- No Windows-only hard failures on Linux code paths.
- Core run loop can execute in a supported Linux environment.
- Windows behavior remains stable and covered by existing tests/CI.
-1
View File
@@ -1 +0,0 @@
-11
View File
@@ -1,11 +0,0 @@
P3
8 8
255
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 255 0 0 0 255 0 0 0 255 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 0 255 0 0 0 255 255 0 0 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 0 0 255 255 255 0 0 255 255 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
-6
View File
@@ -1,6 +0,0 @@
P3
3 3
255
255 0 0 0 255 0 0 0 255
0 255 0 0 0 255 255 0 0
0 0 255 255 255 0 0 255 255
-224
View File
@@ -1,224 +0,0 @@
"""
D2R capture tool - works with Windows DPI scaling.
Set DPI awareness then grab the D2R client area directly.
Keys: F1-full OCR F2-dialogue F3-questlog F4-NPCs F5-pixel F12-exit
"""
import os, sys, cv2, numpy as np, keyboard, win32gui, win32con, ctypes
from datetime import datetime
# Set DPI awareness - this makes Win32 APIs return logical (unscaled) coordinates
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except:
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except:
pass
# Fix tesserocr DLLs
if sys.platform == "win32":
_dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin")
if os.path.isdir(_dll):
os.add_dll_directory(_dll)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
SAVE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots", "debug")
os.makedirs(SAVE, exist_ok=True)
def find_d2r():
hwnds = []
def cb(h, r):
if 'diablo' in win32gui.GetWindowText(h).lower() and win32gui.IsWindowVisible(h):
r.append(h)
win32gui.EnumWindows(cb, hwnds)
return hwnds[0] if hwnds else None
def grab():
"""Grab D2R client area at native 1280x720 resolution."""
from mss import mss
hwnd = find_d2r()
if not hwnd:
print(" [ERROR] D2R not found")
return None
client = win32gui.GetClientRect(hwnd)
w, h = client[2]-client[0], client[3]-client[1]
screen_pos = win32gui.ClientToScreen(hwnd, (0, 0))
with mss() as sct:
region = {
'top': screen_pos[1],
'left': screen_pos[0],
'width': w,
'height': h
}
sct_img = sct.grab(region)
img = np.array(sct_img)[:, :, :3] # BGRA -> BGR
# Resize to 1280x720 if needed
if w != 1280 or h != 720:
img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR)
print(f" [RESIZED] {w}x{h} -> 1280x720")
else:
print(f" [CAPTURED] {w}x{h}")
return img
def ocr(img, roi=None):
try:
from d2r_image.ocr import image_to_text
target = img if roi is None else img[roi[1]:roi[1]+roi[3], roi[0]:roi[0]+roi[2]]
result = image_to_text(target, psm=6, scale=1.5, threshold=25)
return [r.text.strip() for r in result if r.text.strip()]
except Exception as e:
return [f"[OCR ERROR] {e}"]
def save(img, label):
path = os.path.join(SAVE, f"{label}_{datetime.now().strftime('%H%M%S')}.png")
cv2.imwrite(path, img)
print(f" [SAVED] {path}")
return path
# === Handlers ===
def on_f1():
print("\n[=== FULL CAPTURE ===]")
img = grab()
if not img:
return
h, w = img.shape[:2]
print(f" Size: {w}x{h}")
save(img, "full")
# UI detection
print(" UI:")
try:
from ui_manager import ScreenObjects, is_visible
found = False
for name in ['InGame', 'Loading', 'MainMenu', 'OnlineStatus', 'DeathScreen',
'NPCDialogue', 'RightPanel', 'LeftPanel', 'SkillsExpanded']:
obj = getattr(ScreenObjects, name, None)
if obj and is_visible(obj, img):
print(f" [VISIBLE] {name}")
found = True
if not found:
print(" (none)")
except Exception as e:
print(f" [err] {e}")
# Full OCR
print(" OCR:")
lines = ocr(img)
for l in lines[:30]:
print(f" {l}")
if len(lines) > 30:
print(f" ... and {len(lines)-30} more")
def on_f2():
print("\n[=== DIALOGUE ===]")
img = grab()
if not img:
return
save(img, "dialogue")
text = ocr(img, (200, 460, 880, 100))
if text:
print(" NPC says:")
for l in text:
print(f" {l}")
else:
print(" (no NPC text)")
opts = ocr(img, (200, 560, 880, 140))
if opts:
print(" Options:")
for i, o in enumerate(opts):
print(f" [{i}] {o}")
else:
print(" (no options detected - is dialogue box open?)")
def on_f3():
print("\n[=== QUEST LOG ===]")
img = grab()
if not img:
return
save(img, "quest_log")
for l in ocr(img, (200, 100, 880, 520)):
print(f" {l}")
def on_f4():
print("\n[=== NPC DETECTION ===]")
img = grab()
if not img:
return
save(img, "npcs")
try:
import template_finder
from npc_manager import npcs
found = []
for name, data in npcs.items():
for t in data.get("template_group", []):
r = template_finder.search(t, img, threshold=0.35)
if r.valid:
found.append(f" {name} at {r.center_monitor} ({r.score:.2f})")
break
for f in found:
print(f)
if not found:
print(" (none)")
except Exception as e:
print(f" [ERROR] {e}")
def on_f5():
img = grab()
if not img:
return
import mouse as _mouse
mx, my = _mouse.get_position()
hwnd = find_d2r()
if hwnd:
screen_pos = win32gui.ClientToScreen(hwnd, (0, 0))
ix = mx - screen_pos[0]
iy = my - screen_pos[1]
if 0 <= ix < img.shape[1] and 0 <= iy < img.shape[0]:
b, g, r = img[iy, ix]
print(f" ({ix},{iy}) RGB({r},{g},{b})")
else:
print(" Mouse outside D2R client area")
# === Run ===
def run():
print("=== Botty Capture Tool ===")
print(" F1 - Full capture + OCR + UI detection")
print(" F2 - Dialogue capture + OCR")
print(" F3 - Quest log OCR (press O in D2R first)")
print(" F4 - Detect NPCs")
print(" F5 - Mouse pixel color")
print(" F12 - Exit")
print("Ready.")
keyboard.add_hotkey('f1', on_f1)
keyboard.add_hotkey('f2', on_f2)
keyboard.add_hotkey('f3', on_f3)
keyboard.add_hotkey('f4', on_f4)
keyboard.add_hotkey('f5', on_f5)
keyboard.add_hotkey('f12', lambda: (print("\nBye."), sys.exit(0)))
keyboard.wait()
if __name__ == "__main__":
run()
-377
View File
@@ -1,377 +0,0 @@
# Botty Auto-Quest: Implementation Plan
## 1. Architecture
```
src/quest/
__init__.py
quest_manager.py # State machine, quest sequencing, persistence
quest_state.py # JSON-based quest progress tracker
quest_npc.py # Generic NPC interaction utilities (talk to NPC, dialogue selection)
quest_items.py # Quest item detection and management
quest_combat.py # Combat helpers for quest-specific fights
a1/
__init__.py # Act 1 quest runner
q1a1_smith.py # Q1: The Search for the Smith
q1a2_cain.py # Q2: Tools of the Trade
q1a3_sacrifice.py # Q3: Sacrifice
q1a4_town_portal.py# Q4: The Summoner
q1a5_skeleton_king.py # Q5: The Shepherd
q1a6_andariel.py # Q6: The Fallen Angel
a2/
__init__.py # Act 2 quest runner
q2a1_jerhyn.py # Q1: Radament
q2a2_atheistic.py # Q2: The Horadric Staff
q2a3_hephalon.py # Q3: Tyrael's Breath
q2a4_atalai.py # Q4: Secrets
q2a5_tarbedit.py # Q5: The Summoner
q2a6_nihlathak.py # Q6: The Seven Tombs
q2a7_duriel.py # Q7: The Fallen Angel
a3/
__init__.py # Act 3 quest runner
q3a1_larzuk.py # Q1: The Forgotten Tower
q3a2_cain.py # Q2: The Quest for the Horizon
q3a3_kaelthas.py # Q3: The Hellforge
q3a4_hellgate.py # Q4: The Hellgate
q3a5_mephisto.py # Q5: The Prime Evil
a4/
__init__.py # Act 4 quest runner
q4a1_izual.py # Q1: The Fallen Angel
q4a2_harumony.py # Q2: The Fallen Angel
q4a3_diablo.py # Q3: The Prime Evil
a5/
__init__.py # Act 5 quest runner
q5a1_ancients.py # Q1: The Fallen Angel
q5a2_cain.py # Q2: The Quest for the Horizon
q5a3_baal.py # Q3: The Prime Evil
```
## 2. Quest Manager Design
```python
# quest/quest_manager.py
from enum import Enum
import json, os
from config import Config
class QuestStatus(Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
SKIPPED = "skipped"
class QuestManager:
_state_file = "config/quest_state.json"
def __init__(self):
self.state = self._load_state()
self.char = None # reference to IChar
self.pather = None
def _load_state(self):
if os.path.exists(self._state_file):
with open(self._state_file) as f:
return json.load(f)
return self._default_state()
def _default_state(self):
# All 25 quests tracked by (act, quest_number)
return {
"current_act": 1,
"quests": {
"1-1": QuestStatus.PENDING, "1-2": QuestStatus.PENDING,
"1-3": QuestStatus.PENDING, "1-4": QuestStatus.PENDING,
"1-5": QuestStatus.PENDING, "1-6": QuestStatus.PENDING,
"2-1": QuestStatus.PENDING, "2-2": QuestStatus.PENDING,
"2-3": QuestStatus.PENDING, "2-4": QuestStatus.PENDING,
"2-5": QuestStatus.PENDING, "2-6": QuestStatus.PENDING,
"2-7": QuestStatus.PENDING,
"3-1": QuestStatus.PENDING, "3-2": QuestStatus.PENDING,
"3-3": QuestStatus.PENDING, "3-4": QuestStatus.PENDING,
"3-5": QuestStatus.PENDING,
"4-1": QuestStatus.PENDING, "4-2": QuestStatus.PENDING,
"4-3": QuestStatus.PENDING,
"5-1": QuestStatus.PENDING, "5-2": QuestStatus.PENDING,
"5-3": QuestStatus.PENDING,
}
}
def save(self):
with open(self._state_file, 'w') as f:
json.dump(self.state, f, indent=2)
def next_quest(self) -> tuple | None:
"""Returns (act, quest_num) of next pending quest, or None if all done."""
current = self.state["current_act"]
for qn in range(1, 8): # max 7 quests per act
key = f"{current}-{qn}"
if key in self.state["quests"] and self.state["quests"][key] == QuestStatus.PENDING:
return (current, qn)
return None
def mark_complete(self, act, qn):
self.state["quests"][f"{act}-{qn}"] = QuestStatus.COMPLETED
def is_act_complete(self, act):
for qn in range(1, 8):
key = f"{act}-{qn}"
if key in self.state["quests"] and self.state["quests"][key] == QuestStatus.PENDING:
return False
return True
def advance_to_next_act(self):
"""Called when all quests in current act are done."""
current = self.state["current_act"]
# ... handle act transition (talk to quest NPC to unlock next act)
self.state["current_act"] = current + 1
self.save()
def run_next_quest(self):
"""Dispatches to the appropriate quest implementation."""
nxt = self.next_quest()
if not nxt:
return
act, qn = nxt
self.state["quests"][f"{act}-{qn}"] = QuestStatus.IN_PROGRESS
# Route to quest implementation
# ... call quest module
```
## 3. Quest NPC Interaction
Every quest requires talking to NPCs. The existing `npc_manager.py` has `talk_to_npc()` which we extend.
```python
# quest/quest_npc.py
# Utilities for NPC dialogue selection
from utils.custom_mouse import mouse
from utils.misc import wait
from template_finder import search_and_wait
from screen import grab
def talk_and_select(dialogue_choice: str, npc_name: str):
"""
Talk to NPC and select a specific dialogue option.
botty already detects NPC and opens dialogue. We need to click
the specific dialogue button.
"""
from npc_manager import talk_to_npc
talk_to_npc(npc_name)
wait(1)
# D2R shows dialogue options in a box at bottom center.
# Detect the text of each option using OCR and click the matching one.
click_dialogue_option(dialogue_choice)
def click_dialogue_option(text: str):
"""
Use OCR to read dialogue options and click the one containing the given text.
"""
from d2r_image.ocr import image_to_text
img = grab()
roi = (300, 530, 680, 190) # dialogue box area
result = image_to_text(cut_roi(img, roi), psm=6)
for line in result.text.split('\n'):
if text.lower() in line.lower():
# Click near the center of this text line
# ... (use OCR bounding box to find click position)
break
def check_quest_item_on_screen() -> bool:
"""Detect if a quest item tooltip is visible (gold item name)."""
# Quest items have a gold/purple glow. Detect by color.
img = grab()
roi = (400, 400, 200, 200) # quest item pickup area
# Check for gold-colored pixels indicating a quest item
...
```
## 4. Quest Item Tracking
Quest items (keys, scrolls, weapons) need to be tracked. We extend the inventory system.
```python
# quest/quest_items.py
QUEST_ITEMS = {
"stone_of_jah": "Stone of Jah",
"hephaestons_key": "Hephaston's Key",
"tal_rashas_will": "Tal Rasha's Will",
"keys_to_the_crypt": "Keys to the Crypt",
"harumony": "Harumony",
"ancients_battle_order": "Ancient's Battle Order",
"horadric_cube": "Horadric Cube",
"horadric_staff": "Horadric Staff",
"amulet_of_the_vipers": "Amulet of the Vipers",
}
def has_quest_item(name: str) -> bool:
"""Check if quest item is in inventory."""
# Use OCR to scan inventory for item name
# OR check by item template matching (faster)
...
def equip_quest_item(name: str):
"""Click on quest item in inventory to equip it."""
...
def pickup_quest_item():
"""Detect and pickup quest items on ground (gold glow)."""
# Quest items have a gold/purple glow around them
# Detect with color thresholding
...
```
## 5. Quest Combat
Most quests involve fighting a boss or clearing a path. Botty already has combat logic.
```python
# quest/quest_combat.py
from char.i_char import IChar
from pather import Pather
def kill_boss(boss_name: str, atk_len: float, char: IChar, pather: Pather):
"""
Navigate to boss, fight until dead.
Reuses botty's existing kill_* methods from run/*.py
"""
# 1. Find boss on screen
# 2. Move to boss position
# 3. Attack until dead (same as run/trav.py)
# 4. Return to town
...
def clear_room(char: IChar, pather: Pather):
"""Clear a room of monsters (for quests requiring room clearance)."""
# Same logic as pather.follow_path() but with combat
...
```
## 6. Integration with Bot State Machine
The existing bot uses a state machine (transitions library). We add quest states.
```python
# bot.py changes
# Add quest states to the state machine
self._states = self._states + [
'quest', 'quest_act_transition'
]
# Add quest transitions
self._transitions = self._transitions + [
{'trigger': 'run_quest', 'source': 'town', 'dest': 'quest', 'before': 'on_run_quest'},
{'trigger': 'end_quest', 'source': 'quest', 'dest': 'town', 'before': 'on_end_quest'},
{'trigger': 'act_transition', 'source': 'town', 'dest': 'quest_act_transition',
'before': 'on_act_transition'},
]
def on_run_quest(self):
"""Start next quest."""
from quest.quest_manager import QuestManager
qm = QuestManager()
nxt = qm.next_quest()
if nxt:
act, qn = nxt
quest_func = self._get_quest_function(act, qn)
quest_func()
qm.mark_complete(act, qn)
qm.save()
def on_end_quest(self):
"""After a quest completes, check if act is done."""
qm = QuestManager()
if qm.is_act_complete(self.current_act):
self.trigger_or_stop('act_transition')
else:
self.trigger_or_stop('run_quest')
def on_act_transition(self):
"""Transition to next act (talk to NPC, unlock next act)."""
qm = QuestManager()
qm.advance_to_next_act()
# ... handle act transition logic
self.trigger_or_stop('start_from_town')
```
## 7. Configuration
Add to `params.ini`:
```ini
[quest]
; Enable quest mode (disables farming runs until quests are done)
enabled = 1
; Skip quests that are too dangerous for your character level
; 0 = run all quests, 50 = skip quests below level 50
min_level = 0
; Auto-stash quest items between acts
auto_stash_quest_items = 1
; Continue farming after all quests are done
farm_after_quest = 1
```
## 8. Implementation Order
### Phase 1: Foundation (Quest Manager + NPC Interaction)
- quest_manager.py with state tracking
- quest_npc.py for dialogue interaction
- quest_state.py for persistence
### Phase 2: Act 1 (6 quests)
- Q1: Search for the Smith (kill Rats, talk to Charsi)
- Q2: Tools of the Trade (get items from Andariel's lair)
- Q3: Sacrifice (kill Skeletons at Crypt)
- Q4: The Summoner (kill Summoner in Catacombs)
- Q5: The Shepherd (kill Skeleton King + Gargothon)
- Q6: The Fallen Angel (kill Andariel)
### Phase 3: Act 2 (7 quests)
- Q1: Radament
- Q2: The Horadric Staff (combine Cube + Scroll)
- Q3: Tyrael's Breath (clear Sewers, talk to Alkor)
- Q4: Secrets (activate 4 stone tablets)
- Q5: The Summoner (kill Duriel)
- Q6: The Seven Tombs (kill all 7 tomb bosses)
- Q7: The Fallen Angel (kill Duriel again)
### Phase 4: Act 3 (5 quests)
- Q1: The Forgotten Tower
- Q2: The Quest for the Horizon (Cain)
- Q3: The Hellforge
- Q4: The Hellgate
- Q5: The Prime Evil (Mephisto)
### Phase 5: Act 4 (3 quests)
- Q1: The Fallen Angel (Harumony)
- Q2: The Fallen Angel (Tal Rasha's Will)
- Q3: The Prime Evil (Diablo)
### Phase 6: Act 5 (3 quests)
- Q1: The Fallen Angel (Ancient's Battle Order)
- Q2: The Quest for the Horizon (Cain)
- Q3: The Prime Evil (Baal)
## 9. Testing Strategy
1. Test each quest independently in singleplayer
2. Test quest-to-quest transitions
3. Test act transitions
4. Test recovery from death during quest
5. Test with different character builds
## 10. Challenges
- **NPC dialogue**: D2R has branching dialogue. Need to handle all paths.
- **Quest items**: Some quests require carrying specific items (keys, weapons).
- **Room secrets**: Act 2 Q4 requires finding hidden passages.
- **Multi-stage quests**: Some quests span multiple areas (Act 3 Q2-4).
- **Character level**: Quests are designed for specific levels. A level 90 character kills everything too fast/slow.
- **Anti-cheat**: Questing is more visible to anti-cheat than farming. Need stealth settings.
-246
View File
@@ -1,246 +0,0 @@
"""
Quest screenshot capture tool.
Guides you through capturing specific game screens needed for building
the quest system.
Usage:
1. Launch D2R, create/select your character
2. Run: python quest_screenshot_tool.py
3. Follow the prompts
Screenshots saved to screenshots/quest/
"""
import os
import sys
import time
import numpy as np
import cv2
from datetime import datetime
# Fix tesserocr DLL loading
if sys.platform == "win32":
_conda_dll_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "conda_env", "Library", "bin")
if not os.path.isdir(_conda_dll_dir):
_conda_dll_dir = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin")
if os.path.isdir(_conda_dll_dir):
os.add_dll_directory(_conda_dll_dir)
from screen import find_and_set_window_position, get_offset_state, grab as screen_grab
QUEST_DIR = "screenshots/quest"
os.makedirs(QUEST_DIR, exist_ok=True)
def capture():
"""Capture the D2R window using botty's grab()."""
find_and_set_window_position()
if not get_offset_state():
print("[ERROR] Could not find D2R window. Is D2R running and visible?")
return None
return screen_grab(force_new=True)
def save(img, name):
"""Save with timestamp prefix."""
ts = datetime.now().strftime("%H%M%S")
path = os.path.join(QUEST_DIR, f"{ts}_{name}.png")
cv2.imwrite(path, img)
abs_path = os.path.abspath(path)
print(f" Saved: {abs_path}")
return abs_path
STEPS = [
{
"name": "act1_town_overview",
"instructions": (
"\nSTEP 1: Act 1 Town Overview\n"
"----------------------------------------\n"
"1. Stand in the middle of Act 1 town (New Tristram)\n"
"2. Make sure NO menus are open (close inventory, skills, etc.)\n"
"3. Position your character so all NPCs are visible\n"
"Press ENTER when the screen shows the full town...\n"
),
},
{
"name": "akara_dialogue_first",
"instructions": (
"\nSTEP 2: Akara - First Dialogue Screen\n"
"----------------------------------------\n"
"1. Walk up to Akara and LEFT-CLICK her\n"
"2. The dialogue box should appear with options\n"
"3. DO NOT click any option - just show this screen\n"
"Press ENTER when the first dialogue is visible...\n"
),
},
{
"name": "akara_dialogue_second",
"instructions": (
"\nSTEP 3: Akara - Second Dialogue Screen\n"
"----------------------------------------\n"
"1. Click the FIRST dialogue option (usually the quest-related one)\n"
"2. The next set of options should appear\n"
"3. This shows the quest dialogue choices\n"
"4. DO NOT click any option - just show this screen\n"
"Press ENTER when the second dialogue is visible...\n"
),
},
{
"name": "akara_quest_given",
"instructions": (
"\nSTEP 4: Quest Given Notification\n"
"----------------------------------------\n"
"1. Click the quest-related option to accept the quest\n"
"2. After the dialogue completes, press ESC to close it\n"
"3. Show the screen with the quest notification/text\n"
" (a message should appear on screen about the quest)\n"
"Press ENTER when you see the quest notification...\n"
),
},
{
"name": "quest_log_open",
"instructions": (
"\nSTEP 5: Quest Log\n"
"----------------------------------------\n"
"1. Press 'O' to open the Quest Log\n"
"2. The quest log panel should be visible\n"
"3. Show the full quest log\n"
"Press ENTER when the quest log is open...\n"
),
},
{
"name": "charsi_dialogue",
"instructions": (
"\nSTEP 6: Charsi NPC Dialogue\n"
"----------------------------------------\n"
"1. Walk up to Charsi\n"
"2. LEFT-CLICK her to open dialogue\n"
"3. Show the first dialogue screen\n"
"Press ENTER when Charsi's dialogue is open...\n"
),
},
{
"name": "kashya_dialogue",
"instructions": (
"\nSTEP 7: Kashya NPC Dialogue\n"
"----------------------------------------\n"
"1. Walk up to Kashya (the skill teacher)\n"
"2. LEFT-CLICK her to open dialogue\n"
"3. Show the first dialogue screen\n"
"Press ENTER when Kashya's dialogue is open...\n"
),
},
{
"name": "sewer_entrance",
"instructions": (
"\nSTEP 8: Sewer / Rat Area Entrance\n"
"----------------------------------------\n"
"1. Go to the sewer entrance (south of town)\n"
"2. Stand near the entrance looking into the rat area\n"
"3. This is for the first quest (kill rats)\n"
"Press ENTER when you can see the rat area...\n"
),
},
{
"name": "item_on_ground",
"instructions": (
"\nSTEP 9: Item on the Ground\n"
"----------------------------------------\n"
"1. Kill some rats in the sewer\n"
"2. If a quest item drops (gold glow), show it on the ground\n"
"3. If no quest item drops, show ANY item on the ground\n"
"4. The item name tooltip should be visible\n"
"Press ENTER when an item is visible on the ground...\n"
),
},
{
"name": "inventory_with_item",
"instructions": (
"\nSTEP 10: Inventory with Item Tooltip\n"
"----------------------------------------\n"
"1. Pick up the item\n"
"2. Press 'I' to open inventory\n"
"3. Hover over the item to show its tooltip\n"
"4. Show the tooltip with the item name visible\n"
"Press ENTER when the item tooltip is visible...\n"
),
},
{
"name": "dialogue_box_full",
"instructions": (
"\nSTEP 11: Full Dialogue Box (Any NPC)\n"
"----------------------------------------\n"
"1. Talk to ANY NPC\n"
"2. Get to a screen with 3+ dialogue options\n"
"3. Show the full dialogue box with all options visible\n"
"4. This helps us measure the dialogue button positions\n"
"Press ENTER when a dialogue with multiple options is visible...\n"
),
},
{
"name": "game_start_menu",
"instructions": (
"\nSTEP 12: Game Start / Difficulty Selection\n"
"----------------------------------------\n"
"1. Save & Exit to return to hero selection\n"
"2. Click Play (or let botty do it)\n"
"3. Show the difficulty selection screen\n"
" (Normal/Nightmare/Hell buttons)\n"
"Press ENTER when the difficulty screen is visible...\n"
),
},
]
def run():
print("=" * 60)
print(" Botty Quest Screenshot Tool")
print("=" * 60)
print()
print("Make sure D2R is running and visible on screen.")
print("You'll be guided through capturing each needed screen.")
print()
print("Press ENTER to start...")
input()
captured = []
failed = []
for i, step in enumerate(STEPS, 1):
print()
print(step["instructions"])
try:
input() # wait for user
img = capture()
if img is not None:
path = save(img, step["name"])
captured.append((step["name"], path))
print(f" [OK] {step['name']}")
else:
print(f" [FAIL] Could not capture for {step['name']}")
failed.append(step["name"])
except KeyboardInterrupt:
print("\n[STOPPED]")
break
# Summary
print()
print("=" * 60)
print(" Capture Summary")
print("=" * 60)
print(f" Captured: {len(captured)}/{len(STEPS)}")
for name, path in captured:
print(f" [OK] {name}")
if failed:
print(f" Failed: {len(failed)}")
for name in failed:
print(f" [XX] {name}")
print()
print(f"All screenshots in: {os.path.abspath(QUEST_DIR)}")
print()
if __name__ == "__main__":
run()
-25
View File
@@ -1,25 +0,0 @@
# Botty Improvements Implementation Plan
## Phase 1: Key Auto-Detection (Issue #940/#905) [DONE]
- `src/utils/key_detector.py` reads D2R .key/.keyo files
- Auto-fills empty char section hotkeys. Wired into config.py load_data()
- Key normalization: left alt ~ alt, left shift ~ shift
- Test: `tools/test_key_detector.py` and `test/test_key_detector.py` (all pass)
## Phase 2: Target Detection False Positives (Issues #959/#964) [DONE]
- Added aspect ratio filtering in `_add_markers()`
- Rejects health bars (w/h > 3.0) and immune text (w/h < 0.5)
- `TARGET_ASPECT_MIN = 0.5`, `TARGET_ASPECT_MAX = 3.0`
## Phase 3: Pickit Timing (Issue #939) [DONE]
- Added 200-300ms wait after `_yoink_item` pickup
- Prevents bot teleporting before item grab animation completes
## Phase 4: Hardcore Chicken Loop (Issue #942) [DONE]
- Added `hardcore` config flag (default 0)
- On HC death: exits safely instead of infinite restart loop
- Sends discord message if enabled
## Phase 5: Parallel Template Search (Issue #848) [PENDING]
## Phase 6: Async Mouse Moves (Issue #955) [PENDING]
## Phase 7: Auto-Label NPCs (Issue #950) [PENDING]
-13
View File
@@ -1,13 +0,0 @@
@echo off
setlocal
set "BOTTY_DIR=%~dp0"
cd /d "%BOTTY_DIR%"
call "%BOTTY_DIR%find_python.bat"
echo === D2R Quick Capture ===
echo Run this, D2R must be visible
echo Press ENTER when D2R is ready...
pause >nul
%PYTHON% "%BOTTY_DIR%asset_extractor.py"
+6 -50
View File
@@ -7,42 +7,13 @@ Import as:
This is a drop-in replacement for the existing `import keyboard` and
`from utils.custom_mouse import mouse` patterns.
On non-Windows (Docker/Linux), uses bridge_input to talk to a Windows host
via TCP. Set BOTTY_BRIDGE_HOST / BOTTY_BRIDGE_PORT env vars.
"""
import os as _os
import threading as _threading
if _os.name == "nt":
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
else:
from .bridge_input import (
_get_vk, key_down, key_up, key_press, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
# bridge_input has no send_key; provide stub
def send_key(key):
key_press(key)
if _os.name == "nt":
from .mouse_impl import mouse
else:
# In bridge mode, mouse_impl imports from win_input which won't work.
# Provide a thin wrapper that delegates to bridge_input.
class _BridgeMouse:
def move_to(self, x, y): mouse_move(x, y)
def click(self, button="left"): mouse_click(button)
def down(self, button="left"): mouse_down(button)
def up(self, button="left"): mouse_up(button)
def wheel(self, clicks): mouse_wheel(clicks)
def get_pos(self): return get_cursor_pos()
mouse = _BridgeMouse()
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
from .mouse_impl import mouse
class _Keyboard:
"""
@@ -205,22 +176,11 @@ class _Keyboard:
def add_hotkey(self, key: str, callback, suppress: bool = False):
"""Register a global hotkey callback."""
if _os.name != "nt":
# In Docker, hotkeys are not available — no-op
return
from .hotkey import add_hotkey as _add_hotkey
_add_hotkey(key, callback, suppress=suppress)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
if _os.name != "nt":
# In Docker, block forever (or until SIGTERM) — bot runs headless
import signal
event = _threading.Event()
signal.signal(signal.SIGTERM, lambda *_: event.set())
signal.signal(signal.SIGINT, lambda *_: event.set())
event.wait()
return
from .hotkey import wait as _wait
return _wait(key, suppress=suppress)
@@ -230,15 +190,11 @@ class _Keyboard:
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events (dev tools only)."""
if _os.name != "nt":
return
from .hotkey import hook as _hook
return _hook(callback, suppress=suppress)
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing (used by npc_auto_label.py)."""
if _os.name != "nt":
return
from .hotkey import pause as _pause
return _pause(seconds, suppress)
+10 -25
View File
@@ -92,11 +92,8 @@ def _log_platform_info():
Logger.info(f"Environment profile: {os_info.environment_file}")
Logger.info(f"Requirements profile: {os_info.requirements_file}")
try:
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
except ImportError:
mouse_mode = "bridge (Docker)"
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
Logger.info(f"Mouse input mode: {mouse_mode}")
# OCR backend check
@@ -146,19 +143,15 @@ def main():
startup_checks()
# Auto-launch D2R only when explicitly enabled in params.ini (auto_login=1)
# In Docker, D2R runs on the Windows host — skip process checks
if os.name == "nt":
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
if Config().general["auto_login"]:
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
else:
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
if Config().general["auto_login"]:
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
else:
Logger.info("D2R is already running")
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
else:
Logger.info("Running in Docker — D2R process check skipped (bridge server handles host interaction)")
Logger.info("D2R is already running")
print(f"============ Botty {__version__} [name: {Config().general['name']}] ============")
_profiles = Config.list_profiles()
@@ -268,15 +261,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
if os.name != "nt":
Logger.info("Docker mode — auto-starting bot")
screen.start_detecting_window()
controllers.game.start()
# Wait for SIGTERM/SIGINT to shut down
keyboard.wait()
else:
keyboard.wait()
keyboard.wait()
if __name__ == "__main__":
-9
View File
@@ -1,9 +0,0 @@
@echo off
:: Launch botty detached with console output captured to log\console_<rand>.log
:: (used for unattended/remote starts where no interactive console exists)
cd /d "C:\Users\alex\Downloads\my-botty"
:: Single-instance guard: F11/F12 are GLOBAL hotkeys, so two bot instances
:: receive every press and fight each other (one starts, the other pauses).
powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | Where-Object {$_.CommandLine -like '*my-botty*main.py*'} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
set "TS=%RANDOM%"
call "C:\Users\alex\Downloads\my-botty\run_botty.bat" > "C:\Users\alex\Downloads\my-botty\log\console_%TS%.log" 2>&1
-11
View File
@@ -1,11 +0,0 @@
$taskName = 'RunBottyNow'
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute 'C:\Users\alex\.conda\envs\botty\python.exe' -Argument 'C:\Users\alex\Downloads\my-botty\src\main.py' -WorkingDirectory 'C:\Users\alex\Downloads\my-botty'
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$principal = New-ScheduledTaskPrincipal -UserId 'alex' -LogonType Interactive -RunLevel Limited
Register-ScheduledTask -TaskName $taskName -Action $action -Settings $settings -Principal $principal -Force
Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 5
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Get-Process python -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq 1 } | Select-Object Id,SessionId,StartTime | Format-Table
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

-129
View File
@@ -1,129 +0,0 @@
# Botty Singleplayer Test Plan
## Pre-Flight Checklist
Before running, verify:
- [ ] D2R installed and running at 1280x720 windowed
- [ ] D2R language is English
- [ ] Offline play is available (no battle.net required for singleplayer)
- [ ] Character is a sorceress with Town Portal + Teleport (level 65+)
- [ ] Belt setup: Col 1=HP potions, Col 2=MP potions, Col 3-4=Rejuv
- [ ] Skill hotkeys match params.ini:
- F1 = Nova (right skill, pre-selected)
- F3 = Energy Shield, F4 = Frozen Armor, F5 = Static Field
- 5 = Teleport, 6 = Town Portal
- W = weapon switch, 7 = Battle Orders, 8 = Battle Command
## Phase 1: Launch Test
Goal: Verify botty starts without import errors.
1. Open cmd, activate the botty env:
conda activate botty
cd C:\Users\alex\Downloads\my-botty
2. Run: python src\main.py
Expected:
- "============ Botty 0.8.1-dev [name: bigfont] ============="
- Hotkey table (F7, F8, F9, F10, F11, F12)
- Console idles waiting for hotkey input
- No Python errors in console
If it crashes here, we fix the import error before proceeding.
## Phase 2: Auto Settings
Goal: Apply the required D2R graphics settings so botty's template matching works.
Preparation:
- D2R must be running (on main menu or character selection)
- Press F9 in botty console
Expected:
- Botty reads your Settings.json and rewrites it
- Botty sets launch options: -mod bigfont -txt
- Console prints: "Adapted settings successfully"
- You restart D2R and it runs at 720p with low settings
After this, restart D2R once.
## Phase 3: Character Selection
Goal: Botty detects the character selection screen and clicks Play.
1. Start D2R, navigate to character selection
2. Highlight your Nova sorc character (left-click it)
3. Make sure the "Offline" tab is selected (bottom left of char selection)
4. Press F11 in botty
Expected console output:
- "Wait for Play button" / "Found Play Btn"
- D2R difficulty key pressed (H = Hell)
- D2R loading screen appears (black screen)
Press F12 to stop botty once the loading screen appears.
If botty can't find the Play button:
- Make sure D2R is at 720p windowed (not fullscreen, not borderless)
- Make sure the window is not minimized
## Phase 4: Full Run Cycle (Travincal)
Goal: Complete first run without manual intervention.
1. D2R at character selection, sorc selected
2. Press F11
3. Watch botty:
- Selects character, starts game
- Loads into town (Kurast for Act 3 / Lut Gholein)
- Pre-buffs: Energy Shield -> Battle Orders -> Battle Command
- Casts Town Portal on ground, steps through
- Teleports to Travincal area, enters the dungeon
- Finds Travincal, attacks with Nova (right click)
- Travincal dies
- Picks up loot
- Town Portal back to town
- Save & Exit
- Returns to hero selection
- Restarts automatically
Watch for problems:
- Character doesn't move -> check Always Run is ON
- Character doesn't attack -> check Nova is pre-selected right skill
- Character doesn't TP -> check teleport hotkey (5)
- Character gets stuck pathing -> bot may need to adjust, or D2R settings off
## Phase 5: Second Run (Eldritch/Shenk)
After Trav completes, botty starts a new game and does the second configured run. Same flow, different destination.
## Phase 6: Multi-Game Stability
Run 5-10 complete rotations without touching the keyboard. Watch for:
- Consistent runs (no crashes or freezes)
- Potions being consumed
- Bot recovering from failures (auto-restart via chicken/save-and-exit)
- Memory/Python stability
## Hotkeys
F9 = Auto Settings (reconfigure D2R graphics)
F11 = Start/Pause bot
F12 = Stop bot
## Troubleshooting
- "No play button found" -> D2R not at main menu, or wrong resolution
- Character doesn't move -> "Always Run" not enabled in D2R options
- Character doesn't attack -> right skill not set to Nova, or hotkey wrong
- "Failed to detect MAIN_MENU" -> restart D2R, make sure window is visible
- OCR errors on character name -> ensure -mod bigfont is in launch options