lgbot: auto recording while two humans fight (lgbot_record 2 / QL_RECORD), analyzer pairs any opponents

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 18:37:40 +03:00
1 parent 3535940331
commit acf625d72b
8 files changed
+129 -47

No files matched your search

+4
View File
@@ -23,6 +23,10 @@ QL_MAP=thunderstruck
# Shown to every player when they join. Empty = no message. # Shown to every player when they join. Empty = no message.
QL_WELCOME=^7Welcome! LG only, vampiric, no knockback. Pair up and duel, there is no match to ready up for. QL_WELCOME=^7Welcome! LG only, vampiric, no knockback. Pair up and duel, there is no match to ready up for.
# Movement recording for the lgbot (home/lgbot-rec.csv, positions/speeds only, no names or IDs):
# 0 = off, 2 = automatically whenever two humans are fighting. See docs/ADMIN.md.
QL_RECORD=0
# Game port (UDP) and RCON port (TCP, bound to 127.0.0.1 only). Change both to run a second server. # Game port (UDP) and RCON port (TCP, bound to 127.0.0.1 only). Change both to run a second server.
QL_PORT=27960 QL_PORT=27960
QL_RCON_PORT=28960 QL_RCON_PORT=28960
+1
View File
@@ -40,6 +40,7 @@ Everything per-server is in `.env` (created from [`.env.example`](.env.example)
| `QL_FACTORY` | `lgvamp` | `lgvamp`, `lgvamp_nolag` or `lgvamp100`, see below | | `QL_FACTORY` | `lgvamp` | `lgvamp`, `lgvamp_nolag` or `lgvamp100`, see below |
| `QL_MAP` | `thunderstruck` | stock map; workshop maps need extra setup | | `QL_MAP` | `thunderstruck` | stock map; workshop maps need extra setup |
| `QL_WELCOME` | short rules text | sent to each player on join; empty = off | | `QL_WELCOME` | short rules text | sent to each player on join; empty = off |
| `QL_RECORD` | `0` | `2` = record movement whenever two humans fight (for the lgbot, see docs/ADMIN.md) |
| `QL_PORT` / `QL_RCON_PORT` | `27960` / `28960` | game UDP / RCON TCP (127.0.0.1 only) | | `QL_PORT` / `QL_RCON_PORT` | `27960` / `28960` | game UDP / RCON TCP (127.0.0.1 only) |
| `QL_NAME` | `ql-lg` | Docker container name | | `QL_NAME` | `ql-lg` | Docker container name |
+2
View File
@@ -62,6 +62,8 @@ Underneath it's plain console settings, if you'd rather: `./rcon addbot lgbot <a
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. 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.
`set lgbot_record 2` (or `QL_RECORD=2` in `.env`, which survives restarts) records automatically, but only while two humans are fighting each other (alive, within 1000 units, about the same floor), with no time limit. That's how to collect real duel movement to teach the bot. Recording stops at 200 MB; delete or move `home/lgbot-rec.csv` to start over. The same `tools/lgbot_rec.py` command reads it.
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`
+41 -19
View File
@@ -43,9 +43,12 @@ 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>, # 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. # 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. # Stops by itself after REC_MAX_S. lgbot_record 2 (auto): only frames where two humans are fighting
# (alive, within REC_FIGHT_DIST on about the same floor), no time limit. Both stop at REC_MAX_BYTES.
REC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lgbot-rec.csv") REC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lgbot-rec.csv")
REC_MAX_S = 900 REC_MAX_S = 900
REC_MAX_BYTES = 200 * 1024 * 1024
REC_FIGHT_DIST, REC_FIGHT_DZ = 1000, 250
def is_bot(steam_id): def is_bot(steam_id):
@@ -160,6 +163,15 @@ def approach(cur, want, max_delta):
return (cur[0] + dx / d * max_delta, cur[1] + dy / d * max_delta) return (cur[0] + dx / d * max_delta, cur[1] + dy / d * max_delta)
def humans_fighting(positions):
"""True when two of these (x, y, z) are within duel range of each other on about the same floor."""
for i, a in enumerate(positions):
for b in positions[i + 1:]:
if math.hypot(a[0] - b[0], a[1] - b[1]) <= REC_FIGHT_DIST and abs(a[2] - b[2]) <= REC_FIGHT_DZ:
return True
return False
def pick_target(bot_pos, candidates, current=None): def pick_target(bot_pos, candidates, current=None):
"""Key of the player to fight, from candidates [((x, y, z), key)]. A new target must be within """Key of the player to fight, from candidates [((x, y, z), key)]. A new target must be within
MAX_TARGET_DIST / MAX_TARGET_DZ; the current one is kept up to the STOP limits unless another MAX_TARGET_DIST / MAX_TARGET_DZ; the current one is kept up to the STOP limits unless another
@@ -237,7 +249,7 @@ if minqlx:
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.state, self.frame_players = "", [] # for the recorder
self.record_on, self.rec, self.rec_start = False, None, 0.0 self.record_mode, self.rec, self.rec_start, self.rec_bytes = 0, None, 0.0, 0
self.add_hook("frame", self.handle_frame) self.add_hook("frame", self.handle_frame)
def handle_frame(self): def handle_frame(self):
@@ -255,7 +267,7 @@ 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.record_mode = to_int(self.get_cvar("lgbot_record")) or 0
self.read_at = now self.read_at = now
return self.settings return self.settings
@@ -320,23 +332,16 @@ if minqlx:
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): def stop_recording(self):
if not self.record_on:
if self.rec: if self.rec:
self.rec.close() self.rec.close()
self.rec = None self.rec = None
return
if self.rec is None: def record(self, now):
self.rec = open(REC_FILE, "a", buffering=1) mode = self.record_mode
self.rec_start = now if mode not in (1, 2):
self.rec.write("# start %d map %s\n" % (int(now), self.get_cvar("mapname"))) return self.stop_recording()
if now - self.rec_start > REC_MAX_S: rows, humans = [], []
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: for p in self.frame_players:
if p.team == "spectator": if p.team == "spectator":
continue continue
@@ -347,8 +352,25 @@ if minqlx:
else: else:
who, state = "h%d" % p.id, "" who, state = "h%d" % p.id, ""
pos, vel = p.position(), p.velocity() pos, vel = p.position(), p.velocity()
self.rec.write("%.3f,%s,%d,%d,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s\n" % ( if who != "bot" and p.is_alive:
t, who, int(bool(p.is_alive)), p.health, pos.x, pos.y, pos.z, vel.x, vel.y, vel.z, state)) humans.append((pos.x, pos.y, pos.z))
rows.append((who, int(bool(p.is_alive)), p.health, pos.x, pos.y, pos.z, vel.x, vel.y, vel.z, state))
if mode == 2 and not humans_fighting(humans):
return
if self.rec is None:
self.rec = open(REC_FILE, "a", buffering=1)
self.rec_bytes = os.path.getsize(REC_FILE)
self.rec_start = now
self.rec.write("# start %d map %s\n" % (int(now), self.get_cvar("mapname")))
if (mode == 1 and now - self.rec_start > REC_MAX_S) or self.rec_bytes > REC_MAX_BYTES:
self.set_cvar("lgbot_record", "0")
self.record_mode = 0
return self.stop_recording()
t = now - self.rec_start
for r in rows:
line = "%.3f,%s,%d,%d,%.0f,%.0f,%.0f,%.0f,%.0f,%.0f,%s\n" % ((t,) + r)
self.rec.write(line)
self.rec_bytes += len(line)
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);
+28 -21
View File
@@ -2,7 +2,8 @@
"""Compare how you and lgbot move, from a recording (set lgbot_record 1, play, set lgbot_record 0). """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] 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 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. relative to the nearest opponent (human or bot), so human-vs-human recordings (lgbot_record 2) work
too: "humans" is every human together. Prints numbers only.
""" """
import math, sys import math, sys
@@ -40,25 +41,31 @@ def parse(lines):
def fight_frames(session): def fight_frames(session):
"""-> {"human": [...], "bot": [...]}, frames (t, x, y, z, vx, vy, vz, ox, oy, state) in time order.""" """-> {who: [...]} ("bot", "h1", ...), frames (t, x, y, z, vx, vy, vz, ox, oy, state) in time order.
out = {"human": [], "bot": []} Each alive player is measured against the nearest alive player in fight range (human or bot)."""
out = {}
for t in sorted(session): for t in sorted(session):
rows = session[t] alive = [(who, r) for who, r in session[t].items() if r[0]]
bot = rows.get("bot") for who, me in alive:
if not bot or not bot[0]:
continue
best = None best = None
for who, r in rows.items(): for other, r in alive:
if who == "bot" or not r[0]: if other == who or abs(r[4] - me[4]) > FIGHT_DZ:
continue continue
d = math.hypot(r[2] - bot[2], r[3] - bot[3]) d = math.hypot(r[2] - me[2], r[3] - me[3])
if best is None or d < best[0]: if d <= FIGHT_DIST and (best is None or d < best[0]):
best = (d, r) best = (d, r)
if best is None or best[0] > FIGHT_DIST or abs(best[1][4] - bot[4]) > FIGHT_DZ: if best is not None:
continue out.setdefault(who, []).append(
h = best[1] (t,) + me[2:8] + (best[1][2], best[1][3], me[8]))
out["human"].append((t,) + h[2:8] + (bot[2], bot[3], "")) return out
out["bot"].append((t,) + bot[2:8] + (h[2], h[3], bot[8]))
def by_kind(per_player):
"""{who: frames} -> {"human": frames, "bot": frames}: one player after another, so time jumps
back at each join and the gap rule keeps players (and sessions) apart."""
out = {"human": [], "bot": []}
for who in sorted(per_player):
out["bot" if who == "bot" else "human"].extend(per_player[who])
return out return out
@@ -143,7 +150,7 @@ ROWS = [("seconds", "fight time s"), ("hold_ms", "strafe hold ms p10/p50/p90"),
def report(human, bot): def report(human, bot):
lines = ["%-28s %-22s %s" % ("", "you", "lgbot")] lines = ["%-28s %-22s %s" % ("", "humans", "lgbot")]
for k, label in ROWS: for k, label in ROWS:
lines.append("%-28s %-22s %s" % (label, fmt(human.get(k)), fmt(bot.get(k)))) lines.append("%-28s %-22s %s" % (label, fmt(human.get(k)), fmt(bot.get(k))))
return "\n".join(lines) return "\n".join(lines)
@@ -153,11 +160,11 @@ def main(argv):
path = argv[1] if len(argv) > 1 else "home/lgbot-rec.csv" path = argv[1] if len(argv) > 1 else "home/lgbot-rec.csv"
with open(path) as f: with open(path) as f:
sessions = parse(f) sessions = parse(f)
both = {"human": [], "bot": []} per_player = {}
for s in sessions: for s in sessions:
fr = fight_frames(s) for who, frames in fight_frames(s).items():
for k in both: per_player.setdefault(who, []).extend(frames) # t restarts each session: a gap
both[k].extend(fr[k]) # t restarts at 0 each session: the gap rule keeps them apart both = by_kind(per_player)
print("%d session(s)" % len(sessions)) print("%d session(s)" % len(sessions))
print(report(analyze(both["human"]), analyze(both["bot"]))) print(report(analyze(both["human"]), analyze(both["bot"])))
return 0 return 0
+1
View File
@@ -29,6 +29,7 @@ set g_password "$(clean "$QL_PASSWORD")"
set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")" set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")"
set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")" set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")"
set qlx_welcome "$(clean "$QL_WELCOME")" set qlx_welcome "$(clean "$QL_WELCOME")"
set lgbot_record "$(clean "${QL_RECORD:-0}")"
map $MAP $FACTORY map $MAP $FACTORY
EOF EOF
echo "$MAP|$FACTORY" > $B/mappool_lg.txt echo "$MAP|$FACTORY" > $B/mappool_lg.txt
+29
View File
@@ -169,6 +169,35 @@ class Tick(unittest.TestCase):
self.assertEqual(self.w.cvars["lgbot_record"], "0") self.assertEqual(self.w.cvars["lgbot_record"], "0")
self.assertIsNone(self.plugin.rec) self.assertIsNone(self.plugin.rec)
def test_auto_records_only_two_humans_fighting(self):
import tempfile
path = os.path.join(tempfile.mkdtemp(), "rec.csv")
self.mod.REC_FILE = path
self.w.cvars["lgbot_record"] = "2"
self.run_frames(40) # one human vs the bot: not recorded
self.assertFalse(os.path.exists(path))
h2 = FakePlayer(2, "other", HUMAN_ID + 1, (450, 300, 0))
self.w.players.append(h2)
self.run_frames(40)
h2.pos = [5000, 0, 0] # walks away: stops writing
self.run_frames(5)
with open(path) as f:
before = f.read()
self.run_frames(40)
with open(path) as f:
after = f.read()
self.assertEqual(before, after)
self.assertIn(",h2,", before)
self.assertNotIn(str(HUMAN_ID), before)
self.plugin.stop_recording()
def test_humans_fighting(self):
f = self.mod.humans_fighting
self.assertTrue(f([(0, 0, 0), (900, 0, 0)]))
self.assertFalse(f([(0, 0, 0), (1200, 0, 0)]))
self.assertFalse(f([(0, 0, 0), (100, 0, 400)]))
self.assertFalse(f([(0, 0, 0)]))
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)
+23 -7
View File
@@ -32,7 +32,7 @@ def still_at(x):
class Stats(unittest.TestCase): class Stats(unittest.TestCase):
def test_strafe_holds_and_sideways(self): def test_strafe_holds_and_sideways(self):
s = r.parse(rec(10, adad(0.5), still_at(450))) s = r.parse(rec(10, adad(0.5), still_at(450)))
st = r.analyze(r.fight_frames(s[0])["human"]) st = r.analyze(r.by_kind(r.fight_frames(s[0]))["human"])
self.assertAlmostEqual(st["hold_ms"][1], 500, delta=30) self.assertAlmostEqual(st["hold_ms"][1], 500, delta=30)
self.assertGreater(st["sideways_share"], 0.95) self.assertGreater(st["sideways_share"], 0.95)
self.assertEqual(st["air_share"], 0) self.assertEqual(st["air_share"], 0)
@@ -41,7 +41,7 @@ class Stats(unittest.TestCase):
def test_bot_steer_share_and_stopped(self): def test_bot_steer_share_and_stopped(self):
s = r.parse(rec(5, adad(0.5), still_at(450))) s = r.parse(rec(5, adad(0.5), still_at(450)))
st = r.analyze(r.fight_frames(s[0])["bot"]) st = r.analyze(r.by_kind(r.fight_frames(s[0]))["bot"])
self.assertEqual(st["steer_share"], 1) self.assertEqual(st["steer_share"], 1)
self.assertEqual(st["stopped_share"], 1) self.assertEqual(st["stopped_share"], 1)
@@ -49,25 +49,41 @@ class Stats(unittest.TestCase):
def hop(t): # one jump a second: 0.7 s in the air, then ground def hop(t): # one jump a second: 0.7 s in the air, then ground
ph = t % 1.0 ph = t % 1.0
return (0, 0, 0, 0, 320, 270 - 800 * ph if ph < 0.7 else 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"]) st = r.analyze(r.by_kind(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["jumps_per_s"], 1.0, delta=0.15)
self.assertAlmostEqual(st["air_share"], 0.7, delta=0.05) self.assertAlmostEqual(st["air_share"], 0.7, delta=0.05)
def test_far_or_other_floor_is_not_fight_time(self): 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]) 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]) 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(far, {})
self.assertEqual(up["human"], []) self.assertEqual(up, {})
def test_sessions_do_not_join(self): def test_sessions_do_not_join(self):
lines = rec(2, adad(0.5), still_at(450)) + rec(2, adad(0.5), still_at(450)) lines = rec(2, adad(0.5), still_at(450)) + rec(2, adad(0.5), still_at(450))
sessions = r.parse(lines) sessions = r.parse(lines)
self.assertEqual(len(sessions), 2) self.assertEqual(len(sessions), 2)
frames = r.fight_frames(sessions[0])["human"] + r.fight_frames(sessions[1])["human"] frames = r.fight_frames(sessions[0])["h1"] + r.fight_frames(sessions[1])["h1"]
self.assertAlmostEqual(r.analyze(frames)["seconds"], 4, delta=0.1) self.assertAlmostEqual(r.analyze(frames)["seconds"], 4, delta=0.1)
def test_two_humans_kept_apart(self):
# h1 strafes with 0.5 s holds, h2 with 0.25 s holds, no bot: each is measured on its own
lines = ["# start 0 map test"]
for i in range(400):
t = i * 0.025
for who, hold, x in (("h1", 0.5, 0), ("h2", 0.25, 450)):
d = 1 if int(t / hold) % 2 == 0 else -1
lines.append("%.3f,%s,1,100,%d,0,0,0,%d,0," % (t, who, x, 320 * d))
per = r.fight_frames(r.parse(lines)[0])
self.assertEqual(sorted(per), ["h1", "h2"])
self.assertAlmostEqual(r.analyze(per["h1"])["hold_ms"][1], 500, delta=30)
self.assertAlmostEqual(r.analyze(per["h2"])["hold_ms"][1], 250, delta=30)
both = r.by_kind(per)["human"]
self.assertAlmostEqual(r.analyze(both)["seconds"], 20, delta=0.2)
self.assertEqual(r.analyze(r.by_kind(per)["bot"])["frames"], 0)
def test_report_prints(self): def test_report_prints(self):
st = r.analyze(r.fight_frames(r.parse(rec(2, adad(0.5), still_at(450)))[0])["human"]) st = r.analyze(r.fight_frames(r.parse(rec(2, adad(0.5), still_at(450)))[0])["h1"])
self.assertIn("strafe hold", r.report(st, r.analyze([]))) self.assertIn("strafe hold", r.report(st, r.analyze([])))