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

326 lines
15 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
# Ground friction takes ~15% of the velocity we set before the frame's move happens at sv_fps 40
# (measured: 416 set -> 353 actual; up to ~25% while the bot AI pushes the other way), so on the
# ground we set this much more to end up at about the wanted speed.
# In the air there is no friction, so no boost (vertical speed 0 = on the ground, near enough).
FRICTION_COMP = 1.2
# How fast the bot's velocity may change, units/s per second: QL ground acceleration (10) x run
# speed, so a full left-right reversal takes ~200 ms like a player, not one frame.
ACCEL = 3200
KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed", "lane"]
LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100),
"range": (150, 750), "range_play": (0, 1), "speed": (50, 100), "lane": (0, 2000)}
PRESETS = {
1: {"strafe_min": 600, "strafe_max": 1000, "fake": 0, "range": 450, "range_play": 0, "speed": 80, "lane": 600},
2: {"strafe_min": 400, "strafe_max": 800, "fake": 10, "range": 450, "range_play": 0, "speed": 90, "lane": 500},
3: {"strafe_min": 250, "strafe_max": 600, "fake": 25, "range": 400, "range_play": 1, "speed": 100, "lane": 400},
4: {"strafe_min": 180, "strafe_max": 450, "fake": 35, "range": 380, "range_play": 1, "speed": 100, "lane": 350},
5: {"strafe_min": 120, "strafe_max": 350, "fake": 50, "range": 350, "range_play": 1, "speed": 100, "lane": 300},
}
PUSH_RANGE, BACKOFF_RANGE = 250, 700
BACKOFF_HEALTH, PUSH_LEAD = 60, 30
# Steering is straight-line (no pathfinding), so only start steering when the target is at most a
# bit beyond LG reach (768) and on about the same floor; otherwise the bot's own navigation walks it
# over. Once steering, keep the target up to the STOP limits so it doesn't flip at the edge, and
# only switch to another player who is clearly closer.
MAX_TARGET_DIST = 900
MAX_TARGET_DZ = 200
STOP_TARGET_DIST = 1000
STOP_TARGET_DZ = 250
SWITCH_MARGIN = 150
BOT_STEAM_ID = 90071996842377216 # minqlx gives bots this + their client number
def is_bot(steam_id):
return steam_id == 0 or steam_id >= BOT_STEAM_ID
def is_lgbot(name, steam_id):
"""The training bot: named lgbot AND a bot (a human can't take it over by renaming)."""
return name.lower() == "lgbot" and is_bot(steam_id)
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.
Lane: strafing sideways to someone moves you around them, and fakes keep you going the same
way, so the net sideways drift is tracked and the bot turns back once it is more than `lane`
units off centre (0 = no limit). That keeps it doing ADAD in front of you instead of circling."""
def __init__(self, rng=random):
self.rng = rng
self.direction = 1
self.until = 0
self.faking = False
self.drift = 0 # signed ms of strafing, + = direction 1
self.last = None
def reset(self):
"""Not steering (dead, no target, walking): drift so far means nothing for the next fight."""
self.drift = 0
self.last = None
def step(self, now_ms, s):
if self.last is not None:
self.drift += self.direction * min(now_ms - self.last, 100) # a pause is not drift
self.last = now_ms
if s["lane"]:
lane_ms = s["lane"] * 1000.0 / (RUN_SPEED * s["speed"] / 100.0)
if self.direction * self.drift >= lane_ms:
self.direction = -self.direction
self.faking = False
self.until = now_ms + self.rng.randint(s["strafe_min"], s["strafe_max"])
return self.direction
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 approach(cur, want, max_delta):
"""Move velocity `cur` toward `want` by at most `max_delta` (acceleration limit)."""
dx, dy = want[0] - cur[0], want[1] - cur[1]
d = math.hypot(dx, dy)
if d <= max_delta:
return (want[0], want[1])
return (cur[0] + dx / d * max_delta, cur[1] + dy / d * max_delta)
def pick_target(bot_pos, candidates, current=None):
"""Key of the player to fight, from candidates [((x, y, z), key)]. A new target must be within
MAX_TARGET_DIST / MAX_TARGET_DZ; the current one is kept up to the STOP limits unless another
is SWITCH_MARGIN closer. None = nobody (the bot AI walks)."""
near, cur_d = [], None
for pos, key in candidates:
dz = abs(pos[2] - bot_pos[2])
d = math.hypot(pos[0] - bot_pos[0], pos[1] - bot_pos[1])
if key == current and d <= STOP_TARGET_DIST and dz <= STOP_TARGET_DZ:
cur_d = d
if d <= MAX_TARGET_DIST and dz <= MAX_TARGET_DZ:
near.append((d, key))
best = min(near, key=lambda t: t[0]) if near else None
if cur_d is not None and (best is None or best[0] > cur_d - SWITCH_MARGIN):
return current
return best[1] if best else None
class StuckGuard:
"""Lets go of the bot when steering keeps getting blocked (a wall, a ledge): if the speed it
makes along the commanded direction stays under `ratio` of the command for `after_ms`, steering
is off for `release_ms` so the bot AI can walk around the obstacle. If it keeps failing, each
release is twice as long (up to `max_release_ms`); `clean_ms` of good steering resets that."""
def __init__(self, ratio=0.6, after_ms=500, release_ms=1500, max_release_ms=6000, clean_ms=3000):
self.ratio, self.after_ms = ratio, after_ms
self.release_ms, self.max_release_ms, self.clean_ms = release_ms, max_release_ms, clean_ms
self.slow_since = None
self.off_until = 0
self.streak = 0
self.clean_since = None
def update(self, now_ms, cmd_speed, along_speed):
if now_ms < self.off_until:
return False
if cmd_speed > 1 and along_speed < cmd_speed * self.ratio:
self.clean_since = None
if self.slow_since is None:
self.slow_since = now_ms
elif now_ms - self.slow_since >= self.after_ms:
self.slow_since = None
self.off_until = now_ms + min(self.max_release_ms, self.release_ms * 2 ** self.streak)
self.streak += 1
return False
else:
self.slow_since = None
if self.clean_since is None:
self.clean_since = now_ms
elif now_ms - self.clean_since >= self.clean_ms:
self.streak = 0
return True
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.guard = StuckGuard()
self.last_cmd = None # last command (for the stuck guard and the acceleration limit)
self.target_id, self.last_tick = None, None
self.dbg_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats
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 idle(self, now, why):
# not steering this frame: nothing for the stuck guard to judge, and the strafe pattern
# starts fresh next time (otherwise the pause counts as sideways drift)
self.last_cmd = self.dbg_cmd = self.target_id = None
self.strafer.reset()
if self.debug and now - self.log_at >= 1.0:
self.log_at = now
self.logger.info("lgbot idle: %s", why)
def tick(self):
now = time.time()
s = self.read_cvars(now)
players = self.players()
bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), None)
if bot is None:
return self.idle(now, "no lgbot on the server")
if not bot.is_alive:
return self.idle(now, "lgbot is dead")
cands = []
for p in players:
if p.id == bot.id or p.team == "spectator" or not p.is_alive:
continue
if is_bot(p.steam_id) and not self.target_bots:
continue
pos = p.position()
cands.append(((pos.x, pos.y, pos.z), p.id))
bpos = bot.position()
tid = pick_target((bpos.x, bpos.y, bpos.z), cands, self.target_id)
if tid is None: # nobody to duel close by: the bot AI walks over
return self.idle(now, "no target within %d on this floor (%d candidates)" % (MAX_TARGET_DIST, len(cands)))
if tid != self.target_id:
self.strafer.reset() # drift was measured around the previous target
self.target_id = tid
target = next(p for p in players if p.id == tid)
vel = bot.velocity()
cmd_speed, along = 0.0, 0.0
if self.last_cmd:
cmd_speed = math.hypot(*self.last_cmd)
if cmd_speed > 1:
along = (vel.x * self.last_cmd[0] + vel.y * self.last_cmd[1]) / cmd_speed
if not self.guard.update(int(now * 1000), cmd_speed, along):
return self.idle(now, "blocked, letting the bot AI walk around it")
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)
dt = min(0.1, now - self.last_tick) if self.last_tick else 0.025
self.last_tick = now
start = self.last_cmd if self.last_cmd else (vel.x, vel.y)
vx, vy = approach(start, (vx, vy), ACCEL * dt) # turn like a player, not in one frame
comp = FRICTION_COMP if abs(vel.z) < 1 else 1.0
bot.velocity(x=vx * comp, y=vy * comp, z=fix_z(vel.z))
self.last_cmd = (vx, vy)
if self.debug:
self.debug_line(now, vel, (vx, vy), tpos, bpos, want, direction)
def debug_line(self, now, vel, cmd, tpos, bpos, want, direction):
# cos = how well last frame's command survived the frame (1 = exactly our direction);
# stuck = times actual speed stayed under half the command for > 0.5 s (walls; the
# StuckGuard is what acts on it, this is only the count)
actual = math.hypot(vel.x, vel.y)
cos = 0.0
if self.dbg_cmd:
lc = math.hypot(*self.dbg_cmd)
if lc > 1 and actual > 1:
cos = (vel.x * self.dbg_cmd[0] + vel.y * self.dbg_cmd[1]) / (lc * actual)
self.slow_frames = self.slow_frames + 1 if actual < lc / 2 else 0
if self.slow_frames == 20:
self.stuck += 1
self.dbg_cmd = cmd
if now - self.log_at >= 1.0:
self.log_at = now
self.logger.info("lgbot dist=%d want=%d dir=%d cmd=%d actual=%d cos=%.2f stuck=%d z=%d",
math.hypot(tpos.x - bpos.x, tpos.y - bpos.y), want, direction,
math.hypot(*cmd), actual, cos, self.stuck, vel.z)