## Virtual bullet tracker. ## Spawns virtual bullets per gun×power bin every tick (no real firing). ## Resolves by travel distance. Rolling window fitness per gun×power. ## Calls onResult() on the owning gun when a bullet resolves. import std/math import std/tables import gun_interface const PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed WindowSize* = 100 ## rolling window ticks for fitness MaxBullets* = 2048 ## hard cap; ponytail: ring buffer, resize if more guns added MinHitRate* = 0.40 ## 40% threshold for acceptable power selection MinObsBeforeCompete* = 15 ## min observations before a gun×bin enters competition type GunId* = int ## index into the guns seq VirtualBullet* = object gunId*: GunId powerBin*: int ## index into PowerBins targetId*: int ## enemy bot ID this bullet was aimed at fireX*, fireY*: float aimX*, aimY*: float ## predicted target (absolute) bulletSpeed*: float travelDist*: float ## accumulated px so far fireDist*: float ## distance to target at fire time active*: bool FitnessWindow* = object ## Ring buffer of hit booleans. hits*: array[WindowSize, bool] count*: int ## total samples so far (capped at WindowSize for rate) head*: int GunFitness* = object bins*: array[len(PowerBins), FitnessWindow] VirtualTracker* = object bullets*: array[MaxBullets, VirtualBullet] head*: int ## ring buffer head numGuns*: int fitness*: Table[int, seq[GunFitness]] ## keyed by enemy bot ID, indexed by GunId proc initTracker*(numGuns: int): VirtualTracker = result.numGuns = numGuns proc hitRate*(fw: FitnessWindow): float = ## Returns fraction of hits in the rolling window. 0.0 when no data. if fw.count == 0: return 0.0 let n = min(fw.count, WindowSize) var h = 0 for i in 0.. StaleTicks). for i in 0.. StaleTicks: b.active = false continue ex = e.x; ey = e.y else: # No data for this target — fall back to selected enemy in state ex = state.enemyX; ey = state.enemyY let dx = b.aimX - b.fireX let dy = b.aimY - b.fireY let dist = hypot(dx, dy) let (bx, by) = if dist < 1e-6: (b.aimX, b.aimY) else: (b.fireX + dx / dist * b.travelDist, b.fireY + dy / dist * b.travelDist) let missDist = hypot(bx - ex, by - ey) let hit = missDist < BotRadius if b.targetId in t.fitness: t.fitness[b.targetId][b.gunId].bins[b.powerBin].record(hit) let fe = FeedbackEvent( prediction: GunPrediction(x: b.aimX, y: b.aimY), actualX: ex, actualY: ey, bulletPower: PowerBins[b.powerBin], missDistance: missDist, hit: hit, ) onResolved(b.gunId, b.powerBin, fe) b.active = false proc fitnessFor(t: VirtualTracker, targetId: int): seq[GunFitness] = ## Returns fitness seq for targetId, or merges all enemies as fallback. ## ponytail: merge is O(enemies*guns*bins), fine for small counts if targetId >= 0 and targetId in t.fitness: return t.fitness[targetId] # Aggregate across all enemies result = newSeq[GunFitness](t.numGuns) for perEnemy in t.fitness.values: for gunId in 0..= MinHitRate. ## Falls back to lowest power bin if nothing qualifies yet. ## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate. let fit = t.fitnessFor(targetId) result = (0, PowerBins[0]) 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: return (binIdx, PowerBins[binIdx]) proc bestGun*(t: VirtualTracker, targetId: int = -1): GunId = ## Pick gun with highest hit rate across all power bins. ## Guns with fewer than MinObsBeforeCompete observations are skipped ## unless every gun is below threshold (then fall back to best of all). ## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate. ## ponytail: O(n*bins), fine for small gun counts let fit = t.fitnessFor(targetId) proc bestAmong(fit: seq[GunFitness], requireMin: bool): GunId = var bestRate = -1.0 result = 0 for gunId in 0.. bestRate: bestRate = r result = gunId var anyQualifies = false for gunId in 0..= MinObsBeforeCompete: anyQualifies = true break if anyQualifies: break result = if anyQualifies: fit.bestAmong(true) else: fit.bestAmong(false)