From 35359403315f92c46f210aec670f34b6cb68b0fe Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 15 Oct 2026 18:32:22 +0300 Subject: [PATCH] lgbot: movement recorder (lgbot_record) and tools/lgbot_rec.py to compare player vs bot movement Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + docs/ADMIN.md | 2 + home/minqlx-plugins/lgbot.py | 54 +++++++++-- tools/lgbot_rec.py | 167 +++++++++++++++++++++++++++++++++++ tools/test_lgbot_plugin.py | 30 +++++++ tools/test_lgbot_rec.py | 75 ++++++++++++++++ 6 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 tools/lgbot_rec.py create mode 100644 tools/test_lgbot_rec.py diff --git a/.gitignore b/.gitignore index 51b2971..80f176e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ home/minqlx.log* __pycache__/ !home/baseq3/botfiles/ home/lgbot.json +home/lgbot-rec.csv diff --git a/docs/ADMIN.md b/docs/ADMIN.md index ed48bc0..6c72f35 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -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 `, `./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. ## Admins and bans: `home/baseq3/access.txt` diff --git a/home/minqlx-plugins/lgbot.py b/home/minqlx-plugins/lgbot.py index 169c1ec..d86c7d4 100644 --- a/home/minqlx-plugins/lgbot.py +++ b/home/minqlx-plugins/lgbot.py @@ -5,6 +5,7 @@ # 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 os import random import time @@ -40,6 +41,11 @@ STOP_TARGET_DIST = 1000 STOP_TARGET_DZ = 250 SWITCH_MARGIN = 150 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, +# 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): @@ -221,6 +227,7 @@ if minqlx: 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.set_cvar_once("lgbot_record", "0") self.strafer = Strafer() self.settings, self.read_at, self.log_at = None, 0.0, 0.0 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.target_id, self.last_tick = None, None 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) def handle_frame(self): try: self.tick() + self.record(time.time()) except Exception: if not self.failed: # log once, never spam 40 tracebacks a second self.failed = True @@ -245,10 +255,12 @@ if minqlx: 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.record_on = bool(to_int(self.get_cvar("lgbot_record"))) self.read_at = now 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 # starts fresh next time (otherwise the pause counts as sideways drift) self.last_cmd = self.dbg_cmd = self.target_id = None @@ -261,11 +273,12 @@ if minqlx: now = time.time() s = self.read_cvars(now) players = self.players() + self.frame_players = 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") + return self.idle(now, "nobot", "no lgbot on the server") if not bot.is_alive: - return self.idle(now, "lgbot is dead") + return self.idle(now, "dead", "lgbot is dead") cands = [] for p in players: 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) 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) - 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 "-")) if tid != self.target_id: self.strafer.reset() # drift was measured around the previous target @@ -291,7 +304,7 @@ if minqlx: 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") + return self.idle(now, "blocked", "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) @@ -303,9 +316,40 @@ if minqlx: 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) + self.state = "steer" if self.debug: 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): # 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 diff --git a/tools/lgbot_rec.py b/tools/lgbot_rec.py new file mode 100644 index 0000000..2e34de9 --- /dev/null +++ b/tools/lgbot_rec.py @@ -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)) diff --git a/tools/test_lgbot_plugin.py b/tools/test_lgbot_plugin.py index dc35692..885f73e 100644 --- a/tools/test_lgbot_plugin.py +++ b/tools/test_lgbot_plugin.py @@ -58,6 +58,9 @@ def load(world): def get_cvar(self, name): return world.cvars.get(name) + def set_cvar(self, name, value): + world.cvars[name] = value + def add_hook(self, event, fn): 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 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): self.human.team = "spectator" self.run_frames(40) diff --git a/tools/test_lgbot_rec.py b/tools/test_lgbot_rec.py new file mode 100644 index 0000000..be6f6c3 --- /dev/null +++ b/tools/test_lgbot_rec.py @@ -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()