Files
SirStone 17c99f542f feat(fixtures): closed-loop DrussGT captures from real Tank Royale battles
The classic captures were OPEN-LOOP: replayed DrussGT never dodged OUR
bullets. These come from real TR battles through the working Java bridge, so
the recording contains genuine reactions to ModularBot's live fire. The
open-loop caveat is gone (perfect-information remains).

PRIMARY RESULT - the boss beats us badly. DrussGT 1447 - ModularBot 300 over
15 rounds, ModularBot winning only round 5 (DrussGT died at tick 1893). Rounds
are long, not truncated: mean 1335 ticks, ModularBot got off 1134 shots.
  ModularBot  1134 shots /  60 hits =  5.3% real hit rate
  DrussGT     1400 shots / 169 hits = 12.1% real hit rate
So DrussGT's gun is ~2.3x more accurate than our entire rack, on top of far
better movement. That is the number to move.

Also captured: shield-on variant (DrussGT 939-287, 9/10 - ModularBot takes
round 1 to the known shield warm-up), and vs SpinBot 1175-0, Crazy 1080-1,
Corners 1659-0. 20,026 + 12,629 + 10,824 + 11,507 + 2,575 ticks.

Movement statistics match the classic set within ~0.04 on the perpendicular
and radial fractions, so this is the same wave surfer in TR physics:
  TR vs modularbot: perp 0.967, radial 0.001, 52.8% at full speed,
                    reversing 46.3%, median range 464 px.

CLOSED LOOP PROVEN, not asserted. ModularBot's fire is a heat-limited near
metronome (median interval 14 ticks), which gives a usable exogenous clock:
  - event-locked |delta heading| oscillates 0.96 -> 2.69 deg about a 1.47 deg
    mean with the fire period, almost every lag outside the 95% band of a
    400-iteration phase-shuffled null;
  - cross-correlation of |delta heading| against the fire impulse peaks at
    r = +0.111, lag 12 ticks, permutation p = 0.005 (null peak mean +0.016);
  - OWN-FIRE CONTROL is flat, so the oscillation is enemy-driven rather than
    an internal cadence;
  - range response is weak (~3 px over 30 ticks, near noise) and is therefore
    NOT claimed, and per-bullet dodging is not claimed either because the
    bullet detector (shield) is off.

CAVEATS: still perfect-information (observer gives true positions every tick,
unlike the live bot's stale between-scan WorldState) so these remain optimistic
vs live play; and they are open-loop AT REPLAY TIME - 'closed_loop' describes
the capture, not a later replay. TR conversion residual is ~1.5 deg mean
because the TR server moves along the pre-turn heading, vs 0.000 deg for the
classic captures.

Adds analyze_closed_loop.py (PSTH event-locking, phase-shuffle permutation
null, cross-correlation, own-fire control) and per-round result sidecars.
2026-09-21 01:18:13 +02:00

357 lines
14 KiB
Python

#!/usr/bin/env python3
"""Closed-loop reactivity analysis for the Tank-Royale bridge fixtures.
Given a fixture JSONL (per-tick DrussGT + adversary state, produced by
``TrBattleCapture``) and the matching raw-events JSONL (bullet fire / hit /
death events), this measures whether **DrussGT's movement responds to the
adversary's live bullets** at capture time -- the property the classic-replay
fixtures cannot have.
Why the raw events: the fixture alone cannot distinguish an enemy *fire* from
enemy *damage* (both are energy drops), and it has no bullet positions. The
events sidecar gives the exact fire tick, muzzle position, direction and power
of every shot, so we can event-lock DrussGT's response and compute predicted
bullet-arrival times.
Outputs
-------
* fire cadence, hits / misses / bullet-bullet, per-round death victim (who won
the round) and DrussGT hits taken;
* event-locked PSTH (peri-stimulus time histogram) of DrussGT's response
(``|dHeading|``, reversal rate, radial-velocity fraction) as a function of
ticks since an adversary fire, with a permutation null band (fire ticks
circularly shifted inside each round);
* cross-correlation of the fire impulse train with the response, peak lag.
Note: fixture roles are fixed by ``TrBattleCapture``: e* = DrussGT (subject),
s* = adversary. The muzzle-position match is used only to *validate* that owner
id mapping independently.
Usage:
python3 tools/robocode_shim/analyze_closed_loop.py FIXTURE.jsonl EVENTS.jsonl
"""
import json
import math
import os
import random
import statistics as st
import sys
def wrap180(a):
return ((a + 180.0) % 360.0) - 180.0
def load_fixture(path):
rows = []
for line in open(path):
line = line.strip()
if not line:
continue
o = json.loads(line)
if "tick" in o:
rows.append(o)
rounds = []
for cand in (path + ".rounds.json",
os.path.join(os.path.dirname(path), "drussgt_meta",
os.path.basename(path) + ".rounds.json")):
try:
rounds = json.load(open(cand))["rounds"]
break
except Exception:
rounds = []
return rows, rounds
def load_events(path):
return [json.loads(l) for l in open(path) if l.strip()]
def build_rounds(rows, rounds):
if not rounds:
out, cur = [], [rows[0]]
for r in rows[1:]:
if math.hypot(r["ex"] - cur[-1]["ex"], r["ey"] - cur[-1]["ey"]) > 20:
out.append(cur)
cur = []
cur.append(r)
out.append(cur)
return [{"rows": c, "start": c[0]["tick"], "round": i + 1}
for i, c in enumerate(out)]
by_tick = {r["tick"]: r for r in rows}
out = []
for rn in rounds:
s = rn["startTick"]
out.append({"round": rn["round"], "start": s,
"rows": [by_tick[t] for t in range(s, s + rn["count"]) if t in by_tick]})
return out
def response_series(rlist):
res = []
for rd in rlist:
R = rd["rows"]
n = len(R)
hc = [float("nan")] * n
rev = [float("nan")] * n
rad = [float("nan")] * n
for i in range(1, n):
hc[i] = abs(wrap180(R[i]["eh"] - R[i - 1]["eh"]))
v0, v1 = R[i - 1]["es"], R[i]["es"]
rev[i] = 1.0 if (abs(v0) > 0.5 and v0 * v1 < 0) else 0.0
theta = math.degrees(math.atan2(R[i]["sy"] - R[i]["ey"],
R[i]["sx"] - R[i]["ex"]))
mu = R[i]["eh"] if v1 >= 0 else R[i]["eh"] + 180.0
rad[i] = abs(math.cos(math.radians(mu - theta)))
res.append({"round": rd["round"], "start": rd["start"], "n": n,
"hc": hc, "rev": rev, "rad": rad})
return res
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(2)
fixture, events_path = sys.argv[1], sys.argv[2]
rows, rounds = load_fixture(fixture)
events = load_events(events_path)
rlist = build_rounds(rows, rounds)
arrays = response_series(rlist)
n_by_round = {a["round"]: a["n"] for a in arrays}
# ---- validate owner -> role mapping from muzzle positions ----
idx = {r["tick"]: i for i, r in enumerate(rows)}
start_of = {rn["round"]: rn["startTick"] for rn in rounds}
role_by_owner = {}
for e in events:
if e["type"] != "fire" or "x" not in e:
continue
start = start_of.get(e["round"])
if start is None:
continue
gi = start + (e["tick"] - 1)
if gi not in idx:
continue
r = rows[idx[gi]]
de = math.hypot(r["ex"] - e["x"], r["ey"] - e["y"])
ds = math.hypot(r["sx"] - e["x"], r["sy"] - e["y"])
role_by_owner.setdefault(e["owner"], []).append("e" if de <= ds else "s")
owner_role = {o: max(set(v), key=v.count) for o, v in role_by_owner.items()}
subj_owner = next((o for o, ro in owner_role.items() if ro == "e"), None)
adv_owner = next((o for o, ro in owner_role.items() if ro == "s"), None)
print(f"fixture : {fixture}")
print(f"events : {events_path}")
print(f"ticks : {len(rows)} rounds: {len(rlist)}")
for o, v in role_by_owner.items():
print(f"owner {o}: muzzle-position role votes e*={v.count('e')} s*={v.count('s')} "
f"-> {owner_role[o]}*")
print(f"subject (DrussGT, e*) = owner {subj_owner}; adversary (s*) = owner {adv_owner}")
fires_adv = [e for e in events if e["type"] == "fire" and e["owner"] == adv_owner]
fires_subj = [e for e in events if e["type"] == "fire" and e["owner"] == subj_owner]
hits_adv = [e for e in events if e["type"] == "hit" and e["owner"] == adv_owner]
hits_subj = [e for e in events if e["type"] == "hit" and e["owner"] == subj_owner]
bhb = [e for e in events if e["type"] == "hitbullet"]
print(f"fires : adversary={len(fires_adv)} subject(DrussGT)={len(fires_subj)}")
print(f"hits : adversary->DrussGT={len(hits_adv)} DrussGT->adversary={len(hits_subj)}"
f" bullet-bullet={len(bhb)}")
if fires_adv:
print(f"adversary hit rate = {len(hits_adv)/len(fires_adv):.3f}")
fire_ticks_by_round = {rd["round"]: [] for rd in rlist}
for e in fires_adv:
fire_ticks_by_round[e["round"]].append(e["tick"])
print("\n=== per-round (ticks, adversary fires, DrussGT hits taken, round death) ===")
per_round_hits = {}
for e in hits_adv:
per_round_hits[e["round"]] = per_round_hits.get(e["round"], 0) + 1
death_by_round = {}
for e in events:
if e["type"] == "death":
death_by_round.setdefault(e["round"], []).append(e["victim"])
for rd in rlist:
rn = rd["round"]
dth = death_by_round.get(rn)
if dth is None:
winner = "no death (timeout?)"
else:
# victimId == adversary owner -> adversary died -> DrussGT won
winner = (f"DrussGT (adversary victimId={dth[0]})" if dth[0] == adv_owner
else f"adversary (DrussGT victimId={dth[0]})")
print(f" round {rn:2d}: ticks={n_by_round[rn]:5d} advFires={len(fire_ticks_by_round[rn]):4d} "
f"DrussGT hits taken={per_round_hits.get(rn,0):2d} winner={winner}")
deltas = []
for rn, fts in fire_ticks_by_round.items():
fts = sorted(fts)
deltas += [b - a for a, b in zip(fts, fts[1:])]
if deltas:
s = sorted(deltas)
print(f"\nadversary fire interval (ticks): median={st.median(deltas):.1f} "
f"mean={st.mean(deltas):.1f} p10={s[len(s)//10]} p90={s[len(s)*9//10]} "
f"(min={s[0]}, max={s[-1]})")
# ---- event-locked PSTH around adversary fire tick ----
tau_lo, tau_hi = -5, 80
fire_by_round = {rd["round"]: [rd["start"] + (t - 1)
for t in sorted(fire_ticks_by_round[rd["round"]])]
for rd in rlist}
def psth_for(key, fires_map):
acc = {t: [] for t in range(tau_lo, tau_hi + 1)}
for arr in arrays:
lo, n = arr["start"], arr["n"]
for f in fires_map[arr["round"]]:
for tau in range(tau_lo, tau_hi + 1):
gi = f + tau
if lo <= gi < lo + n:
v = arr[key][gi - lo]
if v == v:
acc[tau].append(v)
return {t: (st.mean(acc[t]) if acc[t] else float("nan")) for t in acc}
random.seed(1234)
for key, label in [("hc", "|dHeading| deg"), ("rev", "reversal rate"), ("rad", "|cos| radial frac")]:
real = psth_for(key, fire_by_round)
nulls = {t: [] for t in range(tau_lo, tau_hi + 1)}
for _ in range(150):
shifted = {}
for arr in arrays:
lo, n = arr["start"], arr["n"]
base = fire_by_round[arr["round"]]
k = random.randint(0, max(1, n - 1))
shifted[arr["round"]] = [lo + ((x - lo + k) % n) for x in base]
acc = psth_for(key, shifted)
for t in nulls:
if acc[t] == acc[t]:
nulls[t].append(acc[t])
print(f"\n=== event-locked PSTH: {label} vs ticks-since-adversary-fire ===")
print(" tau : real null95-low null95-high flag")
for t in range(tau_lo, tau_hi + 1):
band = sorted(nulls[t])
lo_b = band[int(0.025 * len(band))] if band else float("nan")
hi_b = band[int(0.975 * len(band))] if band else float("nan")
flag = ""
if real[t] == real[t] and band and (real[t] < lo_b or real[t] > hi_b):
flag = "OUTSIDE-NULL"
if t <= 45:
print(f" {t:4d}: {real[t]:8.4f} {lo_b:9.4f} {hi_b:9.4f} {flag}")
# ---- control: is DrussGT's turning also locked to its OWN fire cadence? ----
own_fire_by_round = {rd["round"]: [] for rd in rlist}
_start = {rd["round"]: rd["start"] for rd in rlist}
for e in fires_subj:
own_fire_by_round[e["round"]].append(_start[e["round"]] + (e["tick"] - 1))
for rn in own_fire_by_round:
own_fire_by_round[rn].sort()
print("\n=== CONTROL PSTH (|dHeading|) locked to DrussGT's OWN fire times ===")
print(" tau : real null95-low null95-high flag")
real_own = psth_for("hc", own_fire_by_round)
for t in range(0, 41):
band = []
for _ in range(80):
shifted = {}
for arr in arrays:
lo, n = arr["start"], arr["n"]
k = random.randint(0, max(1, n - 1))
shifted[arr["round"]] = [lo + ((x - lo + k) % n) for x in own_fire_by_round[arr["round"]]]
band.append(psth_for("hc", shifted)[t])
band = sorted(v for v in band if v == v)
lo_b = band[int(0.025 * len(band))] if band else float("nan")
hi_b = band[int(0.975 * len(band))] if band else float("nan")
flag = "OUTSIDE-NULL" if (real_own[t] == real_own[t] and band and
(real_own[t] < lo_b or real_own[t] > hi_b)) else ""
print(f" {t:4d}: {real_own[t]:8.4f} {lo_b:9.4f} {hi_b:9.4f} {flag}")
# ---- range-keeping vs ticks since adversary fire ----
print("\n=== range response: mean range (px) vs ticks-since-adversary-fire ===")
for t in range(0, 41, 5):
vals = []
for rd in rlist:
R = rd["rows"]
lo = rd["start"]
for f in fire_by_round[rd["round"]]:
gi = f + t
if lo <= gi < lo + len(R):
r = R[gi - lo]
vals.append(math.hypot(r["ex"] - r["sx"], r["ey"] - r["sy"]))
print(f" tau={t:3d}: mean range = {st.mean(vals):7.1f} (n={len(vals)})")
# ---- cross-correlation: fire impulse train vs response ----
print("\n=== cross-correlation of adversary-fire impulse with response ===")
# Sparse computation: fires are rare (k ~ 1000 out of ~20000 ticks), so the
# only permutation-dependent term is sum_xy at each lag.
for key, label in [("hc", "|dHeading|"), ("rev", "reversal")]:
resp = [arr[key] for arr in arrays]
fire_rel = []
for arr in arrays:
lo, n = arr["start"], arr["n"]
fire_rel.append([gi - lo for gi in fire_by_round[arr["round"]] if lo <= gi < lo + n])
denom = {}
for lag in range(0, 41):
npar = 0
sy = sy2 = 0.0
for r in resp:
n = len(r)
start = 1 if lag == 0 else 0 # r[0] is NaN (round start)
for j in range(start + lag, n):
v = r[j]
if v == v:
npar += 1
sy += v
sy2 += v * v
denom[lag] = (npar, sy, sy2)
def sparse_corr(fr_lists):
out = {}
for lag in range(0, 41):
npar, sy, sy2 = denom[lag]
if npar < 10:
out[lag] = float("nan")
continue
k = 0
sxy = 0.0
for r, fl in zip(resp, fr_lists):
n = len(r)
start = 1 if lag == 0 else 0
for fi in fl:
if start <= fi < n - lag:
v = r[fi + lag]
if v == v:
k += 1
sxy += v
mux = k / npar
muy = sy / npar
cov = sxy / npar - mux * muy
varx = k / npar - mux * mux
vary = sy2 / npar - muy * muy
out[lag] = (cov / math.sqrt(varx * vary)
if varx > 0 and vary > 0 else float("nan"))
return out
real = sparse_corr(fire_rel)
best = max((v, k) for k, v in real.items() if v == v)
print(f" {label}: peak r={best[0]:+.4f} at lag={best[1]} ticks; "
f"r(0)={real[0]:+.4f} r(10)={real[10]:+.4f} r(20)={real[20]:+.4f} "
f"r(30)={real[30]:+.4f}")
null_peaks = []
for _ in range(200):
shifted = []
for arr, fl in zip(arrays, fire_rel):
n = arr["n"]
kk = random.randint(0, max(1, n - 1))
shifted.append([(x + kk) % n for x in fl])
null_peaks.append(max(v for v in sparse_corr(shifted).values() if v == v))
p = (1 + sum(1 for v in null_peaks if v >= best[0])) / (1 + len(null_peaks))
print(f" permutation p(peak >= real peak) = {p:.4f} "
f"(null peak mean={st.mean(null_peaks):+.4f}, max={max(null_peaks):+.4f})")
if __name__ == "__main__":
main()