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:
@@ -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()
|
||||
Reference in New Issue
Block a user