feat(ModularBot): melee targeting — multi-enemy tracker, per-enemy gun fitness, radar auto-switch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:25:10 +02:00
parent 30d00ba163
commit ab473c6b68
9 changed files with 375 additions and 174 deletions
+4 -3
View File
@@ -19,8 +19,9 @@ proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool =
elif delta < -180.0: delta += 360.0
abs(delta) <= AimThresholdDeg and gunHeat <= 0.0
proc selectShot*(t: VirtualTracker): (GunId, int, float) =
proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) =
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
let gunId = t.bestGun()
let (binIdx, power) = t.bestPower(gunId)
## Pass targetId to pick the best gun for that specific enemy.
let gunId = t.bestGun(targetId)
let (binIdx, power) = t.bestPower(gunId, targetId)
result = (gunId, binIdx, power)
+66 -25
View File
@@ -4,6 +4,7 @@
## Calls onResult() on the owning gun when a bullet resolves.
import std/math
import std/tables
import gun_interface
const
@@ -19,6 +20,7 @@ type
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
@@ -36,12 +38,13 @@ type
bins*: array[len(PowerBins), FitnessWindow]
VirtualTracker* = object
bullets*: array[MaxBullets, VirtualBullet]
head*: int ## ring buffer head
fitness*: seq[GunFitness] ## indexed by GunId
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.fitness = newSeq[GunFitness](numGuns)
result.numGuns = numGuns
proc hitRate*(fw: FitnessWindow): float =
## Returns fraction of hits in the rolling window. 0.0 when no data.
@@ -58,8 +61,11 @@ proc record(fw: var FitnessWindow, hit: bool) =
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
predictions: array[len(PowerBins), GunPrediction],
state: WorldState) =
state: WorldState, targetId: int) =
## Call once per gun per tick with predictions for all power bins.
## Lazily creates fitness entry for targetId on first spawn.
if targetId notin t.fitness:
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
for binIdx in 0..<len(PowerBins):
let power = PowerBins[binIdx]
let speed = bulletSpeed(power)
@@ -69,6 +75,7 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
t.bullets[slot] = VirtualBullet(
gunId: gunId,
powerBin: binIdx,
targetId: targetId,
fireX: state.selfX,
fireY: state.selfY,
aimX: pred.x,
@@ -80,17 +87,32 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
)
t.head = (t.head + 1) mod MaxBullets
const StaleTicks* = 20 ## discard bullet if target not seen within this many ticks
proc tickBullets*(t: var VirtualTracker, state: WorldState,
enemies: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]],
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
## Uses per-target enemy position from enemies table for accurate miss distance.
## Discards bullet without scoring if target is dead or stale (> StaleTicks).
for i in 0..<MaxBullets:
var b = addr t.bullets[i]
if not b.active: continue
b.travelDist += b.bulletSpeed
if b.travelDist < b.fireDist: continue
# Resolved: compute miss distance against current enemy position
# Direction from fire point to aim point
# Resolved: look up the correct enemy position
var ex, ey: float
if b.targetId in enemies:
let e = enemies[b.targetId]
if not e.alive or (state.tick - e.lastSeenTick) > 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)
@@ -98,15 +120,16 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
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 - state.enemyX, by - state.enemyY)
let missDist = hypot(bx - ex, by - ey)
let hit = missDist < BotRadius
t.fitness[b.gunId].bins[b.powerBin].record(hit)
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: state.enemyX,
actualY: state.enemyY,
actualX: ex,
actualY: ey,
bulletPower: PowerBins[b.powerBin],
missDistance: missDist,
hit: hit,
@@ -114,41 +137,59 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
onResolved(b.gunId, b.powerBin, fe)
b.active = false
proc bestPower*(t: VirtualTracker, gunId: GunId): (int, float) =
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..<t.numGuns:
for binIdx in 0..<len(PowerBins):
let src = perEnemy[gunId].bins[binIdx]
var dst = addr result[gunId].bins[binIdx]
for k in 0..<min(src.count, WindowSize):
dst[].record(src.hits[k])
proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, float) =
## Returns (binIdx, power) with highest power that has >= 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 = t.fitness[gunId].bins[binIdx].hitRate()
if rate >= MinHitRate or t.fitness[gunId].bins[binIdx].count == 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): GunId =
proc bestGun*(t: VirtualTracker, targetId: int = -1): GunId =
## Pick gun with highest hit rate across all power bins.
## Guns with fewer than MinObsBeforeCompete observations across all bins are
## skipped unless every gun is below threshold (then fall back to best of all).
## 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
proc bestAmong(t: VirtualTracker, requireMin: bool): GunId =
let fit = t.fitnessFor(targetId)
proc bestAmong(fit: seq[GunFitness], requireMin: bool): GunId =
var bestRate = -1.0
result = 0
for gunId in 0..<t.fitness.len:
for gunId in 0..<fit.len:
var maxCount = 0
for binIdx in 0..<len(PowerBins):
maxCount = max(maxCount, t.fitness[gunId].bins[binIdx].count)
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
if requireMin and maxCount < MinObsBeforeCompete: continue
for binIdx in 0..<len(PowerBins):
let r = t.fitness[gunId].bins[binIdx].hitRate()
let r = fit[gunId].bins[binIdx].hitRate()
if r > bestRate:
bestRate = r
result = gunId
# Try gated selection first; fall back to ungated if nothing qualifies
var anyQualifies = false
for gunId in 0..<t.fitness.len:
for gunId in 0..<fit.len:
for binIdx in 0..<len(PowerBins):
if t.fitness[gunId].bins[binIdx].count >= MinObsBeforeCompete:
if fit[gunId].bins[binIdx].count >= MinObsBeforeCompete:
anyQualifies = true
break
if anyQualifies: break
result = if anyQualifies: t.bestAmong(true) else: t.bestAmong(false)
result = if anyQualifies: fit.bestAmong(true) else: fit.bestAmong(false)