diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index 9b48b4c..0ca0c11 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -43,8 +43,7 @@ const BETA = 1.0 # surrogate steepness (unitless; potentials are unitless) NOISE_AMP = 0.3 # exploration noise amplitude # ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning - FIRE_POWER = 1.0 # fixed firing power; bullet speed = 20 - 3*FIRE_POWER - BULLET_SPEED = 20.0 - 3.0 * FIRE_POWER + ENERGY_GUARD = 15.0 # don't fire below this energy # ── SNN types ───────────────────────────────────────────────────────────────── @@ -216,8 +215,13 @@ type lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE lastInput: BitVec80 # previous EVALUATE-time input (for change detection) lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time - bulletsFired: int # count of bullets fired (power > 0) - bulletsHit: int # count of bullets that hit + bulletsFired: int # count of bullets fired this round + bulletsHit: int # count of bullets that hit this round + hitRateEMA: float # exponential moving average of hit rate + currentFirePower: float # dynamic fire power (1.0 / 2.0 / 3.0) + bulletSpeed: float # 20 - 3 * currentFirePower + pendingPower: float # candidate new power level + powerChangeCounter: int # ticks the new power has been suggested # ── aimTo helper ────────────────────────────────────────────────────────────── @@ -276,9 +280,15 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) = bot.velSpeed = 0.0 bot.phase = DECIDE bot.tick = 0 - # Reset per-round stats (learning persists across rounds) - bot.bulletsFired = 0 - bot.bulletsHit = 0 + # Reset per-round stats (learning and currentFirePower persist across rounds) + bot.bulletsFired = 0 + bot.bulletsHit = 0 + bot.hitRateEMA = 0.5 # optimistic start + bot.powerChangeCounter = 0 + # currentFirePower persists; init to 1.0 on first round (zero-value) + if bot.currentFirePower == 0.0: + bot.currentFirePower = 1.0 + bot.bulletSpeed = 20.0 - 3.0 * bot.currentFirePower bot.lastEnemyX = 0.0 bot.lastEnemyY = 0.0 bot.lastAbsBearing = 0.0 @@ -292,8 +302,30 @@ method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) = initSNN(bot.snn) bot.res = initBinaryAimer() +proc selectFirePower(bot: SNNBot): float = + ## Power ladder with hysteresis: only commit after 8 consecutive same-direction evaluations. + if getEnergy() < ENERGY_GUARD: + return 0.0 + let desired = + if bot.hitRateEMA > 0.45: 3.0 + elif bot.hitRateEMA > 0.30: 2.0 + else: 1.0 + if desired != bot.currentFirePower: + if desired == bot.pendingPower: + if bot.powerChangeCounter >= 8: + return desired # commit + # counter incremented at call site + return bot.currentFirePower # hold + return bot.currentFirePower + +method onBulletFired*(bot: SNNBot, e: BulletFiredEvent) = + inc bot.bulletsFired + # decay EMA toward miss on each confirmed fire + bot.hitRateEMA = bot.hitRateEMA * 0.93 + method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) = inc bot.bulletsHit + bot.hitRateEMA = bot.hitRateEMA * 0.85 + 1.0 * 0.15 # ── Reservoir helpers ───────────────────────────────────────────────────────── @@ -359,15 +391,15 @@ method run*(bot: SNNBot) = bot.lastBinInput = binInput bot.lastDecideGunDir = gunDir bot.lastAbsBearing = absBearing - let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 + let rate = if bot.bulletsFired > 0: float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0 else: 0.0 if aimOffset <= -999.0: # cold start: aim directly at enemy (no lead) bot.targetAngle = absBearing - echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%" + echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) else: # apply lead correction offset to current bearing bot.targetAngle = (absBearing + aimOffset + 360.0) mod 360.0 - echo "RES tick=" & $bot.tick & " offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%" + echo "RES tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% offset=" & formatFloat(aimOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) else: let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks @@ -407,16 +439,43 @@ method run*(bot: SNNBot) = # Fire gate: only fire when error within adaptive dead zone if err < adaptiveDeadZone: - discard setFire(FIRE_POWER) - inc bot.bulletsFired - bot.phase = EVALUATE + # Power selection with hysteresis + let desired = + if getEnergy() < ENERGY_GUARD: 0.0 + elif bot.hitRateEMA > 0.45: 3.0 + elif bot.hitRateEMA > 0.30: 2.0 + else: 1.0 + if desired != bot.currentFirePower and desired > 0.0: + if desired == bot.pendingPower: + inc bot.powerChangeCounter + if bot.powerChangeCounter >= 8: + let oldPower = bot.currentFirePower + bot.currentFirePower = desired + bot.bulletSpeed = 20.0 - 3.0 * desired + bot.powerChangeCounter = 0 + bot.pendingPower = 0.0 + # old exemplars learned at different bullet speed — clear them + if oldPower != desired: + bot.res.count = 0 + bot.res.nextSlot = 0 + else: + bot.pendingPower = desired + bot.powerChangeCounter = 1 + elif desired == 0.0: + discard setFire(0.0) + # skip EVALUATE so we don't corrupt learning with a non-shot + # stay in WAITING for next tick + if desired > 0.0: + discard setFire(bot.currentFirePower) + # bulletsFired counted in onBulletFired event + bot.phase = EVALUATE else: discard setFire(0.0) of EVALUATE: # Predictive error signal: extrapolate enemy position at bullet impact time. if bot.hasLastPos: - let travelTime = bot.enemyDist / BULLET_SPEED + let travelTime = bot.enemyDist / bot.bulletSpeed let velRad = degToRad(bot.velDirDeg) let futureX = bot.lastEnemyX + cos(velRad) * bot.velSpeed * travelTime let futureY = bot.lastEnemyY + sin(velRad) * bot.velSpeed * travelTime @@ -446,13 +505,17 @@ method run*(bot: SNNBot) = let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0 let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 echo "RES tick=" & $bot.tick & + " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & + " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "%" & + " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & + " rate=" & (if bot.bulletsFired > 0: formatFloat(float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0, ffDecimal, 1) else: "0.0") & "%" & + " energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " offset=" & (if producedOffset > -999.0: formatFloat(producedOffset, ffDecimal, 1) else: "cold") & " correctOffset=" & formatFloat(correctOffset, ffDecimal, 1) & " err=" & (if producedOffset > -999.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & " hammingDist=" & $hammingDist & " inputChanged=" & $inputChanged & - " exemplars=" & $bot.res.count & - " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%" + " exemplars=" & $bot.res.count else: let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir) bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel) @@ -474,7 +537,7 @@ method run*(bot: SNNBot) = meanWout += abs(w) meanWout /= float(2 * N_HID) let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0 - echo "tick=" & $bot.tick & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%" + echo "tick=" & $bot.tick & " pwr=" & formatFloat(bot.currentFirePower, ffDecimal, 1) & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1) # Update lastInput for next EVALUATE cycle's change detection bot.lastInput = bot.lastBinInput