e53690036b
Four guns cached a whole prediction per tick while predict() is called once per power bin, so every bin after the first (and the real fired shot, which shares lastState) reused the power-1.0 lead. Fixed by caching only the speed-INDEPENDENT derived state and recomputing the lead per requested speed: - stop_shot: also fixes prevSpeed being written before it was read, which made abs(speed) < abs(prev) permanently false and the entire stop-prediction branch unreachable (it was just Linear). - displacement: the cache key included bulletSpeed, so the guard missed on all four bins and the 15-tick window advanced ~4x/tick, making the inferred velocity ~4x too small. - averaged_lead: tick cache removed outright. pattern_matcher: split into speed-independent match+path and per-call lead. FeedbackEvent gains fireTick/powerBin (additive; only virtual_bullets constructs one) so guns can pair feedback to the exact shot instead of guessing by coordinates. tsetlin uses it: traces are now keyed exactly by (fireTick, powerBin) with a 1024-slot ring, and the 10-frame window shifts at most once per tick (it was shifting ~4-5x/tick, so isWarmedUp tripped after ~2 ticks). KNOWN INCOMPLETE: tsetlin still does not diverge from Linear in battle. The two named bugs are fixed (a 600-tick sim shows trainedShots=2141, traceMisses=0, and a fixed-input probe converges to a 9.6px correction), but the TM's clause feedback itself is broken: ~131 of 1740 literals end up included per clause, so its conjunction never fires. Sweeping TM_S, TM_N_CLAUSES and a two-branch Type-I update did not change the correction from 0. Needs a real TM fix or removal, not another bug fix. First-ever guard tests for the gun selector: common_libs/tests/ test_gun_harness.nim (14 checks, headless, no Java). There were none before, which is how six broken guns survived a full analysis cycle. Against the previous HEAD, 5 of these checks FAIL - that is the regression guard.
249 lines
10 KiB
Nim
249 lines
10 KiB
Nim
## 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 std/random
|
||
import std/algorithm
|
||
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* = 8192 ## hard cap; ring buffer. 52 spawns/tick and a
|
||
## full-map long shot (~90 ticks) need ~4700 slots;
|
||
## 8192 wraps only after ~157 ticks. Each VirtualBullet
|
||
## is 88 bytes, so this array costs ~704 KiB.
|
||
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
|
||
MinObsBeforeCompete* = 50 ## min observations before a gun×bin enters competition
|
||
TieMargin* = 0.02 ## guns within this hit-rate margin of best are tied
|
||
MinHitRateFloor* = 0.10 ## if best gun < this, fall back to gun 0 (HeadOn)
|
||
|
||
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
|
||
fireTick*: int ## tick this bullet was spawned; lets a gun pair its
|
||
## predict() trace with the exact resolution event
|
||
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
|
||
droppedBullets*: int ## unresolved bullets clobbered by the ring buffer (should stay 0)
|
||
|
||
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..<n: h += (if fw.hits[i]: 1 else: 0)
|
||
result = h.float / n.float
|
||
|
||
proc record(fw: var FitnessWindow, hit: bool) =
|
||
fw.hits[fw.head] = hit
|
||
fw.head = (fw.head + 1) mod WindowSize
|
||
inc fw.count
|
||
|
||
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||
predictions: array[len(PowerBins), GunPrediction],
|
||
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)
|
||
let pred = predictions[binIdx]
|
||
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
|
||
let slot = t.head mod MaxBullets
|
||
# Measurement integrity: if the slot we are about to overwrite still holds an
|
||
# unresolved bullet, that bullet will never be scored. Count it instead of
|
||
# silently dropping it (non-zero after a battle means MaxBullets is too small).
|
||
if t.bullets[slot].active:
|
||
inc t.droppedBullets
|
||
t.bullets[slot] = VirtualBullet(
|
||
gunId: gunId,
|
||
powerBin: binIdx,
|
||
targetId: targetId,
|
||
fireTick: state.tick,
|
||
fireX: state.selfX,
|
||
fireY: state.selfY,
|
||
aimX: pred.x,
|
||
aimY: pred.y,
|
||
bulletSpeed: speed,
|
||
travelDist: 0.0,
|
||
fireDist: fireDist,
|
||
active: true,
|
||
)
|
||
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: 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)
|
||
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],
|
||
fireTick: b.fireTick,
|
||
powerBin: 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.
|
||
##
|
||
## The fallback is a RECENCY-WEIGHTED AGGREGATE over the last WindowSize
|
||
## samples, NOT a pooled rate: each per-enemy window is replayed into one fresh
|
||
## window, so once the total exceeds WindowSize the earliest samples are
|
||
## overwritten by later ones. Enemies are visited in ascending target-id order
|
||
## so the result is identical on every run (std/tables iteration order is hash
|
||
## order and therefore nondeterministic).
|
||
## ponytail: merge is O(enemies*guns*bins*WindowSize), fine for small counts
|
||
if targetId >= 0 and targetId in t.fitness:
|
||
return t.fitness[targetId]
|
||
# Aggregate across all enemies, deterministically ordered.
|
||
result = newSeq[GunFitness](t.numGuns)
|
||
var enemyIds: seq[int]
|
||
for id in t.fitness.keys: enemyIds.add id
|
||
enemyIds.sort()
|
||
for id in enemyIds:
|
||
let perEnemy = t.fitness[id]
|
||
for gunId in 0..<t.numGuns:
|
||
for binIdx in 0..<len(PowerBins):
|
||
let src = perEnemy[gunId].bins[binIdx]
|
||
for k in 0..<min(src.count, WindowSize):
|
||
result[gunId].bins[binIdx].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])
|
||
# 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:
|
||
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.
|
||
## Ties (within TieMargin) are broken randomly to avoid index-0 bias.
|
||
## 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
|
||
for gunId in 0..<fit.len:
|
||
var maxCount = 0
|
||
for binIdx in 0..<len(PowerBins):
|
||
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
|
||
if requireMin and maxCount < MinObsBeforeCompete: continue
|
||
for binIdx in 0..<len(PowerBins):
|
||
let r = fit[gunId].bins[binIdx].hitRate()
|
||
if r > bestRate: bestRate = r
|
||
# Floor check: if nothing hits well enough, HeadOn is the safe default
|
||
if bestRate < MinHitRateFloor: return 0
|
||
var tied: seq[GunId]
|
||
for gunId in 0..<fit.len:
|
||
var maxCount = 0
|
||
for binIdx in 0..<len(PowerBins):
|
||
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
|
||
if requireMin and maxCount < MinObsBeforeCompete: continue
|
||
for binIdx in 0..<len(PowerBins):
|
||
let r = fit[gunId].bins[binIdx].hitRate()
|
||
if r >= bestRate - TieMargin:
|
||
tied.add(gunId)
|
||
break
|
||
if tied.len == 0: return 0
|
||
return tied[rand(tied.len - 1)]
|
||
|
||
var anyQualifies = false
|
||
for gunId in 0..<fit.len:
|
||
for binIdx in 0..<len(PowerBins):
|
||
if fit[gunId].bins[binIdx].count >= MinObsBeforeCompete:
|
||
anyQualifies = true
|
||
break
|
||
if anyQualifies: break
|
||
|
||
result = if anyQualifies: fit.bestAmong(true) else: fit.bestAmong(false)
|