## 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..= 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.. 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..