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
+133 -51

No files matched your search

+31 -24
View File
@@ -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
+1
View File
@@ -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
+29
View File
@@ -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)
+23 -7
View File
@@ -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([])))