Files
my-botty/tools/testbed.py
T
alexpolo1andClaude Sonnet 4.6 f42637a5d8 Add Hermes: vision-LLM agent for live D2R game analysis
tools/hermes.py grabs the current D2R window (same grab() path the bot
uses), sends it to the local Qwen vision model at 192.168.1.98:8010,
and runs an interactive REPL. The system prompt gives Hermes full context
on coordinate systems, template matching, input layer, and bot patterns
so it can reason about what's on screen and suggest feature code.

  <botty-env-python> tools/hermes.py [initial prompt]
  <botty-env-python> tools/testbed.py hermes [initial prompt]

Also wired into testbed.py as the 'hermes' scenario.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-16 20:44:51 +02:00

222 lines
9.5 KiB
Python

"""
Testbed: named in-game test runs for individual bot subsystems.
Each scenario assumes D2R is in the required state (noted below), runs ONE
subsystem in isolation, and reports — so features can be tested and improved
without farming runs around them.
<botty-env-python> tools/testbed.py <scenario>
Scenarios:
binds Verify + auto-set skill hotkeys from params/profile (in a game).
capture Capture skill-slot icon templates for the current binds (in a game).
audit Audit all stash pages against the current pickit; list FREE-able
items (in a game, stash OPEN).
gems Force-run the gem transmute routine: cube flawless -> perfect from
stash, restock per stash_destination (in a game, stash OPEN).
stash Force-run the stashing routine: deposit gold + stash keep-flagged
inventory items, with page rotation (in a game, stash OPEN).
keyo Verify Controls-page key bindings (.keyo) match params (anywhere;
--fix requires D2R closed).
loot Unit-check pickup priority order + dive-mode flag (no game needed).
hermes Interactive LLM vision agent: grabs live D2R screen and sends it to
the local Qwen model for game-state analysis / feature development.
Optional: pass an initial prompt as extra args (no game state needed
for the LLM call itself, but D2R must be visible to grab).
State tip: to get 'stash OPEN with the bot paused', start the bot, and the
moment the log prints 'Stashing items', press F11 — or run a watcher like
log/_stash_pause_watcher.ps1.
"""
import os
import subprocess
import sys
TOOLS = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(TOOLS)
PY = sys.executable
def _run_tool(script: str, *args) -> int:
return subprocess.call([PY, os.path.join(TOOLS, script), *args], cwd=ROOT)
def _patch_ssl():
# The conda env's python chokes on a malformed cert in the Windows store
# (ssl.SSLError: ASN1 NOT_ENOUGH_DATA) when aiohttp builds its default
# context at import time. Fall back to certifi's bundle.
import ssl
_orig = ssl.SSLContext.load_default_certs
def _safe(self, purpose=ssl.Purpose.CLIENT_AUTH):
try:
_orig(self, purpose)
except ssl.SSLError:
try:
import certifi
self.load_verify_locations(certifi.where())
except Exception:
pass
ssl.SSLContext.load_default_certs = _safe
def _gems() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
import time
print("Stash must be OPEN in-game. Forcing gem transmute in 5s...")
time.sleep(5)
from screen import start_detecting_window, stop_detecting_window
from game_stats import GameStats
from transmute import Transmute
start_detecting_window()
try:
Transmute(GameStats()).run_transmutes(force=True)
finally:
stop_detecting_window()
return 0
def _stash() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
import time
print("Requires being in a game, in town. Running stash routine in 5s...")
time.sleep(5)
from screen import start_detecting_window, stop_detecting_window, grab
start_detecting_window()
try:
from config import Config
from inventory import personal, common
from inventory import stash as stash_state
from ui_manager import is_visible, ScreenObjects
img = grab()
stash_open = (is_visible(ScreenObjects.GoldBtnStash, img)
or is_visible(ScreenObjects.GoldBtnInventory, img))
if not stash_open:
print("stash: stash UI not open — walking to the stash chest...")
import template_finder
from ui import main_menu
from pather import Pather
from item.pickit import PickIt
from town import TownManager, A1, A2, A3, A4, A5
pather = Pather()
if Config().char["type"] in ("hammerdin", "paladin"):
from char.paladin.hammerdin import Hammerdin
char = Hammerdin(Config().hammerdin, pather, PickIt())
else:
from char.basic import Basic
char = Basic(Config().basic, pather)
char.discover_capabilities()
tm = TownManager(A1(pather, char), A2(pather, char), A3(pather, char),
A4(pather, char), A5(pather, char))
if template_finder.search(main_menu.MAIN_MENU_MARKERS, grab(), best_match=True).valid:
print("stash: on main menu — creating a game first...")
if not main_menu.start_game():
print("stash: FAIL — could not create a game from the main menu")
return 1
act = tm.wait_for_town_spawn()
else:
act = tm.detect_current_act()
if act is None:
print("stash: FAIL — no town marker found (is the char in town?)")
return 1
if not tm.open_stash(act):
print("stash: FAIL — could not open the stash chest")
return 1
if not common.wait_for_left_inventory():
print("stash: FAIL — stash UI not detected after opening")
return 1
# "all" widens the scan to all 10 inventory columns so existing items
# (charms etc. in cols 4-9) get inspected — used to exercise the
# keep-item transfer branch when the loot columns (0-3) are empty.
if "all" in sys.argv[2:]:
prev_cols = Config().char["num_loot_columns"]
Config().char["num_loot_columns"] = 10
print(f"stash: scanning all 10 columns (was {prev_cols}) to test keep-item path")
items = personal.inspect_items(grab(), close_window=False, ignore_sell=True) or []
keep = [i for i in items if i.keep]
print(f"stash: inventory has {len(items)} item(s), {len(keep)} flagged keep"
+ (f" ({', '.join(i.name for i in keep)})" if keep else ""))
pages = stash_state.get_curr_stash()
print(f"stash: starting pages — items={pages['items']}, gold={pages['gold']}")
personal.set_inventory_gold_full(True) # force the gold-deposit branch
left = personal.stash_all_items(items if keep else None)
leftover = [i for i in (left or []) if i.keep]
gold_done = is_visible(ScreenObjects.GoldNone)
print(f"stash: gold deposited: {'yes' if gold_done else 'NO — gold still in inventory'}")
if leftover:
if "all" in sys.argv[2:]:
# In `all` mode cols 4-9 include the charm/belt area. Ctrl+click on those
# positions is blocked by D2R (equipped-area guard) so the items can't be
# stashed — they're meant to STAY in inventory. The correct behavior after
# the Bug-19 fix is: detect the transfer failure, leave them in inventory,
# and never call stash_full(). That is a PASS, not a FAIL.
unstashable = [i for i in leftover if i.column is not None and i.column >= 4]
real_fail = [i for i in leftover if i.column is None or i.column < 4]
if unstashable:
print(f"stash: OK — {len(unstashable)} item(s) in equipped-area cols "
f"correctly left in inventory (not stash-full): "
f"{', '.join(i.name for i in unstashable)}")
if real_fail:
print(f"stash: FAIL — {len(real_fail)} keep item(s) in loot cols "
f"left in inventory: {', '.join(i.name for i in real_fail)}")
return 1
print("stash: PASS — gold branch ran; equipped-area items correctly retained")
return 0
print(f"stash: FAIL — {len(leftover)} keep item(s) left in inventory: "
f"{', '.join(i.name for i in leftover)}")
return 1
print("stash: PASS — gold branch ran and no keep items left in inventory")
return 0
finally:
stop_detecting_window()
def _loot() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import time
import health_manager as hm
from item.pickit import PickIt
class FakeItem:
def __init__(self, name, dist):
self.Name, self.Distance = name, dist
items = [FakeItem("SUPER MANA POTION", 50), FakeItem("JAH RUNE", 400), FakeItem("700 GOLD", 20)]
ordered = sorted(items, key=lambda i: (0 if PickIt._is_high_value(i) else 1, i.Distance))
assert ordered[0].Name == "JAH RUNE", [i.Name for i in ordered]
hm.set_loot_priority(True, duration=0.2)
assert hm.loot_priority_active()
time.sleep(0.3)
assert not hm.loot_priority_active()
print("loot: PASS (Jah-first ordering, dive flag + expiry)")
return 0
def main():
scenarios = {
"binds": lambda: _run_tool("set_binds_from_params.py"),
"capture": lambda: _run_tool("capture_skill_hotkeys.py"),
"audit": lambda: _run_tool("audit_stash_pickit.py"),
"keyo": lambda: _run_tool("set_controls_keyo.py", *sys.argv[2:]),
"gems": _gems,
"stash": _stash,
"loot": _loot,
"hermes": lambda: _run_tool("hermes.py", *sys.argv[2:]),
}
if len(sys.argv) < 2 or sys.argv[1] not in scenarios:
print(__doc__)
raise SystemExit(1)
raise SystemExit(scenarios[sys.argv[1]]())
if __name__ == "__main__":
main()