# 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 ~25% of the velocity we set before the frame's move happens (measured: # 288 set -> ~220 actual), so on the ground we set this much more to end up at the wanted speed. # In the air there is no friction, so no boost (vertical speed 0 = on the ground, near enough). FRICTION_COMP = 1.3 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 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.""" 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.last_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 tick(self): players = self.players() bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), 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 is_bot(p.steam_id) and not self.target_bots: 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() comp = FRICTION_COMP if abs(vel.z) < 1 else 1.0 bot.velocity(x=vx * comp, y=vy * comp, z=fix_z(vel.z)) 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) actual = math.hypot(vel.x, vel.y) cos = 0.0 if self.last_cmd: lc = math.hypot(*self.last_cmd) if lc > 1 and actual > 1: cos = (vel.x * self.last_cmd[0] + vel.y * self.last_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.last_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)