feat(gun_harness): per-gun REAL hit attribution + bestPower cold-start fix

Attribution is proven, not guessed: the server assigns a per-round-unique
bulletId (GunEngine.nextBulletId) and stamps the same id on BulletFired,
BulletHitBot, BulletHitWall and BulletHitBullet. Keep a FIFO of fired gun
ids, stamp bulletId -> gunId on onBulletFired, resolve through that map.

Hits are deferred when onBulletHit precedes onBulletFired in the same
turn (client dispatches priority 70 > 60), which recovered 14
unattributed hits. 99.9% of shots and 99.8% of hits attributed.

Stats lines now carry per-gun realShots/realHits/realHitRate; the old
keys and round-level totals are unchanged.

bestPower: a gun with zero observations in every bin previously returned
the HIGHEST bin (power 3.0) because an empty bin satisfied the
'count == 0' clause on the first countdown iteration. Cold guns now
return the lowest bin as the docstring always claimed. Warm-gun path
untouched.
This commit is contained in:
2026-09-20 22:15:42 +02:00
parent 343e631633
commit 26b66cbb24
2 changed files with 68 additions and 4 deletions
+56 -4
View File
@@ -3,7 +3,7 @@
## Radar: RadarLockModule (1v1) / MeleeScanModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
import std/[math, os, strformat, tables, json]
import std/[math, os, strformat, tables, sets, json]
import robocode_tankroyale_botapi
import radar_harness/radar_interface
import radars/radar_lock_module
@@ -90,9 +90,39 @@ type
roundNumber: int
realShotsFired: int
realHits: int
gunRealShots: array[13, int]
gunRealHits: array[13, int]
pendingFireGuns: seq[int] ## FIFO of fired guns awaiting onBulletFired bulletId stamp
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
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 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]
# 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]
bot.pendingHitBullets.excl(bulletId)
bot.bulletGun.del(bulletId)
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
## Look up the gun that fired bulletId and drop it from the in-flight map.
## Returns the gun id, or -1 if the bullet was not attributed.
result = bot.bulletGun.getOrDefault(bulletId, -1)
if result >= 0:
bot.bulletGun.del(bulletId)
proc printConfig(bot: ModularBot, changed: string = "") =
let gunName = GunNames[bot.currentGun]
let radarName = if bot.radarMode == 0: "radar_lock" else: "melee_scan"
@@ -140,18 +170,25 @@ method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) =
inc bot.realShotsFired
bot.registerOwnBullet(e.bullet.bulletId)
bot.moveTracker.shadows.addBullet(e.bullet.x, e.bullet.y,
degToRad(e.bullet.direction), e.bullet.power)
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
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.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.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
@@ -192,13 +229,19 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
for k in 0..<n:
if fw.hits[k]: inc totalHits
let hitRate = if totalShots > 0: totalHits.float / totalShots.float else: 0.0
let rShots = bot.gunRealShots[gid]
let rHits = bot.gunRealHits[gid]
let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0
gunsArr.add(%*{
"id": gid,
"name": GunNames[gid],
"vShots": totalShots,
"vHits": totalHits,
"hitRate": (hitRate * 100.0).int,
"selected": bot.gunSelectionCount[gid]
"selected": bot.gunSelectionCount[gid],
"realShots": rShots,
"realHits": rHits,
"realHitRate": rRate.round(1)
})
let row = %*{
@@ -219,7 +262,13 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.realShotsFired = 0
bot.realHits = 0
bot.lastKnownTargetId = -1
for i in 0..<13: bot.gunSelectionCount[i] = 0
bot.pendingFireGuns.setLen(0)
bot.bulletGun.clear()
bot.pendingHitBullets.clear()
for i in 0..<13:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
@@ -523,7 +572,10 @@ method run*(bot: ModularBot) =
elif normDelta < -180.0: normDelta += 360.0
if shouldFire(gunDir, aimTarget, gunHeat):
discard setFire(power)
# 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)
setGunTurnRate(normDelta)