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
@@ -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..<len(PowerBins):
if fit[gunId].bins[binIdx].count > 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: