From 26b66cbb2491196a13f3f762d3dc232dbf763c69 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 20 Sep 2026 22:15:42 +0200 Subject: [PATCH] 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. --- ModularBot_garage/src/ModularBot.nim | 60 +++++++++++++++++++-- common_libs/gun_harness/virtual_bullets.nim | 12 +++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index c633fc0..4c0e695 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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.. 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) diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index 682057e..26d1411 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -161,6 +161,18 @@ proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, floa ## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate. let fit = t.fitnessFor(targetId) result = (0, PowerBins[0]) + # Cold gun (zero observations in every bin): fall back to the lowest power bin, + # as documented. Without this the countdown loop below would hit the empty + # highest bin first and wrongly return power 3.0. + var anyObs = false + for binIdx in 0.. 0: + anyObs = true + break + if not anyObs: + return (0, PowerBins[0]) + # Warm gun: unchanged — return the highest power bin clearing MinHitRate + # (or an empty bin, which the existing logic treats as acceptable). for binIdx in countdown(len(PowerBins) - 1, 0): let rate = fit[gunId].bins[binIdx].hitRate() if rate >= MinHitRate or fit[gunId].bins[binIdx].count == 0: