feat(tools): add firebolt_roam — standalone roam-and-cast for levelling

src/run/cold_plains.py needs a waypoint, the town manager and the bot state
machine. None of that is usable for a clvl 1 character standing in Blood Moor,
which has no waypoint at all.

This starts from wherever the character already is: scan with target_detect ->
cast the configured skill at the nearest target -> roam if nothing is visible.
No waypoints, no town, no state machine.

    python tools/firebolt_roam.py --key f1 --minutes 10

The skill is a plain hotkey, so Fire Bolt now and Fireball at clvl 12 is the
same command. Stop key (default F12) is polled between every cast, and there is
a hard --minutes budget.

Explicitly NOT included: potion, chicken or death handling. Documented in the
module docstring — it will keep casting while dying, so it wants supervision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-22 09:00:55 +02:00
parent 394aca35f4
commit fd82f0ff05

111
tools/firebolt_roam.py Normal file
View File

@@ -0,0 +1,111 @@
"""
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)")
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 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
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())