lgbot: steer only within LG reach on the same floor; let the AI walk around walls

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 14:59:56 +03:00
1 parent f494223305
commit a998614224
4 files changed
+89 -22

No files matched your search

+1 -1
View File
@@ -60,7 +60,7 @@ Range is the distance it tries to keep (LG reaches 768). Lane is how far it may
Underneath it's plain console settings, if you'd rather: `./rcon addbot lgbot <aim>`, `./rcon set lgbot_move <1-5>`, and `lgbot_strafe_min`, `lgbot_strafe_max`, `lgbot_fake`, `lgbot_range`, `lgbot_range_play`, `lgbot_speed`, `lgbot_lane`. The plugin rereads them every second. `set lgbot_debug 1` writes one line a second about its movement to `home/minqlx.log`. Underneath it's plain console settings, if you'd rather: `./rcon addbot lgbot <aim>`, `./rcon set lgbot_move <1-5>`, and `lgbot_strafe_min`, `lgbot_strafe_max`, `lgbot_fake`, `lgbot_range`, `lgbot_range_play`, `lgbot_speed`, `lgbot_lane`. The plugin rereads them every second. `set lgbot_debug 1` writes one line a second about its movement to `home/minqlx.log`.
The bot fights the nearest human; with nobody nearby it roams like a normal bot. One lgbot at a time. The bot fights the nearest human. Its own movement only takes over when you're within LG reach (900 units) on about the same floor; farther away, the normal bot AI walks it over to you. If a wall blocks its strafing for half a second, it lets the AI walk around for 1.5 s. One lgbot at a time.
## Admins and bans: `home/baseq3/access.txt` ## Admins and bans: `home/baseq3/access.txt`
+1 -1
View File
@@ -23,7 +23,7 @@ In warmup the client draws "The match will begin when more players join" / "Pres
Quake 3 bots take their personality from a character file. `home/baseq3/botfiles/bots/lgbot_c.c` (registered by `scripts/lgbot.bot`) sets aim accuracy, aim skill, reaction time and view speed per skill level, sets jumping, crouching and rocket jumping to 0, and weights the LG above everything else. QL reads skill blocks 1, 4 and 5 and interpolates 2 and 3 between 1 and 4, which is why the file has only three. The server loads these loose files from `fs_homepath` alongside the ones in `pak00.pk3`, so no pk3 is needed (bots run on the server only, so clients don't need the files). Quake 3 bots take their personality from a character file. `home/baseq3/botfiles/bots/lgbot_c.c` (registered by `scripts/lgbot.bot`) sets aim accuracy, aim skill, reaction time and view speed per skill level, sets jumping, crouching and rocket jumping to 0, and weights the LG above everything else. QL reads skill blocks 1, 4 and 5 and interpolates 2 and 3 between 1 and 4, which is why the file has only three. The server loads these loose files from `fs_homepath` alongside the ones in `pak00.pk3`, so no pk3 is needed (bots run on the server only, so clients don't need the files).
The stock bot AI moves like a bot: it runs at you, hops around and goes for items. So the movement is overridden. The `lgbot` minqlx plugin sets the bot's horizontal velocity on every server frame: sideways to its target in the current strafe direction, plus an in/out part that holds the wanted distance (capped at 70% of the speed so it still strafes while closing in). The strafe direction comes from a small pattern generator: hold one side for a random time between the min and max, then switch. Sometimes it fakes instead: a 60–120 ms tap the other way, then a new hold on the same side. Strafing sideways to someone moves you around them, and fakes keep you going the same way, so the bot would slowly circle you. The pattern therefore tracks its net sideways drift and turns back once it is `lane` units off centre. Ground friction removes 15–25% of the set speed before the move happens, so on the ground it sets 1.2× (measured at sv_fps 40: the bot then moves at about the set speed, never faster than a player can). An upward speed of 200–320 (a jump; QL jumps at ~275) is cancelled. Falling, slopes and jump pads are left alone. The AI still aims and shoots, so aim and movement don't interfere. Measured on a test server, the plugin costs no extra CPU you could measure. The stock bot AI moves like a bot: it runs at you, hops around and goes for items. So the movement is overridden. The `lgbot` minqlx plugin sets the bot's horizontal velocity on every server frame: sideways to its target in the current strafe direction, plus an in/out part that holds the wanted distance (capped at 70% of the speed so it still strafes while closing in). The strafe direction comes from a small pattern generator: hold one side for a random time between the min and max, then switch. Sometimes it fakes instead: a 60–120 ms tap the other way, then a new hold on the same side. Strafing sideways to someone moves you around them, and fakes keep you going the same way, so the bot would slowly circle you. The pattern therefore tracks its net sideways drift and turns back once it is `lane` units off centre. Ground friction removes 15–25% of the set speed before the move happens, so on the ground it sets 1.2× (measured at sv_fps 40: the bot then moves at about the set speed, never faster than a player can). An upward speed of 200–320 (a jump; QL jumps at ~275) is cancelled. Falling, slopes and jump pads are left alone. The AI still aims and shoots, so aim and movement don't interfere. The steering is straight-line, with no pathfinding. So it only runs when the target is within 900 units horizontally and 200 vertically. Farther away, the AI's own navigation walks the bot over. If the speed it makes along the steered direction stays under 60% for half a second (a wall, a ledge), steering pauses for 1.5 s so the AI can get around it. Without this, on thunderstruck it pushed against a wall toward a target on another level for 12 s. Measured on a test server, the plugin costs no extra CPU you could measure.
minqlx gives bots Steam IDs from 90071996842377216 up. The plugin uses that to pick human targets only. minqlx gives bots Steam IDs from 90071996842377216 up. The plugin uses that to pick human targets only.
+58 -15
View File
@@ -27,7 +27,10 @@ PRESETS = {
} }
PUSH_RANGE, BACKOFF_RANGE = 250, 700 PUSH_RANGE, BACKOFF_RANGE = 250, 700
BACKOFF_HEALTH, PUSH_LEAD = 60, 30 BACKOFF_HEALTH, PUSH_LEAD = 60, 30
MAX_TARGET_DIST = 1500 # 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 BOT_STEAM_ID = 90071996842377216 # minqlx gives bots this + their client number
@@ -129,15 +132,43 @@ def fix_z(vz):
return 0.0 if 200 <= vz <= 320 else vz return 0.0 if 200 <= vz <= 320 else vz
def pick_target(bot_xy, candidates, max_dist=MAX_TARGET_DIST): 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 best, best_d = None, max_dist
for xy, obj in candidates: for pos, obj in candidates:
d = math.hypot(xy[0] - bot_xy[0], xy[1] - bot_xy[1]) 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: if d <= best_d:
best, best_d = obj, d best, best_d = obj, d
return best 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: try:
import minqlx import minqlx
except ImportError: # imported by tests / the setup script outside the server except ImportError: # imported by tests / the setup script outside the server
@@ -156,7 +187,9 @@ if minqlx:
self.settings, self.read_at, self.log_at = None, 0.0, 0.0 self.settings, self.read_at, self.log_at = None, 0.0, 0.0
self.debug = self.target_bots = False self.debug = self.target_bots = False
self.failed = False self.failed = False
self.last_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats 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) self.add_hook("frame", self.handle_frame)
def handle_frame(self): def handle_frame(self):
@@ -177,6 +210,7 @@ if minqlx:
return self.settings return self.settings
def idle(self, now, why): 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: if self.debug and now - self.log_at >= 1.0:
self.log_at = now self.log_at = now
self.logger.info("lgbot idle: %s", why) self.logger.info("lgbot idle: %s", why)
@@ -197,34 +231,43 @@ if minqlx:
if is_bot(p.steam_id) and not self.target_bots: if is_bot(p.steam_id) and not self.target_bots:
continue continue
pos = p.position() pos = p.position()
cands.append(((pos.x, pos.y), p)) cands.append(((pos.x, pos.y, pos.z), p))
bpos = bot.position() bpos = bot.position()
target = pick_target((bpos.x, bpos.y), cands) target = pick_target((bpos.x, bpos.y, bpos.z), cands)
if target is None: # nobody to duel: stock AI roams if target is None: # nobody to duel close by: the bot AI walks over
return self.idle(now, "no target within %d (%d candidates)" % (MAX_TARGET_DIST, len(cands))) 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() tpos = target.position()
direction = self.strafer.step(int(now * 1000), s) direction = self.strafer.step(int(now * 1000), s)
want = wanted_range(s, bot.health, target.health) want = wanted_range(s, bot.health, target.health)
vx, vy = steer((bpos.x, bpos.y), (tpos.x, tpos.y), direction, s, want) 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 comp = FRICTION_COMP if abs(vel.z) < 1 else 1.0
bot.velocity(x=vx * comp, y=vy * comp, z=fix_z(vel.z)) bot.velocity(x=vx * comp, y=vy * comp, z=fix_z(vel.z))
self.last_cmd = (vx, vy)
if self.debug: if self.debug:
self.debug_line(now, vel, (vx, vy), tpos, bpos, want, direction) self.debug_line(now, vel, (vx, vy), tpos, bpos, want, direction)
def debug_line(self, now, vel, cmd, 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); # 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) # 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) actual = math.hypot(vel.x, vel.y)
cos = 0.0 cos = 0.0
if self.last_cmd: if self.dbg_cmd:
lc = math.hypot(*self.last_cmd) lc = math.hypot(*self.dbg_cmd)
if lc > 1 and actual > 1: if lc > 1 and actual > 1:
cos = (vel.x * self.last_cmd[0] + vel.y * self.last_cmd[1]) / (lc * actual) 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 self.slow_frames = self.slow_frames + 1 if actual < lc / 2 else 0
if self.slow_frames == 20: if self.slow_frames == 20:
self.stuck += 1 self.stuck += 1
self.last_cmd = cmd self.dbg_cmd = cmd
if now - self.log_at >= 1.0: if now - self.log_at >= 1.0:
self.log_at = now self.log_at = now
self.logger.info("lgbot dist=%d want=%d dir=%d cmd=%d actual=%d cos=%.2f stuck=%d z=%d", self.logger.info("lgbot dist=%d want=%d dir=%d cmd=%d actual=%d cos=%.2f stuck=%d z=%d",
+29 -5
View File
@@ -1,7 +1,7 @@
import math, os, random, sys, unittest import math, os, random, sys, unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "home", "minqlx-plugins")) sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "home", "minqlx-plugins"))
from lgbot import (PRESETS, KEYS, resolve, Strafer, wanted_range, steer, fix_z, pick_target, to_int, is_bot, is_lgbot) from lgbot import (PRESETS, KEYS, resolve, Strafer, wanted_range, steer, fix_z, pick_target, to_int, is_bot, is_lgbot, StuckGuard)
def preset(n, **kw): def preset(n, **kw):
@@ -156,10 +156,34 @@ class Misc(unittest.TestCase):
self.assertIsNone(to_int(None)) self.assertIsNone(to_int(None))
def test_pick_target(self): def test_pick_target(self):
c = [((300, 0), "near"), ((900, 0), "far")] c = [((300, 0, 0), "near"), ((800, 0, 0), "far")]
self.assertEqual(pick_target((0, 0), c), "near") self.assertEqual(pick_target((0, 0, 0), c), "near")
self.assertIsNone(pick_target((0, 0), [((2000, 0), "x")])) self.assertIsNone(pick_target((0, 0, 0), [((1000, 0, 0), "x")])) # beyond LG reach: AI walks there
self.assertIsNone(pick_target((0, 0), [])) self.assertIsNone(pick_target((0, 0, 0), []))
def test_pick_target_ignores_other_floors(self):
c = [((200, 0, 300), "above"), ((600, 0, 40), "same floor")]
self.assertEqual(pick_target((0, 0, 0), c), "same floor")
self.assertIsNone(pick_target((0, 0, 0), [((200, 0, -300), "below")]))
class Stuck(unittest.TestCase):
def test_steers_while_moving(self):
g = StuckGuard()
self.assertTrue(all(g.update(t, 320, 300) for t in range(0, 3000, 25)))
def test_lets_go_when_blocked_then_retries(self):
g = StuckGuard()
results = [(t, g.update(t, 320, 50)) for t in range(0, 4000, 25)]
first_off = next(t for t, ok in results if not ok)
self.assertTrue(450 <= first_off <= 550)
back_on = next(t for t, ok in results if t > first_off and ok)
self.assertTrue(1900 <= back_on - first_off + 500 <= 2100) # off for 1.5 s
def test_short_bump_is_fine(self):
g = StuckGuard()
ok = [g.update(t, 320, 50 if 1000 <= t < 1300 else 300) for t in range(0, 3000, 25)]
self.assertTrue(all(ok))
import json, tempfile import json, tempfile