From acf625d72b4c779a3a7f8c3d06f50a91ab1f11c5 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 15 Oct 2026 18:37:40 +0300 Subject: [PATCH] lgbot: auto recording while two humans fight (lgbot_record 2 / QL_RECORD), analyzer pairs any opponents Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +++ README.md | 1 + docs/ADMIN.md | 2 ++ home/minqlx-plugins/lgbot.py | 62 ++++++++++++++++++++++++------------ tools/lgbot_rec.py | 55 ++++++++++++++++++-------------- tools/mkcfg.sh | 1 + tools/test_lgbot_plugin.py | 29 +++++++++++++++++ tools/test_lgbot_rec.py | 30 +++++++++++++---- 8 files changed, 133 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index af2a608..8b6c25b 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ QL_MAP=thunderstruck # 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. +# 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. QL_PORT=27960 QL_RCON_PORT=28960 diff --git a/README.md b/README.md index 42c65e1..834acb2 100644 --- a/README.md +++ b/README.md @@ -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_MAP` | `thunderstruck` | stock map; workshop maps need extra setup | | `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_NAME` | `ql-lg` | Docker container name | diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 6c72f35..74fa9a3 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -62,6 +62,8 @@ Underneath it's plain console settings, if you'd rather: `./rcon addbot lgbot , # 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_MAX_S = 900 +REC_MAX_BYTES = 200 * 1024 * 1024 +REC_FIGHT_DIST, REC_FIGHT_DZ = 1000, 250 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) +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): """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 @@ -237,7 +249,7 @@ if minqlx: 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.record_mode, self.rec, self.rec_start, self.rec_bytes = 0, None, 0.0, 0 self.add_hook("frame", self.handle_frame) def handle_frame(self): @@ -255,7 +267,7 @@ 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.record_mode = to_int(self.get_cvar("lgbot_record")) or 0 self.read_at = now return self.settings @@ -320,23 +332,16 @@ if minqlx: 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 + def stop_recording(self): + if self.rec: self.rec.close() self.rec = None - return - t = now - self.rec_start + + def record(self, now): + mode = self.record_mode + if mode not in (1, 2): + return self.stop_recording() + rows, humans = [], [] for p in self.frame_players: if p.team == "spectator": continue @@ -347,8 +352,25 @@ if minqlx: 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)) + if who != "bot" and p.is_alive: + 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): # cos = how well last frame's command survived the frame (1 = exactly our direction); diff --git a/tools/lgbot_rec.py b/tools/lgbot_rec.py index 2e34de9..7d3646a 100644 --- a/tools/lgbot_rec.py +++ b/tools/lgbot_rec.py @@ -2,7 +2,8 @@ """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. +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 @@ -40,25 +41,31 @@ def parse(lines): def fight_frames(session): - """-> {"human": [...], "bot": [...]}, frames (t, x, y, z, vx, vy, vz, ox, oy, state) in time order.""" - out = {"human": [], "bot": []} + """-> {who: [...]} ("bot", "h1", ...), frames (t, x, y, z, vx, vy, vz, ox, oy, state) in time order. + Each alive player is measured against the nearest alive player in fight range (human or bot).""" + out = {} 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])) + alive = [(who, r) for who, r in session[t].items() if r[0]] + for who, me in alive: + best = None + for other, r in alive: + if other == who or abs(r[4] - me[4]) > FIGHT_DZ: + continue + d = math.hypot(r[2] - me[2], r[3] - me[3]) + if d <= FIGHT_DIST and (best is None or d < best[0]): + best = (d, r) + if best is not None: + out.setdefault(who, []).append( + (t,) + me[2:8] + (best[1][2], best[1][3], me[8])) + return out + + +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 @@ -143,7 +150,7 @@ ROWS = [("seconds", "fight time s"), ("hold_ms", "strafe hold ms p10/p50/p90"), def report(human, bot): - lines = ["%-28s %-22s %s" % ("", "you", "lgbot")] + lines = ["%-28s %-22s %s" % ("", "humans", "lgbot")] for k, label in ROWS: lines.append("%-28s %-22s %s" % (label, fmt(human.get(k)), fmt(bot.get(k)))) return "\n".join(lines) @@ -153,11 +160,11 @@ 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": []} + per_player = {} 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 + for who, frames in fight_frames(s).items(): + per_player.setdefault(who, []).extend(frames) # t restarts each session: a gap + both = by_kind(per_player) print("%d session(s)" % len(sessions)) print(report(analyze(both["human"]), analyze(both["bot"]))) return 0 diff --git a/tools/mkcfg.sh b/tools/mkcfg.sh index b43cfd5..b309ddb 100755 --- a/tools/mkcfg.sh +++ b/tools/mkcfg.sh @@ -29,6 +29,7 @@ set g_password "$(clean "$QL_PASSWORD")" set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")" set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")" set qlx_welcome "$(clean "$QL_WELCOME")" +set lgbot_record "$(clean "${QL_RECORD:-0}")" map $MAP $FACTORY EOF echo "$MAP|$FACTORY" > $B/mappool_lg.txt diff --git a/tools/test_lgbot_plugin.py b/tools/test_lgbot_plugin.py index 885f73e..70dbfad 100644 --- a/tools/test_lgbot_plugin.py +++ b/tools/test_lgbot_plugin.py @@ -169,6 +169,35 @@ class Tick(unittest.TestCase): self.assertEqual(self.w.cvars["lgbot_record"], "0") 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): self.human.team = "spectator" self.run_frames(40) diff --git a/tools/test_lgbot_rec.py b/tools/test_lgbot_rec.py index be6f6c3..3f2b375 100644 --- a/tools/test_lgbot_rec.py +++ b/tools/test_lgbot_rec.py @@ -32,7 +32,7 @@ def still_at(x): 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"]) + st = r.analyze(r.by_kind(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) @@ -41,7 +41,7 @@ class Stats(unittest.TestCase): 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"]) + st = r.analyze(r.by_kind(r.fight_frames(s[0]))["bot"]) self.assertEqual(st["steer_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 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"]) + 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["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"], []) + self.assertEqual(far, {}) + self.assertEqual(up, {}) 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"] + frames = r.fight_frames(sessions[0])["h1"] + r.fight_frames(sessions[1])["h1"] 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): - 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([])))