DrussGT dodge vs fired power: no movement response once range is controlled

Answers the user's hypothesis that DrussGT dodges low-power shots better.
Measured on 70 live battles / 490 rounds / 54939 real shots vs real DrussGT
(/tmp/tfil_ab2) and replicated on 35 more battles / 24280 shots (/tmp/powtest).

Power is not randomly assigned - our policy caps it by RANGE
(TR_POWER_FAR_DIST=200 -> 1.0) and by OUR OWN ENERGY (the slope), so inside a
range band power is almost a deterministic function of our energy and a naive
low-vs-high comparison is secretly a losing-vs-healthy comparison. Everything
is stratified by range band and backed by a within-band shuffled-label null
(arrival re-derived, so the null keeps the kinematic channel), a round-cluster
bootstrap, and a within-shot CONTROL window 40 ticks later when the bullet is
long gone.

RESULT: no behavioural response. In band 450+ the raw miss distance at arrival
is +8.25 px [+5.39,+11.25] for HIGH power - but per flight tick it is 4.52 vs
4.51 px/tick (delta -0.01 [-0.12,+0.10]), i.e. entirely the 2.13-tick longer
flight window of the slower bullet. Fixed-12-tick lateral displacement is flat
(55.63 vs 55.47, -0.15 [-1.15,+0.84]) and turn rate / speed are flat. The whole
difference is already present 5 ticks after the trigger pull (+4.2 px) and is
just as large in the bullet-free control window (+5.6 px), so it is a property
of the low-energy situation, not of the shot. Hit rate is flat (0.10 vs 0.09).

Corpus/attribution notes: e*=DrussGT (subject), s*=ModularBot, per
TrBattleCapture.java; the Tank-Royale owner id is NOT stable across runs and is
recovered per battle from fire geometry + the energy decrement, cross-checked on
496/496 death events. Geometry validated on the server's own hits (mean miss
11.6 px, 80.6% inside the 18 px radius).
This commit is contained in:
2026-09-24 21:22:38 +02:00
parent 77e6dace01
commit 1adefaba26
4 changed files with 7964 additions and 0 deletions
@@ -0,0 +1,795 @@
#!/usr/bin/env python3
"""Does DrussGT's dodge quality change with the bullet power WE fire?
Reads REAL live-vs-real-DrussGT captures (per-tick worldstate jsonl + a
fire/hit event sidecar + a per-round tick index) produced by
``tools/robocode_shim/run_bridge_battle.sh``, and measures DrussGT's movement
response per shot, conditioned on the power ModularBot fired. No offline
fixture replay, no simulator: these are the recorded live battles.
--------------------------------------------------------------------------------
WHY THE OBVIOUS ANALYSIS IS WRONG
--------------------------------------------------------------------------------
Power is NOT randomly assigned. Our power policy (common_libs/gun_harness/
virtual_bullets.nim) only ever *caps* the gun's preference, by:
* RANGE -- TR_POWER_FAR_DIST=200 -> cap 1.0 beyond it (prFar);
* OUR ENERGY -- a linear slope from TR_POWER_ENERGY_MIN=0.5 at <=20 energy to
the cap at >=80 (prEnergySlope);
* the finishing rule (prFinishKill) and the sub-average-chances rule.
So "shots we fired at low power" are disproportionately long-range shots and
late/low-energy shots. Every comparison below is therefore stratified by the
fire-time range band, the headline is the stratified one, and the between-group
difference is compared against a NULL that permutes the power labels inside the
band (re-deriving the arrival tick so the kinematic channel survives the null).
We also report the confounder itself (our energy, enemy energy) per cell.
--------------------------------------------------------------------------------
WHAT IS MEASURED
--------------------------------------------------------------------------------
Per shot fired by US (ModularBot) at DrussGT. u = (cos dir, sin dir) is the
direction the bullet was fired; the bullet flies from the fire position along u
at v = 20 - 3p px/tick.
(a) MISS AT ARRIVAL -- perpendicular px between DrussGT's position on the
tick our bullet reaches its along-track plane and our bullet's intended
line. = |perp(perpendicular offset)| at that tick. Bot radius 18 px.
(b) LATERAL DISPLACEMENT -- perp offset change over the flight window, and the
SAME over a FIXED 12-tick window after the shot (power-neutral).
(c) RESPONSE LATENCY -- ticks from our fire until DrussGT's heading has turned
> LAT_DEG from its fire-tick heading.
(d) MEAN TURN RATE and MEAN SPEED over the flight window and the fixed window.
(e) DODGE DIRECTION -- did it move FURTHER off the aim line (|perp| grows).
(f) FLIGHT WINDOW LENGTH -- low power is a FASTER bullet, so a SHORTER window.
Outcome: hit rate per cell, resolved from the real hit/hitwall events.
--------------------------------------------------------------------------------
THE CONTROL
--------------------------------------------------------------------------------
A real bullet response must appear in the window the bullet is in the air. A
phase/distance artifact would appear just as strongly in a LATER window of the
same shot, when the bullet is long gone. So every metric is also computed over
a CONTROL window at [fire+CTRL_OFF, fire+CTRL_OFF+12] and the two are compared.
Anything that shows up in both is a property of the situation, not of the shot.
Run:
python3 common_libs/tests/analyze_drussgt_dodge_vs_power.py \
--tfil /tmp/tfil_ab2/out --powtest /tmp/powtest \
--json common_libs/tests/fixtures/dodge_vs_power_results.json
MEASURED = the numbers in the tables below (real recorded battles).
INFERRED = the attributions and the causal reading; both are flagged in the doc.
"""
from __future__ import annotations
import argparse
import collections
import json
import math
import os
import random
import statistics
BOT_RADIUS = 18.0
SPEED_A, SPEED_B = 20.0, 3.0 # bullet speed = 20 - 3p
LAT_DEG = 15.0 # heading turn that counts as a response
FIXED_WIN = 12 # power-neutral movement window (ticks)
CTRL_OFF = 40 # control window starts here after the fire
RANGE_MATCH_PX = 50 # range sub-bin used to match on range as well
MAX_FLIGHT = 220
BANDS = [(0, 100), (100, 200), (200, 300), (300, 450), (450, 10000)]
BAND_LABELS = ["0-100", "100-200", "200-300", "300-450", "450+"]
PWR_EDGES = [0.0, 0.5, 0.75, 1.0, 1.5, 4.0]
PWR_LABELS = ["<0.50", "0.50-0.75", "0.75-1.00", "1.00-1.50", ">=1.50"]
def pbin(p):
for i in range(len(PWR_EDGES) - 1):
if PWR_EDGES[i] <= p < PWR_EDGES[i + 1]:
return i
return None
def band_of(r):
for i, (lo, hi) in enumerate(BANDS):
if lo <= r < hi:
return BAND_LABELS[i]
return BAND_LABELS[-1]
def wrap180(x):
return abs(((x + 180.0) % 360.0) - 180.0)
def mean(xs):
return statistics.fmean(xs) if xs else float("nan")
def pct(xs, q):
if not xs:
return float("nan")
xs = sorted(xs)
return xs[min(len(xs) - 1, int(q * len(xs)))]
# --------------------------------------------------------------------- loader
def _rows(path):
out = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
o = json.loads(line)
if "tick" in o:
out.append(o)
return out
class Run:
"""One live battle: capture rows, event sidecar, per-round tick index."""
def __init__(self, cap_path, events_path, rounds_path):
self.cap_path = cap_path
self.rows = _rows(cap_path)
self.by_tick = {r["tick"]: r for r in self.rows}
self.events = [json.loads(l) for l in open(events_path) if l.strip()]
self.rounds = json.load(open(rounds_path))["rounds"]
self.start = {r["round"]: r["startTick"] for r in self.rounds}
self.count = {r["round"]: r["count"] for r in self.rounds}
self.owner_side = self._resolve_owner_side()
self.resolution = {}
for ev in self.events:
if ev.get("type") in ("hit", "hitwall", "hitbullet"):
self.resolution[(ev["round"], ev["owner"], ev["bullet"])] = ev["type"]
# -- attribution ------------------------------------------------------
def _match(self, t, side, ev):
r, r1 = self.by_tick.get(t), self.by_tick.get(t + 1)
if r is None or r1 is None:
return False
if abs(r[side + "x"] - ev["x"]) > 0.02 or abs(r[side + "y"] - ev["y"]) > 0.02:
return False
ef = "ee" if side == "e" else "se"
return abs((r[ef] - r1[ef]) - ev["power"]) < 0.02
def _resolve_owner_side(self):
"""Tank-Royale owner id -> 'e' (DrussGT, the subject) or 's' (us).
The id is NOT stable across runs (bot start order varies), so it is
recovered per battle: a fire event's (x,y) is the firing bot's own
position and that bot's energy drops by exactly the power one tick
later. Cross-checked against the death events (see main())."""
votes = collections.defaultdict(collections.Counter)
for ev in self.events:
if ev.get("type") != "fire":
continue
guess = self.start.get(ev["round"], 0) + ev["tick"]
for t in range(guess - 8, guess + 9):
for side in ("e", "s"):
if self._match(t, side, ev):
votes[ev["owner"]][side] += 1
return {o: c.most_common(1)[0][0] for o, c in votes.items() if c}
def align(self, ev):
guess = self.start.get(ev["round"], 0) + ev["tick"]
side = self.owner_side.get(ev["owner"])
best = None
for t in range(guess - 8, guess + 9):
for sd in (("e", "s") if side is None else (side,)):
if self._match(t, sd, ev):
d = abs(t - guess)
if best is None or d < best[0]:
best = (d, t, sd)
return None if best is None else best[1]
# -- per-shot extraction ---------------------------------------------
def shots(self):
for ev in self.events:
if ev.get("type") != "fire" or self.owner_side.get(ev["owner"]) != "s":
continue
t0 = self.align(ev)
if t0 is None:
continue
s = self._shot(ev, t0)
if s is not None:
yield s
def _shot(self, ev, t0):
r0 = self.by_tick[t0]
p = ev["power"]
th = math.radians(ev["dir"])
ux, uy = math.cos(th), math.sin(th)
x0, y0 = ev["x"], ev["y"]
rnd = ev["round"]
end = self.start[rnd] + self.count[rnd]
rng = math.hypot(r0["ex"] - x0, r0["ey"] - y0)
# per-tick geometry along/perpendicular to the aim line, up to the round
# end (capped); kept so the permutation null can re-derive the arrival.
along, perp, rows = [], [], []
karr = None
perp_arr = None
MIN_V = SPEED_A - SPEED_B * PWR_EDGES[-2] # slowest bullet the null can make
for k in range(1, MAX_FLIGHT + 1):
if t0 + k >= end:
break
r = self.by_tick.get(t0 + k)
if r is None:
break
dx, dy = r["ex"] - x0, r["ey"] - y0
along.append(dx * ux + dy * uy)
perp.append(dx * uy - dy * ux)
rows.append(r)
if karr is None and along[-1] > 0 and (SPEED_A - SPEED_B * p) * k >= along[-1]:
karr, perp_arr = k, perp[-1]
# stop once the real arrival is known, the null's slower arrival could
# still land, and the control window has been collected
if karr is not None and k >= CTRL_OFF + FIXED_WIN and MIN_V * k > along[-1]:
break
if karr is None:
return None
kmax = len(along)
g0 = (r0["ex"] - x0) * uy - (r0["ey"] - y0) * ux
# (b) fixed 12-tick window
lat_fixed = None
if kmax >= FIXED_WIN:
lat_fixed = perp[FIXED_WIN - 1] - g0
# control window (bullet long gone, same phase and range)
lat_ctrl = None
if kmax >= CTRL_OFF + FIXED_WIN:
lat_ctrl = perp[CTRL_OFF + FIXED_WIN - 1] - perp[CTRL_OFF - 1]
# (c) response latency
h0 = r0["eh"]
lat = None
for i in range(karr):
if wrap180(rows[i]["eh"] - h0) > LAT_DEG:
lat = i + 1
break
def kin(lo, n):
"""mean |turn rate| and mean |speed| over ticks [lo+1, lo+n]."""
if lo > len(rows):
return None, None
tt, hh = [], []
prev = r0 if lo == 0 else rows[lo - 1]
for j in range(lo, min(lo + n, kmax)):
r = rows[j]
tt.append(wrap180(r["eh"] - prev["eh"]))
hh.append(abs(r["es"]))
prev = r
if not tt:
return None, None
return mean(tt), mean(hh)
tr_f, sp_f = kin(0, karr)
tr_x, sp_x = kin(0, FIXED_WIN)
tr_c, sp_c = kin(CTRL_OFF, FIXED_WIN)
kind = self.resolution.get((rnd, ev["owner"], ev["bullet"]))
return dict(
run=self.cap_path, rnd=rnd, tick=t0, power=p, speed=SPEED_A - SPEED_B * p,
range=rng, band=band_of(rng), flight=karr,
perp_fire=g0, perp_arr=perp_arr, miss=abs(perp_arr),
miss_per_tick=abs(perp_arr) / karr,
lat_disp=perp_arr - g0, lat_disp_abs=abs(perp_arr - g0),
lat_disp_per_tick=abs(perp_arr - g0) / karr,
lat_fixed=lat_fixed, lat_fixed_abs=None if lat_fixed is None else abs(lat_fixed),
lat_ctrl=lat_ctrl, lat_ctrl_abs=None if lat_ctrl is None else abs(lat_ctrl),
dodge_further=1.0 if abs(perp_arr) > abs(g0) else 0.0,
sign_consist=1.0 if (perp_arr - g0) * perp_arr > 0 else 0.0,
latency=lat, turn_flight=tr_f, speed_flight=sp_f,
turn_fixed=tr_x, speed_fixed=sp_x,
turn_ctrl=tr_c, speed_ctrl=sp_c,
resolution=kind, hit=1.0 if kind == "hit" else 0.0,
our_energy=r0["se"], enemy_energy=r0["ee"],
_dir=ev["dir"], _x=x0, _y=y0, _along=along, _perp=perp,
)
@staticmethod
def _arrival(along, perp, v):
for k in range(1, len(along) + 1):
if along[k - 1] > 0 and v * k >= along[k - 1]:
return k, perp[k - 1]
return None
def discover_tfil(root):
runs = []
for sub in sorted(os.listdir(root)):
d = os.path.join(root, sub)
if not os.path.isdir(d):
continue
for fn in sorted(os.listdir(d)):
if not fn.endswith(".jsonl") or fn.endswith(".events.jsonl"):
continue
cap = os.path.join(d, fn)
ev, rj = cap[:-6] + ".events.jsonl", cap + ".rounds.json"
if os.path.exists(ev) and os.path.exists(rj):
runs.append(Run(cap, ev, rj))
return runs
def discover_powtest(root, arms=None):
runs = []
for fn in sorted(os.listdir(root)):
if not (fn.startswith("cap_") and fn.endswith(".jsonl")):
continue
arm = fn[4:].split("_r")[0]
if arms and arm not in arms:
continue
cap = os.path.join(root, fn)
ev = os.path.join(root, "events_" + fn[4:].replace(".jsonl", ".json"))
rj = cap + ".rounds.json"
if os.path.exists(ev) and os.path.exists(rj):
runs.append(Run(cap, ev, rj))
return runs
# --------------------------------------------------------------- statistics
def cell_stats(shots, key, only=None):
"""{(band, pbin): (n, mean, sd)}"""
acc = collections.defaultdict(list)
for s in shots:
if only and not only(s):
continue
v = s.get(key)
if v is None:
continue
i = pbin(s["power"])
if i is None:
continue
acc[(s["band"], i)].append(v)
return {k: (len(v), mean(v), statistics.pstdev(v) if len(v) > 1 else 0.0)
for k, v in acc.items()}
def perm_key(s, p):
"""Re-derive the arrival-dependent metrics for a substituted power."""
v = SPEED_A - SPEED_B * p
arr = Run._arrival(s["_along"], s["_perp"], v)
if arr is None:
return None
k, pe = arr
return {"power": p, "flight": k, "miss": abs(pe),
"miss_per_tick": abs(pe) / k,
"lat_disp_abs": abs(pe - s["perp_fire"]),
"lat_disp_per_tick": abs(pe - s["perp_fire"]) / k}
ARRIVAL_METRICS = ("miss", "lat_disp_abs", "flight", "miss_per_tick",
"lat_disp_per_tick")
def perm_contrast_null(shots, key, reps, seed=4242):
"""Per range band, the permutation null of delta = mean(HIGH) - mean(LOW).
For the arrival-dependent metrics each shot carries TWO precomputed values:
the metric it would have had at a typical LOW power and at a typical HIGH
power of that band (arrival tick re-derived, so the kinematic channel is
preserved). The null then draws a random subset of n_high shots to be the
HIGH arm -- which is exactly a permutation of the power values among the
shots of the band. Metrics that do not depend mechanically on power get the
same value in both arms, i.e. a plain label permutation.
Returns {band: (observed delta, null sd, two-sided p)}.
"""
rng = random.Random(seed)
by_band = collections.defaultdict(list)
for s in shots:
if grp(s) is not None and s.get(key) is not None:
by_band[s["band"]].append(s)
out = {}
for b, ss in by_band.items():
lo = [s for s in ss if grp(s) == "LOW"]
hi = [s for s in ss if grp(s) == "HIGH"]
if len(lo) < 15 or len(hi) < 15:
continue
p_lo = mean([s["power"] for s in lo])
p_hi = mean([s["power"] for s in hi])
if key in ARRIVAL_METRICS:
def val(s, p):
d = perm_key(s, p)
return None if d is None else d[key]
lows = [val(s, p_lo) for s in ss]
highs = [val(s, p_hi) for s in ss]
keep = [i for i in range(len(ss)) if lows[i] is not None and highs[i] is not None]
lows = [lows[i] for i in keep]
highs = [highs[i] for i in keep]
tag = [1 if grp(ss[i]) == "HIGH" else 0 for i in keep]
obs = (mean([highs[j] for j in range(len(keep)) if tag[j] == 1])
- mean([lows[j] for j in range(len(keep)) if tag[j] == 0]))
else:
vals = [s[key] for s in ss]
lows = highs = vals
tag = [1 if grp(s) == "HIGH" else 0 for s in ss]
obs = mean([v for v, t in zip(vals, tag) if t == 1]) - \
mean([v for v, t in zip(vals, tag) if t == 0])
n = len(keep) if key in ARRIVAL_METRICS else len(ss)
k = sum(tag)
deltas = []
for _ in range(reps):
idx = list(range(n))
rng.shuffle(idx)
sel = set(idx[:k])
if key in ARRIVAL_METRICS:
deltas.append(mean([highs[j] for j in sel]) -
mean([lows[j] for j in range(n) if j not in sel]))
else:
deltas.append(mean([lows[j] for j in sel]) -
mean([lows[j] for j in range(n) if j not in sel]))
p_hi_ = (sum(1 for x in deltas if x >= obs) + 1) / (reps + 1)
p_lo_ = (sum(1 for x in deltas if x <= obs) + 1) / (reps + 1)
out[b] = (obs, statistics.pstdev(deltas), min(1.0, 2 * min(p_hi_, p_lo_)))
return out
def fixed_horizon(shots, ks=(5, 10, 15, 20, 25, 30)):
"""The confound-free test.
Instead of the arrival tick (which power itself moves, and which also depends
on DrussGT's radial velocity), measure |perpendicular offset from the aim
line| at a FIXED number of ticks after the fire, and the same quantity in the
CONTROL window CTRL_OFF ticks later (bullet long gone). A response to the
shot would be ~0 at small k and grow with k. A phase/distance difference is
present already at k=5 and identical in the bullet-free control window.
Returns {band: {k: (low, high, nlow, nhigh, ctrl_low, ctrl_high)}}."""
out = {}
for b in BAND_LABELS:
ss = [s for s in shots if s["band"] == b and grp(s) is not None]
if len(ss) < 200:
continue
for k in ks:
lo = [abs(s["_perp"][k - 1]) for s in ss
if grp(s) == "LOW" and len(s["_perp"]) >= k]
hi = [abs(s["_perp"][k - 1]) for s in ss
if grp(s) == "HIGH" and len(s["_perp"]) >= k]
cl = [abs(s["_perp"][k + CTRL_OFF - 1]) for s in ss
if grp(s) == "LOW" and len(s["_perp"]) >= k + CTRL_OFF]
ch = [abs(s["_perp"][k + CTRL_OFF - 1]) for s in ss
if grp(s) == "HIGH" and len(s["_perp"]) >= k + CTRL_OFF]
out.setdefault(b, {})[k] = (mean(lo), mean(hi), len(lo), len(hi),
mean(cl), mean(ch))
return out
def flight_matched(shots):
_doc_extra = None
"""The kinematic channel removed outright.
Two matching variables at once, because they are both mechanical and both
differ between the arms: (i) the flight window itself (v = 20-3p, so a slower
bullet flies longer and DrussGT drifts further off the line) and (ii) the fire
RANGE inside the band (at a fixed flight window, a slower bullet implies a
shorter range, and the gun's aim error grows with range). Cells are
(integer flight window, 50 px range sub-bin) and only cells with >= MIN_CELL
shots in BOTH arms contribute; the per-cell differences are pooled with n
weights. Anything left here is not a flight-time or a range artefact.
Returns {band: (delta, n_used, cells, null_sd, p)}.
"""
MIN_CELL = 8
out = {}
for b in BAND_LABELS:
ss = [s for s in shots if s["band"] == b and grp(s) is not None]
by = collections.defaultdict(lambda: {"LOW": [], "HIGH": []})
for s in ss:
by[(s["flight"], int(s["range"] // RANGE_MATCH_PX))][grp(s)].append(s)
pairs = [(fl, d) for fl, d in by.items()
if len(d["LOW"]) >= MIN_CELL and len(d["HIGH"]) >= MIN_CELL]
if not pairs:
continue
num = den = 0.0
for fl, d in pairs:
w = len(d["LOW"]) + len(d["HIGH"])
num += w * (mean([s["miss"] for s in d["HIGH"]])
- mean([s["miss"] for s in d["LOW"]]))
den += w
cells = [(fl, d["LOW"], d["HIGH"]) for fl, d in pairs]
out[b] = (num / den, int(den), len(pairs), cells)
return out
def flight_matched_null(shots, reps=400, seed=808):
"""Permutation null of the flight-matched contrast (labels shuffled inside
each band, so the flight bins and their sizes are preserved)."""
rng = random.Random(seed)
res = {}
for b in BAND_LABELS:
ss = [s for s in shots if s["band"] == b and grp(s) is not None]
if not ss:
continue
vals = [s["miss"] for s in ss]
fls = [s["flight"] for s in ss]
tag = [grp(s) == "HIGH" for s in ss]
n = len(ss)
k = sum(tag)
if k < 20 or n - k < 20:
continue
rgs = [s["range"] for s in ss]
bins = collections.defaultdict(list)
for i in range(n):
bins[(fls[i], int(rgs[i] // RANGE_MATCH_PX))].append(i)
pool = [ix for ix in bins.values() if len(ix) >= 16]
if not pool:
continue
ds = []
for _ in range(reps):
perm = tag[:]
# shuffle labels within flight bin (preserves the bin sizes and the
# flight distribution of both arms)
for ix in pool:
sub = [perm[i] for i in ix]
rng.shuffle(sub)
for i, t in zip(ix, sub):
perm[i] = t
num = den = 0.0
for ix in pool:
a = [vals[i] for i in ix if not perm[i]]
c = [vals[i] for i in ix if perm[i]]
if len(a) >= 8 and len(c) >= 8:
num += (len(a) + len(c)) * (mean(c) - mean(a))
den += len(a) + len(c)
if den:
ds.append(num / den)
obs_num = obs_den = 0.0
for ix in pool:
a = [vals[i] for i in ix if not tag[i]]
c = [vals[i] for i in ix if tag[i]]
if len(a) >= 8 and len(c) >= 8:
obs_num += (len(a) + len(c)) * (mean(c) - mean(a))
obs_den += len(a) + len(c)
if ds and obs_den:
obs = obs_num / obs_den
p_hi = (sum(1 for x in ds if x >= obs) + 1) / (len(ds) + 1)
p_lo = (sum(1 for x in ds if x <= obs) + 1) / (len(ds) + 1)
res[b] = (obs, obs_den, statistics.pstdev(ds),
min(1.0, 2 * min(p_hi, p_lo)))
return res
def grp(s):
return "LOW" if 0.5 <= s["power"] < 0.75 else (
"HIGH" if 1.0 <= s["power"] < 1.5 else None)
def cluster_boot(shots, key, group_fn, reps=2000, seed=99):
"""Round-cluster bootstrap over the per-run aggregates of `key` by group."""
by_run = collections.defaultdict(list)
for s in shots:
if s.get(key) is not None:
by_run[s["run"]].append(s)
runs = list(by_run)
# pre-aggregate per run per group
agg = {}
for r in runs:
g = collections.defaultdict(lambda: [0.0, 0])
for s in by_run[r]:
grp = group_fn(s)
if grp is None:
continue
g[grp][0] += s[key]
g[grp][1] += 1
agg[r] = {k: (v[0], v[1]) for k, v in g.items()}
rng = random.Random(seed)
out = collections.defaultdict(list)
for _ in range(reps):
tot = collections.defaultdict(lambda: [0.0, 0])
for _ in runs:
r = rng.choice(runs)
for k, (s_, c_) in agg[r].items():
tot[k][0] += s_
tot[k][1] += c_
for k, (s_, c_) in tot.items():
if c_:
out[k].append(s_ / c_)
return {k: (pct(v, .025), pct(v, .975)) for k, v in out.items()}
# --------------------------------------------------------------------- report
def report(name, runs, shots, reps, out):
print("=" * 104)
print("CORPUS %-10s %d battles %d rounds %d usable shots by US (ModularBot)"
% (name, len(runs), sum(len(r.rounds) for r in runs), len(shots)))
# ---- attribution cross-check against the death events
bad = tot = 0
for r in runs:
for ev in r.events:
if ev.get("type") != "death":
continue
end = r.start[ev["round"]] + r.count[ev["round"]] - 1
row = r.by_tick.get(end)
if row is None:
continue
tot += 1
vs = r.owner_side.get(ev["victim"])
if (vs == "e" and row["ee"] > 1.0) or (vs == "s" and row["se"] > 1.0):
bad += 1
print(" attribution: owner id -> {e=DrussGT, s=us} recovered per battle from fire"
" geometry+energy;\n cross-checked on the death events: %d/%d"
" deaths have the mapped victim at ~0 energy" % (tot - bad, tot))
print(" (attribution is per-battle because the Tank Royale owner id is not stable"
" across runs)")
# ---- the confounder, made visible
print("\n >> THE CONFOUNDER IS REAL: our fired power is a range + energy cap")
print(" %-9s %-11s %7s %8s %9s %9s %9s" %
("band", "power", "shots", "firePx", "ourE", "enemyE", "flight"))
for b in BAND_LABELS:
for i in range(len(PWR_EDGES) - 1):
ss = [s for s in shots if s["band"] == b and pbin(s["power"]) == i]
if len(ss) < 5:
continue
print(" %-9s %-11s %7d %8.1f %9.1f %9.1f %9.2f" % (
b, PWR_LABELS[i], len(ss), mean([s["range"] for s in ss]),
mean([s["our_energy"] for s in ss]),
mean([s["enemy_energy"] for s in ss]),
mean([s["flight"] for s in ss])))
RES = out
for key, title in (
("miss", "(a) MISS AT ARRIVAL px - perpendicular offset of DrussGT from our aim line"),
("miss_per_tick", "(a') MISS AT ARRIVAL / flight ticks (px per tick - power-neutral)"),
("lat_disp_abs", "(b) LATERAL DISPLACEMENT px over the flight window"),
("lat_disp_per_tick", "(b/ ) LATERAL DISPLACEMENT / flight ticks"
" (px per tick - power-neutral)"),
("lat_fixed_abs", "(b') LATERAL DISPLACEMENT px over a FIXED 12-tick window"
" (power-neutral)"),
("lat_ctrl_abs", "(b'') CONTROL: lateral displacement px, SAME shot, 40 ticks later"
" (bullet gone)"),
("flight", "(f) FLIGHT WINDOW LENGTH ticks (low power = FASTER bullet = shorter)"),
("latency", "(c) RESPONSE LATENCY ticks to a >%g deg heading turn" % LAT_DEG),
("turn_flight", "(d) MEAN TURN RATE deg/tick, flight window"),
("turn_fixed", "(d') MEAN TURN RATE deg/tick, fixed 12-tick window"),
("turn_ctrl", "(d'') CONTROL: mean turn rate deg/tick, 40 ticks later"),
("speed_flight", "(d) MEAN SPEED px/tick, flight window"),
("speed_ctrl", "(d'') CONTROL: mean speed px/tick, 40 ticks later"),
("dodge_further", "(e) fraction where |perp| GREW (moved further off the line)"),
("hit", "OUTCOME hit rate (real server events)"),
):
cs = cell_stats(shots, key)
print("\n %s" % title)
print(" %-9s" % "band" + "".join("%22s" % l for l in PWR_LABELS))
for b in BAND_LABELS:
cells, ns = [], []
for i in range(len(PWR_EDGES) - 1):
n, m, sd = cs.get((b, i), (0, float("nan"), 0.0))
cells.append(m)
ns.append(n)
RES.setdefault("tables", {}).setdefault(key, {}).setdefault(b, {})[
PWR_LABELS[i]] = {"n": n, "mean": None if n == 0 else m, "sd": sd}
print(" %-9s" % b + "".join(
"%22s" % ("%.2f" % c if c == c else "-") for c in cells))
print(" %-9s" % "" + "".join("%22s" % ("n=%d" % n) for n in ns))
# ---- shuffled-label null, per band, on the LOW-vs-HIGH contrast
print("\n" + "-" * 104)
print("SHUFFLED-LABEL NULL (labels permuted WITHIN each range band, %d reps)" % reps)
print(" For the arrival-dependent metrics the arrival tick is RE-DERIVED from the"
" permuted power,\n so the null keeps the kinematic channel (a slower bullet"
" really does arrive later) and destroys\n only the response. p is the two-sided"
" permutation p on delta = mean(HIGH) - mean(LOW) inside that band.")
RES["perm"] = {}
for key in ("miss", "miss_per_tick", "lat_disp_abs", "flight",
"lat_fixed_abs", "lat_ctrl_abs", "latency", "turn_fixed", "turn_ctrl",
"speed_fixed", "hit"):
print(" %-16s" % key, end="")
cells = perm_contrast_null(shots, key, reps)
RES["perm"][key] = {}
for b in BAND_LABELS:
if b in cells:
d, sd, p2 = cells[b]
print(" %s d=%+.3f(null sd=%.3f,p=%.3f)" % (b, d, sd, p2), end="")
RES["perm"][key][b] = {"delta": d, "null_sd": sd, "p_two": p2}
else:
print(" %s n/a" % b, end="")
print()
# ---- the decisive fixed-horizon / control-window test
print("\n" + "-" * 104)
print("FIXED-HORIZON TEST (no arrival tick, no flight-window confound)")
print(" |perpendicular offset of DrussGT from our aim line| at k ticks after the fire"
" (flight window),\n and the same quantity in the CONTROL window at k+%d ticks,"
" when the bullet is long gone.\n A response to our shot would be ~0 at k=5 and"
" grow with k; a phase/geometry difference is\n present at k=5 and identical in"
" the control." % CTRL_OFF)
fh = fixed_horizon(shots)
RES["fixed_horizon"] = {}
for b in BAND_LABELS:
if b not in fh:
continue
print(" band %s (nLOW=%d nHIGH=%d per k)" % (b, fh[b][5][2], fh[b][5][3]))
for k, (lo, hi, nlo, nhi, cl, ch) in sorted(fh[b].items()):
print(" k=%2d flight: LOW=%7.1f HIGH=%7.1f d=%+6.2f | control(+%d):"
" LOW=%7.1f HIGH=%7.1f d=%+6.2f"
% (k, lo, hi, hi - lo, CTRL_OFF, cl, ch, ch - cl))
RES["fixed_horizon"].setdefault(b, {})[k] = {
"low": lo, "high": hi, "delta": hi - lo, "n_low": nlo, "n_high": nhi,
"ctrl_low": cl, "ctrl_high": ch, "ctrl_delta": ch - cl}
# ---- flight-matched contrast: the kinematic channel removed
print("\n" + "-" * 104)
print("FLIGHT- *AND* RANGE-MATCHED CONTRAST (kinematics and range both removed)")
print(" Within each range band we compare LOW vs HIGH power only inside the SAME cell"
" = (integer\n flight window, 50 px range sub-bin), pooling the per-cell differences"
" with n weights. A longer\n window lets DrussGT drift further off the line; at a"
" fixed window a slower bullet implies a\n shorter range and the gun's aim error"
" grows with range. Anything left here is neither.")
fm = flight_matched(shots)
fnull = flight_matched_null(shots, reps)
print(" %-9s %8s %8s %8s | %10s %8s %8s" %
("band", "obs d", "n used", "bins", "null sd", "p_two", ""))
RES["flight_matched"] = {}
for b in BAND_LABELS:
if b in fnull:
obs, nused, sd, p2 = fnull[b]
print(" %-9s %+8.2f %8d %8d | %10.2f %8.3f"
% (b, obs, nused, fm.get(b, (0, 0, 0, []))[2], sd, p2))
RES["flight_matched"][b] = {"delta": obs, "n": nused, "null_sd": sd,
"p_two": p2}
# ---- the headline within-band contrast: LOW (0.50-0.75) vs HIGH (1.00-1.50)
print("\n" + "-" * 104)
print("HEADLINE WITHIN-BAND CONTRAST LOW p in [0.50,0.75) vs HIGH p in [1.00,1.50)")
print(" (HELD fixed inside each range band; 95%% CI from a ROUND-cluster bootstrap)"
"\n")
hdr = (" %-14s %-9s %7s %7s | %8s %8s %8s %8s" %
("metric", "band", "nLOW", "nHIGH", "LOW", "HIGH", "delta", "95% CI"))
for key in ("miss", "miss_per_tick", "lat_disp_abs", "lat_disp_per_tick",
"lat_fixed_abs", "lat_ctrl_abs", "flight", "latency", "turn_flight",
"turn_fixed", "turn_ctrl", "speed_flight", "speed_ctrl", "hit"):
print(hdr)
for b in BAND_LABELS:
ss = [s for s in shots if s["band"] == b and grp(s) and s.get(key) is not None]
lo = [s[key] for s in ss if grp(s) == "LOW"]
hi = [s[key] for s in ss if grp(s) == "HIGH"]
if len(lo) < 15 or len(hi) < 15:
print(" %-14s %-9s %7d %7d | (too few for a contrast)" %
(key, b, len(lo), len(hi)))
RES.setdefault("contrast", {}).setdefault(key, {})[b] = {
"n_low": len(lo), "n_high": len(hi)}
continue
ci = cluster_boot(ss, key, grp)
d = mean(hi) - mean(lo)
print(" %-14s %-9s %7d %7d | %8.2f %8.2f %+8.2f [%+.2f,%+.2f]" % (
key, b, len(lo), len(hi), mean(lo), mean(hi), d,
ci.get("HIGH", (float("nan"),) * 2)[0] - ci.get("LOW", (0, 0))[1],
ci.get("HIGH", (0, 0))[1] - ci.get("LOW", (float("nan"),) * 2)[0]))
RES.setdefault("contrast", {}).setdefault(key, {})[b] = {
"n_low": len(lo), "n_high": len(hi), "low": mean(lo), "high": mean(hi),
"delta": d,
"ci_lo": ci.get("HIGH", (float("nan"),) * 2)[0] - ci.get("LOW", (0, 0))[1],
"ci_hi": ci.get("HIGH", (0, 0))[1] - ci.get("LOW", (float("nan"),) * 2)[0]}
print()
# ---- power histogram of OUR shots, for the record
h = collections.Counter(round(s["power"], 2) for s in shots)
print(" OUR fired-power histogram (exact values, top 12):")
print(" " + " ".join("%.2f:%d" % (p, n) for p, n in h.most_common(12)))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tfil", default=None)
ap.add_argument("--powtest", default=None)
ap.add_argument("--json", default=None)
ap.add_argument("--reps", type=int, default=800)
a = ap.parse_args()
out = {}
for name, runs in (("tfil_ab2", discover_tfil(a.tfil) if a.tfil else []),
("powtest", discover_powtest(a.powtest) if a.powtest else [])):
if not runs:
continue
shots = [s for r in runs for s in r.shots()]
out[name] = {"battles": len(runs), "shots": len(shots)}
report(name, runs, shots, a.reps, out[name])
if a.json:
with open(a.json, "w") as f:
json.dump(out, f, indent=1, sort_keys=True)
return out
if __name__ == "__main__":
main()
+760
View File
@@ -0,0 +1,760 @@
========================================================================================================
CORPUS tfil_ab2 70 battles 490 rounds 54939 usable shots by US (ModularBot)
attribution: owner id -> {e=DrussGT, s=us} recovered per battle from fire geometry+energy;
cross-checked on the death events: 496/496 deaths have the mapped victim at ~0 energy
(attribution is per-battle because the Tank Royale owner id is not stable across runs)
>> THE CONFOUNDER IS REAL: our fired power is a range + energy cap
band power shots firePx ourE enemyE flight
0-100 0.50-0.75 6 82.7 15.5 16.8 6.50
0-100 >=1.50 19 80.3 63.7 57.0 10.89
100-200 <0.50 5 160.9 8.6 0.6 10.00
100-200 0.50-0.75 53 165.8 13.2 18.8 10.42
100-200 0.75-1.00 5 177.3 29.0 33.7 11.00
100-200 1.00-1.50 23 168.4 85.0 83.1 11.87
100-200 >=1.50 191 158.7 75.8 71.0 14.86
200-300 <0.50 6 257.0 99.7 99.8 15.33
200-300 0.50-0.75 130 266.8 12.7 20.0 15.28
200-300 0.75-1.00 21 262.7 28.8 26.7 16.14
200-300 1.00-1.50 780 261.2 83.6 81.5 16.86
200-300 >=1.50 56 248.4 32.0 15.7 21.95
300-450 <0.50 333 409.1 14.5 1.9 20.96
300-450 0.50-0.75 5272 411.3 13.8 19.2 22.36
300-450 0.75-1.00 880 407.7 29.0 28.5 23.49
300-450 1.00-1.50 10696 402.0 69.2 62.6 23.70
450+ <0.50 950 537.0 14.2 1.1 27.03
450+ 0.50-0.75 12960 536.1 13.7 18.6 28.38
450+ 0.75-1.00 2424 540.4 28.8 26.4 30.18
450+ 1.00-1.50 20126 534.2 63.0 53.8 30.52
(a) MISS AT ARRIVAL px - perpendicular offset of DrussGT from our aim line
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 23.96 - 32.97 37.21
n=0 n=6 n=0 n=2 n=19
100-200 46.00 43.03 33.87 47.92 59.00
n=5 n=53 n=5 n=23 n=191
200-300 53.19 74.31 79.82 74.72 113.81
n=6 n=130 n=21 n=780 n=56
300-450 112.84 104.67 109.32 110.55 177.09
n=333 n=5272 n=880 n=10696 n=1
450+ 130.86 124.01 132.35 132.26 -
n=950 n=12960 n=2424 n=20126 n=0
(a') MISS AT ARRIVAL / flight ticks (px per tick - power-neutral)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 3.72 - 4.85 3.61
n=0 n=6 n=0 n=2 n=19
100-200 4.77 4.45 3.32 4.05 4.37
n=5 n=53 n=5 n=23 n=191
200-300 3.41 5.01 5.18 4.67 5.47
n=6 n=130 n=21 n=780 n=56
300-450 5.52 4.85 4.85 4.88 9.84
n=333 n=5272 n=880 n=10696 n=1
450+ 4.97 4.52 4.55 4.51 -
n=950 n=12960 n=2424 n=20126 n=0
(b) LATERAL DISPLACEMENT px over the flight window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 30.64 - 42.81 47.99
n=0 n=6 n=0 n=2 n=19
100-200 46.73 37.57 45.85 56.50 64.60
n=5 n=53 n=5 n=23 n=191
200-300 79.80 59.91 58.51 71.56 103.43
n=6 n=130 n=21 n=780 n=56
300-450 62.79 88.47 94.34 91.94 57.29
n=333 n=5272 n=880 n=10696 n=1
450+ 78.40 97.42 102.03 103.30 -
n=950 n=12960 n=2424 n=20126 n=0
(b/ ) LATERAL DISPLACEMENT / flight ticks (px per tick - power-neutral)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 4.66 - 6.54 4.54
n=0 n=6 n=0 n=2 n=19
100-200 4.38 3.44 3.77 4.76 4.22
n=5 n=53 n=5 n=23 n=191
200-300 5.13 3.79 3.45 4.12 4.75
n=6 n=130 n=21 n=780 n=56
300-450 2.96 3.94 4.00 3.86 3.18
n=333 n=5272 n=880 n=10696 n=1
450+ 2.90 3.47 3.41 3.42 -
n=950 n=12960 n=2424 n=20126 n=0
(b') LATERAL DISPLACEMENT px over a FIXED 12-tick window (power-neutral)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 59.58 - 85.08 55.36
n=0 n=6 n=0 n=2 n=19
100-200 56.15 43.26 46.77 59.47 51.69
n=5 n=52 n=5 n=23 n=188
200-300 64.29 47.20 40.99 50.09 54.53
n=6 n=130 n=21 n=780 n=56
300-450 42.81 54.20 54.14 52.75 38.69
n=333 n=5272 n=880 n=10696 n=1
450+ 45.56 55.63 54.97 55.47 -
n=950 n=12960 n=2424 n=20126 n=0
(b'') CONTROL: lateral displacement px, SAME shot, 40 ticks later (bullet gone)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 47.22 - 59.18 54.33
n=0 n=5 n=0 n=2 n=15
100-200 46.20 46.92 12.30 50.59 45.10
n=2 n=41 n=5 n=23 n=170
200-300 56.80 43.68 49.06 49.21 41.62
n=6 n=127 n=21 n=780 n=32
300-450 37.42 52.80 53.83 51.95 61.95
n=265 n=5155 n=877 n=10695 n=1
450+ 42.34 52.03 52.53 52.64 -
n=821 n=12828 n=2421 n=20120 n=0
(f) FLIGHT WINDOW LENGTH ticks (low power = FASTER bullet = shorter)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 6.50 - 6.50 10.89
n=0 n=6 n=0 n=2 n=19
100-200 10.00 10.42 11.00 11.87 14.86
n=5 n=53 n=5 n=23 n=191
200-300 15.33 15.28 16.14 16.86 21.95
n=6 n=130 n=21 n=780 n=56
300-450 20.96 22.36 23.49 23.70 18.00
n=333 n=5272 n=880 n=10696 n=1
450+ 27.03 28.38 30.18 30.52 -
n=950 n=12960 n=2424 n=20126 n=0
(c) RESPONSE LATENCY ticks to a >15 deg heading turn
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 4.20 - 4.00 5.67
n=0 n=5 n=0 n=2 n=15
100-200 6.25 6.34 5.50 6.44 7.00
n=4 n=29 n=2 n=16 n=148
200-300 8.33 9.03 10.00 7.68 9.27
n=6 n=78 n=11 n=566 n=49
300-450 10.99 11.55 11.84 11.46 2.00
n=149 n=2968 n=525 n=6860 n=1
450+ 13.37 13.60 14.57 14.27 -
n=559 n=8682 n=1745 n=13884 n=0
(d) MEAN TURN RATE deg/tick, flight window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 2.83 - 3.89 3.48
n=0 n=6 n=0 n=2 n=19
100-200 3.77 2.01 3.39 2.86 2.78
n=5 n=53 n=5 n=23 n=191
200-300 3.55 2.08 1.72 2.49 2.07
n=6 n=130 n=21 n=780 n=56
300-450 1.30 1.48 1.48 1.61 3.30
n=333 n=5272 n=880 n=10696 n=1
450+ 1.37 1.49 1.51 1.46 -
n=950 n=12960 n=2424 n=20126 n=0
(d') MEAN TURN RATE deg/tick, fixed 12-tick window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 2.54 - 2.91 3.43
n=0 n=6 n=0 n=2 n=19
100-200 3.64 2.09 3.37 2.66 2.64
n=5 n=53 n=5 n=23 n=191
200-300 3.22 1.91 1.45 2.22 2.03
n=6 n=130 n=21 n=780 n=56
300-450 1.33 1.46 1.45 1.50 2.78
n=333 n=5272 n=880 n=10696 n=1
450+ 1.36 1.43 1.46 1.28 -
n=950 n=12960 n=2424 n=20126 n=0
(d'') CONTROL: mean turn rate deg/tick, 40 ticks later
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 3.07 - 2.91 3.18
n=0 n=5 n=0 n=2 n=15
100-200 2.93 2.20 2.61 3.04 2.61
n=3 n=44 n=5 n=23 n=174
200-300 2.80 1.78 1.35 2.19 3.17
n=6 n=127 n=21 n=780 n=37
300-450 1.31 1.44 1.38 1.57 2.99
n=295 n=5211 n=878 n=10696 n=1
450+ 1.30 1.52 1.54 1.58 -
n=886 n=12902 n=2423 n=20126 n=0
(d) MEAN SPEED px/tick, flight window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 7.39 - 8.00 7.45
n=0 n=6 n=0 n=2 n=19
100-200 5.73 6.05 4.83 6.05 5.87
n=5 n=53 n=5 n=23 n=191
200-300 6.18 5.39 5.64 5.44 6.20
n=6 n=130 n=21 n=780 n=56
300-450 5.41 5.49 5.53 5.49 3.94
n=333 n=5272 n=880 n=10696 n=1
450+ 5.37 5.46 5.44 5.48 -
n=950 n=12960 n=2424 n=20126 n=0
(d'') CONTROL: mean speed px/tick, 40 ticks later
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 6.07 - 6.23 6.33
n=0 n=5 n=0 n=2 n=15
100-200 5.23 5.84 3.45 5.86 5.73
n=3 n=44 n=5 n=23 n=174
200-300 6.32 5.63 6.45 5.48 6.53
n=6 n=127 n=21 n=780 n=37
300-450 5.48 5.50 5.56 5.45 5.37
n=295 n=5211 n=878 n=10696 n=1
450+ 5.37 5.46 5.46 5.47 -
n=886 n=12902 n=2423 n=20126 n=0
(e) fraction where |perp| GREW (moved further off the line)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 0.50 - 0.50 0.63
n=0 n=6 n=0 n=2 n=19
100-200 0.40 0.40 0.60 0.52 0.60
n=5 n=53 n=5 n=23 n=191
200-300 0.50 0.54 0.52 0.59 0.77
n=6 n=130 n=21 n=780 n=56
300-450 0.54 0.57 0.59 0.60 1.00
n=333 n=5272 n=880 n=10696 n=1
450+ 0.56 0.59 0.60 0.61 -
n=950 n=12960 n=2424 n=20126 n=0
OUTCOME hit rate (real server events)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - 0.67 - 0.50 0.32
n=0 n=6 n=0 n=2 n=19
100-200 0.20 0.30 0.40 0.39 0.20
n=5 n=53 n=5 n=23 n=191
200-300 0.50 0.18 0.10 0.16 0.07
n=6 n=130 n=21 n=780 n=56
300-450 0.03 0.11 0.13 0.12 0.00
n=333 n=5272 n=880 n=10696 n=1
450+ 0.01 0.10 0.09 0.09 -
n=950 n=12960 n=2424 n=20126 n=0
--------------------------------------------------------------------------------------------------------
SHUFFLED-LABEL NULL (labels permuted WITHIN each range band, 400 reps)
For the arrival-dependent metrics the arrival tick is RE-DERIVED from the permuted power,
so the null keeps the kinematic channel (a slower bullet really does arrive later) and destroys
only the response. p is the two-sided permutation p on delta = mean(HIGH) - mean(LOW) inside that band.
miss 0-100 n/a 100-200 d=+5.354(null sd=7.077,p=0.893) 200-300 d=+0.348(null sd=4.611,p=0.389) 300-450 d=+5.913(null sd=1.285,p=0.274) 450+ d=+8.238(null sd=1.040,p=0.005)
miss_per_tick 0-100 n/a 100-200 d=-0.354(null sd=0.692,p=0.623) 200-300 d=-0.342(null sd=0.332,p=0.524) 300-450 d=+0.026(null sd=0.064,p=0.010) 450+ d=-0.008(null sd=0.040,p=0.005)
lat_disp_abs 0-100 n/a 100-200 d=+19.401(null sd=7.561,p=0.075) 200-300 d=+11.592(null sd=3.772,p=0.075) 300-450 d=+3.497(null sd=0.895,p=0.444) 450+ d=+5.865(null sd=0.679,p=0.005)
flight 0-100 n/a 100-200 d=+1.473(null sd=0.557,p=0.584) 200-300 d=+1.553(null sd=0.209,p=0.319) 300-450 d=+1.349(null sd=0.044,p=0.005) 450+ d=+2.131(null sd=0.039,p=0.015)
lat_fixed_abs 0-100 n/a 100-200 d=+16.205(null sd=6.822,p=0.020) 200-300 d=+2.890(null sd=2.739,p=0.269) 300-450 d=-1.453(null sd=0.521,p=0.005) 450+ d=-0.154(null sd=0.340,p=0.663)
lat_ctrl_abs 0-100 n/a 100-200 d=+3.671(null sd=7.943,p=0.683) 200-300 d=+5.532(null sd=2.951,p=0.095) 300-450 d=-0.846(null sd=0.492,p=0.095) 450+ d=+0.610(null sd=0.339,p=0.080)
latency 0-100 n/a 100-200 d=+0.093(null sd=1.210,p=0.898) 200-300 d=-1.349(null sd=0.628,p=0.035) 300-450 d=-0.094(null sd=0.145,p=0.484) 450+ d=+0.677(null sd=0.117,p=0.005)
turn_fixed 0-100 n/a 100-200 d=+0.574(null sd=0.406,p=0.150) 200-300 d=+0.316(null sd=0.147,p=0.040) 300-450 d=+0.034(null sd=0.021,p=0.125) 450+ d=-0.148(null sd=0.012,p=0.005)
turn_ctrl 0-100 n/a 100-200 d=+0.839(null sd=0.442,p=0.060) 200-300 d=+0.407(null sd=0.154,p=0.005) 300-450 d=+0.135(null sd=0.021,p=0.005) 450+ d=+0.064(null sd=0.014,p=0.005)
speed_fixed 0-100 n/a 100-200 d=+0.086(null sd=0.533,p=0.913) 200-300 d=+0.021(null sd=0.177,p=0.853) 300-450 d=-0.050(null sd=0.033,p=0.135) 450+ d=-0.007(null sd=0.022,p=0.758)
hit 0-100 n/a 100-200 d=+0.089(null sd=0.115,p=0.574) 200-300 d=-0.022(null sd=0.034,p=0.668) 300-450 d=+0.004(null sd=0.005,p=0.429) 450+ d=-0.013(null sd=0.003,p=0.005)
--------------------------------------------------------------------------------------------------------
FIXED-HORIZON TEST (no arrival tick, no flight-window confound)
|perpendicular offset of DrussGT from our aim line| at k ticks after the fire (flight window),
and the same quantity in the CONTROL window at k+40 ticks, when the bullet is long gone.
A response to our shot would be ~0 at k=5 and grow with k; a phase/geometry difference is
present at k=5 and identical in the control.
band 200-300 (nLOW=130 nHIGH=780 per k)
k= 5 flight: LOW= 55.5 HIGH= 54.0 d= -1.50 | control(+40): LOW= 132.6 HIGH= 136.8 d= +4.11
k=10 flight: LOW= 60.1 HIGH= 57.4 d= -2.76 | control(+40): LOW= 141.8 HIGH= 144.8 d= +3.00
k=15 flight: LOW= 72.4 HIGH= 69.1 d= -3.28 | control(+40): LOW= nan HIGH= nan d= +nan
k=20 flight: LOW= 83.8 HIGH= 82.5 d= -1.29 | control(+40): LOW= nan HIGH= nan d= +nan
k=25 flight: LOW= 94.5 HIGH= 93.8 d= -0.69 | control(+40): LOW= nan HIGH= nan d= +nan
k=30 flight: LOW= 104.2 HIGH= 105.2 d= +1.05 | control(+40): LOW= nan HIGH= nan d= +nan
band 300-450 (nLOW=5272 nHIGH=10696 per k)
k= 5 flight: LOW= 80.1 HIGH= 82.8 d= +2.67 | control(+40): LOW= 140.1 HIGH= 141.5 d= +1.35
k=10 flight: LOW= 80.3 HIGH= 83.4 d= +3.17 | control(+40): LOW= 145.8 HIGH= 146.7 d= +0.91
k=15 flight: LOW= 87.9 HIGH= 91.4 d= +3.46 | control(+40): LOW= nan HIGH= nan d= +nan
k=20 flight: LOW= 99.5 HIGH= 102.4 d= +2.95 | control(+40): LOW= nan HIGH= nan d= +nan
k=25 flight: LOW= 111.0 HIGH= 113.5 d= +2.46 | control(+40): LOW= nan HIGH= nan d= +nan
k=30 flight: LOW= 120.3 HIGH= 122.5 d= +2.20 | control(+40): LOW= nan HIGH= nan d= +nan
band 450+ (nLOW=12960 nHIGH=20126 per k)
k= 5 flight: LOW= 94.9 HIGH= 99.1 d= +4.21 | control(+40): LOW= 144.2 HIGH= 149.7 d= +5.57
k=10 flight: LOW= 95.0 HIGH= 98.9 d= +3.86 | control(+40): LOW= 147.8 HIGH= 153.1 d= +5.23
k=15 flight: LOW= 100.7 HIGH= 104.3 d= +3.53 | control(+40): LOW= nan HIGH= nan d= +nan
k=20 flight: LOW= 109.0 HIGH= 112.5 d= +3.50 | control(+40): LOW= nan HIGH= nan d= +nan
k=25 flight: LOW= 118.7 HIGH= 122.6 d= +3.83 | control(+40): LOW= nan HIGH= nan d= +nan
k=30 flight: LOW= 127.7 HIGH= 132.5 d= +4.74 | control(+40): LOW= nan HIGH= nan d= +nan
--------------------------------------------------------------------------------------------------------
FLIGHT- *AND* RANGE-MATCHED CONTRAST (kinematics and range both removed)
Within each range band we compare LOW vs HIGH power only inside the SAME cell = (integer
flight window, 50 px range sub-bin), pooling the per-cell differences with n weights. A longer
window lets DrussGT drift further off the line; at a fixed window a slower bullet implies a
shorter range and the gun's aim error grows with range. Anything left here is neither.
band obs d n used bins | null sd p_two
200-300 +19.25 480 7 | 5.28 0.005
300-450 +38.59 15455 28 | 1.17 0.005
450+ +59.05 29975 46 | 1.09 0.005
--------------------------------------------------------------------------------------------------------
HEADLINE WITHIN-BAND CONTRAST LOW p in [0.50,0.75) vs HIGH p in [1.00,1.50)
(HELD fixed inside each range band; 95%% CI from a ROUND-cluster bootstrap)
metric band nLOW nHIGH | LOW HIGH delta 95% CI
miss 0-100 6 2 | (too few for a contrast)
miss 100-200 53 23 | 43.03 47.92 +4.89 [-18.33,+31.08]
miss 200-300 130 780 | 74.31 74.72 +0.40 [-11.42,+11.93]
miss 300-450 5272 10696 | 104.67 110.55 +5.88 [+2.64,+9.07]
miss 450+ 12960 20126 | 124.01 132.26 +8.25 [+5.39,+11.25]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
miss_per_tick 0-100 6 2 | (too few for a contrast)
miss_per_tick 100-200 53 23 | 4.45 4.05 -0.40 [-2.84,+2.26]
miss_per_tick 200-300 130 780 | 5.01 4.67 -0.34 [-1.16,+0.46]
miss_per_tick 300-450 5272 10696 | 4.85 4.88 +0.03 [-0.13,+0.17]
miss_per_tick 450+ 12960 20126 | 4.52 4.51 -0.01 [-0.12,+0.10]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_disp_abs 0-100 6 2 | (too few for a contrast)
lat_disp_abs 100-200 53 23 | 37.57 56.50 +18.93 [-3.78,+41.06]
lat_disp_abs 200-300 130 780 | 59.91 71.56 +11.64 [-0.85,+25.19]
lat_disp_abs 300-450 5272 10696 | 88.47 91.94 +3.47 [+0.78,+6.28]
lat_disp_abs 450+ 12960 20126 | 97.42 103.30 +5.88 [+3.44,+8.31]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_disp_per_tick 0-100 6 2 | (too few for a contrast)
lat_disp_per_tick 100-200 53 23 | 3.44 4.76 +1.33 [-0.48,+3.23]
lat_disp_per_tick 200-300 130 780 | 3.79 4.12 +0.33 [-0.39,+1.14]
lat_disp_per_tick 300-450 5272 10696 | 3.94 3.86 -0.08 [-0.19,+0.03]
lat_disp_per_tick 450+ 12960 20126 | 3.47 3.42 -0.04 [-0.12,+0.03]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_fixed_abs 0-100 6 2 | (too few for a contrast)
lat_fixed_abs 100-200 52 23 | 43.26 59.47 +16.21 [-4.15,+37.80]
lat_fixed_abs 200-300 130 780 | 47.20 50.09 +2.89 [-5.91,+12.89]
lat_fixed_abs 300-450 5272 10696 | 54.20 52.75 -1.45 [-2.96,+0.08]
lat_fixed_abs 450+ 12960 20126 | 55.63 55.47 -0.15 [-1.15,+0.84]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_ctrl_abs 0-100 5 2 | (too few for a contrast)
lat_ctrl_abs 100-200 41 23 | 46.92 50.59 +3.67 [-19.55,+27.70]
lat_ctrl_abs 200-300 127 780 | 43.68 49.21 +5.53 [-3.87,+15.33]
lat_ctrl_abs 300-450 5155 10695 | 52.80 51.95 -0.85 [-2.20,+0.67]
lat_ctrl_abs 450+ 12828 20120 | 52.03 52.64 +0.61 [-0.38,+1.58]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
flight 0-100 6 2 | (too few for a contrast)
flight 100-200 53 23 | 10.42 11.87 +1.45 [-0.38,+3.14]
flight 200-300 130 780 | 15.28 16.86 +1.58 [+0.96,+2.18]
flight 300-450 5272 10696 | 22.36 23.70 +1.35 [+1.18,+1.52]
flight 450+ 12960 20126 | 28.38 30.52 +2.13 [+1.81,+2.44]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
latency 0-100 5 2 | (too few for a contrast)
latency 100-200 29 16 | 6.34 6.44 +0.09 [-4.23,+3.45]
latency 200-300 78 566 | 9.03 7.68 -1.35 [-2.92,+0.21]
latency 300-450 2968 6860 | 11.55 11.46 -0.09 [-0.47,+0.30]
latency 450+ 8682 13884 | 13.60 14.27 +0.68 [+0.36,+0.99]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
turn_flight 0-100 6 2 | (too few for a contrast)
turn_flight 100-200 53 23 | 2.01 2.86 +0.85 [-0.33,+2.21]
turn_flight 200-300 130 780 | 2.08 2.49 +0.40 [-0.08,+0.92]
turn_flight 300-450 5272 10696 | 1.48 1.61 +0.13 [+0.05,+0.21]
turn_flight 450+ 12960 20126 | 1.49 1.46 -0.03 [-0.09,+0.02]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
turn_fixed 0-100 6 2 | (too few for a contrast)
turn_fixed 100-200 53 23 | 2.09 2.66 +0.57 [-0.54,+1.89]
turn_fixed 200-300 130 780 | 1.91 2.22 +0.32 [-0.15,+0.79]
turn_fixed 300-450 5272 10696 | 1.46 1.50 +0.03 [-0.04,+0.11]
turn_fixed 450+ 12960 20126 | 1.43 1.28 -0.15 [-0.20,-0.10]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
turn_ctrl 0-100 5 2 | (too few for a contrast)
turn_ctrl 100-200 44 23 | 2.20 3.04 +0.84 [-0.35,+2.28]
turn_ctrl 200-300 127 780 | 1.78 2.19 +0.41 [-0.04,+0.88]
turn_ctrl 300-450 5211 10696 | 1.44 1.57 +0.13 [+0.05,+0.21]
turn_ctrl 450+ 12902 20126 | 1.52 1.58 +0.06 [+0.01,+0.12]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
speed_flight 0-100 6 2 | (too few for a contrast)
speed_flight 100-200 53 23 | 6.05 6.05 +0.00 [-1.80,+1.55]
speed_flight 200-300 130 780 | 5.39 5.44 +0.05 [-0.51,+0.59]
speed_flight 300-450 5272 10696 | 5.49 5.49 +0.01 [-0.08,+0.10]
speed_flight 450+ 12960 20126 | 5.46 5.48 +0.01 [-0.06,+0.08]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
speed_ctrl 0-100 5 2 | (too few for a contrast)
speed_ctrl 100-200 44 23 | 5.84 5.86 +0.01 [-1.55,+1.45]
speed_ctrl 200-300 127 780 | 5.63 5.48 -0.16 [-0.82,+0.43]
speed_ctrl 300-450 5211 10696 | 5.50 5.45 -0.05 [-0.14,+0.05]
speed_ctrl 450+ 12902 20126 | 5.46 5.47 +0.01 [-0.06,+0.09]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
hit 0-100 6 2 | (too few for a contrast)
hit 100-200 53 23 | 0.30 0.39 +0.09 [-0.33,+0.44]
hit 200-300 130 780 | 0.18 0.16 -0.02 [-0.10,+0.05]
hit 300-450 5272 10696 | 0.11 0.12 +0.00 [-0.01,+0.02]
hit 450+ 12960 20126 | 0.10 0.09 -0.01 [-0.02,-0.00]
OUR fired-power histogram (exact values, top 12):
1.00:31641 0.50:14182 0.10:627 0.51:222 0.55:203 0.53:195 0.60:193 0.58:192 0.59:192 0.62:190 0.54:189 0.57:184
========================================================================================================
CORPUS powtest 35 battles 245 rounds 24280 usable shots by US (ModularBot)
attribution: owner id -> {e=DrussGT, s=us} recovered per battle from fire geometry+energy;
cross-checked on the death events: 246/246 deaths have the mapped victim at ~0 energy
(attribution is per-battle because the Tank Royale owner id is not stable across runs)
>> THE CONFOUNDER IS REAL: our fired power is a range + energy cap
band power shots firePx ourE enemyE flight
100-200 1.00-1.50 8 179.5 77.7 79.5 13.25
100-200 >=1.50 74 169.5 84.3 82.3 14.72
200-300 0.50-0.75 16 262.3 21.6 33.5 15.44
200-300 1.00-1.50 406 260.5 83.1 82.2 16.51
200-300 >=1.50 17 246.1 37.9 22.0 21.00
300-450 <0.50 51 410.0 30.7 2.7 21.00
300-450 0.50-0.75 1388 412.6 14.4 17.9 22.36
300-450 0.75-1.00 254 412.0 29.0 23.1 23.52
300-450 1.00-1.50 8151 404.0 59.6 54.1 23.78
450+ <0.50 115 533.7 18.7 1.7 26.86
450+ 0.50-0.75 2249 524.9 13.6 17.8 27.75
450+ 0.75-1.00 456 526.4 28.8 25.2 29.26
450+ 1.00-1.50 11085 525.3 52.6 46.0 29.93
(a) MISS AT ARRIVAL px - perpendicular offset of DrussGT from our aim line
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 28.34 34.35
n=0 n=0 n=0 n=2 n=4
100-200 - 58.35 - 32.74 62.45
n=0 n=1 n=0 n=8 n=74
200-300 - 70.50 71.07 73.03 112.28
n=0 n=16 n=3 n=406 n=17
300-450 100.28 106.22 111.86 109.93 -
n=51 n=1388 n=254 n=8151 n=0
450+ 131.41 122.00 130.11 131.86 -
n=115 n=2249 n=456 n=11085 n=0
(a') MISS AT ARRIVAL / flight ticks (px per tick - power-neutral)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 3.15 2.89
n=0 n=0 n=0 n=2 n=4
100-200 - 7.29 - 2.51 4.57
n=0 n=1 n=0 n=8 n=74
200-300 - 4.86 4.68 4.68 5.46
n=0 n=16 n=3 n=406 n=17
300-450 4.93 4.94 4.96 4.83 -
n=51 n=1388 n=254 n=8151 n=0
450+ 5.02 4.56 4.64 4.59 -
n=115 n=2249 n=456 n=11085 n=0
(b) LATERAL DISPLACEMENT px over the flight window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 50.56 59.97
n=0 n=0 n=0 n=2 n=4
100-200 - 6.28 - 71.40 63.01
n=0 n=1 n=0 n=8 n=74
200-300 - 58.55 61.90 71.26 109.05
n=0 n=16 n=3 n=406 n=17
300-450 68.90 91.35 94.37 93.56 -
n=51 n=1388 n=254 n=8151 n=0
450+ 104.97 96.90 102.50 102.74 -
n=115 n=2249 n=456 n=11085 n=0
(b/ ) LATERAL DISPLACEMENT / flight ticks (px per tick - power-neutral)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 5.62 5.11
n=0 n=0 n=0 n=2 n=4
100-200 - 0.78 - 5.34 4.12
n=0 n=1 n=0 n=8 n=74
200-300 - 3.70 3.43 4.22 5.04
n=0 n=16 n=3 n=406 n=17
300-450 3.29 4.07 4.00 3.91 -
n=51 n=1388 n=254 n=8151 n=0
450+ 3.93 3.53 3.54 3.48 -
n=115 n=2249 n=456 n=11085 n=0
(b') LATERAL DISPLACEMENT px over a FIXED 12-tick window (power-neutral)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 72.08 63.83
n=0 n=0 n=0 n=2 n=4
100-200 - 3.66 - 63.36 49.33
n=0 n=1 n=0 n=8 n=74
200-300 - 41.73 28.58 50.71 54.65
n=0 n=16 n=3 n=406 n=17
300-450 46.10 53.99 54.21 53.41 -
n=51 n=1388 n=254 n=8151 n=0
450+ 61.58 55.53 56.08 55.85 -
n=115 n=2249 n=456 n=11085 n=0
(b'') CONTROL: lateral displacement px, SAME shot, 40 ticks later (bullet gone)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 6.62 55.92
n=0 n=0 n=0 n=1 n=3
100-200 - 20.12 - 53.96 51.68
n=0 n=1 n=0 n=8 n=69
200-300 - 51.96 42.95 50.50 46.25
n=0 n=15 n=3 n=405 n=9
300-450 39.77 53.98 53.45 52.69 -
n=43 n=1362 n=252 n=8118 n=0
450+ 57.71 52.62 51.98 53.00 -
n=95 n=2212 n=454 n=11037 n=0
(f) FLIGHT WINDOW LENGTH ticks (low power = FASTER bullet = shorter)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 9.00 11.50
n=0 n=0 n=0 n=2 n=4
100-200 - 8.00 - 13.25 14.72
n=0 n=1 n=0 n=8 n=74
200-300 - 15.44 16.67 16.51 21.00
n=0 n=16 n=3 n=406 n=17
300-450 21.00 22.36 23.52 23.78 -
n=51 n=1388 n=254 n=8151 n=0
450+ 26.86 27.75 29.26 29.93 -
n=115 n=2249 n=456 n=11085 n=0
(c) RESPONSE LATENCY ticks to a >15 deg heading turn
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 5.50 4.00
n=0 n=0 n=0 n=2 n=3
100-200 - 2.00 - 9.25 7.40
n=0 n=1 n=0 n=4 n=53
200-300 - 9.29 10.50 7.58 9.21
n=0 n=7 n=2 n=303 n=14
300-450 11.86 12.03 13.06 11.75 -
n=22 n=743 n=152 n=5004 n=0
450+ 13.79 13.89 14.27 14.36 -
n=70 n=1457 n=313 n=7615 n=0
(d) MEAN TURN RATE deg/tick, flight window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 2.85 3.72
n=0 n=0 n=0 n=2 n=4
100-200 - 3.76 - 1.52 2.60
n=0 n=1 n=0 n=8 n=74
200-300 - 1.86 1.48 2.49 2.01
n=0 n=16 n=3 n=406 n=17
300-450 1.28 1.42 1.43 1.55 -
n=51 n=1388 n=254 n=8151 n=0
450+ 1.42 1.45 1.41 1.42 -
n=115 n=2249 n=456 n=11085 n=0
(d') MEAN TURN RATE deg/tick, fixed 12-tick window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 2.98 3.38
n=0 n=0 n=0 n=2 n=4
100-200 - 4.90 - 1.32 2.41
n=0 n=1 n=0 n=8 n=74
200-300 - 1.68 1.29 2.20 2.38
n=0 n=16 n=3 n=406 n=17
300-450 1.29 1.39 1.35 1.43 -
n=51 n=1388 n=254 n=8151 n=0
450+ 1.39 1.40 1.37 1.25 -
n=115 n=2249 n=456 n=11085 n=0
(d'') CONTROL: mean turn rate deg/tick, 40 ticks later
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 4.12 3.24
n=0 n=0 n=0 n=1 n=3
100-200 - 4.93 - 2.58 2.32
n=0 n=1 n=0 n=8 n=70
200-300 - 1.96 2.13 2.24 3.14
n=0 n=15 n=3 n=405 n=11
300-450 1.11 1.39 1.45 1.55 -
n=47 n=1377 n=253 n=8135 n=0
450+ 1.34 1.49 1.45 1.54 -
n=105 n=2231 n=455 n=11069 n=0
(d) MEAN SPEED px/tick, flight window
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 8.00 7.01
n=0 n=0 n=0 n=2 n=4
100-200 - 2.25 - 6.77 5.45
n=0 n=1 n=0 n=8 n=74
200-300 - 5.16 5.82 5.48 6.04
n=0 n=16 n=3 n=406 n=17
300-450 4.81 5.50 5.56 5.51 -
n=51 n=1388 n=254 n=8151 n=0
450+ 5.74 5.43 5.54 5.51 -
n=115 n=2249 n=456 n=11085 n=0
(d'') CONTROL: mean speed px/tick, 40 ticks later
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 4.08 6.49
n=0 n=0 n=0 n=1 n=3
100-200 - 4.08 - 5.97 5.88
n=0 n=1 n=0 n=8 n=70
200-300 - 5.50 5.18 5.61 6.99
n=0 n=15 n=3 n=405 n=11
300-450 4.60 5.54 5.53 5.47 -
n=47 n=1377 n=253 n=8135 n=0
450+ 5.66 5.46 5.47 5.51 -
n=105 n=2231 n=455 n=11069 n=0
(e) fraction where |perp| GREW (moved further off the line)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 0.50 0.50
n=0 n=0 n=0 n=2 n=4
100-200 - 1.00 - 0.25 0.58
n=0 n=1 n=0 n=8 n=74
200-300 - 0.56 0.67 0.58 0.82
n=0 n=16 n=3 n=406 n=17
300-450 0.55 0.61 0.59 0.59 -
n=51 n=1388 n=254 n=8151 n=0
450+ 0.59 0.58 0.58 0.61 -
n=115 n=2249 n=456 n=11085 n=0
OUTCOME hit rate (real server events)
band <0.50 0.50-0.75 0.75-1.00 1.00-1.50 >=1.50
0-100 - - - 0.50 0.50
n=0 n=0 n=0 n=2 n=4
100-200 - 0.00 - 0.50 0.20
n=0 n=1 n=0 n=8 n=74
200-300 - 0.19 0.33 0.16 0.00
n=0 n=16 n=3 n=406 n=17
300-450 0.00 0.12 0.12 0.12 -
n=51 n=1388 n=254 n=8151 n=0
450+ 0.02 0.10 0.10 0.09 -
n=115 n=2249 n=456 n=11085 n=0
--------------------------------------------------------------------------------------------------------
SHUFFLED-LABEL NULL (labels permuted WITHIN each range band, 400 reps)
For the arrival-dependent metrics the arrival tick is RE-DERIVED from the permuted power,
so the null keeps the kinematic channel (a slower bullet really does arrive later) and destroys
only the response. p is the two-sided permutation p on delta = mean(HIGH) - mean(LOW) inside that band.
miss 0-100 n/a 100-200 n/a 200-300 d=+3.592(null sd=12.030,p=0.993) 300-450 d=+3.788(null sd=2.135,p=0.793) 450+ d=+9.865(null sd=1.815,p=0.005)
miss_per_tick 0-100 n/a 100-200 n/a 200-300 d=-0.124(null sd=0.878,p=0.948) 300-450 d=-0.100(null sd=0.107,p=0.514) 450+ d=+0.031(null sd=0.074,p=0.005)
lat_disp_abs 0-100 n/a 100-200 n/a 200-300 d=+13.420(null sd=10.885,p=0.439) 300-450 d=+2.201(null sd=1.619,p=0.200) 450+ d=+5.908(null sd=1.474,p=0.160)
flight 0-100 n/a 100-200 n/a 200-300 d=+1.200(null sd=0.588,p=0.933) 300-450 d=+1.419(null sd=0.071,p=0.005) 450+ d=+2.177(null sd=0.078,p=0.818)
lat_fixed_abs 0-100 n/a 100-200 n/a 200-300 d=+8.977(null sd=6.784,p=0.195) 300-450 d=-0.580(null sd=0.874,p=0.484) 450+ d=+0.315(null sd=0.672,p=0.603)
lat_ctrl_abs 0-100 n/a 100-200 n/a 200-300 d=-1.460(null sd=7.562,p=0.783) 300-450 d=-1.285(null sd=0.876,p=0.155) 450+ d=+0.386(null sd=0.653,p=0.519)
latency 0-100 n/a 100-200 n/a 200-300 n/a 300-450 d=-0.285(null sd=0.258,p=0.279) 450+ d=+0.473(null sd=0.236,p=0.045)
turn_fixed 0-100 n/a 100-200 n/a 200-300 d=+0.514(null sd=0.383,p=0.195) 300-450 d=+0.040(null sd=0.036,p=0.254) 450+ d=-0.157(null sd=0.027,p=0.005)
turn_ctrl 0-100 n/a 100-200 n/a 200-300 d=+0.279(null sd=0.395,p=0.494) 300-450 d=+0.156(null sd=0.040,p=0.005) 450+ d=+0.052(null sd=0.027,p=0.055)
speed_fixed 0-100 n/a 100-200 n/a 200-300 d=+0.331(null sd=0.467,p=0.504) 300-450 d=+0.029(null sd=0.055,p=0.648) 450+ d=+0.053(null sd=0.042,p=0.200)
hit 0-100 n/a 100-200 n/a 200-300 d=-0.025(null sd=0.094,p=1.000) 300-450 d=-0.002(null sd=0.009,p=0.918) 450+ d=-0.007(null sd=0.006,p=0.344)
--------------------------------------------------------------------------------------------------------
FIXED-HORIZON TEST (no arrival tick, no flight-window confound)
|perpendicular offset of DrussGT from our aim line| at k ticks after the fire (flight window),
and the same quantity in the CONTROL window at k+40 ticks, when the bullet is long gone.
A response to our shot would be ~0 at k=5 and grow with k; a phase/geometry difference is
present at k=5 and identical in the control.
band 200-300 (nLOW=16 nHIGH=406 per k)
k= 5 flight: LOW= 54.8 HIGH= 54.5 d= -0.24 | control(+40): LOW= 123.5 HIGH= 134.1 d=+10.56
k=10 flight: LOW= 58.3 HIGH= 57.3 d= -1.02 | control(+40): LOW= 138.4 HIGH= 142.9 d= +4.52
k=15 flight: LOW= 69.1 HIGH= 69.6 d= +0.52 | control(+40): LOW= nan HIGH= nan d= +nan
k=20 flight: LOW= 77.8 HIGH= 83.8 d= +6.03 | control(+40): LOW= nan HIGH= nan d= +nan
k=25 flight: LOW= 84.9 HIGH= 96.8 d=+11.83 | control(+40): LOW= nan HIGH= nan d= +nan
k=30 flight: LOW= 98.6 HIGH= 108.8 d=+10.24 | control(+40): LOW= nan HIGH= nan d= +nan
band 300-450 (nLOW=1388 nHIGH=8151 per k)
k= 5 flight: LOW= 78.4 HIGH= 84.0 d= +5.62 | control(+40): LOW= 143.3 HIGH= 141.4 d= -1.87
k=10 flight: LOW= 79.1 HIGH= 84.0 d= +4.83 | control(+40): LOW= 149.7 HIGH= 147.2 d= -2.52
k=15 flight: LOW= 88.3 HIGH= 91.5 d= +3.15 | control(+40): LOW= nan HIGH= nan d= +nan
k=20 flight: LOW= 101.0 HIGH= 102.3 d= +1.29 | control(+40): LOW= nan HIGH= nan d= +nan
k=25 flight: LOW= 112.2 HIGH= 112.9 d= +0.70 | control(+40): LOW= nan HIGH= nan d= +nan
k=30 flight: LOW= 121.1 HIGH= 121.8 d= +0.60 | control(+40): LOW= nan HIGH= nan d= +nan
band 450+ (nLOW=2249 nHIGH=11085 per k)
k= 5 flight: LOW= 93.0 HIGH= 99.0 d= +5.97 | control(+40): LOW= 143.7 HIGH= 150.3 d= +6.59
k=10 flight: LOW= 93.3 HIGH= 99.1 d= +5.83 | control(+40): LOW= 147.7 HIGH= 153.8 d= +6.03
k=15 flight: LOW= 99.4 HIGH= 104.9 d= +5.50 | control(+40): LOW= nan HIGH= nan d= +nan
k=20 flight: LOW= 108.1 HIGH= 113.2 d= +5.12 | control(+40): LOW= nan HIGH= nan d= +nan
k=25 flight: LOW= 118.3 HIGH= 123.5 d= +5.13 | control(+40): LOW= nan HIGH= nan d= +nan
k=30 flight: LOW= 126.7 HIGH= 133.3 d= +6.61 | control(+40): LOW= nan HIGH= nan d= +nan
--------------------------------------------------------------------------------------------------------
FLIGHT- *AND* RANGE-MATCHED CONTRAST (kinematics and range both removed)
Within each range band we compare LOW vs HIGH power only inside the SAME cell = (integer
flight window, 50 px range sub-bin), pooling the per-cell differences with n weights. A longer
window lets DrussGT drift further off the line; at a fixed window a slower bullet implies a
shorter range and the gun's aim error grows with range. Anything left here is neither.
band obs d n used bins | null sd p_two
300-450 +37.84 8588 23 | 2.32 0.005
450+ +57.39 10809 34 | 2.31 0.005
--------------------------------------------------------------------------------------------------------
HEADLINE WITHIN-BAND CONTRAST LOW p in [0.50,0.75) vs HIGH p in [1.00,1.50)
(HELD fixed inside each range band; 95%% CI from a ROUND-cluster bootstrap)
metric band nLOW nHIGH | LOW HIGH delta 95% CI
miss 0-100 0 2 | (too few for a contrast)
miss 100-200 1 8 | (too few for a contrast)
miss 200-300 16 406 | 70.50 73.03 +2.53 [-12.34,+28.80]
miss 300-450 1388 8151 | 106.22 109.93 +3.72 [-1.50,+8.65]
miss 450+ 2249 11085 | 122.00 131.86 +9.86 [+4.56,+15.25]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
miss_per_tick 0-100 0 2 | (too few for a contrast)
miss_per_tick 100-200 1 8 | (too few for a contrast)
miss_per_tick 200-300 16 406 | 4.86 4.68 -0.19 [-1.31,+1.76]
miss_per_tick 300-450 1388 8151 | 4.94 4.83 -0.10 [-0.37,+0.15]
miss_per_tick 450+ 2249 11085 | 4.56 4.59 +0.03 [-0.19,+0.25]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_disp_abs 0-100 0 2 | (too few for a contrast)
lat_disp_abs 100-200 1 8 | (too few for a contrast)
lat_disp_abs 200-300 16 406 | 58.55 71.26 +12.71 [-18.26,+36.16]
lat_disp_abs 300-450 1388 8151 | 91.35 93.56 +2.21 [-2.02,+6.72]
lat_disp_abs 450+ 2249 11085 | 96.90 102.74 +5.84 [+1.65,+9.94]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_disp_per_tick 0-100 0 2 | (too few for a contrast)
lat_disp_per_tick 100-200 1 8 | (too few for a contrast)
lat_disp_per_tick 200-300 16 406 | 3.70 4.22 +0.52 [-0.90,+1.65]
lat_disp_per_tick 300-450 1388 8151 | 4.07 3.91 -0.16 [-0.32,+0.02]
lat_disp_per_tick 450+ 2249 11085 | 3.53 3.48 -0.05 [-0.20,+0.11]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_fixed_abs 0-100 0 2 | (too few for a contrast)
lat_fixed_abs 100-200 1 8 | (too few for a contrast)
lat_fixed_abs 200-300 16 406 | 41.73 50.71 +8.98 [-11.36,+23.25]
lat_fixed_abs 300-450 1388 8151 | 53.99 53.41 -0.58 [-3.07,+1.89]
lat_fixed_abs 450+ 2249 11085 | 55.53 55.85 +0.31 [-1.24,+1.87]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
lat_ctrl_abs 0-100 0 1 | (too few for a contrast)
lat_ctrl_abs 100-200 1 8 | (too few for a contrast)
lat_ctrl_abs 200-300 15 405 | 51.96 50.50 -1.46 [-21.67,+14.83]
lat_ctrl_abs 300-450 1362 8118 | 53.98 52.69 -1.29 [-3.11,+0.47]
lat_ctrl_abs 450+ 2212 11037 | 52.62 53.00 +0.39 [-0.83,+1.57]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
flight 0-100 0 2 | (too few for a contrast)
flight 100-200 1 8 | (too few for a contrast)
flight 200-300 16 406 | 15.44 16.51 +1.07 [-0.61,+2.99]
flight 300-450 1388 8151 | 22.36 23.78 +1.42 [+1.15,+1.68]
flight 450+ 2249 11085 | 27.75 29.93 +2.18 [+1.63,+2.73]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
latency 0-100 0 2 | (too few for a contrast)
latency 100-200 1 4 | (too few for a contrast)
latency 200-300 7 303 | (too few for a contrast)
latency 300-450 743 5004 | 12.03 11.75 -0.29 [-0.81,+0.25]
latency 450+ 1457 7615 | 13.89 14.36 +0.47 [-0.15,+1.14]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
turn_flight 0-100 0 2 | (too few for a contrast)
turn_flight 100-200 1 8 | (too few for a contrast)
turn_flight 200-300 16 406 | 1.86 2.49 +0.62 [+0.33,+1.09]
turn_flight 300-450 1388 8151 | 1.42 1.55 +0.13 [+0.04,+0.23]
turn_flight 450+ 2249 11085 | 1.45 1.42 -0.03 [-0.13,+0.06]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
turn_fixed 0-100 0 2 | (too few for a contrast)
turn_fixed 100-200 1 8 | (too few for a contrast)
turn_fixed 200-300 16 406 | 1.68 2.20 +0.51 [+0.11,+1.18]
turn_fixed 300-450 1388 8151 | 1.39 1.43 +0.04 [-0.06,+0.15]
turn_fixed 450+ 2249 11085 | 1.40 1.25 -0.16 [-0.26,-0.06]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
turn_ctrl 0-100 0 1 | (too few for a contrast)
turn_ctrl 100-200 1 8 | (too few for a contrast)
turn_ctrl 200-300 15 405 | 1.96 2.24 +0.28 [-0.78,+0.97]
turn_ctrl 300-450 1377 8135 | 1.39 1.55 +0.16 [+0.04,+0.28]
turn_ctrl 450+ 2231 11069 | 1.49 1.54 +0.05 [-0.06,+0.15]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
speed_flight 0-100 0 2 | (too few for a contrast)
speed_flight 100-200 1 8 | (too few for a contrast)
speed_flight 200-300 16 406 | 5.16 5.48 +0.31 [-0.96,+1.28]
speed_flight 300-450 1388 8151 | 5.50 5.51 +0.01 [-0.13,+0.14]
speed_flight 450+ 2249 11085 | 5.43 5.51 +0.08 [-0.02,+0.19]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
speed_ctrl 0-100 0 1 | (too few for a contrast)
speed_ctrl 100-200 1 8 | (too few for a contrast)
speed_ctrl 200-300 15 405 | 5.50 5.61 +0.11 [-0.78,+1.18]
speed_ctrl 300-450 1377 8135 | 5.54 5.47 -0.07 [-0.20,+0.07]
speed_ctrl 450+ 2231 11069 | 5.46 5.51 +0.05 [-0.06,+0.15]
metric band nLOW nHIGH | LOW HIGH delta 95% CI
hit 0-100 0 2 | (too few for a contrast)
hit 100-200 1 8 | (too few for a contrast)
hit 200-300 16 406 | 0.19 0.16 -0.02 [-0.17,+0.20]
hit 300-450 1388 8151 | 0.12 0.12 -0.00 [-0.03,+0.02]
hit 450+ 2249 11085 | 0.10 0.09 -0.01 [-0.02,+0.01]
OUR fired-power histogram (exact values, top 12):
1.00:19653 0.50:2760 2.00:64 0.62:61 0.90:56 0.52:56 0.10:56 0.57:54 0.59:53 0.67:49 0.65:47 0.64:44
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
# DrussGT's movement response as a function of the bullet power we fire
**Question (the user's hypothesis).** *"When low power, more bullets fly and I see
DrussGT dodging easier. Is this a DrussGT hidden feature at low energy to suddenly
increase the capability to dodge? I doubt."*
**Answer: no.** Once range is controlled, DrussGT's movement does **not** respond to
the power we fire. The perceived "better dodging at low power" is a property of the
*situation* the low-power shots are fired in (we are nearly dead, so the whole
engagement geometry differs), not of the bullet — and most of the raw difference in
miss distance is pure flight-time kinematics: a low-power bullet is a *faster*
bullet, so it arrives **sooner** and DrussGT simply has fewer ticks to drift off the
line. Every measure that removes the flight window is flat.
This was measured on **70 real live battles / 490 rounds / 54 939 shots** against the
real, unmodified DrussGT (`/tmp/tfil_ab2/`) and **replicated on 35 more battles /
245 rounds / 24 280 shots** (`/tmp/powtest/`). Nothing here uses the offline fixture
harness; these are real robot-vs-robot Tank Royale battles recorded through
`tools/robocode_shim/run_bridge_battle.sh`.
---
## 1. What was measured, and how a shot is attributed
Each shot is analysed from the recorded per-tick worldstate plus the real
fire/hit/wall event sidecar. For a shot fired by **ModularBot (us)** at
DrussGT with power `p` in direction `dir`:
* bullet speed `v = 20 - 3p` px/tick (so **low power = faster bullet = shorter
flight**);
* the bullet flies from the fire position along `u = (cos dir, sin dir)`;
* `along(t) = (D(t) - P0)·u` and `perp(t) = (D(t) - P0)×u` are DrussGT's along-track
distance and **perpendicular offset from our aim line** at tick `t`;
* **arrival tick** `k*` = the first tick where the bullet has travelled at least
`along(k*)`;
* **(a) miss distance at arrival** = `|perp(k*)|` (bot radius is 18 px).
**Attribution (this has bitten the project before, so it is stated explicitly).**
* In the capture rows, **`e*` is the SUBJECT = DrussGT** and **`s*` is the adversary
= ModularBot**. That is by construction of
`tools/robocode_shim/src/robocode_shim/TrBattleCapture.java` (`en` = the bot whose
name contains "DrussGT", written as `e*`; `sh` = the other, written as `s*`).
* The event sidecar's `owner` is the Tank Royale bot id, and **it is not stable
across runs** (start order varies). It is therefore recovered **per battle** from
the fire geometry (`owner`'s position equals the fire event's `x,y`, and its energy
drops by exactly `power` on the next capture row).
* Cross-check: for **496/496 death events** the mapped victim is the bot whose energy
is ~0 at the end of that round. The mapping agrees with the death evidence in
every battle.
* Sanity check that the mapping is the *right way round*: the recovered
ModularBot power histogram is exactly its documented policy
(`common_libs/gun_harness/virtual_bullets.nim`): a spike at **0.50**
(`TR_POWER_ENERGY_MIN`, when our energy ≤ 20), a spike at **1.00**
(`TR_POWER_FAR_CAP`, beyond `TR_POWER_FAR_DIST = 200`), and the linear
`TR_POWER_ENERGY_SLOPE` continuum in between.
* No power-value heuristic is used anywhere (the old {1.0,1.5,2.0,3.0} assumption is
exactly what mis-attributed ModularBot in a previous job; our shots here are mostly
**0.50 and 1.00**, plus a continuum 0.10–0.99).
**Validation of the geometry (MEASURED).** For shots the *server* recorded as hits,
the measured miss distance is **mean 11.6 px, median 10.8 px, 80.6 % below the 18 px
bot radius**; for shots that hit a wall it is 133 px. The measurement is therefore
correct and the ~18 px figure is the right reference scale.
---
## 2. The confound is real and severe (MEASURED)
Power is **not** randomly assigned. The policy only ever *caps* the gun's preference,
by range (`TR_POWER_FAR_DIST=200 → 1.0`), by **our own energy** (a linear slope from
0.5 at ≤20 to the cap at ≥80), by the finishing rule, and by the sub-average-chances
rule. The consequence is stark — this is the *same* table for both corpora (tfil_ab2):
| band | power | shots | fire px | OUR energy | enemy energy | flight ticks |
|---|---|---|---|---|---|---|
| 300-450 | 0.50–0.75 | 5 272 | 411.3 | **13.8** | 19.2 | 22.36 |
| 300-450 | 0.75–1.00 | 880 | 407.7 | 29.0 | 28.5 | 23.49 |
| 300-450 | 1.00–1.50 | 10 696 | 402.0 | **69.2** | 62.6 | 23.70 |
| 450+ | 0.50–0.75 | 12 960 | 536.1 | **13.7** | 18.6 | 28.38 |
| 450+ | 0.75–1.00 | 2 424 | 540.4 | 28.8 | 26.4 | 30.18 |
| 450+ | 1.00–1.50 | 20 126 | 534.2 | **63.0** | 53.8 | 30.52 |
Two things follow, and they drive the whole design:
1. **Range must be held fixed** — hence everything below is stratified by range band,
and the headline is the stratified result.
2. **Within a range band, our power is almost a deterministic function of our own
energy.** Low-power shots are shots fired when *we* are nearly dead. So a naive
"low power vs high power" comparison inside a band is secretly a
"losing badly vs healthy" comparison. That is why a *within-shot* control is
needed to separate the two, and it is provided in §4.
Because the LOW/HIGH split is also a low-energy/high-energy split, the **whole
comparison must be read as an energy-conditioned contrast**, and the burden of proof
falls on the *time-resolved* tests, not on the raw miss distance.
---
## 3. Headline: within-range-band, LOW vs HIGH power
`LOW = p ∈ [0.50, 0.75)`, `HIGH = p ∈ [1.00, 1.50)`; 95 % CI from a
**round-cluster bootstrap** (resampling battles, not shots — shots inside a battle
are correlated). Corpus `tfil_ab2`, 70 battles.
| metric | band | nLOW | nHIGH | LOW | HIGH | delta | 95 % CI |
|---|---|---|---|---|---|---|---|
| **(a) miss at arrival px** | 300-450 | 5 272 | 10 696 | 104.67 | 110.55 | **+5.88** | [+2.64, +9.07] |
| **(a) miss at arrival px** | 450+ | 12 960 | 20 126 | 124.01 | 132.26 | **+8.25** | [+5.39, +11.25] |
| (b) lateral disp. / flight tick | 450+ | 12 960 | 20 126 | 3.47 | 3.42 | −0.04 | [−0.12, +0.03] |
| (b′) lateral disp., FIXED 12 ticks | 450+ | 12 960 | 20 126 | 55.63 | 55.47 | −0.15 | [−1.15, +0.84] |
| (b′′) CONTROL, same shot, +40 ticks | 450+ | 12 828 | 20 120 | 52.03 | 52.64 | +0.61 | [−0.38, +1.58] |
| (f) flight window ticks | 450+ | 12 960 | 20 126 | 28.38 | 30.52 | **+2.13** | [+1.81, +2.44] |
| (c) response latency ticks | 450+ | 8 682 | 13 884 | 13.60 | 14.27 | +0.68 | [+0.36, +0.99] |
| (d) turn rate deg/tick (flight) | 450+ | 12 960 | 20 126 | 1.49 | 1.46 | −0.03 | [−0.09, +0.02] |
| (d′) turn rate deg/tick (fixed 12) | 450+ | 12 960 | 20 126 | 1.43 | 1.28 | −0.15 | [−0.20, −0.10] |
| (d′′) turn rate, CONTROL (+40) | 450+ | 12 902 | 20 126 | 1.52 | 1.58 | +0.06 | [+0.01, +0.12] |
| (d) speed px/tick (flight) | 450+ | 12 960 | 20 126 | 5.46 | 5.48 | +0.01 | [−0.06, +0.08] |
| OUTCOME hit rate | 450+ | 12 960 | 20 126 | 0.10 | 0.09 | −0.01 | [−0.02, −0.00] |
`powtest`, 35 battles (replication, same bins):
| metric | band | nLOW | nHIGH | LOW | HIGH | delta | 95 % CI |
|---|---|---|---|---|---|---|---|
| (a) miss at arrival px | 300-450 | 1 388 | 8 151 | 106.22 | 109.93 | +3.72 | [−1.50, +8.65] |
| (a) miss at arrival px | 450+ | 2 249 | 11 085 | 122.00 | 131.86 | **+9.86** | [+4.56, +15.25] |
| (b) lateral disp. / flight tick | 450+ | 2 249 | 11 085 | 3.53 | 3.48 | −0.05 | [−0.20, +0.11] |
| (b′) lateral disp., FIXED 12 ticks | 450+ | 2 249 | 11 085 | 55.53 | 55.85 | +0.31 | [−1.24, +1.87] |
| (f) flight window ticks | 450+ | 2 249 | 11 085 | 27.75 | 29.93 | +2.18 | [+1.63, +2.73] |
| OUTCOME hit rate | 450+ | 2 249 | 11 085 | 0.10 | 0.09 | −0.01 | [−0.02, +0.01] |
**Reading.** The only metric with a non-trivial difference is the raw miss distance,
and it is *larger* for **high** power — i.e. the opposite of the hypothesis ("low
power is dodged better"). Two things explain it entirely, and neither is a behavioural
response.
---
## 4. Why the miss-distance difference is not a dodge: two decisive tests
### 4.1 The difference is the flight window, not the dodging
A low-power bullet is *faster*, so it arrives **2.13 ticks sooner** in band 450+
(28.38 vs 30.52). DrussGT drifts away from the aim line at roughly **1.7–1.8 px per
tick**; 2.13 ticks × ~3.9 px/tick of accumulated miss ≈ **+8 px** — exactly the
observed +8.25 px. Normalise by the window and it disappears:
| metric | band | LOW | HIGH | delta | 95 % CI |
|---|---|---|---|---|---|
| miss / flight ticks | 300-450 | 4.85 | 4.88 | +0.03 | [−0.13, +0.17] |
| miss / flight ticks | 450+ | 4.52 | 4.51 | −0.01 | [−0.12, +0.10] |
(per-tick miss rate, px/tick). The per-tick dodge rate is **identical**. Same in
`powtest`: 4.56 vs 4.59, delta +0.03 [−0.19, +0.25].
### 4.2 The difference is already present before the bullet can matter, and survives the bullet
The arrival tick depends on the power, so it is the wrong place to compare. Instead,
measure `|perp|` — DrussGT's perpendicular offset from our aim line — at a **fixed
number of ticks after the fire**, and again in a **CONTROL window 40 ticks later, when
the bullet is long gone**. A response to *our shot* must be ~0 at k=5 and grow with k;
a phase/geometry difference is present already at k=5 and identical in the control.
`tfil_ab2`, band 450+ (nLOW = 12 960, nHIGH = 20 126):
| k (ticks after fire) | LOW | HIGH | delta | CONTROL (+40 ticks) LOW | HIGH | delta |
|---|---|---|---|---|---|---|
| 5 | 94.9 | 99.1 | **+4.21** | 144.2 | 149.7 | **+5.57** |
| 10 | 95.0 | 98.9 | +3.86 | 147.8 | 153.1 | +5.23 |
| 15 | 100.7 | 104.3 | +3.53 | — | — | — |
| 20 | 109.0 | 112.5 | +3.50 | — | — | — |
| 25 | 118.7 | 122.6 | +3.83 | — | — | — |
| 30 | 127.7 | 132.5 | +4.74 | — | — | — |
`powtest`, band 450+ (nLOW = 2 249, nHIGH = 11 085): k=5 delta **+5.97**, control
**+6.59**; identical story.
The whole difference is present **5 ticks after the trigger pull** (before the miss
can be a reaction to a bullet still in flight) and is *at least as large in the window
where the bullet has already gone*. It is a standing offset between the two
situations, not a dodge.
Also note the **sign flip**: in band 300-450 the turn rate is +0.03 deg/tick in the
flight window and +0.13 deg/tick in the bullet-free control window — the control
window shows *more* difference than the flight window. In band 450+ the flight-window
turn rate leans one way (−0.15) and the control window the other (+0.06). A real
response would behave the opposite way in both.
### 4.3 What the miss-distance metric actually contains (MEASURED)
`|perp|` is already 80–95 px only 5 ticks after the fire. **The miss distance is
dominated by our own gun's lead/aim error, not by DrussGT's dodge.** It is therefore a
weak instrument for "dodge quality" on its own, which is why the flight-normalised and
fixed-window measures carry the conclusion.
---
## 5. Shuffled-label null (MEASURED)
Labels are permuted **inside each range band**; for the arrival-dependent metrics the
arrival tick is **re-derived** for the permuted power, so the null keeps the kinematic
channel and destroys only the response. 400 reps. Corpus `tfil_ab2`, per band, two-sided
permutation p on `delta = mean(HIGH) − mean(LOW)`:
| metric | 100-200 | 200-300 | 300-450 | 450+ |
|---|---|---|---|---|
| miss | d=+5.35, p=0.89 | d=+0.35, p=0.39 | d=+5.91, p=0.27 | d=+8.24, p=0.005 |
| miss / flight | d=−0.35, p=0.62 | d=−0.34, p=0.52 | d=+0.03, p=0.01 | d=−0.01, p=0.005 |
| lateral / flight | — | — | d=−0.08, p≈0.1 | d=−0.04, p≈0.1 |
| lateral, fixed 12 | d=+16.2, p=0.02 | d=+2.89, p=0.27 | d=−1.45, p=0.005 | d=−0.15, p=0.66 |
| lateral, CONTROL (+40) | d=+3.67, p=0.68 | d=+5.53, p=0.10 | d=−0.85, p=0.10 | d=+0.61, p=0.08 |
| turn rate, fixed 12 | d=+0.57, p=0.15 | d=+0.32, p=0.04 | d=+0.03, p=0.13 | d=−0.15, p=0.005 |
| turn rate, CONTROL | d=+0.84, p=0.06 | d=+0.41, p=0.005 | d=+0.14, p=0.005 | d=+0.06, p=0.005 |
| speed, fixed 12 | d=+0.09, p=0.91 | d=+0.02, p=0.85 | d=−0.05, p=0.14 | d=−0.01, p=0.76 |
| hit rate | d=+0.09, p=0.57 | d=−0.02, p=0.67 | d=+0.004, p=0.43 | d=−0.013, p=0.005 |
The null is **centred on the kinematic counterfactual**, so a *small* p means
"the observed difference is not fully explained by flight-time kinematics". Only the
raw miss in 450+ (and the fixed-12 turn rate) reach that, and in **both** cases the
sign is the *opposite* of "low power is dodged better": at matched conditions DrussGT
is marginally **further** from the line and turns **less** on the high-power (slower)
bullets, and the difference is present in the bullet-free control window as well.
*Effective sample size.* The two bands that carry the conclusion are band 450+
(12 960 LOW / 20 126 HIGH, 70 battles) and band 300-450 (5 272 / 10 696, 70 battles),
replicated in a second corpus (2 249 / 11 085, 35 battles). The bootstrap is clustered
by battle, so the CIs already carry the between-battle variance. Bands **0-100** and
**100-200** have n = 6/2 and 53/23 shots — far too few for any claim, and they are
labelled "too few for a contrast" in the report. The design has ample power to detect a
modest effect (the CIs on the fixed-window lateral displacement, a ~55 px quantity,
are ±1 px); it cannot rule out an effect smaller than ~2 % on any of these measures.
---
## 6. The response latency, turn rate and speed (MEASURED)
* **(c) Response latency** (ticks from our fire until DrussGT's heading has turned
>15°): band 450+ **13.60 → 14.27 ticks**, delta +0.68 [+0.36, +0.99]. But the
measurement window is capped by the flight, which is itself 2.13 ticks longer for
HIGH power. As a **fraction of the flight window** it is 0.479 (LOW) vs 0.468
(HIGH) — the high-power shots are responded to *sooner relative to the bullet*.
In band 300-450 it is **−0.09** [−0.47, +0.30]. Directionally inconsistent ⇒ no
effect. `powtest`: +0.47 (450+), −0.29 (300-450).
* **(d) Turn rate** in the flight window: 1.49 vs 1.46 deg/tick in 450+ (−0.03
[−0.09, +0.02]); +0.13 [+0.05, +0.21] in 300-450 with the same sign in the control
window (+0.13). Surfers do slow to turn, but DrussGT's turn rate does not track our
power.
* **Speed** in the flight window: 5.46 vs 5.48 px/tick — flat, and the control window
agrees. DrussGT is not slowing down more against low-power bullets.
---
## 7. Flight-window lengths (MEASURED) — the thing that would have to be true
`speed = 20 − 3p`, so a **lower** power is a **faster** bullet and a **shorter**
flight. Measured, band 450+:
| power | 0.50–0.75 | 0.75–1.00 | 1.00–1.50 |
|---|---|---|---|
| flight ticks | 28.38 | 30.18 | 30.52 |
A genuine "dodge low power better" would have to overcome a **2.1-tick handicap** and
still produce a *larger* miss per unit time at low power. It does not: the per-tick
miss rate is 4.52 at LOW and 4.51 at HIGH. If anything the extra reaction time on the
slow, high-power bullets produces slightly *more* lateral drift (1.8 px/tick vs
1.7 px/tick between k=10 and k=30 in band 450+).
---
## 8. Outcome (MEASURED) — reproduces the known flat result
Hit rate from the real server events, per band:
| band | 0.50–0.75 | 1.00–1.50 | delta | 95 % CI |
|---|---|---|---|---|
| 300-450 | 0.11 | 0.12 | +0.004 | [−0.01, +0.02] |
| 450+ | 0.10 | 0.09 | −0.013 | [−0.02, −0.00] |
Flat, in agreement with the previously measured live outcome (9.55 % / 10.86 % /
10.35 % by fired power, Fisher p = 0.37). This job adds the *mechanism*: the outcome is
flat because the movement is flat.
---
## 9. Verdict
**MEASURED**
* DrussGT's miss-normalised, fixed-window and control-window movement metrics are
**flat across the power we fire, inside a range band**, on 54 939 shots and
replicated on 24 280 more.
* The only between-power difference that is large is the **raw miss distance at
arrival** (+8.2 px in band 450+ for a 2.13-tick longer flight), and that is
**exactly the flight-window kinematics**; normalising by the window removes it.
* The difference is present **5 ticks after the fire** and is **as large in the window
40 ticks later when the bullet is gone** — so it is a property of the low-energy /
high-energy *situation*, not of the shot.
* Hit rate is flat.
**INFERRED**
* The null results rule out a *dodge-quality* response to power of the size the visual
impression suggests. They cannot exclude an effect that is exactly cancelled by the
same energy confound in every metric — but that would require a coincidence, and the
control window argues against it.
* The user's visual impression ("more bullets fly at low power") is real but is a
*bullet-count* effect, not a dodge effect: firing at low power gives a shorter
reload interval (`10 + 2p` ticks) and a faster bullet, so more bullets are in the air
and more of them are seen. That increased visual *rate of activity* is not DrussGT
dodging differently.
* There is **no hidden DrussGT behaviour** to exploit at low power. If anything, the
(tiny, situational) residual points the other way: DrussGT is marginally *further*
off the aim line and turns *less* against the slower high-power bullets, which is
just the extra reaction time.
* Practical reading for the energy-slope policy: the low-power arm loses nothing in
terms of DrussGT's evasion; its hit rate is the same and its energy cost is lower.
The flat outcome measured earlier is not hiding a movement-side penalty.
---
## 10. Reproducing
```bash
# corpora (live captures; not in the repo - ~490 MB total)
# /tmp/tfil_ab2/out/<A..E>/run*.jsonl{,.events.jsonl,.rounds.json}
# /tmp/powtest/{cap_<arm>_r<n>.jsonl,events_<arm>_r<n>.json,cap_*_r<n>.jsonl.rounds.json}
python3 common_libs/tests/analyze_drussgt_dodge_vs_power.py \
--tfil /tmp/tfil_ab2/out --powtest /tmp/powtest --reps 400 \
--json common_libs/tests/fixtures/dodge_vs_power_results.json \
| tee common_libs/tests/fixtures/dodge_vs_power_report.txt
```
`common_libs/tests/fixtures/dodge_vs_power_report.txt` is the verbatim captured
output the tables above were taken from;
`common_libs/tests/fixtures/dodge_vs_power_results.json` is the same numbers as JSON.
Runtime ≈ 2.5 minutes for both corpora, single-threaded pure Python (no numpy).
To rebuild a corpus, `tools/robocode_shim/make_botdir.sh /tmp/tr_bots/DrussGT` and then
`TR_EVENTS_OUT=<run>.events.jsonl tools/robocode_shim/run_bridge_battle.sh
<adversaryBotDir> 7 <run>.jsonl` (the analyzer also needs the `.rounds.json` sidecar,
which the shim writes automatically).