diff --git a/common_libs/gun_harness/gun_interface.nim b/common_libs/gun_harness/gun_interface.nim index 427411e..25b5f13 100644 --- a/common_libs/gun_harness/gun_interface.nim +++ b/common_libs/gun_harness/gun_interface.nim @@ -39,6 +39,8 @@ type prediction*: GunPrediction actualX*, actualY*: float ## actual enemy position at resolution time 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 hit*: bool diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index cc39069..5f876cc 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -28,6 +28,8 @@ type 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 @@ -89,6 +91,7 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId, gunId: gunId, powerBin: binIdx, targetId: targetId, + fireTick: state.tick, fireX: state.selfX, fireY: state.selfY, aimX: pred.x, @@ -144,6 +147,8 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState, actualX: ex, actualY: ey, bulletPower: PowerBins[b.powerBin], + fireTick: b.fireTick, + powerBin: b.powerBin, missDistance: missDist, hit: hit, ) diff --git a/common_libs/guns/averaged_lead.nim b/common_libs/guns/averaged_lead.nim index 8647ad0..65d2e65 100644 --- a/common_libs/guns/averaged_lead.nim +++ b/common_libs/guns/averaged_lead.nim @@ -10,17 +10,16 @@ type AveragedLeadGun* = object linear: LinearGun circular: CircularGun wallBounce: WallBounceGun - cachedTick: int - cachedPred: GunPrediction # per-tick cache; ponytail: single cache, extend if multi-power needed debugGraphics*: bool proc initAveragedLeadGun*(): AveragedLeadGun = AveragedLeadGun(wallBounce: initWallBounceGun(), debugGraphics: false) proc predict*(g: var AveragedLeadGun, state: WorldState, bulletSpeed: float): GunPrediction = - if state.tick == g.cachedTick: - return g.cachedPred - + # No tick cache: every sub-gun's lead depends on bulletSpeed (dist/bulletSpeed), + # 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 cp = g.circular.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) py = clamp(py, BotRadius, state.arenaHeight - BotRadius) - g.cachedTick = state.tick - g.cachedPred = GunPrediction(x: px, y: py) - g.cachedPred + GunPrediction(x: px, y: py) proc onResult*(g: var AveragedLeadGun, e: FeedbackEvent) = discard # analytical average — no learning diff --git a/common_libs/guns/displacement.nim b/common_libs/guns/displacement.nim index 37daa34..66d9e33 100644 --- a/common_libs/guns/displacement.nim +++ b/common_libs/guns/displacement.nim @@ -14,35 +14,40 @@ type posY: array[WindowSize + 1, float] count: int # frames collected so far head: int # ring-buffer head - lastTick: int # for per-tick cache - cacheSpeed: float - cachePred: GunPrediction + # Per-tick derived state. The ring must advance exactly ONCE per tick and the + # average per-tick velocity is speed-independent, so both are computed once + # 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 proc predict*(g: var DisplacementGun, state: WorldState, bulletSpeed: float): GunPrediction = - # Per-tick cache: same tick + same speed => same prediction - if state.tick == g.lastTick and bulletSpeed == g.cacheSpeed: - return g.cachePred + # Sample the enemy position exactly once per tick (the harness calls predict() + # 4-5x/tick, once per power bin). Keying the old cache on bulletSpeed too made + # 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 - 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 + # Need at least N+1 frames; fall back to head-on if not enough + if g.count < WindowSize + 1: + g.ready = false + else: + g.ready = true + # 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 - g.cacheSpeed = bulletSpeed - - # 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 + if not g.ready: + return GunPrediction(x: state.enemyX, y: state.enemyY) # Iterate time estimate 5 times 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 py = state.enemyY for _ in 0..4: - px = state.enemyX + dx * ticks - py = state.enemyY + dy * ticks + px = state.enemyX + g.dx * ticks + py = state.enemyY + g.dy * ticks ticks = hypot(px - state.selfX, py - state.selfY) / bulletSpeed px = clamp(px, 0.0, state.arenaWidth) py = clamp(py, 0.0, state.arenaHeight) - g.cachePred = GunPrediction(x: px, y: py) - g.cachePred + GunPrediction(x: px, y: py) proc onResult*(g: var DisplacementGun, e: FeedbackEvent) = discard # analytical gun — no learning proc initDisplacementGun*(): DisplacementGun = - DisplacementGun(count: 0, head: 0, lastTick: -1, debugGraphics: false) + DisplacementGun(count: 0, head: 0, derivedTick: -1, debugGraphics: false) diff --git a/common_libs/guns/pattern_matcher.nim b/common_libs/guns/pattern_matcher.nim index 10fde3c..afc2301 100644 --- a/common_libs/guns/pattern_matcher.nim +++ b/common_libs/guns/pattern_matcher.nim @@ -23,11 +23,19 @@ type prevSpeed: float prevTick: int hasPrev: bool - # per-tick cache — avoid re-searching for multiple power bins - cacheTick: int - cacheX: float - cacheY: float + # Per-tick, speed-INDEPENDENT pattern state. Both the history search and the + # replayed enemy path depend only on observed movement, never on bulletSpeed, + # so they are built at most once per tick. The bullet lead (number of replay + # steps + coast) is derived from this path on every call. 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 # --- circular buffer helpers --- @@ -55,11 +63,11 @@ proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) = # --- pattern search + play-forward --- -proc searchAndProject(g: PatternMatcherGun, state: WorldState, - bulletSpeed: float): (float, float) = - ## Returns projected (x, y). Falls back to linear if history too short. +proc findBestMatch(g: PatternMatcherGun): int = + ## Speed-independent history search. Returns the start index of the best + ## matching pattern, or -1 when there is not enough history. if g.count < PatternLen * 2: - return linearPredict(state, bulletSpeed) + return -1 # key = last PatternLen entries let keyStart = g.count - PatternLen @@ -79,14 +87,28 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState, if score < bestScore: bestScore = score bestMatch = i + bestMatch - if bestMatch < 0: - return linearPredict(state, bulletSpeed) - - # play forward from bestMatch + PatternLen - let playStart = bestMatch + PatternLen - let playAvail = g.count - 1 - playStart # ticks we can replay +proc buildPath(g: var PatternMatcherGun, state: WorldState, bestMatch: int) = + ## Precompute the matched pattern replayed forward from the current state. + ## Only depends on observed movement, so it is valid for every power bin. + g.playStart = bestMatch + PatternLen + g.playAvail = g.count - 1 - g.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 let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) var t = dist0 / bulletSpeed @@ -94,22 +116,14 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState, var ey = state.enemyY for _ in 0..4: - let steps = min(int(t + 0.5), playAvail) - ex = state.enemyX - ey = state.enemyY - 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 + let steps = min(int(t + 0.5), g.playAvail) + ex = g.pathX[steps] + ey = g.pathY[steps] # if we ran out of replay data, coast linearly from last simulated pos let remaining = t - steps.float if remaining > 0.0: - ex += cos(heading) * speed * remaining - ey += sin(heading) * speed * remaining + ex += cos(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining + ey += sin(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining let ndx = ex - state.selfX let ndy = ey - state.selfY t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed @@ -125,30 +139,33 @@ proc predict*(g: var PatternMatcherGun, state: WorldState, if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) - # Update history once per tick - if g.hasPrev and state.tick > g.prevTick: - var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading) - # wrap to [-π, π] - while dh > PI: dh -= 2.0 * PI - while dh < -PI: dh += 2.0 * PI - g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh)) + # Roll history forward and (re)build the speed-independent pattern path at + # most once per tick. The old code returned a single cached (x, y) per tick, + # so all four power bins shared bin 0's lead. + if not g.cacheValid or state.tick != g.cacheTick: + if g.hasPrev: + var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading) + # 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.prevSpeed = state.enemySpeed g.prevTick = state.tick 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) - if g.cacheValid and state.tick == g.cacheTick: - return GunPrediction(x: g.cacheX, y: g.cacheY) + g.bestMatch = g.findBestMatch() + if g.bestMatch >= 0: + g.buildPath(state, g.bestMatch) - let (px, py) = g.searchAndProject(state, bulletSpeed) - g.cacheX = px - g.cacheY = py - g.cacheTick = state.tick - g.cacheValid = true + if g.bestMatch < 0: + let (px, py) = linearPredict(state, bulletSpeed) + return GunPrediction(x: px, y: py) + + let (px, py) = g.projectFromPath(state, bulletSpeed) GunPrediction(x: px, y: py) proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) = diff --git a/common_libs/guns/stop_shot.nim b/common_libs/guns/stop_shot.nim index b649123..fa551d8 100644 --- a/common_libs/guns/stop_shot.nim +++ b/common_libs/guns/stop_shot.nim @@ -15,9 +15,15 @@ type StopShotGun* = object prevHeading: float prevTick: int frames: int - cachedTick: int - cachedPredX: float - cachedPredY: float + # Per-tick derived state. It depends only on the ENEMY's motion (speed delta, + # chosen deceleration, simulated stop point), never on the bullet speed, so it + # 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 proc initStopShotGun*(): StopShotGun = StopShotGun(debugGraphics: false) @@ -26,73 +32,68 @@ proc predict*(g: var StopShotGun, state: WorldState, bulletSpeed: float): GunPre if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) - # Per-tick cache: all power bins share one prediction - if state.tick == g.cachedTick: - return GunPrediction(x: g.cachedPredX, y: g.cachedPredY) - - defer: - g.cachedTick = state.tick - g.cachedPredX = result.x - g.cachedPredY = result.y - - # Update history - let isNew = state.tick > g.prevTick - if isNew: - g.prevSpeed = state.enemySpeed - g.prevHeading = state.enemyHeading - g.prevTick = state.tick - inc g.frames + # Roll the observation window forward at most once per tick. prevSpeed must + # hold the PREVIOUS tick's speed when deceleration is tested, so it is read + # before being overwritten with the current tick's speed. The old code + # overwrote it first, making `prev == speed` and the stop branch unreachable. + if state.tick != g.derivedTick: + g.derivedTick = state.tick + let prev = g.prevSpeed + if state.tick > g.prevTick: + g.prevSpeed = state.enemySpeed + g.prevHeading = state.enemyHeading + g.prevTick = state.tick + inc g.frames + g.warmEnough = g.frames >= 2 + # Detect deceleration: |speed| is shrinking toward zero. + g.decelerating = g.warmEnough and abs(state.enemySpeed) < abs(prev) and + 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 - if g.frames < 2: - result = GunPrediction(x: state.enemyX, y: state.enemyY) - return + if not g.warmEnough: + return GunPrediction(x: state.enemyX, y: state.enemyY) - let speed = state.enemySpeed - let prev = g.prevSpeed + let speed = state.enemySpeed + 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 - let decelerating = abs(speed) < abs(prev) and abs(speed) > 0.01 - - if not decelerating: + if not g.decelerating: # Linear fallback - let headRad = degToRad(state.enemyHeading) - let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) - let t = dist / bulletSpeed - var px = state.enemyX + cos(headRad) * speed * t - var py = state.enemyY + sin(headRad) * speed * t - result = GunPrediction( + var px = state.enemyX + cos(headRad) * speed * bulletTicks + var py = state.enemyY + sin(headRad) * speed * bulletTicks + return GunPrediction( x: clamp(px, 0.0, state.arenaWidth), y: clamp(py, 0.0, state.arenaHeight) ) - return - # Pick decel rate: braking (speed toward zero on same sign) = 2, else 1 - # ponytail: simplified; TR has exact rules per direction but this is close enough - let decel = if speed * prev > 0.0: BrakeDecel else: CoastDecel + # If the bullet arrives well after the enemy stops, aim at the stop point; + # otherwise blend a linear lead. stopTicks is speed-independent, so only this + # 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 - 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( + GunPrediction( x: clamp(px, BotRadius, state.arenaWidth - BotRadius), y: clamp(py, BotRadius, state.arenaHeight - BotRadius) ) diff --git a/common_libs/guns/tsetlin.nim b/common_libs/guns/tsetlin.nim index e9b52a4..f6e78cc 100644 --- a/common_libs/guns/tsetlin.nim +++ b/common_libs/guns/tsetlin.nim @@ -4,6 +4,7 @@ import std/[math, random, strformat] 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) ───── @@ -188,13 +189,18 @@ proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8], # ── TsetlinGun public type ──────────────────────────────────────────────────── const - TM_TRACE_SLOTS = 64 # ring buffer of pending traces - # ponytail: 64 slots >> TRACE_MAX_AGE=40 ticks, safe margin; grow if many guns/bins + # Ring of pending traces keyed EXACTLY by (fireTick, powerBin). A power-3 shot + # 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 type 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 cache: TmClauseCache alive: bool @@ -203,14 +209,30 @@ type net: TmNet frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded] bufferCount: int + frameTick: int # last tick the window was shifted (once per tick) traces: array[TM_TRACE_SLOTS, TmTrace] - traceHead: int 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 +proc tmBinForSpeed(spd: float): int {.inline.} = + ## Map a virtual-bullet speed back to its power-bin index. + for i in 0.. 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 = # states init at 0 (boundary); one Type I step crosses into Include for s in result.net.states.mitems: s = 0'i16 + result.frameTick = -1 randomize() result.debugGraphics = false @@ -228,11 +250,16 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred state.arenaWidth - state.enemyX, state.enemyX, state.selfEnergy, # use self energy as proxy (enemy energy not in WorldState) ) - # Shift window: index 0 = newest - 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 + # Shift window: index 0 = newest. Do this at most once per tick — the harness + # calls predict() 4-5x/tick (once per power bin), which used to shift the + # 10-frame window ~4-5x/tick (representing ~2 real ticks and tripping + # isWarmedUp after 2-3 ticks instead of 10). + 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 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 predY = clamp(linearY + cy, 0.0, state.arenaHeight) - # Store trace keyed by prediction coords - let slot = g.traceHead mod TM_TRACE_SLOTS - g.traces[slot] = TmTrace(predX: predX, predY: predY, input: vec, cache: cache, alive: true) - g.traceHead = (slot + 1) mod TM_TRACE_SLOTS + # Store trace keyed exactly by (fireTick, powerBin) so the resolution event + # can find it no matter how many other guns/bins fired in between. + let binIdx = tmBinForSpeed(bulletSpeed) + 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) proc onResult*(g: var TsetlinGun, e: FeedbackEvent) = inc g.shotCount - # Find matching trace by prediction coords - for i in 0.. 0.01 or abs(t.predY - e.prediction.y) > 0.01: - continue - # Directional residual: actual enemy pos minus our prediction - # 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} 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 - break + # Exact pairing: index the trace by the tick the bullet was fired and the power + # bin it belonged to. The old coordinate-matched 64-slot ring lost the trace + # long before a long shot resolved, so the TM never trained and its output was + # pure linear extrapolation. + let binIdx = if e.powerBin >= 0 and e.powerBin < len(vb.PowerBins): e.powerBin + else: tmBinForSpeed(bulletSpeed(e.bulletPower)) + if binIdx < 0: + inc g.traceMisses + return + let slot = tmTraceSlot(e.fireTick, binIdx) + var t = addr g.traces[slot] + if not t.alive or t.fireTick != e.fireTick or t.powerBin != binIdx: + inc g.traceMisses + return + + # Directional residual: actual enemy pos minus our prediction + # 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 diff --git a/common_libs/tests/config.nims b/common_libs/tests/config.nims new file mode 100644 index 0000000..0f840a1 --- /dev/null +++ b/common_libs/tests/config.nims @@ -0,0 +1 @@ +--path:".." diff --git a/common_libs/tests/test_gun_harness.nim b/common_libs/tests/test_gun_harness.nim new file mode 100644 index 0000000..9a94c90 --- /dev/null +++ b/common_libs/tests/test_gun_harness.nim @@ -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.. 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.. 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.. 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."