diff --git a/SNNBot_garage/.gitignore b/SNNBot_garage/.gitignore new file mode 100644 index 0000000..f3685e2 --- /dev/null +++ b/SNNBot_garage/.gitignore @@ -0,0 +1,3 @@ +nimble.develop +nimble.paths +nimbledeps diff --git a/SNNBot_garage/SNNBot b/SNNBot_garage/SNNBot new file mode 100755 index 0000000..ea39f7c Binary files /dev/null and b/SNNBot_garage/SNNBot differ diff --git a/SNNBot_garage/SNNBot.nimble b/SNNBot_garage/SNNBot.nimble new file mode 100644 index 0000000..88c7bda --- /dev/null +++ b/SNNBot_garage/SNNBot.nimble @@ -0,0 +1,12 @@ +# Package +version = "0.1.0" +author = "Davide Cappellini" +description = "SNN prototype — population-coded LIF neurons, aiming loop, no learning" +license = "MIT" +srcDir = "src" +bin = @["SNNBot"] + +# Dependencies +requires "nim >= 2.0.0" +requires "robocode_tankroyale_botapi >= 1.0.7" +# radar_lock is vendored in-tree (common_libs/) and wired via config.nims --path diff --git a/SNNBot_garage/SNNBot_garage.json b/SNNBot_garage/SNNBot_garage.json new file mode 100644 index 0000000..c560039 --- /dev/null +++ b/SNNBot_garage/SNNBot_garage.json @@ -0,0 +1,9 @@ +{ + "name": "SNNBot", + "version": "0.1.0", + "authors": ["Davide Cappellini"], + "description": "SNN prototype — population-coded LIF neurons aiming toward enemy", + "gameTypes": ["classic", "1v1"], + "platform": "Nim", + "programmingLang": "Nim" +} diff --git a/SNNBot_garage/SNNBot_garage.sh b/SNNBot_garage/SNNBot_garage.sh new file mode 100755 index 0000000..a4be365 --- /dev/null +++ b/SNNBot_garage/SNNBot_garage.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" +exec ./out/SNNBot >> /tmp/snnbot_stdout.log 2>> /tmp/snnbot_stderr.log diff --git a/SNNBot_garage/config.nims b/SNNBot_garage/config.nims new file mode 100644 index 0000000..c95874e --- /dev/null +++ b/SNNBot_garage/config.nims @@ -0,0 +1,7 @@ +--path:"../common_libs" +switch("outdir", "out") +switch("path", thisDir() & "/src") +# begin Nimble config (version 2) +when withDir(thisDir(), system.fileExists("nimble.paths")): + include "nimble.paths" +# end Nimble config diff --git a/SNNBot_garage/src/SNNBot.json b/SNNBot_garage/src/SNNBot.json new file mode 100644 index 0000000..72c15c9 --- /dev/null +++ b/SNNBot_garage/src/SNNBot.json @@ -0,0 +1,11 @@ +{ + "name": "SNNBot", + "version": "0.1.0", + "authors": ["Davide Cappellini"], + "description": "SNN prototype — population-coded LIF neurons aiming toward enemy", + "homepage": "", + "countryCodes": ["IT"], + "gameTypes": ["classic", "1v1"], + "platform": "Nim", + "programmingLang": "Nim" +} diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim new file mode 100644 index 0000000..84433f2 --- /dev/null +++ b/SNNBot_garage/src/SNNBot.nim @@ -0,0 +1,292 @@ +# SNNBot — SNN core + aiming loop prototype (issue #152). +# 36-input population-coded → 6 LIF hidden → 1 membrane-readout output. +# No movement, no firing, no learning. Proves plumbing. +# +# State machine: +# DECIDE → feed bearing into SNN, store targetAngle, → WAITING +# WAITING → aimTo() each tick; when error < 2° → EVALUATE +# EVALUATE → measure error, compute reward, log, → DECIDE + +import std/[math, random, os] +import robocode_tankroyale_botapi +import radar_lock/radar_lock as radar_lock + +# ── Constants ────────────────────────────────────────────────────────────────── + +const botJsonPath = currentSourcePath().parentDir / "SNNBot.json" + +const + N_IN = 36 # input neurons (10°-wide bands, -180..+180) + N_HID = 6 # hidden LIF neurons + BAND_DEG = 10.0 # degrees per input band + LEAK = 0.9 # LIF membrane leak factor + THRESH = 1.0 # LIF spike threshold + MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees) + AIM_TOL = 2.0 # arrive tolerance (degrees) + +# ── SNN types ───────────────────────────────────────────────────────────────── + +type + SNN = object + wih: array[N_IN * N_HID, float] # 36×6 input→hidden weights + who: array[N_HID, float] # 6×1 hidden→output weights + vHid: array[N_HID, float] # hidden membrane potentials + vOut: float # output membrane potential (readout) + +proc initSNN(snn: var SNN) = + # ponytail: uniform random init, fine until STDP training added in #155 + randomize() + for w in snn.wih.mitems: w = rand(1.0) - 0.5 + for w in snn.who.mitems: w = rand(1.0) - 0.5 + +proc encodeInput(bearing: float): array[N_IN, float] = + ## Population-code bearing into N_IN neurons. + ## Each neuron owns a 10° band centred at -175, -165, …, +175. + ## The band that contains the bearing fires 1.0; both neighbours get + ## linear interpolation for smoother encoding. + let norm = ((bearing + 180.0) / BAND_DEG) # 0..36 + let lo = int(norm) mod N_IN + let hi = (lo + 1) mod N_IN + let frac = norm - float(int(norm)) # fractional position in band + result[lo] = 1.0 - frac + result[hi] = frac + +proc forward(snn: var SNN, inputs: array[N_IN, float]): float = + ## One SNN tick. Returns target angle in degrees (-180..+180). + # Hidden layer: LIF update + var spikes: array[N_HID, float] + for h in 0 ..< N_HID: + var wsum = 0.0 + for i in 0 ..< N_IN: + wsum += inputs[i] * snn.wih[i * N_HID + h] + snn.vHid[h] = LEAK * snn.vHid[h] + wsum + if snn.vHid[h] >= THRESH: + spikes[h] = 1.0 + snn.vHid[h] = 0.0 + else: + spikes[h] = 0.0 + + # Output layer: membrane readout (no threshold) + var osum = 0.0 + for h in 0 ..< N_HID: + osum += spikes[h] * snn.who[h] + snn.vOut = LEAK * snn.vOut + osum + + # Scale vOut to [-180, +180]. vOut is unbounded so clamp after tanh-like squash. + # ponytail: simple linear scale; replace with proper output neuron tuning in #155 + result = tanh(snn.vOut) * 180.0 + +# ── Bot state machine ───────────────────────────────────────────────────────── + +type + Phase = enum DECIDE, WAITING, EVALUATE + + SNNBot = ref object of Bot + snn: SNN + phase: Phase + targetAngle: float # SNN output (absolute bearing) + enemyBearing: float # last known enemy bearing + enemyDist: float # last known distance to enemy + hasContact: bool + tick: int + +# ── aimTo helper ────────────────────────────────────────────────────────────── + +proc aimTo(targetAngle, gunDir: float) {.inline.} = + let delta = normalizeRelativeAngle(targetAngle - gunDir) + setGunTurnRate(delta.clamp(-MAX_GUN_TURN, MAX_GUN_TURN)) + +# ── Debug overlay ───────────────────────────────────────────────────────────── + +proc drawOverlay(bot: SNNBot, myX, myY, gunDir, enemyBearing, targetAngle: float) = + let LINE_LEN = bot.enemyDist + 50.0 + # Convert degree bearing to SVG direction (SVG y-axis is inverted; 0=east, CCW+) + template toXY(bearing, len: float): (float, float) = + (myX + cos(degToRad(bearing)) * len, + myY + sin(degToRad(bearing)) * len) + + # Green: enemy direction (truth) + setStrokeColor(GREEN) + setStrokeWidth(2.0) + let (ex, ey) = toXY(enemyBearing, LINE_LEN) + drawLine(myX, myY, ex, ey) + + # Red: gun direction + setStrokeColor(RED) + let (gx, gy) = toXY(gunDir, LINE_LEN) + drawLine(myX, myY, gx, gy) + + # Yellow: SNN target angle + setStrokeColor(YELLOW) + let (tx, ty) = toXY(targetAngle, LINE_LEN) + drawLine(myX, myY, tx, ty) + + # ── Neuron panel — barcode style (top-left corner at 10,10) ────────────── + const + PX = 10.0 # panel x + PY = 10.0 # panel y + let + panelW = getArenaWidth().float / 2.0 + panelH = getArenaHeight().float / 2.0 + CH = (panelH - PY - 20.0) / 7.0 # row height (same formula as before) + LAYER_V = CH * 2.0 # inter-layer gap + IN_W = panelW # input layer spans full panel width + IN_STEP = IN_W / float(N_IN) # spacing between input lines + HID_STEP = IN_W / float(N_HID) # spacing between hidden lines + # Anchor x of each hidden line — centred within IN_W + HID_OFF = 0.0 + + # Input layer: vertical line per neuron, visible when activation > 0 + let inputs = encodeInput(normalizeRelativeAngle(enemyBearing - gunDir)) + setStrokeWidth(1.0) + for i in 0 ..< N_IN: + let alpha = uint8(inputs[i] * 255.0) + if alpha > 0: + let lx = PX + (float(i) + 0.5) * IN_STEP + setStrokeColor(fromRgba(0, 200, 255, alpha)) + drawLine(lx, PY, lx, PY + CH) + + # Hidden layer: vertical line per neuron, visible when spiking + let hidY = PY + CH + LAYER_V + setStrokeWidth(1.5) + for h in 0 ..< N_HID: + let v = bot.snn.vHid[h].clamp(0.0, THRESH) / THRESH + if v > 0.0: + let lx = PX + HID_OFF + (float(h) + 0.5) * HID_STEP + let alpha = uint8(v * 255.0) + setStrokeColor(fromRgba(255, 140, 0, alpha)) + drawLine(lx, hidY, lx, hidY + CH) + + # Weight lines: input → hidden (sample every 4th input to avoid clutter) + # Fixed light-blue color; thickness proportional to normalized weight magnitude. + var maxAbsWih = 0.0 + for w in bot.snn.wih: maxAbsWih = max(maxAbsWih, abs(w)) + setStrokeColor(fromRgba(180, 220, 255, 180)) + for i in 0 ..< N_IN: + let ix = PX + (float(i) + 0.5) * IN_STEP + let iy = PY + CH + for h in 0 ..< N_HID: + let hx = PX + HID_OFF + (float(h) + 0.5) * HID_STEP + let hy = hidY + let w = bot.snn.wih[i * N_HID + h] + let norm = if maxAbsWih > 0.0: abs(w) / maxAbsWih else: 0.0 + setStrokeWidth(0.5 + norm * 2.5) + drawLine(ix, iy, hx, hy) + + # Output neuron: single vertical line centred in panel, visible when active + let outY = hidY + CH + LAYER_V + let outV = (tanh(bot.snn.vOut) + 1.0) / 2.0 # 0..1 for display + if outV > 0.0: + let lx = PX + IN_W / 2.0 + let alpha = uint8(outV * 255.0) + setStrokeColor(fromRgba(200, 0, 255, alpha)) + setStrokeWidth(2.0) + drawLine(lx, outY, lx, outY + CH) + + # ── Aiming-line legend ──────────────────────────────────────────────────── + # Arena Y=0 is bottom; top = getArenaHeight(). LEG_Y is the bottom edge of + # the legend block so it sits near the top of the screen. + const + LEG_X = 10.0 # left margin + LEG_SQ = 8.0 # coloured square side + LEG_GAP = 4.0 # gap between square and text + LEG_ROW = 14.0 # row height + LEG_PAD = 6.0 # inner padding of background rect + let LEG_Y = getArenaHeight().float - 60.0 # near top of arena + + # Semi-transparent background + setFillColor(fromRgba(0, 0, 0, 160)) + fillRectangle(LEG_X - LEG_PAD, + LEG_Y - LEG_PAD, + LEG_SQ + LEG_GAP + 80.0 + LEG_PAD, + 3.0 * LEG_ROW + LEG_PAD) + + # Row 0 — Green: Enemy bearing + setFillColor(GREEN) + fillRectangle(LEG_X, LEG_Y, LEG_SQ, LEG_SQ) + setFillColor(WHITE) + drawText("Enemy bearing", LEG_X + LEG_SQ + LEG_GAP, LEG_Y + LEG_SQ) + + # Row 1 — Red: Gun direction + setFillColor(RED) + fillRectangle(LEG_X, LEG_Y + LEG_ROW, LEG_SQ, LEG_SQ) + setFillColor(WHITE) + drawText("Gun direction", LEG_X + LEG_SQ + LEG_GAP, LEG_Y + LEG_ROW + LEG_SQ) + + # Row 2 — Yellow: SNN target + setFillColor(YELLOW) + fillRectangle(LEG_X, LEG_Y + 2.0 * LEG_ROW, LEG_SQ, LEG_SQ) + setFillColor(WHITE) + drawText("SNN target", LEG_X + LEG_SQ + LEG_GAP, LEG_Y + 2.0 * LEG_ROW + LEG_SQ) + +# ── Event handlers ──────────────────────────────────────────────────────────── + +method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) = + let bx = getX(); let by = getY() + bot.enemyBearing = directionTo(bx, by, e.x, e.y) + bot.enemyDist = distanceTo(bx, by, e.x, e.y) + bot.hasContact = true + +method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) = + setAdjustGunForBodyTurn(true) + setAdjustRadarForBodyTurn(true) + setAdjustRadarForGunTurn(true) + radar_lock.init() + bot.hasContact = false + bot.phase = DECIDE + bot.tick = 0 + setTargetSpeed(0.0) + setTurnRate(0.0) + +method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) = + initSNN(bot.snn) + +# ── Main loop ───────────────────────────────────────────────────────────────── + +method run*(bot: SNNBot) = + while isRunning(): + inc bot.tick + setTargetSpeed(0.0) + setTurnRate(0.0) + + if not bot.hasContact: + setRadarTurnRate(45.0) + go() + continue + + let myX = getX() + let myY = getY() + let gunDir = getGunDirection() + + case bot.phase + of DECIDE: + let relBearing = normalizeRelativeAngle(bot.enemyBearing - gunDir) + let inputs = encodeInput(relBearing) + bot.targetAngle = bot.snn.forward(inputs) + bot.phase = WAITING + + of WAITING: + aimTo(bot.targetAngle, gunDir) + let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir)) + if err < AIM_TOL: + bot.phase = EVALUATE + + of EVALUATE: + let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) + let reward = 1.0 / (1.0 + err) + echo "tick=" & $bot.tick & " error=" & $err & "° reward=" & $reward + bot.phase = DECIDE + + # Radar lock + setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing)) + + # Debug overlay + drawOverlay(bot, myX, myY, gunDir, bot.enemyBearing, bot.targetAngle) + + go() + +# ── Entry point ─────────────────────────────────────────────────────────────── + +when isMainModule: + var bot = SNNBot(phase: DECIDE) + start(bot, botJsonPath) diff --git a/SNNBot_garage/tests/.gitkeep b/SNNBot_garage/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/SNNBot_garage/tests/config.nims b/SNNBot_garage/tests/config.nims new file mode 100644 index 0000000..c5ea4ed --- /dev/null +++ b/SNNBot_garage/tests/config.nims @@ -0,0 +1 @@ +--path:"../../common_libs" diff --git a/SNNBot_garage/tests/out/test_basic_battle b/SNNBot_garage/tests/out/test_basic_battle new file mode 100755 index 0000000..b7ddf38 Binary files /dev/null and b/SNNBot_garage/tests/out/test_basic_battle differ diff --git a/SNNBot_garage/tests/test_basic_battle.nim b/SNNBot_garage/tests/test_basic_battle.nim new file mode 100644 index 0000000..b278081 --- /dev/null +++ b/SNNBot_garage/tests/test_basic_battle.nim @@ -0,0 +1,66 @@ +## Integration test: SNNBot vs SittingDuck. +## Requires a running TR server (set TR_SERVER_JAR / TR_BATTLE_RUNNER env vars). +## Also includes an offline parseServerOutput unit test — no Java needed. + +import std/[os, unittest] +import test_framework/battle_result + +# ---------- offline unit test (no server needed) ---------- + +suite "parseServerOutput": + test "parses a 1-round battle fixture": + const fixture = """ +{"event":"game_started","bots":["SNNBot","SittingDuck"]} +{"event":"round_ended","round":1,"results":[{"name":"SNNBot","score":100,"rank":1,"survived":true},{"name":"SittingDuck","score":0,"rank":2,"survived":false}]} +{"event":"battle_ended","results":[{"name":"SNNBot","totalScore":100,"rank":1,"firstPlaces":1,"survivalCount":1},{"name":"SittingDuck","totalScore":0,"rank":2,"firstPlaces":0,"survivalCount":0}]} +""" + let r = parseServerOutput(fixture) + + check r.bots == @["SNNBot", "SittingDuck"] + check r.rounds.len == 1 + check r.rounds[0].results[0].name == "SNNBot" + check r.rounds[0].results[0].survived == true + check r.results.len == 2 + check r.winners == @["SNNBot"] + +# ---------- integration tests (require TR server + BattleRunner JARs) ---------- + +const + DefaultServerJar = "/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar" + DefaultRunnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar" + +let serverJar = getEnv("TR_SERVER_JAR", DefaultServerJar) +let runnerJar = getEnv("TR_BATTLE_RUNNER", DefaultRunnerJar) +if not fileExists(serverJar) or not fileExists(runnerJar): + echo "Skipping integration tests: JAR files not found (set TR_SERVER_JAR / TR_BATTLE_RUNNER to override)" + quit(0) + +import test_framework/test_framework + +const + snnbotDir = currentSourcePath().parentDir.parentDir + sittingDuckDir = currentSourcePath().parentDir.parentDir.parentDir / + "common_libs" / "test_framework" / "adversaries" / "SittingDuck" + +suite "SNNBot integration battles": + test "completes 1 round vs SittingDuck without crashing": + let r = runBattle(@[snnbotDir, sittingDuckDir], rounds = 1) + + check r.rounds.len == 1 + check r.results.len == 2 + + var snn, duck: BotResult + for b in r.results: + if b.name == "SNNBot": snn = b + if b.name == "SittingDuck": duck = b + check snn.name == "SNNBot" + check duck.name == "SittingDuck" + + # Just assert it completed and scored something — no aiming quality check + check snn.totalScore >= 0 + check duck.totalScore >= 0 + + for rnd in r.rounds: + check rnd.results.len == 2 + for br in rnd.results: + check br.score >= 0