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

+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))