lgbot: movement recorder (lgbot_record) and tools/lgbot_rec.py to compare player vs bot movement

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 18:32:22 +03:00
1 parent e1c64bed4c
commit 3535940331
6 files changed
+324 -5

No files matched your search

+1
View File
@@ -15,3 +15,4 @@ home/minqlx.log*
__pycache__/ __pycache__/
!home/baseq3/botfiles/ !home/baseq3/botfiles/
home/lgbot.json home/lgbot.json
home/lgbot-rec.csv
+2
View File
@@ -60,6 +60,8 @@ Range is the distance it tries to keep (LG reaches 768). Lane is roughly how far
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`.
To compare your movement with the bot's: `./rcon set lgbot_record 1`, fight it for a few minutes, `./rcon set lgbot_record 0`, then `python3 tools/lgbot_rec.py home/lgbot-rec.csv` prints both side by side (strafe hold times, speeds, jumps, distance). The recording has positions and speeds only, no names or Steam IDs, and stops by itself after 15 minutes.
The bot fights the nearest human and sticks with them unless someone else is clearly closer. Its own movement takes over when you're within 900 units (a bit beyond LG reach) on about the same floor, and keeps going until you're past 1000. 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, then longer (up to 6 s) if it keeps getting stuck. It speeds up and turns like a player (a full left-right reversal takes about 0.2 s), not instantly. One lgbot at a time. The bot fights the nearest human and sticks with them unless someone else is clearly closer. Its own movement takes over when you're within 900 units (a bit beyond LG reach) on about the same floor, and keeps going until you're past 1000. 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, then longer (up to 6 s) if it keeps getting stuck. It speeds up and turns like a player (a full left-right reversal takes about 0.2 s), not instantly. One lgbot at a time.
## Admins and bans: `home/baseq3/access.txt` ## Admins and bans: `home/baseq3/access.txt`
+49 -5
View File
@@ -5,6 +5,7 @@
# override one part of it, -1 = use the preset. The pattern code below has no minqlx dependency so # 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. # tools/test_lgbot.py and tools/lgbot.py can import it.
import math import math
import os
import random import random
import time import time
@@ -40,6 +41,11 @@ STOP_TARGET_DIST = 1000
STOP_TARGET_DZ = 250 STOP_TARGET_DZ = 250
SWITCH_MARGIN = 150 SWITCH_MARGIN = 150
BOT_STEAM_ID = 90071996842377216 # minqlx gives bots this + their client number BOT_STEAM_ID = 90071996842377216 # minqlx gives bots this + their client number
# lgbot_record 1: every frame, the position/velocity of the bot and each human (as h<client number>,
# no names or Steam IDs) go to home/lgbot-rec.csv, for tools/lgbot_rec.py to compare how they move.
# Stops by itself after REC_MAX_S.
REC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lgbot-rec.csv")
REC_MAX_S = 900
def is_bot(steam_id): def is_bot(steam_id):
@@ -221,6 +227,7 @@ if minqlx:
self.set_cvar_once("lgbot_" + k, "-1") self.set_cvar_once("lgbot_" + k, "-1")
self.set_cvar_once("lgbot_debug", "0") self.set_cvar_once("lgbot_debug", "0")
self.set_cvar_once("lgbot_target_bots", "0") # test aid: let it fight other bots self.set_cvar_once("lgbot_target_bots", "0") # test aid: let it fight other bots
self.set_cvar_once("lgbot_record", "0")
self.strafer = Strafer() self.strafer = Strafer()
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
@@ -229,11 +236,14 @@ if minqlx:
self.last_cmd = None # last command (for the stuck guard and the acceleration limit) self.last_cmd = None # last command (for the stuck guard and the acceleration limit)
self.target_id, self.last_tick = None, None self.target_id, self.last_tick = None, None
self.dbg_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats self.dbg_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats
self.state, self.frame_players = "", [] # for the recorder
self.record_on, self.rec, self.rec_start = False, None, 0.0
self.add_hook("frame", self.handle_frame) self.add_hook("frame", self.handle_frame)
def handle_frame(self): def handle_frame(self):
try: try:
self.tick() self.tick()
self.record(time.time())
except Exception: except Exception:
if not self.failed: # log once, never spam 40 tracebacks a second if not self.failed: # log once, never spam 40 tracebacks a second
self.failed = True self.failed = True
@@ -245,10 +255,12 @@ if minqlx:
self.settings = resolve(to_int(self.get_cvar("lgbot_move")), over) self.settings = resolve(to_int(self.get_cvar("lgbot_move")), over)
self.debug = bool(to_int(self.get_cvar("lgbot_debug"))) self.debug = bool(to_int(self.get_cvar("lgbot_debug")))
self.target_bots = bool(to_int(self.get_cvar("lgbot_target_bots"))) self.target_bots = bool(to_int(self.get_cvar("lgbot_target_bots")))
self.record_on = bool(to_int(self.get_cvar("lgbot_record")))
self.read_at = now self.read_at = now
return self.settings return self.settings
def idle(self, now, why): def idle(self, now, code, why):
self.state = code
# not steering this frame: nothing for the stuck guard to judge, and the strafe pattern # 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) # starts fresh next time (otherwise the pause counts as sideways drift)
self.last_cmd = self.dbg_cmd = self.target_id = None self.last_cmd = self.dbg_cmd = self.target_id = None
@@ -261,11 +273,12 @@ if minqlx:
now = time.time() now = time.time()
s = self.read_cvars(now) s = self.read_cvars(now)
players = self.players() players = self.players()
self.frame_players = players
bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), None) bot = next((p for p in players if is_lgbot(p.clean_name, p.steam_id)), None)
if bot is None: if bot is None:
return self.idle(now, "no lgbot on the server") return self.idle(now, "nobot", "no lgbot on the server")
if not bot.is_alive: if not bot.is_alive:
return self.idle(now, "lgbot is dead") return self.idle(now, "dead", "lgbot is dead")
cands = [] cands = []
for p in players: for p in players:
if p.id == bot.id or p.team == "spectator" or not p.is_alive: if p.id == bot.id or p.team == "spectator" or not p.is_alive:
@@ -278,7 +291,7 @@ if minqlx:
tid = pick_target((bpos.x, bpos.y, bpos.z), cands, self.target_id) 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 if tid is None: # nobody to duel close by: the bot AI walks over
near = min(((math.hypot(c[0][0] - bpos.x, c[0][1] - bpos.y), c[0][2] - bpos.z) for c in cands), default=None) near = min(((math.hypot(c[0][0] - bpos.x, c[0][1] - bpos.y), c[0][2] - bpos.z) for c in cands), default=None)
return self.idle(now, "no target within %d on this floor (%d candidates, nearest %s)" % ( return self.idle(now, "notarget", "no target within %d on this floor (%d candidates, nearest %s)" % (
MAX_TARGET_DIST, len(cands), "dist=%d dz=%d bot=%d,%d" % (near + (bpos.x, bpos.y)) if near else "-")) MAX_TARGET_DIST, len(cands), "dist=%d dz=%d bot=%d,%d" % (near + (bpos.x, bpos.y)) if near else "-"))
if tid != self.target_id: if tid != self.target_id:
self.strafer.reset() # drift was measured around the previous target self.strafer.reset() # drift was measured around the previous target
@@ -291,7 +304,7 @@ if minqlx:
if cmd_speed > 1: if cmd_speed > 1:
along = (vel.x * self.last_cmd[0] + vel.y * self.last_cmd[1]) / cmd_speed 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): if not self.guard.update(int(now * 1000), cmd_speed, along):
return self.idle(now, "blocked, letting the bot AI walk around it") return self.idle(now, "blocked", "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)
@@ -303,9 +316,40 @@ if minqlx:
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) self.last_cmd = (vx, vy)
self.state = "steer"
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 record(self, now):
if not self.record_on:
if self.rec:
self.rec.close()
self.rec = None
return
if self.rec is None:
self.rec = open(REC_FILE, "a", buffering=1)
self.rec_start = now
self.rec.write("# start %d map %s\n" % (int(now), self.get_cvar("mapname")))
if now - self.rec_start > REC_MAX_S:
self.set_cvar("lgbot_record", "0")
self.record_on = False
self.rec.close()
self.rec = None
return
t = now - self.rec_start
for p in self.frame_players:
if p.team == "spectator":
continue
if is_lgbot(p.clean_name, p.steam_id):
who, state = "bot", self.state
elif is_bot(p.steam_id):
continue
else:
who, state = "h%d" % p.id, ""
pos, vel = p.position(), p.velocity()
self.rec.write("%.3f,%s,%d,%d,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s\n" % (
t, who, int(bool(p.is_alive)), p.health, pos.x, pos.y, pos.z, vel.x, vel.y, vel.z, state))
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; the # stuck = times actual speed stayed under half the command for > 0.5 s (walls; the
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Compare how you and lgbot move, from a recording (set lgbot_record 1, play, set lgbot_record 0).
python3 tools/lgbot_rec.py [home/lgbot-rec.csv]
Only fight time counts: both alive, within 1000 units and about the same floor. Sideways/toward are
relative to the opponent (you vs the bot, the bot vs the nearest human). Prints numbers only.
"""
import math, sys
FIGHT_DIST, FIGHT_DZ = 1000, 250
SIDE_DEADBAND = 60 # u/s sideways before it counts as strafing to one side
GAP_S = 0.2 # a hole in the recording longer than this breaks a run
STOP_SPEED = 100
JUMP_VZ = 150
def parse(lines):
"""-> list of sessions, each {t: {who: row}}; row = (alive, hp, x, y, z, vx, vy, vz, state)."""
sessions, cur = [], None
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith("#"):
cur = {}
sessions.append(cur)
continue
f = line.split(",")
if len(f) < 11:
continue
if cur is None:
cur = {}
sessions.append(cur)
try:
t = float(f[0])
row = (int(f[2]), int(f[3])) + tuple(float(v) for v in f[4:10]) + (f[10],)
except ValueError:
continue
cur.setdefault(t, {})[f[1]] = row
return sessions
def fight_frames(session):
"""-> {"human": [...], "bot": [...]}, frames (t, x, y, z, vx, vy, vz, ox, oy, state) in time order."""
out = {"human": [], "bot": []}
for t in sorted(session):
rows = session[t]
bot = rows.get("bot")
if not bot or not bot[0]:
continue
best = None
for who, r in rows.items():
if who == "bot" or not r[0]:
continue
d = math.hypot(r[2] - bot[2], r[3] - bot[3])
if best is None or d < best[0]:
best = (d, r)
if best is None or best[0] > FIGHT_DIST or abs(best[1][4] - bot[4]) > FIGHT_DZ:
continue
h = best[1]
out["human"].append((t,) + h[2:8] + (bot[2], bot[3], ""))
out["bot"].append((t,) + bot[2:8] + (h[2], h[3], bot[8]))
return out
def pct(vals, q):
if not vals:
return None
v = sorted(vals)
return v[min(len(v) - 1, int(q / 100.0 * len(v)))]
def analyze(frames):
"""Movement stats for one player's fight frames (see fight_frames)."""
n = len(frames)
res = {"seconds": 0.0, "frames": n}
if n < 2:
return res
ground_speed, air_speed, dist, side_share = [], [], [], []
holds, side, side_since, prev = [], 0, None, None
air = stops = jumps = steer = 0
for fr in frames:
t, x, y, z, vx, vy, vz, ox, oy, state = fr
dx, dy = ox - x, oy - y
d = math.hypot(dx, dy) or 1.0
ux, uy = dx / d, dy / d
lat = -uy * vx + ux * vy
rad = ux * vx + uy * vy
speed = math.hypot(vx, vy)
dist.append(d)
on_ground = abs(vz) < 1
(ground_speed if on_ground else air_speed).append(speed)
if not on_ground:
air += 1
if speed < STOP_SPEED:
stops += 1
if abs(lat) + abs(rad) > 1:
side_share.append(abs(lat) / (abs(lat) + abs(rad)))
if state == "steer":
steer += 1
gap = prev is not None and not 0 <= t - prev[0] <= GAP_S
if prev is not None and not gap and abs(prev[6]) < 1 and vz > JUMP_VZ:
jumps += 1
if gap:
side, side_since = 0, None
s = 1 if lat > SIDE_DEADBAND else -1 if lat < -SIDE_DEADBAND else 0
if s and s != side:
if side and side_since is not None:
holds.append(int((t - side_since) * 1000))
side, side_since = s, t
prev = fr
secs = sum(min(max(b[0] - a[0], 0), GAP_S) for a, b in zip(frames, frames[1:]))
res.update({
"seconds": secs,
"hold_ms": (pct(holds, 10), pct(holds, 50), pct(holds, 90)),
"reversals_per_s": len(holds) / secs if secs else 0,
"ground_speed": (pct(ground_speed, 50), pct(ground_speed, 90)),
"air_speed": (pct(air_speed, 50), pct(air_speed, 90)),
"air_share": air / n,
"jumps_per_s": jumps / secs if secs else 0,
"sideways_share": sum(side_share) / len(side_share) if side_share else 0,
"stopped_share": stops / n,
"dist": (pct(dist, 10), pct(dist, 50), pct(dist, 90)),
"steer_share": steer / n,
})
return res
def fmt(v):
if v is None:
return "-"
if isinstance(v, tuple):
return " / ".join(fmt(x) for x in v)
if isinstance(v, float):
return "%.2f" % v if abs(v) < 10 else "%.0f" % v
return str(v)
ROWS = [("seconds", "fight time s"), ("hold_ms", "strafe hold ms p10/p50/p90"),
("reversals_per_s", "direction changes /s"), ("ground_speed", "ground speed p50/p90"),
("air_speed", "air speed p50/p90"), ("air_share", "time in air"), ("jumps_per_s", "jumps /s"),
("sideways_share", "sideways (vs toward/away)"), ("stopped_share", "time nearly stopped"),
("dist", "distance p10/p50/p90"), ("steer_share", "plugin steering (bot)")]
def report(human, bot):
lines = ["%-28s %-22s %s" % ("", "you", "lgbot")]
for k, label in ROWS:
lines.append("%-28s %-22s %s" % (label, fmt(human.get(k)), fmt(bot.get(k))))
return "\n".join(lines)
def main(argv):
path = argv[1] if len(argv) > 1 else "home/lgbot-rec.csv"
with open(path) as f:
sessions = parse(f)
both = {"human": [], "bot": []}
for s in sessions:
fr = fight_frames(s)
for k in both:
both[k].extend(fr[k]) # t restarts at 0 each session: the gap rule keeps them apart
print("%d session(s)" % len(sessions))
print(report(analyze(both["human"]), analyze(both["bot"])))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+30
View File
@@ -58,6 +58,9 @@ def load(world):
def get_cvar(self, name): def get_cvar(self, name):
return world.cvars.get(name) return world.cvars.get(name)
def set_cvar(self, name, value):
world.cvars[name] = value
def add_hook(self, event, fn): def add_hook(self, event, fn):
pass pass
@@ -139,6 +142,33 @@ class Tick(unittest.TestCase):
# per frame: at most the acceleration step (plus friction compensation), never a 640 u/s flip # per frame: at most the acceleration step (plus friction compensation), never a 640 u/s flip
self.assertLess(biggest, self.mod.ACCEL * 0.025 * comp + 0.15 * 320 * comp + 1) self.assertLess(biggest, self.mod.ACCEL * 0.025 * comp + 0.15 * 320 * comp + 1)
def test_recorder_writes_rows_without_names(self):
import tempfile
path = os.path.join(tempfile.mkdtemp(), "rec.csv")
self.mod.REC_FILE = path
self.w.cvars["lgbot_record"] = "1"
self.run_frames(80)
self.w.cvars["lgbot_record"] = "0"
self.w.now += 1.1 # cvars are reread once a second
self.run_frames(2)
with open(path) as f:
text = f.read()
rows = [l.split(",") for l in text.splitlines() if not l.startswith("#")]
self.assertGreater(len(rows), 100)
self.assertEqual({row[1] for row in rows}, {"bot", "h1"})
self.assertIn("steer", {row[10] for row in rows})
self.assertNotIn(str(HUMAN_ID), text)
self.assertIsNone(self.plugin.rec)
def test_recorder_stops_itself(self):
import tempfile
self.mod.REC_FILE = os.path.join(tempfile.mkdtemp(), "rec.csv")
self.mod.REC_MAX_S = 1
self.w.cvars["lgbot_record"] = "1"
self.run_frames(80)
self.assertEqual(self.w.cvars["lgbot_record"], "0")
self.assertIsNone(self.plugin.rec)
def test_no_target_means_no_steering(self): def test_no_target_means_no_steering(self):
self.human.team = "spectator" self.human.team = "spectator"
self.run_frames(40) self.run_frames(40)
+75
View File
@@ -0,0 +1,75 @@
"""tools/lgbot_rec.py: parsing a recording and the movement stats."""
import os, sys, unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import lgbot_rec as r # noqa: E402
def rec(seconds, human_fn, bot_fn, dt=0.025):
"""Recording lines: human_fn/bot_fn(t) -> (x, y, z, vx, vy, vz)."""
lines = ["# start 0 map test"]
t = 0.0
while t < seconds:
for who, fn, state in (("h1", human_fn, ""), ("bot", bot_fn, "steer")):
x, y, z, vx, vy, vz = fn(t)
lines.append("%.3f,%s,1,100,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s" % (t, who, x, y, z, vx, vy, vz, state))
t += dt
return lines
def adad(hold_s):
# sideways (y) at 320 u/s, switching every hold_s, standing 450 in front of the other on x
def fn(t):
d = 1 if int(t / hold_s) % 2 == 0 else -1
return (0, 0, 0, 0, 320 * d, 0)
return fn
def still_at(x):
return lambda t: (x, 0, 0, 0, 0, 0)
class Stats(unittest.TestCase):
def test_strafe_holds_and_sideways(self):
s = r.parse(rec(10, adad(0.5), still_at(450)))
st = r.analyze(r.fight_frames(s[0])["human"])
self.assertAlmostEqual(st["hold_ms"][1], 500, delta=30)
self.assertGreater(st["sideways_share"], 0.95)
self.assertEqual(st["air_share"], 0)
self.assertAlmostEqual(st["ground_speed"][1], 320, delta=1)
self.assertAlmostEqual(st["seconds"], 10, delta=0.1)
def test_bot_steer_share_and_stopped(self):
s = r.parse(rec(5, adad(0.5), still_at(450)))
st = r.analyze(r.fight_frames(s[0])["bot"])
self.assertEqual(st["steer_share"], 1)
self.assertEqual(st["stopped_share"], 1)
def test_jumps_counted(self):
def hop(t): # one jump a second: 0.7 s in the air, then ground
ph = t % 1.0
return (0, 0, 0, 0, 320, 270 - 800 * ph if ph < 0.7 else 0)
st = r.analyze(r.fight_frames(r.parse(rec(10, hop, still_at(450)))[0])["human"])
self.assertAlmostEqual(st["jumps_per_s"], 1.0, delta=0.15)
self.assertAlmostEqual(st["air_share"], 0.7, delta=0.05)
def test_far_or_other_floor_is_not_fight_time(self):
far = r.fight_frames(r.parse(rec(2, adad(0.5), still_at(1500)))[0])
up = r.fight_frames(r.parse(rec(2, adad(0.5), lambda t: (450, 0, 400, 0, 0, 0)))[0])
self.assertEqual(far["human"], [])
self.assertEqual(up["human"], [])
def test_sessions_do_not_join(self):
lines = rec(2, adad(0.5), still_at(450)) + rec(2, adad(0.5), still_at(450))
sessions = r.parse(lines)
self.assertEqual(len(sessions), 2)
frames = r.fight_frames(sessions[0])["human"] + r.fight_frames(sessions[1])["human"]
self.assertAlmostEqual(r.analyze(frames)["seconds"], 4, delta=0.1)
def test_report_prints(self):
st = r.analyze(r.fight_frames(r.parse(rec(2, adad(0.5), still_at(450)))[0])["human"])
self.assertIn("strafe hold", r.report(st, r.analyze([])))
if __name__ == "__main__":
unittest.main()