391318a7bd
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).
177 lines
6.9 KiB
Nim
177 lines
6.9 KiB
Nim
## OFFLINE MEASUREMENT (read-only analysis). No live battles, no bot rebuild.
|
|
##
|
|
## Measurement 1, virtual-metric half: does an enemy being near a WALL raise our
|
|
## virtual-bullet hit rate, and does that hold per-gun (within-gun comparison)?
|
|
##
|
|
## This deliberately does NOT modify `gun_harness/offline_range.nim`. It re-runs
|
|
## the same replay contract (spawn -> tickBullets) in a local loop so it can
|
|
## capture one outcome per spawned bullet and bucket it by the enemy's distance
|
|
## to the nearest arena wall AT THE TICK WE FIRED.
|
|
##
|
|
## Because `spawnBullets` is not cooldown-gated, every gun spawns one bullet per
|
|
## power bin per tick; the sample size is therefore far larger than a live battle
|
|
## and the absolute rates are NOT live rates. The comparison of interest is
|
|
## WITHIN a gun, across wall-distance buckets, which shares all that sampling.
|
|
##
|
|
## Usage:
|
|
## nim c -r common_libs/tests/measure_cornering_guns.nim [fixture.jsonl ...]
|
|
## If no fixture is given, the TR + classic DrussGT fixtures are used.
|
|
|
|
import std/[os, strformat, tables, math, algorithm]
|
|
import gun_harness/offline_range
|
|
import range_guns
|
|
|
|
const
|
|
NumBuckets = 5
|
|
BucketEdges = [30.0, 60.0, 120.0, 250.0]
|
|
BucketNames = ["<=30", "30-60", "60-120", "120-250", ">250"]
|
|
|
|
type
|
|
BStat = object
|
|
shots*, hits*: int
|
|
|
|
proc bucketIdx(d: float): int =
|
|
if d <= BucketEdges[0]: 0
|
|
elif d <= BucketEdges[1]: 1
|
|
elif d <= BucketEdges[2]: 2
|
|
elif d <= BucketEdges[3]: 3
|
|
else: 4
|
|
|
|
proc enemyWallDist(s: WorldState): float =
|
|
## Distance from the ENEMY (e*) to the nearest arena wall.
|
|
min(min(s.enemyX, s.enemyY),
|
|
min(s.arenaWidth - s.enemyX, s.arenaHeight - s.enemyY))
|
|
|
|
proc replayBucketed(fx: Fixture, drivers: seq[GunDriver],
|
|
liveActual = false): seq[array[NumBuckets, BStat]] =
|
|
## Copy of offline_range.replayFixture's ordering, extended to attribute each
|
|
## resolved virtual bullet to its fire tick's wall-distance bucket.
|
|
let tid = fx.enemyId
|
|
let skipFinal = liveActual and fx.enemyDied
|
|
var tracker = initTracker(drivers.len, ActiveMetric)
|
|
var wallAtFire = newSeq[float](fx.states.len)
|
|
for i, s in fx.states:
|
|
wallAtFire[i] = enemyWallDist(s)
|
|
|
|
# key = (gunId, powerBin, fireTick) -> bucket index
|
|
var pending = initTable[(int, int, int), int]()
|
|
result = newSeq[array[NumBuckets, BStat]](drivers.len)
|
|
|
|
for si in 0..<fx.states.len:
|
|
let state = fx.states[si]
|
|
let bi = bucketIdx(wallAtFire[si])
|
|
for gi in 0..<drivers.len:
|
|
var preds: array[len(PowerBins), GunPrediction]
|
|
for i in 0..<len(PowerBins):
|
|
preds[i] = drivers[gi].predictCb(state, bulletSpeed(PowerBins[i]))
|
|
let ready = if drivers[gi].readyCb == nil: true else: drivers[gi].readyCb()
|
|
if ready:
|
|
let h0 = tracker.head
|
|
tracker.spawnBullets(gi, preds, state, tid)
|
|
# spawnBullets appended exactly len(PowerBins) bullets starting at h0.
|
|
for j in 0..<len(PowerBins):
|
|
let slot = (h0 + j) mod MaxBullets
|
|
if tracker.bullets[slot].active and
|
|
tracker.bullets[slot].fireTick == state.tick and
|
|
tracker.bullets[slot].gunId == gi:
|
|
pending[(gi, j, state.tick)] = bi
|
|
|
|
let actIdx = if liveActual and si + 1 < fx.states.len: si + 1 else: si
|
|
let act = fx.states[actIdx]
|
|
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
|
|
var lst = act.tick
|
|
if actIdx < fx.lastSeen.len and fx.lastSeen[actIdx] >= 0: lst = fx.lastSeen[actIdx]
|
|
if act.enemies.len > 0:
|
|
for e in act.enemies:
|
|
enemyPositions[e.id] = (x: e.x, y: e.y, lastSeenTick: lst, alive: true)
|
|
else:
|
|
enemyPositions[tid] = (x: act.enemyX, y: act.enemyY, lastSeenTick: lst, alive: true)
|
|
|
|
let res = addr result
|
|
let pend = addr pending
|
|
let dref = drivers
|
|
if not (skipFinal and si == fx.states.len - 1):
|
|
tracker.tickBullets(state, enemyPositions,
|
|
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
|
|
# Forward to the owning gun exactly as replayFixture does, so learning
|
|
# guns (GF/DecayGF/KNN) keep their history.
|
|
dref[gunId].resultCb(e)
|
|
let key = (gunId, binIdx, e.fireTick)
|
|
if pend[].hasKey(key):
|
|
let bb = pend[][key]
|
|
inc res[][gunId][bb].shots
|
|
if e.hit: inc res[][gunId][bb].hits
|
|
pend[].del(key))
|
|
|
|
if tracker.droppedBullets != 0:
|
|
stderr.writeLine(&"[measure_cornering_guns] WARNING droppedBullets={tracker.droppedBullets}")
|
|
|
|
proc z2prop(h1, n1, h2, n2: int): float =
|
|
if n1 == 0 or n2 == 0: return 0.0
|
|
let p1 = h1.float / n1.float
|
|
let p2 = h2.float / n2.float
|
|
let p = (h1 + h2).float / (n1 + n2).float
|
|
let se = sqrt(p * (1.0 - p) * (1.0 / n1.float + 1.0 / n2.float))
|
|
if se <= 0.0: 0.0 else: (p1 - p2) / se
|
|
|
|
proc printGun(name: string, b: array[NumBuckets, BStat]) =
|
|
var ts, th = 0
|
|
var line = &"{name:<12}"
|
|
for i in 0..<NumBuckets:
|
|
let s = b[i]
|
|
ts += s.shots; th += s.hits
|
|
let rate = if s.shots > 0: s.hits.float / s.shots.float * 100.0 else: 0.0
|
|
line.add &" {BucketNames[i]}={s.hits}/{s.shots} {rate:5.2f}%"
|
|
let tr = if ts > 0: th.float / ts.float * 100.0 else: 0.0
|
|
echo line
|
|
echo &" TOTAL={th}/{ts} {tr:5.2f}%"
|
|
# adjacent (<=60) vs open (>60)
|
|
let adjS = b[0].shots + b[1].shots
|
|
let adjH = b[0].hits + b[1].hits
|
|
let opS = b[2].shots + b[3].shots + b[4].shots
|
|
let opH = b[2].hits + b[3].hits + b[4].hits
|
|
let adjR = if adjS > 0: adjH.float / adjS.float * 100.0 else: 0.0
|
|
let opR = if opS > 0: opH.float / opS.float * 100.0 else: 0.0
|
|
echo &" ADJ(<=60)={adjH}/{adjS} {adjR:5.2f}% OPEN(>60)={opH}/{opS} {opR:5.2f}% " &
|
|
&"z={z2prop(adjH, adjS, opH, opS):+.2f}"
|
|
|
|
proc main() =
|
|
var files: seq[string]
|
|
for i in 1..paramCount():
|
|
files.add paramStr(i)
|
|
if files.len == 0:
|
|
let dir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
|
|
for f in walkFiles(dir / "*drussgt*.jsonl"):
|
|
files.add f
|
|
files.sort()
|
|
|
|
let drivers = buildAllGunDrivers(enableTmSelector = false, seed = 1)
|
|
var pooled: seq[array[NumBuckets, BStat]] = newSeq[array[NumBuckets, BStat]](drivers.len)
|
|
|
|
for path in files:
|
|
let fx = loadFixture(path)
|
|
let res = replayBucketed(fx, drivers, liveActual = (fx.meta.source == "live"))
|
|
for gi in 0..<drivers.len:
|
|
for bi in 0..<NumBuckets:
|
|
pooled[gi][bi].shots += res[gi][bi].shots
|
|
pooled[gi][bi].hits += res[gi][bi].hits
|
|
echo &"# {extractFilename(path)} ticks={fx.states.len}"
|
|
|
|
echo ""
|
|
echo "== M1 VIRTUAL per-gun (full-run, all resolved bullets) by DrussGT wall dist at fire =="
|
|
echo " (NOT the live fire cadence; within-gun comparison across buckets is the point)"
|
|
for gi, d in drivers:
|
|
printGun(d.name, pooled[gi])
|
|
|
|
echo ""
|
|
echo "== pooled over all 13 guns =="
|
|
var all: array[NumBuckets, BStat]
|
|
for gi in 0..<drivers.len:
|
|
for bi in 0..<NumBuckets:
|
|
all[bi].shots += pooled[gi][bi].shots
|
|
all[bi].hits += pooled[gi][bi].hits
|
|
printGun("ALL", all)
|
|
|
|
when isMainModule:
|
|
main()
|