fix(guns): speed-sensitive caches, dead stop-shot branch, exact TM trace pairing
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.
This commit is contained in:
@@ -39,6 +39,8 @@ type
|
|||||||
prediction*: GunPrediction
|
prediction*: GunPrediction
|
||||||
actualX*, actualY*: float ## actual enemy position at resolution time
|
actualX*, actualY*: float ## actual enemy position at resolution time
|
||||||
bulletPower*: float
|
bulletPower*: float
|
||||||
|
fireTick*: int ## tick the virtual bullet was spawned (predict time)
|
||||||
|
powerBin*: int ## index into PowerBins the bullet belongs to
|
||||||
missDistance*: float ## px; < BotRadius = hit
|
missDistance*: float ## px; < BotRadius = hit
|
||||||
hit*: bool
|
hit*: bool
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type
|
|||||||
gunId*: GunId
|
gunId*: GunId
|
||||||
powerBin*: int ## index into PowerBins
|
powerBin*: int ## index into PowerBins
|
||||||
targetId*: int ## enemy bot ID this bullet was aimed at
|
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
|
fireX*, fireY*: float
|
||||||
aimX*, aimY*: float ## predicted target (absolute)
|
aimX*, aimY*: float ## predicted target (absolute)
|
||||||
bulletSpeed*: float
|
bulletSpeed*: float
|
||||||
@@ -89,6 +91,7 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
|||||||
gunId: gunId,
|
gunId: gunId,
|
||||||
powerBin: binIdx,
|
powerBin: binIdx,
|
||||||
targetId: targetId,
|
targetId: targetId,
|
||||||
|
fireTick: state.tick,
|
||||||
fireX: state.selfX,
|
fireX: state.selfX,
|
||||||
fireY: state.selfY,
|
fireY: state.selfY,
|
||||||
aimX: pred.x,
|
aimX: pred.x,
|
||||||
@@ -144,6 +147,8 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
|||||||
actualX: ex,
|
actualX: ex,
|
||||||
actualY: ey,
|
actualY: ey,
|
||||||
bulletPower: PowerBins[b.powerBin],
|
bulletPower: PowerBins[b.powerBin],
|
||||||
|
fireTick: b.fireTick,
|
||||||
|
powerBin: b.powerBin,
|
||||||
missDistance: missDist,
|
missDistance: missDist,
|
||||||
hit: hit,
|
hit: hit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,17 +10,16 @@ type AveragedLeadGun* = object
|
|||||||
linear: LinearGun
|
linear: LinearGun
|
||||||
circular: CircularGun
|
circular: CircularGun
|
||||||
wallBounce: WallBounceGun
|
wallBounce: WallBounceGun
|
||||||
cachedTick: int
|
|
||||||
cachedPred: GunPrediction # per-tick cache; ponytail: single cache, extend if multi-power needed
|
|
||||||
debugGraphics*: bool
|
debugGraphics*: bool
|
||||||
|
|
||||||
proc initAveragedLeadGun*(): AveragedLeadGun =
|
proc initAveragedLeadGun*(): AveragedLeadGun =
|
||||||
AveragedLeadGun(wallBounce: initWallBounceGun(), debugGraphics: false)
|
AveragedLeadGun(wallBounce: initWallBounceGun(), debugGraphics: false)
|
||||||
|
|
||||||
proc predict*(g: var AveragedLeadGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
proc predict*(g: var AveragedLeadGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||||
if state.tick == g.cachedTick:
|
# No tick cache: every sub-gun's lead depends on bulletSpeed (dist/bulletSpeed),
|
||||||
return g.cachedPred
|
# so caching one result per tick and reusing it for all four power bins would
|
||||||
|
# silently collapse every bin onto the first. linear/wallBounce are stateless
|
||||||
|
# and cheap; circular only caches its speed-independent omega internally.
|
||||||
let lp = g.linear.predict(state, bulletSpeed)
|
let lp = g.linear.predict(state, bulletSpeed)
|
||||||
let cp = g.circular.predict(state, bulletSpeed)
|
let cp = g.circular.predict(state, bulletSpeed)
|
||||||
let wp = g.wallBounce.predict(state, bulletSpeed)
|
let wp = g.wallBounce.predict(state, bulletSpeed)
|
||||||
@@ -30,9 +29,7 @@ proc predict*(g: var AveragedLeadGun, state: WorldState, bulletSpeed: float): Gu
|
|||||||
px = clamp(px, BotRadius, state.arenaWidth - BotRadius)
|
px = clamp(px, BotRadius, state.arenaWidth - BotRadius)
|
||||||
py = clamp(py, BotRadius, state.arenaHeight - BotRadius)
|
py = clamp(py, BotRadius, state.arenaHeight - BotRadius)
|
||||||
|
|
||||||
g.cachedTick = state.tick
|
GunPrediction(x: px, y: py)
|
||||||
g.cachedPred = GunPrediction(x: px, y: py)
|
|
||||||
g.cachedPred
|
|
||||||
|
|
||||||
proc onResult*(g: var AveragedLeadGun, e: FeedbackEvent) =
|
proc onResult*(g: var AveragedLeadGun, e: FeedbackEvent) =
|
||||||
discard # analytical average — no learning
|
discard # analytical average — no learning
|
||||||
|
|||||||
@@ -14,35 +14,40 @@ type
|
|||||||
posY: array[WindowSize + 1, float]
|
posY: array[WindowSize + 1, float]
|
||||||
count: int # frames collected so far
|
count: int # frames collected so far
|
||||||
head: int # ring-buffer head
|
head: int # ring-buffer head
|
||||||
lastTick: int # for per-tick cache
|
# Per-tick derived state. The ring must advance exactly ONCE per tick and the
|
||||||
cacheSpeed: float
|
# average per-tick velocity is speed-independent, so both are computed once
|
||||||
cachePred: GunPrediction
|
# per tick and shared by all four power bins. The iterative bullet lead is
|
||||||
|
# recomputed from (dx, dy) on every call.
|
||||||
|
derivedTick: int
|
||||||
|
dx, dy: float
|
||||||
|
ready: bool
|
||||||
debugGraphics*: bool
|
debugGraphics*: bool
|
||||||
|
|
||||||
proc predict*(g: var DisplacementGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
proc predict*(g: var DisplacementGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||||
# Per-tick cache: same tick + same speed => same prediction
|
# Sample the enemy position exactly once per tick (the harness calls predict()
|
||||||
if state.tick == g.lastTick and bulletSpeed == g.cacheSpeed:
|
# 4-5x/tick, once per power bin). Keying the old cache on bulletSpeed too made
|
||||||
return g.cachePred
|
# every bin miss, so the nominal 15-tick window was actually advanced ~4x/tick.
|
||||||
|
if state.tick != g.derivedTick:
|
||||||
|
g.derivedTick = state.tick
|
||||||
|
# Push current position into ring buffer
|
||||||
|
g.head = (g.head + 1) mod (WindowSize + 1)
|
||||||
|
g.posX[g.head] = state.enemyX
|
||||||
|
g.posY[g.head] = state.enemyY
|
||||||
|
if g.count < WindowSize + 1:
|
||||||
|
inc g.count
|
||||||
|
|
||||||
# Push current position into ring buffer
|
# Need at least N+1 frames; fall back to head-on if not enough
|
||||||
g.head = (g.head + 1) mod (WindowSize + 1)
|
if g.count < WindowSize + 1:
|
||||||
g.posX[g.head] = state.enemyX
|
g.ready = false
|
||||||
g.posY[g.head] = state.enemyY
|
else:
|
||||||
if g.count < WindowSize + 1:
|
g.ready = true
|
||||||
inc g.count
|
# Oldest frame is (head + 1) mod (WindowSize + 1)
|
||||||
|
let oldest = (g.head + 1) mod (WindowSize + 1)
|
||||||
|
g.dx = (state.enemyX - g.posX[oldest]) / WindowSize.float
|
||||||
|
g.dy = (state.enemyY - g.posY[oldest]) / WindowSize.float
|
||||||
|
|
||||||
g.lastTick = state.tick
|
if not g.ready:
|
||||||
g.cacheSpeed = bulletSpeed
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||||
|
|
||||||
# Need at least N+1 frames; fall back to head-on if not enough
|
|
||||||
if g.count < WindowSize + 1:
|
|
||||||
g.cachePred = GunPrediction(x: state.enemyX, y: state.enemyY)
|
|
||||||
return g.cachePred
|
|
||||||
|
|
||||||
# Oldest frame is (head + 1) mod (WindowSize + 1)
|
|
||||||
let oldest = (g.head + 1) mod (WindowSize + 1)
|
|
||||||
let dx = (state.enemyX - g.posX[oldest]) / WindowSize.float
|
|
||||||
let dy = (state.enemyY - g.posY[oldest]) / WindowSize.float
|
|
||||||
|
|
||||||
# Iterate time estimate 5 times
|
# Iterate time estimate 5 times
|
||||||
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||||
@@ -50,17 +55,16 @@ proc predict*(g: var DisplacementGun, state: WorldState, bulletSpeed: float): Gu
|
|||||||
var px = state.enemyX
|
var px = state.enemyX
|
||||||
var py = state.enemyY
|
var py = state.enemyY
|
||||||
for _ in 0..4:
|
for _ in 0..4:
|
||||||
px = state.enemyX + dx * ticks
|
px = state.enemyX + g.dx * ticks
|
||||||
py = state.enemyY + dy * ticks
|
py = state.enemyY + g.dy * ticks
|
||||||
ticks = hypot(px - state.selfX, py - state.selfY) / bulletSpeed
|
ticks = hypot(px - state.selfX, py - state.selfY) / bulletSpeed
|
||||||
|
|
||||||
px = clamp(px, 0.0, state.arenaWidth)
|
px = clamp(px, 0.0, state.arenaWidth)
|
||||||
py = clamp(py, 0.0, state.arenaHeight)
|
py = clamp(py, 0.0, state.arenaHeight)
|
||||||
g.cachePred = GunPrediction(x: px, y: py)
|
GunPrediction(x: px, y: py)
|
||||||
g.cachePred
|
|
||||||
|
|
||||||
proc onResult*(g: var DisplacementGun, e: FeedbackEvent) =
|
proc onResult*(g: var DisplacementGun, e: FeedbackEvent) =
|
||||||
discard # analytical gun — no learning
|
discard # analytical gun — no learning
|
||||||
|
|
||||||
proc initDisplacementGun*(): DisplacementGun =
|
proc initDisplacementGun*(): DisplacementGun =
|
||||||
DisplacementGun(count: 0, head: 0, lastTick: -1, debugGraphics: false)
|
DisplacementGun(count: 0, head: 0, derivedTick: -1, debugGraphics: false)
|
||||||
|
|||||||
@@ -23,11 +23,19 @@ type
|
|||||||
prevSpeed: float
|
prevSpeed: float
|
||||||
prevTick: int
|
prevTick: int
|
||||||
hasPrev: bool
|
hasPrev: bool
|
||||||
# per-tick cache — avoid re-searching for multiple power bins
|
# Per-tick, speed-INDEPENDENT pattern state. Both the history search and the
|
||||||
cacheTick: int
|
# replayed enemy path depend only on observed movement, never on bulletSpeed,
|
||||||
cacheX: float
|
# so they are built at most once per tick. The bullet lead (number of replay
|
||||||
cacheY: float
|
# steps + coast) is derived from this path on every call.
|
||||||
cacheValid: bool
|
cacheValid: bool
|
||||||
|
cacheTick: int
|
||||||
|
bestMatch: int ## -1 = no usable match (linear fallback)
|
||||||
|
playStart: int
|
||||||
|
playAvail: int
|
||||||
|
pathX: array[HistorySize + 1, float]
|
||||||
|
pathY: array[HistorySize + 1, float]
|
||||||
|
pathHeading: array[HistorySize + 1, float] ## radians after s steps
|
||||||
|
pathSpeed: array[HistorySize + 1, float] ## speed after s steps
|
||||||
debugGraphics*: bool
|
debugGraphics*: bool
|
||||||
|
|
||||||
# --- circular buffer helpers ---
|
# --- circular buffer helpers ---
|
||||||
@@ -55,11 +63,11 @@ proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) =
|
|||||||
|
|
||||||
# --- pattern search + play-forward ---
|
# --- pattern search + play-forward ---
|
||||||
|
|
||||||
proc searchAndProject(g: PatternMatcherGun, state: WorldState,
|
proc findBestMatch(g: PatternMatcherGun): int =
|
||||||
bulletSpeed: float): (float, float) =
|
## Speed-independent history search. Returns the start index of the best
|
||||||
## Returns projected (x, y). Falls back to linear if history too short.
|
## matching pattern, or -1 when there is not enough history.
|
||||||
if g.count < PatternLen * 2:
|
if g.count < PatternLen * 2:
|
||||||
return linearPredict(state, bulletSpeed)
|
return -1
|
||||||
|
|
||||||
# key = last PatternLen entries
|
# key = last PatternLen entries
|
||||||
let keyStart = g.count - PatternLen
|
let keyStart = g.count - PatternLen
|
||||||
@@ -79,14 +87,28 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState,
|
|||||||
if score < bestScore:
|
if score < bestScore:
|
||||||
bestScore = score
|
bestScore = score
|
||||||
bestMatch = i
|
bestMatch = i
|
||||||
|
bestMatch
|
||||||
|
|
||||||
if bestMatch < 0:
|
proc buildPath(g: var PatternMatcherGun, state: WorldState, bestMatch: int) =
|
||||||
return linearPredict(state, bulletSpeed)
|
## Precompute the matched pattern replayed forward from the current state.
|
||||||
|
## Only depends on observed movement, so it is valid for every power bin.
|
||||||
# play forward from bestMatch + PatternLen
|
g.playStart = bestMatch + PatternLen
|
||||||
let playStart = bestMatch + PatternLen
|
g.playAvail = g.count - 1 - g.playStart # ticks we can replay
|
||||||
let playAvail = g.count - 1 - playStart # ticks we can replay
|
g.pathX[0] = state.enemyX
|
||||||
|
g.pathY[0] = state.enemyY
|
||||||
|
g.pathHeading[0] = degToRad(state.enemyHeading)
|
||||||
|
g.pathSpeed[0] = state.enemySpeed
|
||||||
|
for s in 1 .. g.playAvail:
|
||||||
|
let m = g.readAt(g.playStart + s - 1)
|
||||||
|
g.pathHeading[s] = g.pathHeading[s - 1] + m.headingDelta
|
||||||
|
g.pathSpeed[s] = m.velocity
|
||||||
|
g.pathX[s] = g.pathX[s - 1] + cos(g.pathHeading[s]) * g.pathSpeed[s]
|
||||||
|
g.pathY[s] = g.pathY[s - 1] + sin(g.pathHeading[s]) * g.pathSpeed[s]
|
||||||
|
|
||||||
|
proc projectFromPath(g: PatternMatcherGun, state: WorldState,
|
||||||
|
bulletSpeed: float): (float, float) =
|
||||||
|
## Speed-dependent lead: walk the cached path as far as this bulletSpeed's
|
||||||
|
## estimated flight time reaches, then coast linearly for the remainder.
|
||||||
# iterative time estimate
|
# iterative time estimate
|
||||||
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||||
var t = dist0 / bulletSpeed
|
var t = dist0 / bulletSpeed
|
||||||
@@ -94,22 +116,14 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState,
|
|||||||
var ey = state.enemyY
|
var ey = state.enemyY
|
||||||
|
|
||||||
for _ in 0..4:
|
for _ in 0..4:
|
||||||
let steps = min(int(t + 0.5), playAvail)
|
let steps = min(int(t + 0.5), g.playAvail)
|
||||||
ex = state.enemyX
|
ex = g.pathX[steps]
|
||||||
ey = state.enemyY
|
ey = g.pathY[steps]
|
||||||
var heading = degToRad(state.enemyHeading)
|
|
||||||
var speed = state.enemySpeed
|
|
||||||
for s in 0 ..< steps:
|
|
||||||
let m = g.readAt(playStart + s)
|
|
||||||
heading += m.headingDelta
|
|
||||||
speed = m.velocity
|
|
||||||
ex += cos(heading) * speed
|
|
||||||
ey += sin(heading) * speed
|
|
||||||
# if we ran out of replay data, coast linearly from last simulated pos
|
# if we ran out of replay data, coast linearly from last simulated pos
|
||||||
let remaining = t - steps.float
|
let remaining = t - steps.float
|
||||||
if remaining > 0.0:
|
if remaining > 0.0:
|
||||||
ex += cos(heading) * speed * remaining
|
ex += cos(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
|
||||||
ey += sin(heading) * speed * remaining
|
ey += sin(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
|
||||||
let ndx = ex - state.selfX
|
let ndx = ex - state.selfX
|
||||||
let ndy = ey - state.selfY
|
let ndy = ey - state.selfY
|
||||||
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
|
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
|
||||||
@@ -125,30 +139,33 @@ proc predict*(g: var PatternMatcherGun, state: WorldState,
|
|||||||
if bulletSpeed <= 0.0:
|
if bulletSpeed <= 0.0:
|
||||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||||
|
|
||||||
# Update history once per tick
|
# Roll history forward and (re)build the speed-independent pattern path at
|
||||||
if g.hasPrev and state.tick > g.prevTick:
|
# most once per tick. The old code returned a single cached (x, y) per tick,
|
||||||
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
|
# so all four power bins shared bin 0's lead.
|
||||||
# wrap to [-π, π]
|
if not g.cacheValid or state.tick != g.cacheTick:
|
||||||
while dh > PI: dh -= 2.0 * PI
|
if g.hasPrev:
|
||||||
while dh < -PI: dh += 2.0 * PI
|
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
|
||||||
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
|
# wrap to [-π, π]
|
||||||
|
while dh > PI: dh -= 2.0 * PI
|
||||||
|
while dh < -PI: dh += 2.0 * PI
|
||||||
|
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
|
||||||
|
|
||||||
if not g.hasPrev or state.tick > g.prevTick:
|
|
||||||
g.prevHeading = state.enemyHeading
|
g.prevHeading = state.enemyHeading
|
||||||
g.prevSpeed = state.enemySpeed
|
g.prevSpeed = state.enemySpeed
|
||||||
g.prevTick = state.tick
|
g.prevTick = state.tick
|
||||||
g.hasPrev = true
|
g.hasPrev = true
|
||||||
g.cacheValid = false # new tick invalidates cache
|
g.cacheValid = true
|
||||||
|
g.cacheTick = state.tick
|
||||||
|
|
||||||
# Return cached result for same-tick calls (multiple power bins)
|
g.bestMatch = g.findBestMatch()
|
||||||
if g.cacheValid and state.tick == g.cacheTick:
|
if g.bestMatch >= 0:
|
||||||
return GunPrediction(x: g.cacheX, y: g.cacheY)
|
g.buildPath(state, g.bestMatch)
|
||||||
|
|
||||||
let (px, py) = g.searchAndProject(state, bulletSpeed)
|
if g.bestMatch < 0:
|
||||||
g.cacheX = px
|
let (px, py) = linearPredict(state, bulletSpeed)
|
||||||
g.cacheY = py
|
return GunPrediction(x: px, y: py)
|
||||||
g.cacheTick = state.tick
|
|
||||||
g.cacheValid = true
|
let (px, py) = g.projectFromPath(state, bulletSpeed)
|
||||||
GunPrediction(x: px, y: py)
|
GunPrediction(x: px, y: py)
|
||||||
|
|
||||||
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
|
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ type StopShotGun* = object
|
|||||||
prevHeading: float
|
prevHeading: float
|
||||||
prevTick: int
|
prevTick: int
|
||||||
frames: int
|
frames: int
|
||||||
cachedTick: int
|
# Per-tick derived state. It depends only on the ENEMY's motion (speed delta,
|
||||||
cachedPredX: float
|
# chosen deceleration, simulated stop point), never on the bullet speed, so it
|
||||||
cachedPredY: float
|
# is computed once per tick and shared by all four power bins. The speed-
|
||||||
|
# dependent lead is recomputed from it on every call.
|
||||||
|
derivedTick: int
|
||||||
|
warmEnough: bool
|
||||||
|
decelerating: bool
|
||||||
|
decel: float
|
||||||
|
stopX, stopY: float
|
||||||
debugGraphics*: bool
|
debugGraphics*: bool
|
||||||
|
|
||||||
proc initStopShotGun*(): StopShotGun = StopShotGun(debugGraphics: false)
|
proc initStopShotGun*(): StopShotGun = StopShotGun(debugGraphics: false)
|
||||||
@@ -26,73 +32,68 @@ proc predict*(g: var StopShotGun, state: WorldState, bulletSpeed: float): GunPre
|
|||||||
if bulletSpeed <= 0.0:
|
if bulletSpeed <= 0.0:
|
||||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||||
|
|
||||||
# Per-tick cache: all power bins share one prediction
|
# Roll the observation window forward at most once per tick. prevSpeed must
|
||||||
if state.tick == g.cachedTick:
|
# hold the PREVIOUS tick's speed when deceleration is tested, so it is read
|
||||||
return GunPrediction(x: g.cachedPredX, y: g.cachedPredY)
|
# before being overwritten with the current tick's speed. The old code
|
||||||
|
# overwrote it first, making `prev == speed` and the stop branch unreachable.
|
||||||
defer:
|
if state.tick != g.derivedTick:
|
||||||
g.cachedTick = state.tick
|
g.derivedTick = state.tick
|
||||||
g.cachedPredX = result.x
|
let prev = g.prevSpeed
|
||||||
g.cachedPredY = result.y
|
if state.tick > g.prevTick:
|
||||||
|
g.prevSpeed = state.enemySpeed
|
||||||
# Update history
|
g.prevHeading = state.enemyHeading
|
||||||
let isNew = state.tick > g.prevTick
|
g.prevTick = state.tick
|
||||||
if isNew:
|
inc g.frames
|
||||||
g.prevSpeed = state.enemySpeed
|
g.warmEnough = g.frames >= 2
|
||||||
g.prevHeading = state.enemyHeading
|
# Detect deceleration: |speed| is shrinking toward zero.
|
||||||
g.prevTick = state.tick
|
g.decelerating = g.warmEnough and abs(state.enemySpeed) < abs(prev) and
|
||||||
inc g.frames
|
abs(state.enemySpeed) > 0.01
|
||||||
|
if g.decelerating:
|
||||||
|
# Pick decel rate: braking (speed toward zero on same sign) = 2, else 1
|
||||||
|
g.decel = if state.enemySpeed * prev > 0.0: BrakeDecel else: CoastDecel
|
||||||
|
# Simulate the enemy coasting to a stop from its current position/heading.
|
||||||
|
let headRad = degToRad(state.enemyHeading)
|
||||||
|
let startSpeed = state.enemySpeed
|
||||||
|
var v = startSpeed
|
||||||
|
var sx = state.enemyX
|
||||||
|
var sy = state.enemyY
|
||||||
|
while abs(v) > 0.001:
|
||||||
|
sx += v * cos(headRad)
|
||||||
|
sy += v * sin(headRad)
|
||||||
|
v += (if v > 0.0: -g.decel else: g.decel)
|
||||||
|
if (v > 0.0) != (startSpeed > 0.0): v = 0.0 # crossed zero
|
||||||
|
g.stopX = sx
|
||||||
|
g.stopY = sy
|
||||||
|
|
||||||
# Need 2+ frames to detect deceleration
|
# Need 2+ frames to detect deceleration
|
||||||
if g.frames < 2:
|
if not g.warmEnough:
|
||||||
result = GunPrediction(x: state.enemyX, y: state.enemyY)
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||||
return
|
|
||||||
|
|
||||||
let speed = state.enemySpeed
|
let speed = state.enemySpeed
|
||||||
let prev = g.prevSpeed
|
let headRad = degToRad(state.enemyHeading)
|
||||||
|
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||||
|
# Speed-dependent lead — always recomputed, never cached across power bins.
|
||||||
|
let bulletTicks = dist / bulletSpeed
|
||||||
|
|
||||||
# Detect deceleration: |speed| is shrinking
|
if not g.decelerating:
|
||||||
let decelerating = abs(speed) < abs(prev) and abs(speed) > 0.01
|
|
||||||
|
|
||||||
if not decelerating:
|
|
||||||
# Linear fallback
|
# Linear fallback
|
||||||
let headRad = degToRad(state.enemyHeading)
|
var px = state.enemyX + cos(headRad) * speed * bulletTicks
|
||||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
var py = state.enemyY + sin(headRad) * speed * bulletTicks
|
||||||
let t = dist / bulletSpeed
|
return GunPrediction(
|
||||||
var px = state.enemyX + cos(headRad) * speed * t
|
|
||||||
var py = state.enemyY + sin(headRad) * speed * t
|
|
||||||
result = GunPrediction(
|
|
||||||
x: clamp(px, 0.0, state.arenaWidth),
|
x: clamp(px, 0.0, state.arenaWidth),
|
||||||
y: clamp(py, 0.0, state.arenaHeight)
|
y: clamp(py, 0.0, state.arenaHeight)
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# Pick decel rate: braking (speed toward zero on same sign) = 2, else 1
|
# If the bullet arrives well after the enemy stops, aim at the stop point;
|
||||||
# ponytail: simplified; TR has exact rules per direction but this is close enough
|
# otherwise blend a linear lead. stopTicks is speed-independent, so only this
|
||||||
let decel = if speed * prev > 0.0: BrakeDecel else: CoastDecel
|
# comparison depends on the requested bulletSpeed.
|
||||||
|
let stopTicks = abs(speed) / g.decel
|
||||||
|
let px = if bulletTicks >= stopTicks: g.stopX
|
||||||
|
else: state.enemyX + cos(headRad) * speed * bulletTicks
|
||||||
|
let py = if bulletTicks >= stopTicks: g.stopY
|
||||||
|
else: state.enemyY + sin(headRad) * speed * bulletTicks
|
||||||
|
|
||||||
# Simulate stop position
|
GunPrediction(
|
||||||
let headRad = degToRad(state.enemyHeading)
|
|
||||||
var v = speed
|
|
||||||
var sx = state.enemyX
|
|
||||||
var sy = state.enemyY
|
|
||||||
while abs(v) > 0.001:
|
|
||||||
sx += v * cos(headRad)
|
|
||||||
sy += v * sin(headRad)
|
|
||||||
let step = if v > 0.0: -decel else: decel
|
|
||||||
v += step
|
|
||||||
if (v > 0.0) != (speed > 0.0): v = 0.0 # crossed zero
|
|
||||||
|
|
||||||
# Bullet travel time to current pos, check against ticks to stop
|
|
||||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
|
||||||
let bulletTicks = dist / bulletSpeed
|
|
||||||
let stopTicks = abs(speed) / decel
|
|
||||||
|
|
||||||
# If bullet arrives well after stop, aim at stop; otherwise linear blend
|
|
||||||
let px = if bulletTicks >= stopTicks: sx else: state.enemyX + cos(headRad) * speed * bulletTicks
|
|
||||||
let py = if bulletTicks >= stopTicks: sy else: state.enemyY + sin(headRad) * speed * bulletTicks
|
|
||||||
|
|
||||||
result = GunPrediction(
|
|
||||||
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||||
y: clamp(py, BotRadius, state.arenaHeight - BotRadius)
|
y: clamp(py, BotRadius, state.arenaHeight - BotRadius)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import std/[math, random, strformat]
|
import std/[math, random, strformat]
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
|
import gun_harness/virtual_bullets as vb # PowerBins (power-bin count for trace keys)
|
||||||
|
|
||||||
# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ─────
|
# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ─────
|
||||||
|
|
||||||
@@ -188,13 +189,18 @@ proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
|
|||||||
# ── TsetlinGun public type ────────────────────────────────────────────────────
|
# ── TsetlinGun public type ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const
|
const
|
||||||
TM_TRACE_SLOTS = 64 # ring buffer of pending traces
|
# Ring of pending traces keyed EXACTLY by (fireTick, powerBin). A power-3 shot
|
||||||
# ponytail: 64 slots >> TRACE_MAX_AGE=40 ticks, safe margin; grow if many guns/bins
|
# can take ~fireDist/speed ~ 128 ticks to resolve, and the rack stores 4 traces
|
||||||
|
# per tick, so 1024 slots (> 128*4) guarantee a live trace is never overwritten
|
||||||
|
# by a newer one. The old 64-slot ring held only ~13 ticks of traces.
|
||||||
|
TM_TRACE_SLOTS = 1024
|
||||||
DebugTM* = false # set true to print [tm-dbg] lines per onResult call
|
DebugTM* = false # set true to print [tm-dbg] lines per onResult call
|
||||||
|
|
||||||
type
|
type
|
||||||
TmTrace = object
|
TmTrace = object
|
||||||
predX, predY: float # key: matches FeedbackEvent.prediction
|
fireTick: int # key part: tick the bullet was fired
|
||||||
|
powerBin: int # key part: power bin the bullet belonged to
|
||||||
|
predX, predY: float # stored prediction, for the directional residual
|
||||||
input: TmBinaryVector
|
input: TmBinaryVector
|
||||||
cache: TmClauseCache
|
cache: TmClauseCache
|
||||||
alive: bool
|
alive: bool
|
||||||
@@ -203,14 +209,30 @@ type
|
|||||||
net: TmNet
|
net: TmNet
|
||||||
frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded]
|
frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded]
|
||||||
bufferCount: int
|
bufferCount: int
|
||||||
|
frameTick: int # last tick the window was shifted (once per tick)
|
||||||
traces: array[TM_TRACE_SLOTS, TmTrace]
|
traces: array[TM_TRACE_SLOTS, TmTrace]
|
||||||
traceHead: int
|
|
||||||
shotCount: int ## total onResult calls received
|
shotCount: int ## total onResult calls received
|
||||||
|
trainedShots*: int ## onResult calls that found and trained their exact trace
|
||||||
|
traceMisses*: int ## onResult calls whose trace was gone (integrity counter)
|
||||||
debugGraphics*: bool
|
debugGraphics*: bool
|
||||||
|
|
||||||
|
proc tmBinForSpeed(spd: float): int {.inline.} =
|
||||||
|
## Map a virtual-bullet speed back to its power-bin index.
|
||||||
|
for i in 0..<len(vb.PowerBins):
|
||||||
|
if abs(spd - bulletSpeed(vb.PowerBins[i])) < 1e-6:
|
||||||
|
return i
|
||||||
|
-1
|
||||||
|
|
||||||
|
proc tmTraceSlot(fireTick, binIdx: int): int {.inline.} =
|
||||||
|
## Exact (fireTick, powerBin) key -> ring slot. TM_TRACE_SLOTS is a multiple of
|
||||||
|
## the bin count and larger than maxResolveTicks*bins, so live traces never
|
||||||
|
## collide with newer ones; unresolved traces are evicted after ~256 ticks.
|
||||||
|
((fireTick * len(vb.PowerBins)) + binIdx) mod TM_TRACE_SLOTS
|
||||||
|
|
||||||
proc initTsetlinGun*(): TsetlinGun =
|
proc initTsetlinGun*(): TsetlinGun =
|
||||||
# states init at 0 (boundary); one Type I step crosses into Include
|
# states init at 0 (boundary); one Type I step crosses into Include
|
||||||
for s in result.net.states.mitems: s = 0'i16
|
for s in result.net.states.mitems: s = 0'i16
|
||||||
|
result.frameTick = -1
|
||||||
randomize()
|
randomize()
|
||||||
result.debugGraphics = false
|
result.debugGraphics = false
|
||||||
|
|
||||||
@@ -228,11 +250,16 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
|
|||||||
state.arenaWidth - state.enemyX, state.enemyX,
|
state.arenaWidth - state.enemyX, state.enemyX,
|
||||||
state.selfEnergy, # use self energy as proxy (enemy energy not in WorldState)
|
state.selfEnergy, # use self energy as proxy (enemy energy not in WorldState)
|
||||||
)
|
)
|
||||||
# Shift window: index 0 = newest
|
# Shift window: index 0 = newest. Do this at most once per tick — the harness
|
||||||
for i in countdown(TM_WINDOW_SIZE - 1, 1):
|
# calls predict() 4-5x/tick (once per power bin), which used to shift the
|
||||||
g.frameBuffer[i] = g.frameBuffer[i - 1]
|
# 10-frame window ~4-5x/tick (representing ~2 real ticks and tripping
|
||||||
g.frameBuffer[0] = frame
|
# isWarmedUp after 2-3 ticks instead of 10).
|
||||||
if g.bufferCount < TM_WINDOW_SIZE: inc g.bufferCount
|
if state.tick != g.frameTick:
|
||||||
|
g.frameTick = state.tick
|
||||||
|
for i in countdown(TM_WINDOW_SIZE - 1, 1):
|
||||||
|
g.frameBuffer[i] = g.frameBuffer[i - 1]
|
||||||
|
g.frameBuffer[0] = frame
|
||||||
|
if g.bufferCount < TM_WINDOW_SIZE: inc g.bufferCount
|
||||||
|
|
||||||
# Warm-up: until window is full, fall back to linear extrapolation
|
# Warm-up: until window is full, fall back to linear extrapolation
|
||||||
let ticksToArrive = if bulletSpeed > 0.0: dist / bulletSpeed else: 1.0
|
let ticksToArrive = if bulletSpeed > 0.0: dist / bulletSpeed else: 1.0
|
||||||
@@ -258,29 +285,48 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
|
|||||||
let predX = clamp(linearX + cx, 0.0, state.arenaWidth)
|
let predX = clamp(linearX + cx, 0.0, state.arenaWidth)
|
||||||
let predY = clamp(linearY + cy, 0.0, state.arenaHeight)
|
let predY = clamp(linearY + cy, 0.0, state.arenaHeight)
|
||||||
|
|
||||||
# Store trace keyed by prediction coords
|
# Store trace keyed exactly by (fireTick, powerBin) so the resolution event
|
||||||
let slot = g.traceHead mod TM_TRACE_SLOTS
|
# can find it no matter how many other guns/bins fired in between.
|
||||||
g.traces[slot] = TmTrace(predX: predX, predY: predY, input: vec, cache: cache, alive: true)
|
let binIdx = tmBinForSpeed(bulletSpeed)
|
||||||
g.traceHead = (slot + 1) mod TM_TRACE_SLOTS
|
if binIdx >= 0:
|
||||||
|
let slot = tmTraceSlot(state.tick, binIdx)
|
||||||
|
g.traces[slot] = TmTrace(
|
||||||
|
fireTick: state.tick,
|
||||||
|
powerBin: binIdx,
|
||||||
|
predX: predX,
|
||||||
|
predY: predY,
|
||||||
|
input: vec,
|
||||||
|
cache: cache,
|
||||||
|
alive: true,
|
||||||
|
)
|
||||||
|
|
||||||
GunPrediction(x: predX, y: predY)
|
GunPrediction(x: predX, y: predY)
|
||||||
|
|
||||||
proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
|
proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
|
||||||
inc g.shotCount
|
inc g.shotCount
|
||||||
# Find matching trace by prediction coords
|
# Exact pairing: index the trace by the tick the bullet was fired and the power
|
||||||
for i in 0..<TM_TRACE_SLOTS:
|
# bin it belonged to. The old coordinate-matched 64-slot ring lost the trace
|
||||||
var t = addr g.traces[i]
|
# long before a long shot resolved, so the TM never trained and its output was
|
||||||
if not t.alive: continue
|
# pure linear extrapolation.
|
||||||
if abs(t.predX - e.prediction.x) > 0.01 or abs(t.predY - e.prediction.y) > 0.01:
|
let binIdx = if e.powerBin >= 0 and e.powerBin < len(vb.PowerBins): e.powerBin
|
||||||
continue
|
else: tmBinForSpeed(bulletSpeed(e.bulletPower))
|
||||||
# Directional residual: actual enemy pos minus our prediction
|
if binIdx < 0:
|
||||||
# On hit residual is 0 (we were right); on miss we push toward actual position.
|
inc g.traceMisses
|
||||||
let rx = if e.hit: 0.0 else: clamp(e.actualX - t.predX, -TM_RESID_MAX, TM_RESID_MAX)
|
return
|
||||||
let ry = if e.hit: 0.0 else: clamp(e.actualY - t.predY, -TM_RESID_MAX, TM_RESID_MAX)
|
let slot = tmTraceSlot(e.fireTick, binIdx)
|
||||||
let lits = tmMakeLiterals(t.input)
|
var t = addr g.traces[slot]
|
||||||
g.net.tmLearnOne(0, lits, t.cache, rx)
|
if not t.alive or t.fireTick != e.fireTick or t.powerBin != binIdx:
|
||||||
g.net.tmLearnOne(1, lits, t.cache, ry)
|
inc g.traceMisses
|
||||||
when DebugTM:
|
return
|
||||||
echo fmt"[tm-dbg] shot={g.shotCount} miss={e.missDistance:.1f}px predicted=({t.predX:.0f},{t.predY:.0f}) actual=({e.actualX:.0f},{e.actualY:.0f}) rx={rx:.1f} ry={ry:.1f} hit={e.hit}"
|
|
||||||
t.alive = false
|
# Directional residual: actual enemy pos minus our prediction
|
||||||
break
|
# On hit residual is 0 (we were right); on miss we push toward actual position.
|
||||||
|
let rx = if e.hit: 0.0 else: clamp(e.actualX - t.predX, -TM_RESID_MAX, TM_RESID_MAX)
|
||||||
|
let ry = if e.hit: 0.0 else: clamp(e.actualY - t.predY, -TM_RESID_MAX, TM_RESID_MAX)
|
||||||
|
let lits = tmMakeLiterals(t.input)
|
||||||
|
g.net.tmLearnOne(0, lits, t.cache, rx)
|
||||||
|
g.net.tmLearnOne(1, lits, t.cache, ry)
|
||||||
|
when DebugTM:
|
||||||
|
echo fmt"[tm-dbg] shot={g.shotCount} tick={e.fireTick} bin={binIdx} miss={e.missDistance:.1f}px predicted=({t.predX:.0f},{t.predY:.0f}) actual=({e.actualX:.0f},{e.actualY:.0f}) rx={rx:.1f} ry={ry:.1f} hit={e.hit}"
|
||||||
|
t.alive = false
|
||||||
|
inc g.trainedShots
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
--path:".."
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
## First guard tests for the gun selector + the speed-sensitivity checks for the
|
||||||
|
## per-tick caching bug class.
|
||||||
|
##
|
||||||
|
## Headless: no Java, no server, no battle. Run with plain
|
||||||
|
## nim c -r common_libs/tests/test_gun_harness.nim
|
||||||
|
##
|
||||||
|
## Selection tests seed the tracker's exported fitness windows directly instead of
|
||||||
|
## dragging virtual bullets through spawnBullets/tickBullets. That is deliberate:
|
||||||
|
## it makes exact hit-rates (and therefore tie/rng/floor behaviour) deterministic
|
||||||
|
## and fast. The spawn/tick pipeline itself is exercised by the droppedBullets
|
||||||
|
## test below and by the full gauntlet.
|
||||||
|
|
||||||
|
import std/[math, random, tables]
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
import gun_harness/virtual_bullets
|
||||||
|
import gun_harness/selector
|
||||||
|
import guns/stop_shot
|
||||||
|
import guns/displacement
|
||||||
|
import guns/averaged_lead
|
||||||
|
import guns/pattern_matcher
|
||||||
|
|
||||||
|
var failures = 0
|
||||||
|
|
||||||
|
proc check(name: string, ok: bool) =
|
||||||
|
if ok:
|
||||||
|
echo "PASS: ", name
|
||||||
|
else:
|
||||||
|
echo "FAIL: ", name
|
||||||
|
inc failures
|
||||||
|
|
||||||
|
proc recordHit(fw: var FitnessWindow, hit: bool) =
|
||||||
|
fw.hits[fw.head] = hit
|
||||||
|
fw.head = (fw.head + 1) mod WindowSize
|
||||||
|
inc fw.count
|
||||||
|
|
||||||
|
proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
|
||||||
|
## Narrowly-scoped test helper: write `hits`/`misses` samples straight into a
|
||||||
|
## gun×bin fitness window (fields are exported by virtual_bullets).
|
||||||
|
if targetId notin t.fitness:
|
||||||
|
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
|
||||||
|
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
|
||||||
|
for _ in 0..<hits: recordHit(fw[], true)
|
||||||
|
for _ in 0..<misses: recordHit(fw[], false)
|
||||||
|
|
||||||
|
proc ws(tick: int, ex, ey, espeed, eheading: float): WorldState =
|
||||||
|
WorldState(selfX: 100.0, selfY: 100.0, enemyX: ex, enemyY: ey,
|
||||||
|
enemySpeed: espeed, enemyHeading: eheading,
|
||||||
|
arenaWidth: 1000.0, arenaHeight: 1000.0, tick: tick)
|
||||||
|
|
||||||
|
proc pointsDiffer(a, b: GunPrediction): bool =
|
||||||
|
abs(a.x - b.x) > 0.5 or abs(a.y - b.y) > 0.5
|
||||||
|
|
||||||
|
# ── selector guards ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc testColdBestGun() =
|
||||||
|
var t = initTracker(3)
|
||||||
|
check "bestGun on a cold tracker returns 0 (HeadOn)", t.bestGun(-1) == 0
|
||||||
|
|
||||||
|
proc testRandomTiebreak() =
|
||||||
|
# Two guns with an identical, well-observed hit rate: the tiebreak must expose
|
||||||
|
# both ids. Before the random tiebreak landed this always returned index 0.
|
||||||
|
var t = initTracker(2)
|
||||||
|
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 50, misses = 0)
|
||||||
|
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 50, misses = 0)
|
||||||
|
var seen: array[2, bool]
|
||||||
|
for _ in 0..<500:
|
||||||
|
let g = t.bestGun(-1)
|
||||||
|
if g >= 0 and g < 2: seen[g] = true
|
||||||
|
check "random tiebreak returns BOTH tied gun ids (no index-0 determinism)",
|
||||||
|
seen[0] and seen[1]
|
||||||
|
|
||||||
|
proc testBestGunDeterministicWinner() =
|
||||||
|
# gun 2 clearly best and past MinObsBeforeCompete; must win every call.
|
||||||
|
var t = initTracker(3)
|
||||||
|
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 25, misses = 25) # 50 obs, 50%
|
||||||
|
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 0, misses = 0) # cold, skipped
|
||||||
|
seedWindow(t, 7, gunId = 2, binIdx = 0, hits = 50, misses = 0) # 50 obs, 100%
|
||||||
|
var allTwo = true
|
||||||
|
for _ in 0..<100:
|
||||||
|
if t.bestGun(-1) != 2: allTwo = false
|
||||||
|
check "gun with clearly best rate and >= MinObsBeforeCompete wins deterministically",
|
||||||
|
allTwo
|
||||||
|
|
||||||
|
proc testBestPowerCold() =
|
||||||
|
var t = initTracker(3)
|
||||||
|
let (bin, power) = t.bestPower(0, -1)
|
||||||
|
check "bestPower on a zero-observation gun returns bin 0 / power 1.0",
|
||||||
|
bin == 0 and power == 1.0
|
||||||
|
|
||||||
|
proc testBestPowerWarmBin3() =
|
||||||
|
var t = initTracker(3)
|
||||||
|
seedWindow(t, 7, gunId = 0, binIdx = 3, hits = 50, misses = 0) # 100% >= MinHitRate
|
||||||
|
let (bin, power) = t.bestPower(0, -1)
|
||||||
|
check "bestPower on a warm gun whose bin 3 rate >= MinHitRate returns bin 3",
|
||||||
|
bin == 3 and power == 3.0
|
||||||
|
|
||||||
|
proc testFitnessForDeterministic() =
|
||||||
|
# Same per-enemy data inserted in opposite orders must aggregate identically.
|
||||||
|
# Before fitnessFor sorted enemy ids, std/tables hash order leaked in.
|
||||||
|
var t1 = initTracker(2)
|
||||||
|
seedWindow(t1, 5, gunId = 0, binIdx = 0, hits = 10, misses = 5)
|
||||||
|
seedWindow(t1, 3, gunId = 0, binIdx = 0, hits = 5, misses = 10)
|
||||||
|
|
||||||
|
var t2 = initTracker(2)
|
||||||
|
seedWindow(t2, 3, gunId = 0, binIdx = 0, hits = 5, misses = 10)
|
||||||
|
seedWindow(t2, 5, gunId = 0, binIdx = 0, hits = 10, misses = 5)
|
||||||
|
|
||||||
|
var same = true
|
||||||
|
for _ in 0..<20:
|
||||||
|
let r1 = t1.fitnessFor(-1)[0].bins[0].hitRate()
|
||||||
|
let r2 = t2.fitnessFor(-1)[0].bins[0].hitRate()
|
||||||
|
if r1 != r2: same = false
|
||||||
|
let expected = 15.0 / 30.0
|
||||||
|
check "fitnessFor is deterministic across insertion orders",
|
||||||
|
same and abs(t1.fitnessFor(-1)[0].bins[0].hitRate() - expected) < 1e-12
|
||||||
|
|
||||||
|
proc testDroppedBullets() =
|
||||||
|
var t = initTracker(1)
|
||||||
|
let state = ws(0, 500.0, 500.0, 0.0, 0.0)
|
||||||
|
let preds = [GunPrediction(x: 500.0, y: 500.0),
|
||||||
|
GunPrediction(x: 500.0, y: 500.0),
|
||||||
|
GunPrediction(x: 500.0, y: 500.0),
|
||||||
|
GunPrediction(x: 500.0, y: 500.0)]
|
||||||
|
# Fill the ring exactly (4 bullets per spawn, no tickBullets -> never resolve).
|
||||||
|
for _ in 0..<(MaxBullets div len(PowerBins)):
|
||||||
|
t.spawnBullets(0, preds, state, 5)
|
||||||
|
check "droppedBullets stays 0 until the ring wraps", t.droppedBullets == 0
|
||||||
|
t.spawnBullets(0, preds, state, 5)
|
||||||
|
check "droppedBullets counts unresolved bullets clobbered by the ring",
|
||||||
|
t.droppedBullets == 4
|
||||||
|
|
||||||
|
# ── caching-bug speed sensitivity (Task 5) ───────────────────────────────────
|
||||||
|
|
||||||
|
proc testStopShotSpeedSensitivity() =
|
||||||
|
let spd0 = bulletSpeed(PowerBins[0])
|
||||||
|
let spd3 = bulletSpeed(PowerBins[3])
|
||||||
|
|
||||||
|
var ss = initStopShotGun()
|
||||||
|
# Constant speed 4: warm two frames, then compare on the same tick. Before the
|
||||||
|
# fix the tick-only cache returned bin 0's lead for every bin.
|
||||||
|
discard ss.predict(ws(1, 400.0, 100.0, 4.0, 0.0), spd0)
|
||||||
|
discard ss.predict(ws(2, 400.0, 100.0, 4.0, 0.0), spd0)
|
||||||
|
let s3 = ws(3, 400.0, 100.0, 4.0, 0.0)
|
||||||
|
let p0 = ss.predict(s3, spd0)
|
||||||
|
let p3 = ss.predict(s3, spd3)
|
||||||
|
check "stop_shot: same tick, different bulletSpeed -> different point",
|
||||||
|
pointsDiffer(p0, p3)
|
||||||
|
|
||||||
|
# Task 1a: deceleration is actually detected (8 -> 4 px/tick). The old ordering
|
||||||
|
# made prev == speed, so this branch was unreachable and the gun was Linear.
|
||||||
|
var ss2 = initStopShotGun()
|
||||||
|
discard ss2.predict(ws(1, 400.0, 100.0, 8.0, 0.0), spd0)
|
||||||
|
let pd = ss2.predict(ws(2, 400.0, 100.0, 4.0, 0.0), spd0)
|
||||||
|
# Stop point is 400 + 4 + 2 = 406 px (BrakeDecel=2); linear lead would be ~470.
|
||||||
|
check "stop_shot: deceleration branch reaches the simulated stop point",
|
||||||
|
abs(pd.x - 406.0) < 1.0
|
||||||
|
|
||||||
|
proc testDisplacementSpeedSensitivity() =
|
||||||
|
let spd0 = bulletSpeed(PowerBins[0])
|
||||||
|
let spd3 = bulletSpeed(PowerBins[3])
|
||||||
|
|
||||||
|
var dg = initDisplacementGun()
|
||||||
|
# Warm 16 ticks emulating the real harness: 4 predict() calls (one per power
|
||||||
|
# bin) on every tick. Feed 16 ticks of constant +5 px/tick motion so the
|
||||||
|
# 15-tick window is ready.
|
||||||
|
for tick in 1..16:
|
||||||
|
for bin in 0..<len(PowerBins):
|
||||||
|
discard dg.predict(ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0),
|
||||||
|
bulletSpeed(PowerBins[bin]))
|
||||||
|
let s17 = ws(17, 300.0 + 5.0 * 17.0, 200.0, 5.0, 0.0)
|
||||||
|
let d0 = dg.predict(s17, spd0)
|
||||||
|
let d3 = dg.predict(s17, spd3)
|
||||||
|
check "displacement: same tick, different bulletSpeed -> different point",
|
||||||
|
pointsDiffer(d0, d3)
|
||||||
|
|
||||||
|
# The real displacement bug: the speed-in-key cache advanced the ring ~4x per
|
||||||
|
# tick, so the nominal 15-tick window spanned ~4 ticks. Sampling once per tick
|
||||||
|
# means 4 calls/tick must be identical to 1 call/tick.
|
||||||
|
var dgMulti = initDisplacementGun()
|
||||||
|
var dgOnce = initDisplacementGun()
|
||||||
|
for tick in 1..16:
|
||||||
|
let s = ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0)
|
||||||
|
for bin in 0..<len(PowerBins):
|
||||||
|
discard dgMulti.predict(s, bulletSpeed(PowerBins[bin]))
|
||||||
|
discard dgOnce.predict(s, spd0)
|
||||||
|
let s18 = ws(18, 300.0 + 5.0 * 18.0, 200.0, 5.0, 0.0)
|
||||||
|
let a = dgMulti.predict(s18, spd0)
|
||||||
|
let b = dgOnce.predict(s18, spd0)
|
||||||
|
check "displacement: ring advances exactly once per tick (4 calls == 1 call)",
|
||||||
|
not pointsDiffer(a, b)
|
||||||
|
|
||||||
|
proc testAveragedLeadSpeedSensitivity() =
|
||||||
|
let spd0 = bulletSpeed(PowerBins[0])
|
||||||
|
let spd3 = bulletSpeed(PowerBins[3])
|
||||||
|
|
||||||
|
var al = initAveragedLeadGun()
|
||||||
|
discard al.predict(ws(1, 400.0, 100.0, 3.0, 0.0), spd0) # warm circular's omega
|
||||||
|
let s2 = ws(2, 400.0, 100.0, 3.0, 0.0)
|
||||||
|
let a0 = al.predict(s2, spd0)
|
||||||
|
let a3 = al.predict(s2, spd3)
|
||||||
|
check "averaged_lead: same tick, different bulletSpeed -> different point",
|
||||||
|
pointsDiffer(a0, a3)
|
||||||
|
|
||||||
|
proc testPatternMatcherSpeedSensitivity() =
|
||||||
|
let spd0 = bulletSpeed(PowerBins[0])
|
||||||
|
let spd3 = bulletSpeed(PowerBins[3])
|
||||||
|
|
||||||
|
var pm = PatternMatcherGun()
|
||||||
|
for tick in 1..25:
|
||||||
|
discard pm.predict(ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0), spd0)
|
||||||
|
let s26 = ws(26, 300.0 + 5.0 * 26.0, 200.0, 5.0, 0.0)
|
||||||
|
let m0 = pm.predict(s26, spd0)
|
||||||
|
let m3 = pm.predict(s26, spd3)
|
||||||
|
check "pattern_matcher: same tick, different bulletSpeed -> different point",
|
||||||
|
pointsDiffer(m0, m3)
|
||||||
|
|
||||||
|
# ── driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
randomize()
|
||||||
|
|
||||||
|
testColdBestGun()
|
||||||
|
testRandomTiebreak()
|
||||||
|
testBestGunDeterministicWinner()
|
||||||
|
testBestPowerCold()
|
||||||
|
testBestPowerWarmBin3()
|
||||||
|
testFitnessForDeterministic()
|
||||||
|
testDroppedBullets()
|
||||||
|
testStopShotSpeedSensitivity()
|
||||||
|
testDisplacementSpeedSensitivity()
|
||||||
|
testAveragedLeadSpeedSensitivity()
|
||||||
|
testPatternMatcherSpeedSensitivity()
|
||||||
|
|
||||||
|
if failures > 0:
|
||||||
|
echo "\n", failures, " check(s) FAILED"
|
||||||
|
quit(1)
|
||||||
|
echo "\nAll gun-harness checks passed."
|
||||||
Reference in New Issue
Block a user