cornering/ramming premise REFUTED on three independent measurements

Hypothesis (user's): pushing an enemy toward a wall makes it predictable, which
both enables a ram and raises our gun hit rate. Measured offline over the
committed DrussGT fixtures using REAL server event attribution (an events
sidecar survived: tools/robocode_shim/evidence/tr_drussgt_vs_modularbot.events.json,
2534 fires / 229 hits; per-round event tick joins the fixture global tick at
global = round.startTick + tick - 2, verified exact over all 2534 fires).

M1 - cornering does NOT raise hit rate.
  REAL attribution, all 1134 ModularBot shots, bucketed by DrussGT's distance
  to the nearest wall at fire time:
    <=30px   69 shots   5 hits   7.25%
    30-60   336        18        5.36%
    60-120  555        26        4.68%
    120-250 172        11        6.40%
    >250      2         0        0.00%
    TOTAL  1134        60        5.29%
  Adjacent(<=60) 5.68% vs Open(>60) 5.08%, z=+0.435 -> NOT significant.
  Per-round ranges fully overlap (adjacent 0-18.2%, open 0-9.6%).
  Virtual per-gun within-gun check: most guns neutral-to-negative; only DecayGF
  favours it. Across all 10 fixtures every one of 12 guns scores LOWER adjacent
  (range-confounded, directional only).

M2 - a wall-adjacent enemy is LESS predictable, not more.
  30-degree tolerance, uniform chance 16.7%, adjacent vs open:
    keep-direction (1 tick)      93.35% vs 95.88%   z=-13.82
    turn-persistence             87.6%  vs 90.9%
    constant-velocity err H=10   45.9%  vs 25.7%    (1.8x MORE deviation)
    "move away from nearest wall" 1.1%  vs 8.4%
    "move toward centre"          0.4%  vs 3.0%
  Wall-adjacent DrussGT reverses more and deviates from constant-velocity ~1.8x
  more. It does NOT flee the wall - it surfs perpendicular. Base rate of
  wall-adjacency: 20.2% of moving ticks.

M3 - the ram is a near-zero-frequency opportunity against DrussGT.
  Strict contact (<=36px): ZERO ticks in all 10 fixtures. Closest global
  approach 39.1px. In the primary fixture (ModularBot vs DrussGT) the closest
  approach was 118.7px - 0 ticks <=80px, 0 near-contact episodes, and ZERO ram
  collisions in 15 rounds. Real ram collisions anywhere in the corpus: 2 total
  (drussgt_vs_ramfire 1/20 rounds, tr_drussgt_vs_crazy 1/10), each a ONE-SHOT
  0.6 energy to both bots, no sustained multi-tick stream.

CORRECTION TO AN EARLIER CLAIM: ram damage is 0.6 per CONTACT EVENT, not
0.6/turn sustained. The efficiency ratio (0.6 damage for 0.6 energy taken,
scored 2.0/pt) still beats firing, but the magnitude is 0.6 vs 16 for a p=3
bullet hit, and against DrussGT the frequency is zero.

CAVEAT (from the analysis): the fixtures capture DrussGT's NATURAL wall
behaviour, not an enemy being actively pushed into a corner by a rammer, so the
exact scenario is not directly represented. But M3 shows we never get close
enough to push in the first place - ModularBot's closest approach in 15 rounds
was 118.7px, so the <50px ram trigger has never fired against this adversary.

Adds two reusable offline instruments:
- measure_cornering_guns.nim (replays a fixture through the real VirtualTracker,
  attributing each resolved virtual bullet to its fire-tick wall bucket)
- measure_cornering_ram.py (real-event join, predictability, ram base rate)
Neither edits offline_range.nim; the 12/12 deterministic-gun contract is
untouched and was not re-run (it requires a live battle).
This commit is contained in:
2026-09-21 22:49:56 +02:00
parent 07f6f3af3f
commit 391318a7bd
2 changed files with 530 additions and 0 deletions
@@ -0,0 +1,176 @@
## OFFLINE MEASUREMENT (read-only analysis). No live battles, no bot rebuild.
##
## Measurement 1, virtual-metric half: does an enemy being near a WALL raise our
## virtual-bullet hit rate, and does that hold per-gun (within-gun comparison)?
##
## This deliberately does NOT modify `gun_harness/offline_range.nim`. It re-runs
## the same replay contract (spawn -> tickBullets) in a local loop so it can
## capture one outcome per spawned bullet and bucket it by the enemy's distance
## to the nearest arena wall AT THE TICK WE FIRED.
##
## Because `spawnBullets` is not cooldown-gated, every gun spawns one bullet per
## power bin per tick; the sample size is therefore far larger than a live battle
## and the absolute rates are NOT live rates. The comparison of interest is
## WITHIN a gun, across wall-distance buckets, which shares all that sampling.
##
## Usage:
## nim c -r common_libs/tests/measure_cornering_guns.nim [fixture.jsonl ...]
## If no fixture is given, the TR + classic DrussGT fixtures are used.
import std/[os, strformat, tables, math, algorithm]
import gun_harness/offline_range
import range_guns
const
NumBuckets = 5
BucketEdges = [30.0, 60.0, 120.0, 250.0]
BucketNames = ["<=30", "30-60", "60-120", "120-250", ">250"]
type
BStat = object
shots*, hits*: int
proc bucketIdx(d: float): int =
if d <= BucketEdges[0]: 0
elif d <= BucketEdges[1]: 1
elif d <= BucketEdges[2]: 2
elif d <= BucketEdges[3]: 3
else: 4
proc enemyWallDist(s: WorldState): float =
## Distance from the ENEMY (e*) to the nearest arena wall.
min(min(s.enemyX, s.enemyY),
min(s.arenaWidth - s.enemyX, s.arenaHeight - s.enemyY))
proc replayBucketed(fx: Fixture, drivers: seq[GunDriver],
liveActual = false): seq[array[NumBuckets, BStat]] =
## Copy of offline_range.replayFixture's ordering, extended to attribute each
## resolved virtual bullet to its fire tick's wall-distance bucket.
let tid = fx.enemyId
let skipFinal = liveActual and fx.enemyDied
var tracker = initTracker(drivers.len, ActiveMetric)
var wallAtFire = newSeq[float](fx.states.len)
for i, s in fx.states:
wallAtFire[i] = enemyWallDist(s)
# key = (gunId, powerBin, fireTick) -> bucket index
var pending = initTable[(int, int, int), int]()
result = newSeq[array[NumBuckets, BStat]](drivers.len)
for si in 0..<fx.states.len:
let state = fx.states[si]
let bi = bucketIdx(wallAtFire[si])
for gi in 0..<drivers.len:
var preds: array[len(PowerBins), GunPrediction]
for i in 0..<len(PowerBins):
preds[i] = drivers[gi].predictCb(state, bulletSpeed(PowerBins[i]))
let ready = if drivers[gi].readyCb == nil: true else: drivers[gi].readyCb()
if ready:
let h0 = tracker.head
tracker.spawnBullets(gi, preds, state, tid)
# spawnBullets appended exactly len(PowerBins) bullets starting at h0.
for j in 0..<len(PowerBins):
let slot = (h0 + j) mod MaxBullets
if tracker.bullets[slot].active and
tracker.bullets[slot].fireTick == state.tick and
tracker.bullets[slot].gunId == gi:
pending[(gi, j, state.tick)] = bi
let actIdx = if liveActual and si + 1 < fx.states.len: si + 1 else: si
let act = fx.states[actIdx]
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
var lst = act.tick
if actIdx < fx.lastSeen.len and fx.lastSeen[actIdx] >= 0: lst = fx.lastSeen[actIdx]
if act.enemies.len > 0:
for e in act.enemies:
enemyPositions[e.id] = (x: e.x, y: e.y, lastSeenTick: lst, alive: true)
else:
enemyPositions[tid] = (x: act.enemyX, y: act.enemyY, lastSeenTick: lst, alive: true)
let res = addr result
let pend = addr pending
let dref = drivers
if not (skipFinal and si == fx.states.len - 1):
tracker.tickBullets(state, enemyPositions,
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
# Forward to the owning gun exactly as replayFixture does, so learning
# guns (GF/DecayGF/KNN) keep their history.
dref[gunId].resultCb(e)
let key = (gunId, binIdx, e.fireTick)
if pend[].hasKey(key):
let bb = pend[][key]
inc res[][gunId][bb].shots
if e.hit: inc res[][gunId][bb].hits
pend[].del(key))
if tracker.droppedBullets != 0:
stderr.writeLine(&"[measure_cornering_guns] WARNING droppedBullets={tracker.droppedBullets}")
proc z2prop(h1, n1, h2, n2: int): float =
if n1 == 0 or n2 == 0: return 0.0
let p1 = h1.float / n1.float
let p2 = h2.float / n2.float
let p = (h1 + h2).float / (n1 + n2).float
let se = sqrt(p * (1.0 - p) * (1.0 / n1.float + 1.0 / n2.float))
if se <= 0.0: 0.0 else: (p1 - p2) / se
proc printGun(name: string, b: array[NumBuckets, BStat]) =
var ts, th = 0
var line = &"{name:<12}"
for i in 0..<NumBuckets:
let s = b[i]
ts += s.shots; th += s.hits
let rate = if s.shots > 0: s.hits.float / s.shots.float * 100.0 else: 0.0
line.add &" {BucketNames[i]}={s.hits}/{s.shots} {rate:5.2f}%"
let tr = if ts > 0: th.float / ts.float * 100.0 else: 0.0
echo line
echo &" TOTAL={th}/{ts} {tr:5.2f}%"
# adjacent (<=60) vs open (>60)
let adjS = b[0].shots + b[1].shots
let adjH = b[0].hits + b[1].hits
let opS = b[2].shots + b[3].shots + b[4].shots
let opH = b[2].hits + b[3].hits + b[4].hits
let adjR = if adjS > 0: adjH.float / adjS.float * 100.0 else: 0.0
let opR = if opS > 0: opH.float / opS.float * 100.0 else: 0.0
echo &" ADJ(<=60)={adjH}/{adjS} {adjR:5.2f}% OPEN(>60)={opH}/{opS} {opR:5.2f}% " &
&"z={z2prop(adjH, adjS, opH, opS):+.2f}"
proc main() =
var files: seq[string]
for i in 1..paramCount():
files.add paramStr(i)
if files.len == 0:
let dir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
for f in walkFiles(dir / "*drussgt*.jsonl"):
files.add f
files.sort()
let drivers = buildAllGunDrivers(enableTmSelector = false, seed = 1)
var pooled: seq[array[NumBuckets, BStat]] = newSeq[array[NumBuckets, BStat]](drivers.len)
for path in files:
let fx = loadFixture(path)
let res = replayBucketed(fx, drivers, liveActual = (fx.meta.source == "live"))
for gi in 0..<drivers.len:
for bi in 0..<NumBuckets:
pooled[gi][bi].shots += res[gi][bi].shots
pooled[gi][bi].hits += res[gi][bi].hits
echo &"# {extractFilename(path)} ticks={fx.states.len}"
echo ""
echo "== M1 VIRTUAL per-gun (full-run, all resolved bullets) by DrussGT wall dist at fire =="
echo " (NOT the live fire cadence; within-gun comparison across buckets is the point)"
for gi, d in drivers:
printGun(d.name, pooled[gi])
echo ""
echo "== pooled over all 13 guns =="
var all: array[NumBuckets, BStat]
for gi in 0..<drivers.len:
for bi in 0..<NumBuckets:
all[bi].shots += pooled[gi][bi].shots
all[bi].hits += pooled[gi][bi].hits
printGun("ALL", all)
when isMainModule:
main()
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""OFFLINE MEASUREMENT ONLY - no live battles, no bot rebuild.
Measurements for the "cornered enemy is predictable -> ram + better guns"
hypothesis, over the committed jsonl fixtures in tools/fixtures/.
M1 real hit attribution (events sidecar) bucketed by enemy wall distance
M2 movement predictability, wall-adjacent vs open (1-tick and H-tick)
M3 ram / body-contact opportunity base rate, episodes, energy loss
Run:
python3 common_libs/tests/measure_cornering_ram.py
"""
import json, math, os, statistics
from collections import defaultdict, Counter
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FIX = os.path.join(ROOT, "tools", "fixtures")
META = os.path.join(FIX, "drussgt_meta")
EVENTS = os.path.join(ROOT, "tools", "robocode_shim", "evidence",
"tr_drussgt_vs_modularbot.events.json")
BOT_RADIUS = 18.0
CONTACT = 2 * BOT_RADIUS # 36 px centre-to-centre
RAM_DAMAGE = 0.6 # Robocode/Tank Royale per-collision energy loss to each bot
WALL_BUCKETS = [("<=30", 0, 30), ("30-60", 30, 60), ("60-120", 60, 120),
("120-250", 120, 250), (">250", 250, 1e9)]
ADJ = ("<=30", "30-60")
OPEN = ("60-120", "120-250", ">250")
def load_fixture(path):
states, meta = [], {}
for line in open(path):
line = line.strip()
if not line:
continue
d = json.loads(line)
if "meta" in d:
meta = d["meta"]
continue
if "end" in d:
continue
states.append(d)
return meta, states
def load_rounds(path, n):
p = os.path.join(META, os.path.basename(path) + ".rounds.json")
if not os.path.exists(p):
return [{"round": 1, "startTick": 0, "count": n}]
return json.load(open(p))["rounds"]
def wall_dist(x, y, w=800.0, h=600.0):
return min(x, y, w - x, h - y)
def bucket_name(d):
for name, lo, hi in WALL_BUCKETS:
if lo < d <= hi:
return name
return "<=30"
def z2prop(h1, n1, h2, n2):
if n1 == 0 or n2 == 0:
return 0.0
p1, p2 = h1 / n1, h2 / n2
p = (h1 + h2) / (n1 + n2)
se = math.sqrt(p * (1 - p) * (1 / n1 + 1 / n2))
return (p1 - p2) / se if se > 0 else 0.0
def ang_diff(a, b):
d = (a - b) % 360.0
return min(d, 360.0 - d)
def norm180(a):
a = (a + 180.0) % 360.0 - 180.0
return a
# ────────────────────────────────────────────────────────────── M1 real ──
def m1_real():
fxpath = os.path.join(FIX, "tr_drussgt_vs_modularbot.jsonl")
meta, states = load_fixture(fxpath)
rounds = load_rounds(fxpath, len(states))
rstarts = {r["round"]: r["startTick"] for r in rounds}
events = [json.loads(l) for l in open(EVENTS)]
fires = [e for e in events if e["type"] == "fire"]
hits = {(e["round"], e["bullet"]) for e in events if e["type"] == "hit"}
shots, hitc = defaultdict(int), defaultdict(int)
perround = defaultdict(lambda: defaultdict(lambda: [0, 0]))
st = ht = 0
for e in fires:
if e["owner"] != 2: # 2 = ModularBot (s*), 1 = DrussGT (e*)
continue
g = rstarts[e["round"]] + e["tick"] - 2
s = states[g]
b = bucket_name(wall_dist(s["ex"], s["ey"]))
ish = (e["round"], e["bullet"]) in hits
shots[b] += 1
hitc[b] += ish
st += 1
ht += ish
perround[e["round"]][b][0] += ish
perround[e["round"]][b][1] += 1
print("== M1a REAL pooled hit attribution (ModularBot shots, all guns) ==")
print(f" {'bucket':>8} {'shots':>6} {'hits':>5} {'rate':>7}")
for name, _, _ in WALL_BUCKETS:
s, h = shots[name], hitc[name]
print(f" {name:>8} {s:>6} {h:>5} {h/s*100 if s else 0:>6.2f}%")
print(f" {'TOTAL':>8} {st:>6} {ht:>5} {ht/st*100:>6.2f}%")
a = sum(shots[n] for n in ADJ); ah = sum(hitc[n] for n in ADJ)
o = sum(shots[n] for n in OPEN); oh = sum(hitc[n] for n in OPEN)
print(f" ADJACENT(<=60) {ah}/{a}={ah/a*100:.2f}% OPEN(>60) {oh}/{o}={oh/o*100:.2f}%"
f" z={z2prop(ah,a,oh,o):.3f}")
ar = [sum(perround[r][b][0] for b in ADJ) / max(1, sum(perround[r][b][1] for b in ADJ)) * 100
for r in range(1, 16) if sum(perround[r][b][1] for b in ADJ)]
orr = [sum(perround[r][b][0] for b in OPEN) / max(1, sum(perround[r][b][1] for b in OPEN)) * 100
for r in range(1, 16) if sum(perround[r][b][1] for b in OPEN)]
print(f" per-round ADJ rate range {min(ar):.1f}-{max(ar):.1f}% (mean {statistics.mean(ar):.2f})")
print(f" per-round OPEN rate range {min(orr):.1f}-{max(orr):.1f}% (mean {statistics.mean(orr):.2f})")
print(" OVERLAP: per-round ranges fully overlap -> not separated by repo convention")
print()
# ────────────────────────────────────────────────────────────── M2 ──
def motion_dir(s):
return s["eh"] if s["es"] >= 0 else s["eh"] + 180.0
def nearest_wall_dir(s, w=800.0, h=600.0):
x, y = s["ex"], s["ey"]
dl, dr, db, dt = x, w - x, y, h - y
m = min(dl, dr, db, dt)
if m == dl:
return 0.0
if m == dr:
return 180.0
if m == db:
return 90.0
return 270.0
def centre_dir(s, w=800.0, h=600.0):
return math.degrees(math.atan2(h / 2 - s["ey"], w / 2 - s["ex"])) % 360.0
def m2_predictability():
files = [f for f in sorted(os.listdir(FIX)) if f.endswith(".jsonl") and "drussgt" in f]
tol = 30.0
HOR = [1, 5, 10, 15]
def newacc():
return dict(n=0, keep=0, turn=0, turn_n=0, rev=0, rev_n=0,
absdh=0.0, absdh_n=0,
away=0, centre=0, vh_err=defaultdict(list), vh_n=defaultdict(int))
acc = defaultdict(newacc)
per_file_adj = defaultdict(lambda: defaultdict(lambda: [0, 0])) # fixture -> bucket -> keep
for fn in files:
path = os.path.join(FIX, fn)
_, states = load_fixture(path)
rounds = load_rounds(path, len(states))
starts = set(r["startTick"] for r in rounds)
for i in range(1, len(states)):
if i in starts:
continue
s0, s1 = states[i - 1], states[i]
b = bucket_name(wall_dist(s0["ex"], s0["ey"]))
a = acc[b]
dh = norm180(s1["eh"] - s0["eh"])
a["absdh"] += abs(dh); a["absdh_n"] += 1
if abs(dh) >= 1.0 and i + 1 < len(states) and (i + 1) not in starts:
dh2 = norm180(states[i + 1]["eh"] - s1["eh"])
if abs(dh2) >= 1.0:
a["turn_n"] += 1
a["turn"] += (dh > 0) == (dh2 > 0)
# 1-tick direction prediction
if i + 1 < len(states) and (i + 1) not in starts:
dx = states[i + 1]["ex"] - s1["ex"]
dy = states[i + 1]["ey"] - s1["ey"]
if math.hypot(dx, dy) >= 0.5:
actual = math.degrees(math.atan2(dy, dx)) % 360.0
a["n"] += 1
a["keep"] += ang_diff(actual, motion_dir(s0)) <= tol
a["away"] += ang_diff(actual, nearest_wall_dir(s0)) <= tol
a["centre"] += ang_diff(actual, centre_dir(s0)) <= tol
per_file_adj[fn][b][1] += 1
per_file_adj[fn][b][0] += ang_diff(actual, motion_dir(s0)) <= tol
# velocity-sign reversals (both ticks have a definite sign)
if s0["es"] != 0.0 and s1["es"] != 0.0:
a["rev_n"] += 1
a["rev"] += (s0["es"] > 0) != (s1["es"] > 0)
# H-tick constant-velocity endpoint error
for H in HOR[1:]:
j = i + H
if j >= len(states) or j in starts:
continue
vx = s0["es"] * math.cos(math.radians(s0["eh"]))
vy = s0["es"] * math.sin(math.radians(s0["eh"]))
px, py = s0["ex"] + H * vx, s0["ey"] + H * vy
err = math.hypot(px - states[j]["ex"], py - states[j]["ey"])
travel = max(1e-6, H * abs(s0["es"]))
a["vh_err"][H].append(err / travel)
a["vh_n"][H] += 1
print("== M2 predictability of DrussGT motion, by wall distance at prediction time ==")
print(f" tuned predictors: keep-direction (1 tick, {tol:.0f} deg tol, chance {2*tol/360*100:.1f}%),")
print(" turn-persistence (sign of heading change), reversal rate, H-tick constant-velocity error")
print(f" {'bucket':>8} {'ticks':>7} {'keep':>7} {'turn':>7} {'rev':>7} {'|dh|':>6} "
+ " ".join(f"{'cv'+str(H):>7}" for H in HOR[1:]))
for name, _, _ in WALL_BUCKETS:
a = acc[name]
if a["n"] == 0:
continue
cells = []
for H in HOR[1:]:
v = a["vh_err"][H]
cells.append(f"{statistics.median(v)*100:>6.1f}%" if v else " -")
print(f" {name:>8} {a['n']:>7} {a['keep']/a['n']*100:>6.1f}% "
f"{(a['turn']/a['turn_n']*100 if a['turn_n'] else 0):>6.1f}% "
f"{(a['rev']/a['rev_n']*100 if a['rev_n'] else 0):>6.1f}% "
f"{a['absdh']/a['absdh_n']:>5.2f}d " + " ".join(cells))
# binary
def binc(keys):
t = newacc()
for k in keys:
for kk, vv in acc[k].items():
if kk == "vh_err":
for H, lst in vv.items():
t["vh_err"][H] += lst
elif kk == "vh_n":
for H, nv in vv.items():
t["vh_n"][H] += nv
else:
t[kk] += vv
return t
adj, opn = binc(ADJ), binc(OPEN)
base = adj["n"] + opn["n"]
print()
print(f" ADJACENT(<=60) = {adj['n']}/{base} ticks ({adj['n']/base*100:.1f}% of moving ticks)")
print(f" {'metric':>26} {'adjacent':>10} {'open':>10} {'delta':>9}")
for key, lab in (("keep", "keep-direction 1t"), ("away", "away-from-wall 1t"),
("centre", "toward-centre 1t")):
ra, ro = adj[key] / adj["n"] * 100, opn[key] / opn["n"] * 100
print(f" {lab:>26} {ra:>9.1f}% {ro:>9.1f}% {ra-ro:>+8.1f}pp")
ra = adj["turn"] / adj["turn_n"] * 100 if adj["turn_n"] else 0
ro = opn["turn"] / opn["turn_n"] * 100 if opn["turn_n"] else 0
print(f" {'turn-persistence':>26} {ra:>9.1f}% {ro:>9.1f}% {ra-ro:>+8.1f}pp")
ra = adj["rev"] / adj["rev_n"] * 100 if adj["rev_n"] else 0
ro = opn["rev"] / opn["rev_n"] * 100 if opn["rev_n"] else 0
print(f" {'velocity-reversal-rate':>26} {ra:>9.1f}% {ro:>9.1f}% {ra-ro:>+8.1f}pp")
for H in HOR[1:]:
ma = statistics.median(adj["vh_err"][H]); mo = statistics.median(opn["vh_err"][H])
print(f" {'cv-error H=%d (median %%tr)'%H:>26} {ma*100:>9.1f}% {mo*100:>9.1f}% {(ma-mo)*100:>+8.1f}pp")
# per-fixture consistency (paired sign test on keep-direction)
print()
print(" per-fixture keep-direction (1t) accuracy, adjacent vs open:")
wins = 0; nf = 0
for fn in files:
f = per_file_adj[fn]
na = sum(f[k][1] for k in ADJ); ha = sum(f[k][0] for k in ADJ)
no = sum(f[k][1] for k in OPEN); ho = sum(f[k][0] for k in OPEN)
if na == 0 or no == 0:
continue
nf += 1
ra, ro = ha / na * 100, ho / no * 100
wins += ra > ro
print(f" {fn:<38} adj {ra:5.1f}% open {ro:5.1f}% {'adj higher' if ra>ro else 'open higher'}")
print(f" fixtures where adjacent > open: {wins}/{nf}")
# ────────────────────────────────────────────────────────────── M3 ──
def near_episodes(flags, start, end):
eps = []
j = start
while j < end:
if flags[j]:
k = j
while k < end and flags[k]:
k += 1
eps.append((j, k))
j = k
else:
j += 1
return eps
def m3_ram():
files = [f for f in sorted(os.listdir(FIX)) if f.endswith(".jsonl") and "drussgt" in f]
print("== M3 ram / body-contact opportunity base rate ==")
print(f" strict contact = centre distance <= {CONTACT:.0f}px (2 x {BOT_RADIUS:.0f}px radius);")
print(f" ram collision signature = both bots lose ~{RAM_DAMAGE} energy on the same tick")
print(f" {'fixture':<36} {'ticks':>6} {'min':>6} {'<=36':>5} {'<=50':>5} {'<=60':>5} "
f"{'<=80':>5} {'eps60':>5} {'maxep':>5} {'ram':>4} {'dE/ram':>8}")
primary = None
for fn in files:
path = os.path.join(FIX, fn)
_, states = load_fixture(path)
rounds = load_rounds(path, len(states))
ivals = [(r["startTick"], r["startTick"] + r["count"]) for r in rounds]
dist = [math.hypot(s["ex"] - s["sx"], s["ey"] - s["sy"]) for s in states]
for name, thr in (("<=36", CONTACT), ("<=50", 50), ("<=60", 60), ("<=80", 80)):
pass
cnt = {t: sum(1 for d in dist if d <= t) for t in (36, 50, 60, 80)}
# near-contact episodes at <=60 inside rounds
eps = []
for a, b in ivals:
flags = [dist[i] <= 60 for i in range(len(states))]
eps += near_episodes(flags, a, b)
# ram collisions: distance <=60 and both energies drop by ~equal amount
rams = []
for a, b in ivals:
for i in range(max(a, 1), b):
de = states[i]["ee"] - states[i - 1]["ee"]
ds = states[i]["se"] - states[i - 1]["se"]
if (dist[i] <= 60 and de <= -0.4 and ds <= -0.4
and abs(de - ds) <= 0.15):
rams.append((i, de, ds))
dE = sum((de + ds) / 2 for _, de, ds in rams)
dEcol = f"{dE:>+8.2f}"
print(f" {fn:<36} {len(states):>6} {min(dist):>6.1f} {cnt[36]:>5} {cnt[50]:>5} "
f"{cnt[60]:>5} {cnt[80]:>5} {len(eps):>5} "
f"{(max(k-j for j,k in eps) if eps else 0):>5} {len(rams):>4} "
f"{dEcol}")
if "tr_drussgt_vs_modularbot.jsonl" == fn:
primary = (min(dist), cnt, len(eps), len(rams))
print()
print(" PRIMARY fixture tr_drussgt_vs_modularbot (ModularBot vs the real DrussGT):")
if primary:
mind, cnt, neps, nram = primary
print(f" closest approach = {mind:.1f}px (never within {CONTACT:.0f}px);"
f" ticks <=50/60/80 = {cnt[50]}/{cnt[60]}/{cnt[80]};"
f" near-contact episodes = {neps}; ram collisions = {nram}")
print()
def main():
m1_real()
m2_predictability()
m3_ram()
if __name__ == "__main__":
main()