Files
SirRoboGarage/BNNBot_garage/src/BNNBot.nim
T
SirStone 254c7dc997 feat(ModularBot): pluggable bot with 4 guns, phantom meteor movement, radar harness
- Gun harness: virtual bullet tracker, rolling fitness, auto-selector
- Guns: head-on, linear (extrapolation), circular (integrated formula), tsetlin machine (learning)
- Movement: phantom meteor gravity engine (danger histograms, phantom bullets, fire detection)
- Radar: harness + radar_lock adapter
- Color-coded modules: turret/bullet color per gun, body per movement, scan per radar
- Beats Target, SpinBot, Crazy, TrackFire in 10-round battles
2026-09-20 00:37:10 +02:00

260 lines
9.0 KiB
Nim

# BNNBot — Hebbian weight matrix with virtual bullet learning.
# 870-bit input → 7-bit aim angle output via forward pass.
# Learns from virtual bullets (no real firing) via three-factor Hebbian rule.
# CSV data collection: BNNBOT_CSV=1 writes per-round CSV to data/battle_{round}.csv
import std/[math, os, strformat, strutils, random]
import robocode_tankroyale_botapi
import radar_lock/radar_lock as radar_lock
import binary_encoding
import hebbian
let csvEnabled = getEnv("BNNBOT_CSV", "0") == "1"
const botJsonPath = currentSourcePath().parentDir / "BNNBot.json"
const
BULLET_SLOTS = 50
BULLET_SPEED = 14.0 # power 2
EPSILON_START = 0.2
EPSILON_MIN = 0.05
EPSILON_DECAY = 0.9995
# CSV: 9 raw decimal fields per frame (matches analysis/backtest.py column names)
# bearing_sin(0-199), bearing_cos(0-199), distance(0-99), velocity(0-15),
# heading_sin(0-199), heading_cos(0-199), enemy_x(px), enemy_y(px), enemy_energy(float)
type
FrameRaw = object
bSin, bCos: int # 0-199
dist: int # 0-99
vel: int # 0-15
hSin, hCos: int # 0-199
ex, ey: float # absolute pixel coords
energy: float
BNNBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastEnemyX: float
lastEnemyY: float
hasLastPos: bool
velocity: float
heading: float
distance: float
tick: int
prevVec: BinaryVector
hasPrev: bool
frameBuffer: array[WINDOW_SIZE, array[FRAME_BITS, uint8]]
frameRawBuf: array[WINDOW_SIZE, FrameRaw] # decimal mirror of frameBuffer
bufferCount: int
net: HebbianNet
bullets: array[BULLET_SLOTS, VirtualBullet]
bulletHead: int
virtualHits: int
virtualMiss: int
epsilon: float
# CSV state
csvFile: File
csvOpen: bool
roundNum: int
# ── CSV helpers ──────────────────────────────────────────────────────────────
proc csvPath(roundNum: int): string =
getAppDir() / "data" / fmt"battle_{roundNum}.csv"
proc buildHeader(): string =
result = "tick"
for fr in 0..<WINDOW_SIZE:
for fn in ["bearing_sin", "bearing_cos", "distance", "velocity",
"heading_sin", "heading_cos", "enemy_x", "enemy_y", "enemy_energy"]:
result &= fmt",f{fr}_{fn}"
proc openCsv(bot: BNNBot) =
if not csvEnabled: return
createDir(csvPath(bot.roundNum).parentDir)
bot.csvFile = open(csvPath(bot.roundNum), fmWrite)
bot.csvOpen = true
bot.csvFile.writeLine(buildHeader())
proc closeCsv(bot: BNNBot) =
if bot.csvOpen:
bot.csvFile.close()
bot.csvOpen = false
proc writeRow(bot: BNNBot) =
if not bot.csvOpen: return
var line = $bot.tick
for i in 0..<WINDOW_SIZE:
let f = bot.frameRawBuf[i]
line &= fmt",{f.bSin},{f.bCos},{f.dist},{f.vel},{f.hSin},{f.hCos},{f.ex:.2f},{f.ey:.2f},{f.energy:.2f}"
bot.csvFile.writeLine(line)
# ── Bot methods ──────────────────────────────────────────────────────────────
method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
let bx = getX(); let by = getY()
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
bot.distance = distanceTo(bx, by, e.x, e.y)
bot.heading = e.direction
bot.velocity = e.speed
bot.lastEnemyX = e.x
bot.lastEnemyY = e.y
bot.hasLastPos = true
bot.hasContact = true
let arenaW = getArenaWidth().float
let arenaH = getArenaHeight().float
let frame = EnemyScanFrame(
bearing: bot.enemyBearing,
distance: bot.distance,
velocity: bot.velocity,
heading: bot.heading,
enemyWallN: arenaH - e.y,
enemyWallS: e.y,
enemyWallE: arenaW - e.x,
enemyWallW: e.x,
enemyEnergy: e.energy,
)
let encoded = encodeFrame(frame)
# Shift window — index 0 = newest
for i in countdown(WINDOW_SIZE - 1, 1):
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
bot.frameRawBuf[i] = bot.frameRawBuf[i - 1]
bot.frameBuffer[0] = encoded
# Build raw decimal record for this frame
let bSin = int((sin(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
let bCos = int((cos(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
let dist = int(clamp(bot.distance / 1414.0 * 99.0, 0.0, 99.0))
let vel = int(clamp(bot.velocity + 8.0, 0.0, 16.0))
let hSin = int((sin(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
let hCos = int((cos(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
bot.frameRawBuf[0] = FrameRaw(bSin: bSin, bCos: bCos, dist: dist, vel: vel,
hSin: hSin, hCos: hCos,
ex: e.x, ey: e.y, energy: e.energy)
if bot.bufferCount < WINDOW_SIZE:
inc bot.bufferCount
if bot.bufferCount < WINDOW_SIZE:
return
let selfState = SelfState(
myWallN: arenaH - by,
myWallS: by,
myWallE: arenaW - bx,
myWallW: bx,
myEnergy: getEnergy(),
canFire: getGunHeat() <= 0.0,
)
let selfEncoded = encodeSelf(selfState)
let vec = encodeFullVector(bot.frameBuffer, selfEncoded)
# ── age & settle existing virtual bullets ────────────────────────────
for idx in 0..<BULLET_SLOTS:
var b = addr bot.bullets[idx]
if not b.active: continue
inc b.trace.age
let bulletDist = b.bulletSpeed * float(b.trace.age)
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
# Shaped reward: +1.0 for perfect hit, decays toward -1.0 as miss distance grows
let reward = 2.0 * exp(-missDistance / 36.0) - 1.0
bot.net.learn(b.trace, reward)
if missDistance < 36.0:
inc bot.virtualHits
else:
inc bot.virtualMiss
b.active = false
# ── forward pass → aim angle ─────────────────────────────────────────
var outVec = bot.net.forward(vec)
# epsilon-greedy exploration
if rand(1.0) < bot.epsilon:
let flipIdx = rand(OUTPUT_BITS - 1)
outVec[flipIdx] = 1'u8 - outVec[flipIdx]
bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY)
let aimOffset = decodeOutput(outVec)
# ── store virtual bullet ──────────────────────────────────────────────
let slot = bot.bulletHead mod BULLET_SLOTS
bot.bulletHead = slot + 1
bot.bullets[slot] = VirtualBullet(
trace: EligibilityTrace(input: vec, output: outVec, age: 0, alive: true),
fireX: bx,
fireY: by,
aimAngleDeg: bot.enemyBearing + aimOffset,
fireDist: bot.distance,
bulletSpeed: BULLET_SPEED,
active: true,
)
# ── CSV row ───────────────────────────────────────────────────────────
bot.writeRow()
# ── stats & echo ─────────────────────────────────────────────────────
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
let totalVirtual = bot.virtualHits + bot.virtualMiss
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
echo align($bot.tick, 4), " ",
hamming, " ", similarity, " ", overlap, " ",
formatFloat(aimOffset, ffDecimal, 2), " ",
bot.virtualHits, " ", bot.virtualMiss, " ",
formatFloat(hitPct, ffDecimal, 1), "%"
bot.prevVec = vec
bot.hasPrev = true
method onRoundStarted*(bot: BNNBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
radar_lock.init()
bot.hasContact = false
bot.hasLastPos = false
bot.tick = 0
bot.hasPrev = false
bot.bufferCount = 0
inc bot.roundNum
bot.openCsv()
# net and epsilon persist across rounds (learning carries over)
method onRoundEnded*(bot: BNNBot, e: RoundEndedEventForBot) =
bot.closeCsv()
method onGameStarted*(bot: BNNBot, e: GameStartedEventForBot) =
discard
method run*(bot: BNNBot) =
while isRunning():
inc bot.tick
setTargetSpeed(0.0)
setTurnRate(0.0)
if not bot.hasContact:
setRadarTurnRate(45.0)
go()
continue
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
go()
when isMainModule:
randomize()
var bot = BNNBot(
net: initHebbianNet(),
epsilon: EPSILON_START,
)
start(bot, botJsonPath)