Files
my-botty/tools/firebolt_roam.py
alexpolo1andClaude Opus 5 fc9a7bfae1 fix(misc): make focus_d2r_window actually win the foreground
Windows refuses SetForegroundWindow from a process that does not already own
the foreground. The refusal is silent-ish and was swallowed, so the window never
came forward and every subsequent key and click went to whatever window WAS
focused. Measured on a live client: pressing the inventory key changed 0.89 mean
pixel value (i.e. nothing happened); after the fix, 16.44.

Attach our input queue to the current foreground thread for the duration of the
call — the documented way round the foreground lock — and return True only if
D2R actually ended up foreground, instead of returning True for "no exception
was raised".

Also harden tools/firebolt_roam.py with two preflights, because both failure
modes are silent — the character just stands there:
  - refuse to start if D2R cannot be brought to the foreground
  - refuse to start if the attack hotkey does not change the right-skill icon,
    i.e. no skill is bound to it (--skip-skill-check overrides)

Verified live: with nothing bound to F1 the tool now exits with an actionable
message rather than roaming silently casting nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 09:20:45 +02:00

138 lines
5.7 KiB
Python

"""
Roam wherever the character is standing and cast one skill at whatever moves.
Built for levelling a fresh character in an open area (Blood Moor, Cold Plains).
Unlike src/run/cold_plains.py this does NOT use the waypoint, the town manager
or the bot state machine — it starts from wherever the character already is, so
it needs no waypoints and works at clvl 1.
<botty-env-python> tools/firebolt_roam.py --key f1 --minutes 10
Put Fire Bolt on that key in game. At clvl 12 put Fireball on the same key and
change nothing else.
Press the stop key (default F12) at any time to stop. Ctrl+C also works.
SAFETY: there is no potion, chicken or death handling here. It will happily keep
casting while dying. Watch it rather than leaving it unattended.
"""
import argparse
import math
import os
import random
import sys
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "src"))
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("--key", default="f1", help="hotkey holding the attack skill (default: f1)")
ap.add_argument("--button", default="right", choices=["left", "right"],
help="mouse button the skill casts on (default: right)")
ap.add_argument("--minutes", type=float, default=10.0, help="stop after this long (default: 10)")
ap.add_argument("--radius", type=int, default=600, help="ignore targets further than this (px)")
ap.add_argument("--casts", type=int, default=4, help="casts per engagement before rescanning")
ap.add_argument("--cast-delay", type=float, default=0.25, help="seconds between casts")
ap.add_argument("--stop-key", default="f12", help="press to stop (default: f12)")
ap.add_argument("--skip-skill-check", action="store_true",
help="don't verify the hotkey actually selects a skill")
opts = ap.parse_args()
import screen
from config import Config
from input_layer import keyboard, mouse
from screen import convert_abs_to_monitor
from target_detect import get_visible_targets
from utils.misc import focus_d2r_window, wait
screen.find_and_set_window_position(force=True)
if not screen.get_offset_state():
print("ERROR: D2R window not found. Is the game running and visible?")
return 2
# Without this every key and click goes to whatever window IS focused and
# the tool silently does nothing at all — no error, just a character
# standing still. Refuse to start rather than pretend to work.
if not focus_d2r_window():
print("ERROR: could not bring D2R to the foreground — input would go to "
"another window. Click on D2R and re-run.")
return 3
# Verify a hotkey actually changes the right-skill icon, i.e. the key really
# has a skill on it. A fresh character has nothing bound, in which case this
# would roam around casting nothing.
import numpy as np
rx, ry, rw, rh = Config().ui_roi["skill_right"]
icon_before = screen.grab(force_new=True)[ry:ry+rh, rx:rx+rw].astype(np.int16)
keyboard.send(opts.key)
wait(0.45, 0.6)
icon_after = screen.grab(force_new=True)[ry:ry+rh, rx:rx+rw].astype(np.int16)
if np.abs(icon_after - icon_before).mean() < 2 and not opts.skip_skill_check:
print(f"ERROR: pressing '{opts.key}' does not change the right-skill "
f"icon — no skill is bound to it. Assign your attack skill to "
f"{opts.key.upper()} in game (right-click it on the skill tree), "
f"or pass --skip-skill-check to override.")
return 4
force_move = Config().char["force_move"]
deadline = time.time() + opts.minutes * 60
kills_attempted = 0
roams = 0
print(f"roaming: key={opts.key} button={opts.button} radius={opts.radius} "
f"for {opts.minutes:g} min — press {opts.stop_key.upper()} to stop")
def stopping() -> bool:
try:
return keyboard.is_pressed(opts.stop_key)
except Exception:
return False
try:
while time.time() < deadline:
if stopping():
print("\nstop key pressed")
break
targets = get_visible_targets(radius_max=opts.radius)
if targets:
kills_attempted += 1
pos = targets[0].center_monitor
keyboard.send(opts.key)
wait(0.06, 0.12)
for _ in range(opts.casts):
if stopping():
break
x = pos[0] + random.randint(-8, 8)
y = pos[1] + random.randint(-8, 8)
mouse.move(x, y, randomize=5, delay_factor=[0.2, 0.4])
mouse.click(button=opts.button)
wait(opts.cast_delay, opts.cast_delay * 1.4)
else:
roams += 1
# y is halved because D2's projection is isometric — an equal
# x/y step travels visibly further vertically than horizontally.
angle = random.uniform(0, 2 * math.pi)
dist = random.randint(150, 260)
pos_abs = (math.cos(angle) * dist, math.sin(angle) * dist * 0.5)
mx, my = convert_abs_to_monitor(pos_abs)
mouse.move(mx, my, randomize=5, delay_factor=[0.2, 0.4])
keyboard.send(force_move)
wait(0.9, 1.4)
left = max(0, deadline - time.time())
print(f"\r engagements={kills_attempted} roams={roams} {left:5.0f}s left ",
end="", flush=True)
except KeyboardInterrupt:
print("\ninterrupted")
print(f"\ndone: {kills_attempted} engagements, {roams} roam steps")
return 0
if __name__ == "__main__":
sys.exit(main())