# 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 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 steer when the target is within LG reach and # on about the same floor; otherwise the bot's own navigation walks it over. MAX_TARGET_DIST = 900 MAX_TARGET_DZ = 200 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 step(self, now_ms, s): if self.last is not None: self.drift += self.direction * (now_ms - self.last) 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 pick_target(bot_pos, candidates, max_dist=MAX_TARGET_DIST, max_dz=MAX_TARGET_DZ): """Nearest candidate (pos = x, y, z) within max_dist horizontally and max_dz vertically.""" best, best_d = None, max_dist for pos, obj in candidates: if abs(pos[2] - bot_pos[2]) > max_dz: continue d = math.hypot(pos[0] - bot_pos[0], pos[1] - bot_pos[1]) if d <= best_d: best, best_d = obj, d return best 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.""" def __init__(self, ratio=0.6, after_ms=500, release_ms=1500): self.ratio, self.after_ms, self.release_ms = ratio, after_ms, release_ms self.slow_since = None self.off_until = 0 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: 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 + self.release_ms return False else: self.slow_since = None 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) 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): self.last_cmd = None # not steering this frame: nothing for the stuck guard to judge 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)) bpos = bot.position() target = pick_target((bpos.x, bpos.y, bpos.z), cands) if target 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))) 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) 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)