feat(selector): range-aware firing gate fitted to 2611 measured shots

Measured, not assumed. With the gate temporarily opened to 20 deg, every
real shot was logged (tick, angle error at fire time, distance, power,
hit) across 3 gauntlets: 2611 shots, 57.3% aggregate. Findings:

- The geometric cone atan(BotRadius/d) is directionally confirmed but a
  WEAK lever: even at 0.0-0.1 deg error the hit rate at 400-600px is only
  ~53-57%, because PREDICTION error dominates alignment error.
- Real effect of tightening the gate: 57.9% -> 68.0% aggregate hit rate
  (fixed 0.1 deg), not the 76.9% previously reported -- that was a
  high-variance draw (per-rep 62.8/66.4/77.2%).
- The shipped range-aware gate (SafetyFactor 0.6) does NOT beat the fixed
  2.0 deg gate on hit rate (55.8% vs 57.9%, ~1.5 sigma, inside noise). It
  fires 22-28% more shots and therefore lands more total hits (~509 vs
  ~434 per rep). No per-adversary score delta exceeded the 300-point
  run-to-run noise band, so no config is demonstrably better on score.

Shipped anyway because it is strictly more expressive (a fixed threshold is
the special case), tunable from one const, and physically motivated, but
the honest verdict is recorded in-code: the gate is not the bottleneck.

AimThresholdDeg is removed; shouldFire now takes distPx. Degenerate or NaN
distance falls back to the ceiling rather than dividing by zero.

Also adds a per-shot logger to ModularBot behind 'const ShotLog' so the
measurement above is reproducible, and 10 new guard checks (24 total, all
passing) covering monotonicity, clamping, formula, perfect alignment,
gross misalignment and degenerate distance.

Cross-checked against the server source: the gun fires BEFORE the turn is
applied, so the logged angle error is the true departure error, and
fireAssist auto-aim is off (unset by the Nim API and forced false by
setAdjustRadarForGunTurn).
This commit is contained in:
2026-09-20 23:28:31 +02:00
parent 54e9757567
commit 3c90a5941d
3 changed files with 193 additions and 18 deletions
+83 -12
View File
@@ -35,6 +35,9 @@ import targeting/target_selector
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
const DebugVBullets = false
const DebugCircular = false
## Task A instrumentation: log every real shot + its eventual outcome to
## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out.
const ShotLog = true
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN"]
const
@@ -47,6 +50,16 @@ const
CLR_RST = "\e[0m" # reset
type
PendingShot = object
## Metadata for one real shot, captured at setFire() time and resolved when
## the server reports the bullet's fate (hit / wall / bullet).
gunId: int
angleErr: float ## |aim error| (deg) at fire time — the value the gate used
distPx: float ## distance (px) to the aim point at fire time
power: float
tick: int
targetId: int
ModularBot = ref object of Bot
hasContact: bool
enemyBearing: float
@@ -92,29 +105,61 @@ type
realHits: int
gunRealShots: array[13, int]
gunRealHits: array[13, int]
pendingFireGuns: seq[int] ## FIFO of fired guns awaiting onBulletFired bulletId stamp
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
gunSelectionCount: array[13, int]
lastKnownTargetId: int ## persists through death, used for round-end stats
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
## Task A: append one JSON line per resolved real shot. The write is fully
## guarded so a full disk / bad path can never take the bot down.
when ShotLog:
let row = %*{
"tick": shot.tick,
"targetId": shot.targetId,
"gunId": shot.gunId,
"angleErr": shot.angleErr,
"distPx": shot.distPx,
"power": shot.power,
"hit": hit,
"unresolved": unresolved
}
try:
let f = open("/tmp/shot_log.jsonl", fmAppend)
f.writeLine($row)
f.close()
except CatchableError:
discard # logging must never crash the bot
proc resolveShotLog(bot: ModularBot, bulletId: int, hit: bool) =
## Look up the shot metadata for a resolved bullet and log its fate once.
## No-op when ShotLog is off (bulletShot is then never populated).
if bot.bulletShot.hasKey(bulletId):
writeShotLog(bot.bulletShot[bulletId], hit, false)
bot.bulletShot.del(bulletId)
proc registerOwnBullet(bot: ModularBot, bulletId: int) =
## onBulletFired: the server assigns a unique bulletId per round. Stamp the
## oldest pending fire with it, then credit that gun with a real shot.
## The FIFO stays aligned because we only enqueue when setFire() succeeds
## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard).
if bot.pendingFireGuns.len == 0: return # unexpected: no pending fire to stamp
let gunId = bot.pendingFireGuns[0]
delete(bot.pendingFireGuns, 0)
bot.bulletGun[bulletId] = gunId
inc bot.gunRealShots[gunId]
if bot.pendingFires.len == 0: return # unexpected: no pending fire to stamp
let shot = bot.pendingFires[0]
delete(bot.pendingFires, 0)
bot.bulletGun[bulletId] = shot.gunId
inc bot.gunRealShots[shot.gunId]
when ShotLog:
bot.bulletShot[bulletId] = shot
# Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and
# BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first.
# If so, credit the hit now and drop the (already resolved) map entry.
if bulletId in bot.pendingHitBullets:
inc bot.gunRealHits[gunId]
inc bot.gunRealHits[shot.gunId]
bot.pendingHitBullets.excl(bulletId)
bot.bulletGun.del(bulletId)
bot.resolveShotLog(bulletId, true)
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
## Look up the gun that fired bulletId and drop it from the in-flight map.
@@ -178,17 +223,22 @@ method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
inc bot.realHits
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
if gunId >= 0: inc bot.gunRealHits[gunId]
else: bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
if gunId >= 0:
inc bot.gunRealHits[gunId]
bot.resolveShotLog(e.bullet.bulletId, true)
else:
bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot
bot.resolveShotLog(e.bullet.bulletId, false)
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
bot.resolveShotLog(e.bullet.bulletId, false)
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
@@ -244,14 +294,24 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
let f = open("/tmp/gun_stats.jsonl", fmAppend)
f.writeLine($row)
f.close()
# Task A: shots still in flight at round end never resolved, so count them as
# misses for the shot log (matching how realHits/realShots treats them).
# Shots fired but never stamped by onBulletFired are not counted in realShots
# either, so they are dropped rather than logged.
when ShotLog:
for _, shot in bot.bulletShot:
writeShotLog(shot, false, true)
bot.bulletShot.clear()
bot.pendingFires.setLen(0)
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.roundNumber = e.roundNumber
bot.realShotsFired = 0
bot.realHits = 0
bot.lastKnownTargetId = -1
bot.pendingFireGuns.setLen(0)
bot.pendingFires.setLen(0)
bot.bulletGun.clear()
bot.bulletShot.clear()
bot.pendingHitBullets.clear()
for i in 0..<13:
bot.gunSelectionCount[i] = 0
@@ -568,11 +628,22 @@ method run*(bot: ModularBot) =
if normDelta > 180.0: normDelta -= 360.0
elif normDelta < -180.0: normDelta += 360.0
if shouldFire(gunDir, aimTarget, gunHeat):
# Distance to the aim point drives the range-aware gate (Task B) and is
# recorded per shot (Task A).
let distPx = hypot(pred.x - getX(), pred.y - getY())
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
if setFire(power) and getEnergy() > power:
bot.pendingFireGuns.add(selectedGun)
bot.pendingFires.add(PendingShot(
gunId: selectedGun,
angleErr: abs(normDelta),
distPx: distPx,
power: power,
tick: bot.tick,
targetId: tid,
))
setGunTurnRate(normDelta)