Files
ql-lg-server/home/minqlx-plugins/lgbot.py
T

179 lines
7.2 KiB
Python

# LG training bot movement: steers the bot named "lgbot" like a human LG duelist (ground ADAD
# strafing with uneven holds and fakes, keeps LG range, never jumps). Aim comes from the bot's
# character file (botfiles/bots/lgbot_c.c, the number in "addbot lgbot N"); this plugin only moves it.
# Settings are cvars (the ./lgbot script sets them): lgbot_move 1-5 picks a preset, the detail cvars
# override one part of it, -1 = use the preset. The pattern code below has no minqlx dependency so
# tools/test_lgbot.py and tools/lgbot.py can import it.
import math
import random
import time
RUN_SPEED = 320 # QL ground run speed, units/s
KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed"]
LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100),
"range": (150, 750), "range_play": (0, 1), "speed": (50, 100)}
PRESETS = {
1: {"strafe_min": 600, "strafe_max": 1000, "fake": 0, "range": 450, "range_play": 0, "speed": 80},
2: {"strafe_min": 400, "strafe_max": 800, "fake": 10, "range": 450, "range_play": 0, "speed": 90},
3: {"strafe_min": 250, "strafe_max": 600, "fake": 25, "range": 400, "range_play": 1, "speed": 100},
4: {"strafe_min": 180, "strafe_max": 450, "fake": 35, "range": 380, "range_play": 1, "speed": 100},
5: {"strafe_min": 120, "strafe_max": 350, "fake": 50, "range": 350, "range_play": 1, "speed": 100},
}
PUSH_RANGE, BACKOFF_RANGE = 250, 700
BACKOFF_HEALTH, PUSH_LEAD = 60, 30
MAX_TARGET_DIST = 1500
def to_int(text):
"""Cvar text -> int, None when unset or junk (so a typo falls back to the preset)."""
try:
return int(float(text))
except (TypeError, ValueError):
return None
def resolve(move, overrides):
"""Preset for movement level `move` with the valid (>= 0) overrides applied, clamped."""
try:
move = min(5, max(1, int(move)))
except (TypeError, ValueError):
move = 3
s = dict(PRESETS[move])
for k, v in overrides.items():
if k in LIMITS and isinstance(v, int) and v >= 0:
lo, hi = LIMITS[k]
s[k] = min(hi, max(lo, v))
s["strafe_max"] = max(s["strafe_max"], s["strafe_min"])
return s
class Strafer:
"""Strafe direction over time: holds one side for strafe_min..strafe_max ms, then switches.
A fake is a 60-120 ms tap to the other side followed by a new hold on the original side."""
def __init__(self, rng=random):
self.rng = rng
self.direction = 1
self.until = 0
self.faking = False
def step(self, now_ms, s):
if now_ms >= self.until:
self.direction = -self.direction
if self.faking:
self.faking = False
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
elif self.rng.random() * 100 < s["fake"]:
self.faking = True
self.until = now_ms + self.rng.randint(60, 120)
else:
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
return self.direction
def wanted_range(s, my_hp, target_hp):
if s["range_play"]:
if my_hp < BACKOFF_HEALTH:
return BACKOFF_RANGE
if my_hp >= target_hp + PUSH_LEAD:
return PUSH_RANGE
return s["range"]
def steer(bot_xy, target_xy, direction, s, want):
"""Horizontal velocity: strafe sideways to the target, plus in/out to hold `want` distance."""
cap = RUN_SPEED * s["speed"] / 100.0
dx, dy = target_xy[0] - bot_xy[0], target_xy[1] - bot_xy[1]
dist = math.hypot(dx, dy)
if dist < 1:
return (direction * cap, 0.0)
ux, uy = dx / dist, dy / dist
radial = max(-0.7 * cap, min(0.7 * cap, (dist - want) * 2.0))
side = math.sqrt(max(0.0, cap * cap - radial * radial))
return (-uy * direction * side + ux * radial, ux * direction * side + uy * radial)
def fix_z(vz):
"""Cancel a jump (QL jump velocity is ~275); leave slopes, falling and jump pads alone."""
return 0.0 if 200 <= vz <= 320 else vz
def pick_target(bot_xy, candidates, max_dist=MAX_TARGET_DIST):
best, best_d = None, max_dist
for xy, obj in candidates:
d = math.hypot(xy[0] - bot_xy[0], xy[1] - bot_xy[1])
if d <= best_d:
best, best_d = obj, d
return best
try:
import minqlx
except ImportError: # imported by tests / the setup script outside the server
minqlx = None
if minqlx:
class lgbot(minqlx.Plugin):
def __init__(self):
super().__init__()
self.set_cvar_once("lgbot_move", "3")
for k in KEYS:
self.set_cvar_once("lgbot_" + k, "-1")
self.set_cvar_once("lgbot_debug", "0")
self.set_cvar_once("lgbot_target_bots", "0") # test aid: let it fight other bots
self.strafer = Strafer()
self.settings, self.read_at, self.log_at = None, 0.0, 0.0
self.debug = self.target_bots = False
self.failed = False
self.add_hook("frame", self.handle_frame)
def handle_frame(self):
try:
self.tick()
except Exception:
if not self.failed: # log once, never spam 40 tracebacks a second
self.failed = True
minqlx.log_exception(self)
def read_cvars(self, now):
if self.settings is None or now - self.read_at >= 1.0:
over = {k: to_int(self.get_cvar("lgbot_" + k)) for k in KEYS}
self.settings = resolve(to_int(self.get_cvar("lgbot_move")), over)
self.debug = bool(to_int(self.get_cvar("lgbot_debug")))
self.target_bots = bool(to_int(self.get_cvar("lgbot_target_bots")))
self.read_at = now
return self.settings
def tick(self):
players = self.players()
bot = next((p for p in players if p.clean_name.lower() == "lgbot"), None)
if bot is None or not bot.is_alive:
return
now = time.time()
s = self.read_cvars(now)
cands = []
for p in players:
if p.id == bot.id or p.team == "spectator" or not p.is_alive:
continue
if p.steam_id == 0 and not self.target_bots: # bots have Steam ID 0
continue
pos = p.position()
cands.append(((pos.x, pos.y), p))
bpos = bot.position()
target = pick_target((bpos.x, bpos.y), cands)
if target is None:
return # nobody to duel: stock AI roams
tpos = target.position()
direction = self.strafer.step(int(now * 1000), s)
want = wanted_range(s, bot.health, target.health)
vx, vy = steer((bpos.x, bpos.y), (tpos.x, tpos.y), direction, s, want)
vel = bot.velocity()
bot.velocity(x=vx, y=vy, z=fix_z(vel.z))
if self.debug and now - self.log_at >= 1.0:
self.log_at = now
dist = math.hypot(tpos.x - bpos.x, tpos.y - bpos.y)
self.logger.info("lgbot dist=%d want=%d dir=%d cmd=%d actual=%d z=%d",
dist, want, direction, math.hypot(vx, vy),
math.hypot(vel.x, vel.y), vel.z)