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