From 254c7dc99716c1c51f9c380a067138719c0c9c72 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 20 Sep 2026 00:37:10 +0200 Subject: [PATCH] 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 --- BNNBot_garage/BNNBot.nimble | 2 +- BNNBot_garage/RESEARCH.md | 92 ++++++ BNNBot_garage/src/BNNBot.nim | 80 ++++- BNNBot_garage/src/TsetlinBot.json | 11 + BNNBot_garage/src/TsetlinBot.nim | 280 +++++++++++++++++ BNNBot_garage/src/WiSARDBot.json | 11 + BNNBot_garage/src/WiSARDBot.nim | 288 ++++++++++++++++++ BNNBot_garage/src/binary_encoding.nim | 32 +- BNNBot_garage/src/predictor.nim | 50 +++ BNNBot_garage/src/tsetlin_predictor.nim | 178 +++++++++++ BNNBot_garage/src/wisard_predictor.nim | 87 ++++++ ModularBot_garage/ModularBot.json | 11 + ModularBot_garage/ModularBot.nimble | 12 + ModularBot_garage/config.nims | 7 + ModularBot_garage/src/ModularBot.json | 11 + ModularBot_garage/src/ModularBot.nim | 187 ++++++++++++ ModularBot_garage/src/ModularBot_minimal.nim | 17 ++ ModularBot_garage/tests/config.nims | 1 + ModularBot_garage/tests/test_3rounds.nim | 32 ++ ModularBot_garage/tests/test_battle_suite.nim | 58 ++++ .../tests/test_gun_diversity.nim | 24 ++ .../tests/test_gun_diversity_detailed.nim | 36 +++ .../tests/test_gun_diversity_verbose.nim | 34 +++ .../tests/test_gun_selection.nim | 39 +++ .../tests/test_gun_single_round.nim | 20 ++ .../tests/test_modularbot_battle.nim | 70 +++++ common_libs/gun_harness/gun_interface.nim | 49 +++ common_libs/gun_harness/selector.nim | 26 ++ common_libs/gun_harness/virtual_bullets.nim | 133 ++++++++ common_libs/guns/circular.nim | 45 ++- common_libs/guns/head_on.nim | 13 + common_libs/guns/linear.nim | 22 ++ common_libs/guns/tsetlin.nim | 286 +++++++++++++++++ .../movement_harness/movement_interface.nim | 20 ++ common_libs/movements/oscillator.nim | 50 +++ common_libs/movements/phantom_meteor.nim | 232 ++++++++++++++ common_libs/radar_harness/radar_interface.nim | 15 + common_libs/radars/radar_lock_module.nim | 18 ++ tools/battle_runner/RunBNNBattle.java | 81 +++++ 39 files changed, 2630 insertions(+), 30 deletions(-) create mode 100644 BNNBot_garage/RESEARCH.md create mode 100644 BNNBot_garage/src/TsetlinBot.json create mode 100644 BNNBot_garage/src/TsetlinBot.nim create mode 100644 BNNBot_garage/src/WiSARDBot.json create mode 100644 BNNBot_garage/src/WiSARDBot.nim create mode 100644 BNNBot_garage/src/predictor.nim create mode 100644 BNNBot_garage/src/tsetlin_predictor.nim create mode 100644 BNNBot_garage/src/wisard_predictor.nim create mode 100644 ModularBot_garage/ModularBot.json create mode 100644 ModularBot_garage/ModularBot.nimble create mode 100644 ModularBot_garage/config.nims create mode 100644 ModularBot_garage/src/ModularBot.json create mode 100644 ModularBot_garage/src/ModularBot.nim create mode 100644 ModularBot_garage/src/ModularBot_minimal.nim create mode 100644 ModularBot_garage/tests/config.nims create mode 100644 ModularBot_garage/tests/test_3rounds.nim create mode 100644 ModularBot_garage/tests/test_battle_suite.nim create mode 100644 ModularBot_garage/tests/test_gun_diversity.nim create mode 100644 ModularBot_garage/tests/test_gun_diversity_detailed.nim create mode 100644 ModularBot_garage/tests/test_gun_diversity_verbose.nim create mode 100644 ModularBot_garage/tests/test_gun_selection.nim create mode 100644 ModularBot_garage/tests/test_gun_single_round.nim create mode 100644 ModularBot_garage/tests/test_modularbot_battle.nim create mode 100644 common_libs/gun_harness/gun_interface.nim create mode 100644 common_libs/gun_harness/selector.nim create mode 100644 common_libs/gun_harness/virtual_bullets.nim create mode 100644 common_libs/guns/head_on.nim create mode 100644 common_libs/guns/linear.nim create mode 100644 common_libs/guns/tsetlin.nim create mode 100644 common_libs/movement_harness/movement_interface.nim create mode 100644 common_libs/movements/oscillator.nim create mode 100644 common_libs/movements/phantom_meteor.nim create mode 100644 common_libs/radar_harness/radar_interface.nim create mode 100644 common_libs/radars/radar_lock_module.nim create mode 100644 tools/battle_runner/RunBNNBattle.java diff --git a/BNNBot_garage/BNNBot.nimble b/BNNBot_garage/BNNBot.nimble index 1f879f0..dec5f3d 100644 --- a/BNNBot_garage/BNNBot.nimble +++ b/BNNBot_garage/BNNBot.nimble @@ -4,7 +4,7 @@ author = "Davide Cappellini" description = "Binary Neural Network Bot — learns aiming with pure binary operations" license = "MIT" srcDir = "src" -bin = @["BNNBot"] +bin = @["BNNBot", "WiSARDBot", "TsetlinBot"] # Dependencies requires "nim >= 2.0.0" diff --git a/BNNBot_garage/RESEARCH.md b/BNNBot_garage/RESEARCH.md new file mode 100644 index 0000000..a0311c7 --- /dev/null +++ b/BNNBot_garage/RESEARCH.md @@ -0,0 +1,92 @@ +# BNNBot Research Brief + +## Problem +Predict enemy future position in Robocode Tank Royale to aim bullets accurately. The prediction must happen online (during battle), without pre-training. + +## Hard Constraints +- NO supervised learning (no labeled input→output training pairs) +- NO gradient descent (no derivatives, no surrogate gradients, no STE) +- Online learning only — must learn and improve during a single battle +- Computational budget: ~1ms per tick +- Binary-friendly (690-bit input encoding already exists) + +## Allowed +- Backpropagation of SIGNALS (non-gradient information flowing backward through layers) +- Reinforcement learning (reward signal available from wave hit system) +- Self-supervised learning +- Unsupervised learning +- Network structure modification during runtime + +## Current Architecture + +### Input Engineering (binary_encoding.nim) +- 690-bit binary vector: 10 frames × 69 bits +- Per frame: bearing sin/cos (16b), distance (7b), velocity (5b), heading sin/cos (16b), enemy X/Y position (14b), enemy energy (11b) +- Gray-coded for Hamming distance smoothness +- Temporal window: 10 most recent radar scans + +### Feedback System (wave system in BNNBot.nim) +- Every tick: 10 circular waves spawned at bot position +- Powers: 0.1 to 3.0 (10 levels), speeds: 19.7 to 11.0 px/tick +- When wave radius reaches enemy: records enemy state as 69-bit frame +- Provides ground truth: "if you fired at power X, the enemy would be HERE when the bullet arrives" +- ~95.6% hit rate in testing + +### Current Predictor (predictor.nim) +- Linear extrapolation: predicted = current_pos + velocity * ticks_to_arrival +- Hebbian residual table: 8 heading sectors × 3 distance bands = 24 cells +- Each cell stores (correction_x, correction_y), updated online with lr=0.2 +- Backtest results: 14-21% MAE reduction over pure linear extrapolation +- Converges within one battle (MAE 14.85 → 2.62, first 50 vs last 50 rows) + +## Key Findings + +### Data Analysis (analysis/report.txt) +- Enemy movement is 97.8% constant-velocity straight lines +- Acceleration is negligible (std 0.25-0.49 px/tick²) +- Heading is very stable across 10-frame windows +- Linear extrapolation MAE: 15-27px (1.5-2.7% of arena) +- Distance to enemy is the main error driver +- Scalar velocity alone is weak predictor (r=0.15); directional velocity from frame deltas is strong + +### Backtest Results (analysis/backtest_report.txt) +- P1 (linear): MAE 8.1-18.7 encoded units +- P2 (weighted 4-frame): ~8% improvement, trivial cost +- P3 (linear + Hebbian residual): 14-21% improvement, converges fast +- Most residual table cells stay empty — only ~10/24 activate + +### Encoding Insights +- sin/cos angle encoding avoids wraparound discontinuity — worth the extra bits +- Enemy X/Y position partially redundant with bearing+distance (encodes absolute position) +- Wall distance → XY% compression saved 140 bits losslessly +- Self-state removed (not needed for aiming) + +## What We've Tried +1. ✅ Input engineering with Gray coding and temporal window — works well +2. ✅ Linear extrapolation — strong baseline, 15-27px error +3. ✅ Hebbian residual table — learns online, 14-21% improvement +4. ❌ Pure XOR layer stacking — collapses (associative, no non-linearity) +5. ❌ XOR + AND layers — AND with fixed mask is still linear over GF(2) +6. ✅ XOR + popcount + threshold = valid binary neuron (non-linear) + +## Open Questions +1. Can we go deeper than the current shallow predictor while respecting the constraints? +2. What non-gradient learning rules can train multi-layer binary networks? +3. Can the temporal structure (10 frames) be exploited by the network architecture? +4. Is there a way to do credit assignment through depth without gradients? +5. Can the wave hit system provide richer learning signal than just miss distance? + +## Architecture Philosophy +- Input engineering IS the feature hierarchy (handcrafted, domain-informed) +- Current approach is essentially reservoir computing: rich fixed features → simple learnable readout +- Question: can we do better with a learnable feature extractor, or is the handcrafted one already near-optimal? + +## Files +- `src/BNNBot.nim` — main bot, wave system, integration +- `src/binary_encoding.nim` — 690-bit input encoding +- `src/predictor.nim` — linear extrapolation + Hebbian residual table +- `analysis/correlations.py` — data analysis script +- `analysis/backtest.py` — predictor comparison script +- `analysis/report.txt` — correlation analysis results +- `analysis/backtest_report.txt` — predictor backtest results +- `data/` — CSV battle logs (enabled via BNNBOT_CSV=1) diff --git a/BNNBot_garage/src/BNNBot.nim b/BNNBot_garage/src/BNNBot.nim index 299578d..0b7f0a1 100644 --- a/BNNBot_garage/src/BNNBot.nim +++ b/BNNBot_garage/src/BNNBot.nim @@ -1,13 +1,16 @@ # 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, strutils, random] +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 @@ -17,7 +20,18 @@ const 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 @@ -31,13 +45,52 @@ type 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 # ring-buffer write index + 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..= MIN_SAMPLES: + let rate = s.hits.float / s.total.float + if rate >= HIT_THRESHOLD and POWER_LEVELS[pi] > bestPow: + bestPow = POWER_LEVELS[pi] + bot.firePower = POWER_LEVELS[pi] + + let selectedSpeed = 20.0 - 3.0 * bot.firePower + + # ── Bug 4 fix: forward pass returns (cx, cy) pixel corrections ──────── + var cache: ClauseCache + let (cx, cy) = bot.net.forwardWithCache(vec, cache) + + # epsilon-greedy exploration: perturb the correction + var corrX = cx + var corrY = cy + if rand(1.0) < bot.epsilon: + corrX += rand(20.0) - 10.0 + corrY += rand(20.0) - 10.0 + bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY) + + # Convert (cx, cy) correction to aim angle. + # Linear extrapolation first, then TM residual correction on top. + let bx2 = getX(); let by2 = getY() + var aimAngle = bot.enemyBearing + if bot.hasLastPos: + let ticksToArrive = bot.distance / selectedSpeed + let extrapolX = cos(degToRad(bot.heading)) * bot.velocity * ticksToArrive + let extrapolY = sin(degToRad(bot.heading)) * bot.velocity * ticksToArrive + let targetX = bot.lastEnemyX + extrapolX + corrX + let targetY = bot.lastEnemyY + extrapolY + corrY + aimAngle = directionTo(bx2, by2, targetX, targetY) + + var aimOffset = aimAngle - bot.enemyBearing + while aimOffset > 180.0: aimOffset -= 360.0 + while aimOffset < -180.0: aimOffset += 360.0 + + bot.aimAngle = bot.enemyBearing + aimOffset + + # ── store virtual bullet (with clause cache for eligibility) ────────── + let slot = bot.bulletHead mod BULLET_SLOTS + bot.bulletHead = slot + 1 + bot.bullets[slot] = VirtualBullet( + trace: EligibilityTrace(input: vec, clauseOut: cache, age: 0, alive: true), + fireX: bx2, + fireY: by2, + aimAngleDeg: bot.enemyBearing + aimOffset, + fireDist: bot.distance, + bulletSpeed: selectedSpeed, + active: true, + ) + + # ── spawn one probe per power level ────────────────────────────────── + for pi in 0.. 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), "% ", + "pwr=", formatFloat(bot.firePower, ffDecimal, 1) + + bot.prevVec = vec + bot.hasPrev = true + +method onRoundStarted*(bot: TsetlinBot, 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 + for i in 0..= 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 residualX = bot.lastEnemyX - bulletX + let residualY = bot.lastEnemyY - bulletY + let missDistance = hypot(residualX, residualY) + bot.net.learn(b.trace, residualX, residualY) + if missDistance < 36.0: inc bot.virtualHits + else: inc bot.virtualMiss + b.active = false + + for pi in 0..= p.fireDist or p.age >= TRACE_MAX_AGE: + let px = p.fireX + cos(degToRad(p.aimAngleDeg)) * pd + let py = p.fireY + sin(degToRad(p.aimAngleDeg)) * pd + let md = hypot(px - bot.lastEnemyX, py - bot.lastEnemyY) + inc bot.powerStats[pi].total + if md < 36.0: inc bot.powerStats[pi].hits + p.active = false + + # Aim gun and fire when ready + let gunDir = getGunDirection() + let gunDelta = normalizeRelativeAngle(bot.aimAngle - gunDir) + setGunTurnRate(gunDelta.clamp(-20.0, 20.0)) + if getGunHeat() <= 0.0 and abs(gunDelta) < 2.0: + discard setFire(bot.firePower) + + go() + +when isMainModule: + randomize() + var bot = TsetlinBot( + net: initTsetlinNet(), + epsilon: EPSILON_START, + firePower: DEFAULT_POWER, + ) + start(bot, botJsonPath) diff --git a/BNNBot_garage/src/WiSARDBot.json b/BNNBot_garage/src/WiSARDBot.json new file mode 100644 index 0000000..f9e7752 --- /dev/null +++ b/BNNBot_garage/src/WiSARDBot.json @@ -0,0 +1,11 @@ +{ + "name": "WiSARDBot", + "version": "0.1.0", + "authors": ["Davide Cappellini"], + "description": "WiSARD (K=14) regression predictor bot — 63 neurons × 16384-entry LUTs over 870-bit binary state", + "homepage": "", + "countryCodes": ["IT"], + "gameTypes": ["classic", "1v1"], + "platform": "Nim", + "programmingLang": "Nim" +} diff --git a/BNNBot_garage/src/WiSARDBot.nim b/BNNBot_garage/src/WiSARDBot.nim new file mode 100644 index 0000000..35351eb --- /dev/null +++ b/BNNBot_garage/src/WiSARDBot.nim @@ -0,0 +1,288 @@ +# WiSARDBot — WiSARD regression predictor for aiming. +# Identical to TsetlinBot except uses wisard_predictor instead of tsetlin_predictor. + +import std/[math, os, strutils, random] +import robocode_tankroyale_botapi +import radar_lock/radar_lock as radar_lock +import binary_encoding +import wisard_predictor + +const botJsonPath = currentSourcePath().parentDir / "WiSARDBot.json" + +const + BULLET_SLOTS = 50 + EPSILON_START = 0.2 + EPSILON_MIN = 0.05 + EPSILON_DECAY = 0.9995 + # Power levels probed each tick to pick best fire power + POWER_LEVELS = [0.1'f64, 0.4, 0.7, 1.0, 1.3, 1.6, 1.9, 2.2, 2.5, 3.0] + N_POWER = POWER_LEVELS.len + HIT_THRESHOLD = 0.40 # min hit-rate to qualify a power level + MIN_SAMPLES = 5 # samples before trusting a power level + DEFAULT_POWER = 1.0 # cold-start fallback + +type + ProbeSlot = object # lightweight wave probe — no learning, just geometry + active: bool + powerIdx: int + fireX: float + fireY: float + aimAngleDeg: float + fireDist: float + bulletSpeed: float + age: int + +type + WiSARDBot = 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]] + bufferCount: int + net: WiSARDNet + bullets: array[BULLET_SLOTS, VirtualBullet] + bulletHead: int + virtualHits: int + virtualMiss: int + epsilon: float + aimAngle: float # absolute gun aim angle, updated each scan + firePower: float # dynamically selected fire power + powerStats: array[N_POWER, tuple[hits, total: int]] + probes: array[N_POWER, ProbeSlot] # one probe per power level per tick + +method onScannedBot*(bot: WiSARDBot, 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 + # lastEnemyX/Y already stored above — available for wave resolution in run() + + 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) + + for i in countdown(WINDOW_SIZE - 1, 1): + bot.frameBuffer[i] = bot.frameBuffer[i - 1] + bot.frameBuffer[0] = encoded + + 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) + + # ── pick best fire power ────────────────────────────────────────────── + bot.firePower = DEFAULT_POWER + var bestPow = -1.0 + for pi in 0..= MIN_SAMPLES: + let rate = s.hits.float / s.total.float + if rate >= HIT_THRESHOLD and POWER_LEVELS[pi] > bestPow: + bestPow = POWER_LEVELS[pi] + bot.firePower = POWER_LEVELS[pi] + + let selectedSpeed = 20.0 - 3.0 * bot.firePower + + # ── WiSARD forward pass: compute addresses then predict correction ──── + let addrs = bot.net.computeAddresses(vec) + let (cx, cy) = bot.net.predictCorrection(addrs) + + # Convert (cx, cy) correction to aim angle offset. + # Linear extrapolation first; cx/cy are residual corrections on top. + let bx2 = getX(); let by2 = getY() + var aimAngle = bot.enemyBearing + if bot.hasLastPos: + let ticksToArrive = bot.distance / selectedSpeed + let extrapolX = cos(degToRad(bot.heading)) * bot.velocity * ticksToArrive + let extrapolY = sin(degToRad(bot.heading)) * bot.velocity * ticksToArrive + let targetX = bot.lastEnemyX + extrapolX + cx + let targetY = bot.lastEnemyY + extrapolY + cy + echo "AIM: enemyXY=(" & formatFloat(bot.lastEnemyX, ffDecimal, 1) & "," & formatFloat(bot.lastEnemyY, ffDecimal, 1) & + ") heading=" & formatFloat(bot.heading, ffDecimal, 1) & + " vel=" & formatFloat(bot.velocity, ffDecimal, 1) & + " ticks=" & formatFloat(ticksToArrive, ffDecimal, 1) & + " extrapol=(" & formatFloat(extrapolX, ffDecimal, 1) & "," & formatFloat(extrapolY, ffDecimal, 1) & + ") corr=(" & formatFloat(cx, ffDecimal, 1) & "," & formatFloat(cy, ffDecimal, 1) & + ") target=(" & formatFloat(targetX, ffDecimal, 1) & "," & formatFloat(targetY, ffDecimal, 1) & + ") aimAng=" & formatFloat(aimAngle, ffDecimal, 1) & + " bearing=" & formatFloat(bot.enemyBearing, ffDecimal, 1) + aimAngle = directionTo(bx2, by2, targetX, targetY) + + var aimOffset = aimAngle - bot.enemyBearing + # Normalise to [-180, 180] + while aimOffset > 180.0: aimOffset -= 360.0 + while aimOffset < -180.0: aimOffset += 360.0 + + # epsilon-greedy exploration: perturb the correction + if rand(1.0) < bot.epsilon: + aimOffset += rand(10.0) - 5.0 + bot.epsilon = max(EPSILON_MIN, bot.epsilon * EPSILON_DECAY) + + bot.aimAngle = bot.enemyBearing + aimOffset + + # ── store virtual bullet (with addresses for eligibility) ──────────── + let slot = bot.bulletHead mod BULLET_SLOTS + bot.bulletHead = slot + 1 + bot.bullets[slot] = VirtualBullet( + trace: WaveTrace(addrs: addrs, valid: true, age: 0), + fireX: bx2, + fireY: by2, + aimAngleDeg: bot.enemyBearing + aimOffset, + fireDist: bot.distance, + bulletSpeed: selectedSpeed, + active: true, + ) + + # ── spawn one probe per power level (each uses its own extrapolation) ─ + for pi in 0.. 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), "% ", + "pwr=", formatFloat(bot.firePower, ffDecimal, 1) + + bot.prevVec = vec + bot.hasPrev = true + +method onRoundStarted*(bot: WiSARDBot, 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 + for i in 0..= 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) + let resX = bot.lastEnemyX - bulletX + let resY = bot.lastEnemyY - bulletY + bot.net.learnCorrection(b.trace.addrs, resX, resY) + if missDistance < 36.0: inc bot.virtualHits + else: inc bot.virtualMiss + b.active = false + + for pi in 0..= p.fireDist or p.age >= TRACE_MAX_AGE: + let px = p.fireX + cos(degToRad(p.aimAngleDeg)) * pd + let py = p.fireY + sin(degToRad(p.aimAngleDeg)) * pd + let md = hypot(px - bot.lastEnemyX, py - bot.lastEnemyY) + inc bot.powerStats[pi].total + if md < 36.0: inc bot.powerStats[pi].hits + p.active = false + + if not bot.hasContact: + setRadarTurnRate(45.0) + go() + continue + + setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing)) + + # Aim gun and fire when ready + let gunDir = getGunDirection() + let gunDelta = normalizeRelativeAngle(bot.aimAngle - gunDir) + setGunTurnRate(gunDelta.clamp(-20.0, 20.0)) + if getGunHeat() <= 0.0 and abs(gunDelta) < 2.0: + discard setFire(bot.firePower) + + go() + +when isMainModule: + randomize() + var bot = WiSARDBot( + net: initWiSARD(), + epsilon: EPSILON_START, + firePower: DEFAULT_POWER, + ) + start(bot, botJsonPath) diff --git a/BNNBot_garage/src/binary_encoding.nim b/BNNBot_garage/src/binary_encoding.nim index fb10d33..0aea2a5 100644 --- a/BNNBot_garage/src/binary_encoding.nim +++ b/BNNBot_garage/src/binary_encoding.nim @@ -28,7 +28,10 @@ type myEnergy*: float # 11 bits canFire*: bool # 1 bit -const MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena +const + MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena + NUM_FRAME_FIELDS* = 9 # for CSV output: bearing_sin, bearing_cos, distance, velocity, heading_sin, heading_cos, enemy_x, enemy_y, energy + FIELD_NAMES* = ["bearing_sin", "bearing_cos", "distance", "velocity", "heading_sin", "heading_cos", "wallN", "wallS", "energy"] proc toGray(value: int): int = value xor (value shr 1) @@ -137,6 +140,33 @@ proc bitwiseAnd*(a, b: BinaryVector): BinaryVector = for i in 0.. 0: result &= "," + result &= $cnt + +proc formatFrameBinary*(frame: array[FRAME_BITS, uint8]): string = + for b in frame: + result &= (if b == 1: "1" else: "0") + +proc formatFrameDecimal*(frame: array[FRAME_BITS, uint8]): string = + # Nine decimal fields from the frame bits + # bearing_sin (0-199), bearing_cos (0-199), distance (0-99), velocity (0-15), heading_sin (0-199), heading_cos (0-199), wall (0-99), wall (0-99), energy (0-1500/10) + result = "199,199,99,15,199,199,99,99,150" # placeholder max values + +proc decodeFrameFields*(frame: array[FRAME_BITS, uint8]): array[9, float] = + # Decode frame to 9 decimal field values (enemy X, Y for field indices 6, 7) + # This is a placeholder—actual decoding would reverse the toBits() encoding + result = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 50.0, 100.0] + # Output encoding ------------------------------------------------------- const diff --git a/BNNBot_garage/src/predictor.nim b/BNNBot_garage/src/predictor.nim new file mode 100644 index 0000000..877c467 --- /dev/null +++ b/BNNBot_garage/src/predictor.nim @@ -0,0 +1,50 @@ +import std/math +import binary_encoding + +const + N_SECTORS = 8 + N_BANDS = 3 + LEARNING_RATE = 0.2 + BAND_THR_LO = 33.0 + BAND_THR_HI = 66.0 + +type + ResidualTable* = object + corrections*: array[N_SECTORS * N_BANDS, tuple[cx, cy: float]] + +proc initPredictor*(): ResidualTable = discard # zero-init is correct + +proc getSector*(headingSin, headingCos: int): int = + let s = headingSin.float / 199.0 * 2.0 - 1.0 + let c = headingCos.float / 199.0 * 2.0 - 1.0 + var deg = radToDeg(arctan2(s, c)) + if deg < 0.0: deg += 360.0 + int(deg / 45.0) mod N_SECTORS + +proc getBand*(distance: int): int = + if distance.float < BAND_THR_LO: 0 + elif distance.float < BAND_THR_HI: 1 + else: 2 + +proc predict*(table: ResidualTable, + f0, f1: array[NUM_FRAME_FIELDS, int], + power: float): tuple[predX, predY: float] = + # field indices: 2=distance, 4=heading_sin, 5=heading_cos, 6=enemy_x, 7=enemy_y + let bulletSpd = 20.0 - 3.0 * power + let distancePx = f0[2].float / 99.0 * 1414.0 + let ticks = distancePx / bulletSpd + let vx = float(f0[6] - f1[6]) + let vy = float(f0[7] - f1[7]) + let sector = getSector(f0[4], f0[5]) + let band = getBand(f0[2]) + let corr = table.corrections[sector * N_BANDS + band] + result.predX = f0[6].float + vx * ticks + corr.cx + result.predY = f0[7].float + vy * ticks + corr.cy + +proc sectorBand*(f0: array[NUM_FRAME_FIELDS, int]): tuple[sector, band: int] = + (getSector(f0[4], f0[5]), getBand(f0[2])) + +proc learn*(table: var ResidualTable, sector, band: int, + residualX, residualY: float) = + table.corrections[sector * N_BANDS + band].cx += LEARNING_RATE * residualX + table.corrections[sector * N_BANDS + band].cy += LEARNING_RATE * residualY diff --git a/BNNBot_garage/src/tsetlin_predictor.nim b/BNNBot_garage/src/tsetlin_predictor.nim new file mode 100644 index 0000000..e04d53d --- /dev/null +++ b/BNNBot_garage/src/tsetlin_predictor.nim @@ -0,0 +1,178 @@ +# Regression Tsetlin Machine for aiming correction. +# Input: 870 bits → 1740 literals (bit + complement) +# N_OUT=2 outputs: cx correction, cy correction (pixel offsets). +# 64 clauses per output (32 positive polarity, 32 negative). +# Clause output = AND of all included literals. +# Regression: vote ∈ [-N_CLAUSES/2, N_CLAUSES/2] → scaled to [-RESID_MAX, RESID_MAX]. +# Online learning via Type I / Ib / Type II stochastic feedback. + +import binary_encoding +import std/[math, random] + +const + N_IN* = TOTAL_BITS # 870 + N_OUT* = 2 # cx, cy pixel corrections + N_LITERALS = N_IN * 2 # 1740 (bit + complement) + N_CLAUSES = 64 # per output; first 32 = pos polarity + HALF_CLAUSES = N_CLAUSES div 2 + N_STATES = 15 # automaton range [-N_STATES..N_STATES] (int8 ok) + T* = float(HALF_CLAUSES) # = 32.0; vote clamped to [-T, T] + S = 4.0 # specificity (higher = sparser clauses) + RESID_MAX* = 80.0 # residual correction range (on top of linear extrapolation) + # ponytail: N_STATES=15 fits int8, keeps array small; raise if underfitting + + TRACE_MAX_AGE* = 40 + +type + # int8 automaton: positive = include literal, negative = exclude + TsetlinNet* = object + # states[out][clause][literal] — flattened + states: array[N_OUT * N_CLAUSES * N_LITERALS, int8] + + # Eligibility: store clause outputs (one bit per clause per output) + ClauseCache* = array[N_OUT * N_CLAUSES, uint8] + + EligibilityTrace* = object + input*: BinaryVector + clauseOut*: ClauseCache + age*: int + alive*: bool + + VirtualBullet* = object + trace*: EligibilityTrace + fireX*: float + fireY*: float + aimAngleDeg*: float + fireDist*: float + bulletSpeed*: float + active*: bool + +# ── helpers ──────────────────────────────────────────────────────────── + +proc stateIdx(outIdx, clause, lit: int): int {.inline.} = + (outIdx * N_CLAUSES + clause) * N_LITERALS + lit + +proc clausePolarity(clause: int): float {.inline.} = + # first HALF_CLAUSES = +1 polarity, rest = -1 + if clause < HALF_CLAUSES: 1.0 else: -1.0 + +proc evalClause(net: TsetlinNet, outIdx, clause: int, + literals: array[N_LITERALS, uint8]): uint8 = + ## Returns 1 if clause fires (AND of all included literals satisfied). + ## Empty clause (no included literals) returns 0 — silent, not vacuously true. + var hasIncluded = false + for lit in 0.. 0: + hasIncluded = true + if literals[lit] == 0: + return 0'u8 + return if hasIncluded: 1'u8 else: 0'u8 + +proc makeLiterals(input: BinaryVector): array[N_LITERALS, uint8] = + for i in 0..= pFeedback: continue + let pol = clausePolarity(c) + let cOut = clauseOut[outIdx * N_CLAUSES + c] + + if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0): + # Type I feedback: grow clause toward current input + if cOut == 1'u8: + # Type Ia: clause fires — reinforce matching features + for lit in 0.. 0: # only if currently in Include range + st = max(st - 1, -N_STATES) + net.states[si] = int8(st) + +proc learn*(net: var TsetlinNet, trace: EligibilityTrace, + residualX: float, residualY: float) = + ## Online TM update from a resolved virtual bullet. + ## residualX/Y: pixel correction needed (actual_target - aimed_point). + if not trace.alive: return + let literals = makeLiterals(trace.input) + net.learnOne(0, literals, trace.clauseOut, residualX) + net.learnOne(1, literals, trace.clauseOut, residualY) diff --git a/BNNBot_garage/src/wisard_predictor.nim b/BNNBot_garage/src/wisard_predictor.nim new file mode 100644 index 0000000..81d0008 --- /dev/null +++ b/BNNBot_garage/src/wisard_predictor.nim @@ -0,0 +1,87 @@ +## WiSARD regression predictor — K=14, ~50 neurons, 16384 entries each. +## Input: TOTAL_BITS-bit BinaryVector. Output: (dx, dy) correction. +## Online learning via eligibility traces (addresses stored per pending wave). + +import std/[math, random] +import binary_encoding + +const + K* = 14 + N_BITS_PAD = ((TOTAL_BITS + K - 1) div K) * K # pad to multiple of K + N_NEURONS* = N_BITS_PAD div K # ceil(690/14) = 50 + LAST_BITS = if TOTAL_BITS mod K == 0: K else: TOTAL_BITS mod K # bits in last neuron + LUT_SIZE = 1 shl K # 16384 + LAST_LUT_SIZE = 1 shl LAST_BITS # smaller LUT for last neuron + BLEACH_THRESHOLD* = 1 # only use entries with count > this + +type + LutEntry = object + sumX: float + sumY: float + count: int + + WiSARDNet* = object + perm*: array[N_BITS_PAD, int] + luts*: array[N_NEURONS, array[LUT_SIZE, LutEntry]] + + # Eligibility trace: LUT addresses computed at fire time, replayed on resolution + WaveTrace* = object + addrs*: array[N_NEURONS, int] + age*: int + valid*: bool + + # Virtual bullet slot (mirrors tsetlin_predictor's VirtualBullet) + VirtualBullet* = object + trace*: WaveTrace + fireX*: float + fireY*: float + aimAngleDeg*: float + fireDist*: float + bulletSpeed*: float + active*: bool + +const TRACE_MAX_AGE* = 40 + +proc initWiSARD*(seed: int64 = 42): WiSARDNet = + ## Build fixed random permutation over TOTAL_BITS; padding slots duplicate + ## valid indices (random) to avoid zero-bias. + var rng = initRand(seed) + for i in 0.. BLEACH_THRESHOLD. + var sx = 0.0; var sy = 0.0; var active = 0 + for n in 0.. BLEACH_THRESHOLD: + sx += e.sumX / float(e.count) + sy += e.sumY / float(e.count) + inc active + if active == 0: return (0.0, 0.0) + (sx / float(active), sy / float(active)) + +proc learnCorrection*(ws: var WiSARDNet, addrs: array[N_NEURONS, int], + dx, dy: float) = + ## Accumulate (dx, dy) residuals at the addressed LUT entries. + if dx.isNaN or dy.isNaN or dx.classify == fcInf or dx.classify == fcNegInf or + dy.classify == fcInf or dy.classify == fcNegInf: return + for n in 0..= 2.0.0" +requires "robocode_tankroyale_botapi >= 1.0.7" +# gun_harness and radar_lock are in common_libs, wired via config.nims --path diff --git a/ModularBot_garage/config.nims b/ModularBot_garage/config.nims new file mode 100644 index 0000000..c95874e --- /dev/null +++ b/ModularBot_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/ModularBot_garage/src/ModularBot.json b/ModularBot_garage/src/ModularBot.json new file mode 100644 index 0000000..388488a --- /dev/null +++ b/ModularBot_garage/src/ModularBot.json @@ -0,0 +1,11 @@ +{ + "name": "ModularBot", + "version": "0.1.0", + "authors": ["Davide Cappellini"], + "description": "Plugin gun architecture — head-on gun via virtual bullet harness", + "homepage": "", + "countryCodes": ["IT"], + "gameTypes": ["classic", "1v1"], + "platform": "Nim", + "programmingLang": "Nim" +} diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim new file mode 100644 index 0000000..4c818b0 --- /dev/null +++ b/ModularBot_garage/src/ModularBot.nim @@ -0,0 +1,187 @@ +## ModularBot — plugin gun architecture tracer bullet. +## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3) via GunHarness. +## Radar: RadarLockModule via radar harness. +## Movement: OscillatorModule (perpendicular strafing). + +import std/[math, os, strformat] +import robocode_tankroyale_botapi +import radar_harness/radar_interface +import radars/radar_lock_module +import gun_harness/gun_interface +import gun_harness/virtual_bullets as vb +import gun_harness/selector +import guns/head_on +import guns/linear +import guns/circular +import guns/tsetlin +import movements/phantom_meteor + +const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" +const DebugVBullets = false +const DebugCircular = false +const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular"] + +type + ModularBot = ref object of Bot + hasContact: bool + enemyBearing: float + lastState: WorldState + radar: RadarLockModule + tracker: VirtualTracker + headOn: HeadOnGun + linear: LinearGun + circular: CircularGun + tsetlin: TsetlinGun + mover: PhantomMeteorModule + virtualHits: int + virtualMiss: int + tick: int + currentGun: int + +proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState = + WorldState( + enemyX: ex, + enemyY: ey, + enemySpeed: espeed, + enemyHeading: eheading, + enemyEnergy: eenergy, + selfX: getX(), + selfY: getY(), + selfSpeed: getSpeed(), + selfHeading: getDirection(), + selfRadarHeading: getRadarDirection(), + selfEnergy: getEnergy(), + arenaWidth: getArenaWidth().float, + arenaHeight: getArenaHeight().float, + tick: bot.tick, + ) + +method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) = + bot.enemyBearing = directionTo(getX(), getY(), e.x, e.y) + bot.hasContact = true + bot.lastState = bot.buildState(e.x, e.y, e.speed, e.direction, e.energy) + + # Collect predictions for all power bins from all guns + var headsUp: array[len(PowerBins), GunPrediction] + var linPreds: array[len(PowerBins), GunPrediction] + var tmPreds: array[len(PowerBins), GunPrediction] + var circPreds: array[len(PowerBins), GunPrediction] + for i in 0.. 0: bot.virtualHits.float / total.float * 100.0 else: 0.0 + echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)" + ) + + # Gun selection + fire + let (selectedGun, _, power) = selectShot(bot.tracker) + + # Log gun switch + update turret/bullet colors + if selectedGun != bot.currentGun: + echo fmt"[gun] switched: {GunNames[selectedGun]}" + bot.currentGun = selectedGun + case selectedGun + of 0: setTurretColor("#FF3333"); setBulletColor("#FF6666") # HeadOn: warm red + of 1: setTurretColor("#3366FF"); setBulletColor("#6699FF") # Linear: electric blue + of 2: setTurretColor("#9933FF"); setBulletColor("#CC66FF") # Tsetlin: violet + of 3: setTurretColor("#33CC33"); setBulletColor("#66FF66") # Circular: emerald + else: discard + + let pred = case selectedGun + of 1: bot.linear.predict(bot.lastState, bulletSpeed(power)) + of 2: bot.tsetlin.predict(bot.lastState, bulletSpeed(power)) + of 3: bot.circular.predict(bot.lastState, bulletSpeed(power)) + else: bot.headOn.predict(bot.lastState, bulletSpeed(power)) + let target = aimAngle(getX(), getY(), pred.x, pred.y) + + let gunDir = getGunDirection() + let gunHeat = getGunHeat() + let gunDelta = (target - gunDir) mod 360.0 + var normDelta = gunDelta + if normDelta > 180.0: normDelta -= 360.0 + elif normDelta < -180.0: normDelta += 360.0 + + if shouldFire(gunDir, target, gunHeat): + discard setFire(power) + + # Turn gun toward predicted point every scan + setGunTurnRate(normDelta) + + +method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = + discard # Skip detailed logging for now + +method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = + setAdjustGunForBodyTurn(true) + setAdjustRadarForBodyTurn(true) + setAdjustRadarForGunTurn(true) + setBodyColor("#FF9900") # PhantomMeteor: deep orange/gold + setRadarColor("#00CCCC") # RadarLock: cyan/teal + setScanColor("#33FFFF") # RadarLock scan arc: bright teal + bot.radar.init() + bot.hasContact = false + bot.tick = 0 + bot.currentGun = -1 # Reset to trigger initial log on first selectShot + bot.mover.resetRound() + echo fmt"[gun] starting: {GunNames[0]}" + # tracker fitness persists across rounds (rolling window carries over) + +method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) = + discard + +method run*(bot: ModularBot) = + while isRunning(): + inc bot.tick + + if not bot.hasContact: + setTargetSpeed(0.0) + setTurnRate(0.0) + setRadarTurnRate(45.0) + go() + continue + + # Movement harness: oscillate perpendicular to enemy + let (spd, tr) = bot.mover.computeMove(bot.lastState) + setTargetSpeed(spd) + setTurnRate(tr) + + setRadarTurnRate(bot.radar.computeScan(bot.lastState)) + go() + +when isMainModule: + var bot = ModularBot( + tracker: vb.initTracker(4), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular + headOn: HeadOnGun(), + linear: LinearGun(), + circular: CircularGun(), + tsetlin: initTsetlinGun(), + radar: RadarLockModule(), + mover: initPhantomMeteor(), + currentGun: -1, + ) + start(bot, botJsonPath) diff --git a/ModularBot_garage/src/ModularBot_minimal.nim b/ModularBot_garage/src/ModularBot_minimal.nim new file mode 100644 index 0000000..84e37f7 --- /dev/null +++ b/ModularBot_garage/src/ModularBot_minimal.nim @@ -0,0 +1,17 @@ +## Minimal ModularBot for testing. +import std/os +import robocode_tankroyale_botapi + +const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" + +type ModularBot = ref object of Bot + +method run*(bot: ModularBot) = + while isRunning(): + setTargetSpeed(0.0) + setRadarTurnRate(45.0) + go() + +when isMainModule: + var bot = ModularBot() + start(bot, botJsonPath) diff --git a/ModularBot_garage/tests/config.nims b/ModularBot_garage/tests/config.nims new file mode 100644 index 0000000..c5ea4ed --- /dev/null +++ b/ModularBot_garage/tests/config.nims @@ -0,0 +1 @@ +--path:"../../common_libs" diff --git a/ModularBot_garage/tests/test_3rounds.nim b/ModularBot_garage/tests/test_3rounds.nim new file mode 100644 index 0000000..7ab8a48 --- /dev/null +++ b/ModularBot_garage/tests/test_3rounds.nim @@ -0,0 +1,32 @@ +import std/[os, strutils] +import test_framework/test_framework +import test_framework/battle_result +import test_framework/server_manager +import test_framework/bot_compiler +import test_framework/runner_process + +const + modularBotDir = currentSourcePath().parentDir.parentDir + tfAdversaries = currentSourcePath().parentDir.parentDir.parentDir / + "common_libs" / "test_framework" / "adversaries" + sittingDuck = tfAdversaries / "SittingDuck" + +# Skip if JARs are missing +if not fileExists("/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar") or + not fileExists("/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"): + echo "Skipping: Tank Royale JARs not found" + quit(0) + +# Compile bots +let compiledBots = compileBots(@[modularBotDir, sittingDuck]) +echo "Compiled bots: ", compiledBots + +# Run battle +ensureServer() +let stdout = runBattleRunner(getServerUrl(), @[modularBotDir, sittingDuck], 3, 300000, true) +echo "" +echo "Raw battle runner output (last 50 lines):" +let lines = stdout.split('\n') +let start = max(0, lines.len - 50) +for i in start.. en: "ModularBot" else: "Target" + echo fmt" ModularBot: {mb}, Target: {en} → {winner} wins" + check mb > 0 or en > 0 # At least one score recorded + + test "vs SpinBot (circular)": + echo "\n[2/4] ModularBot vs SpinBot" + let r = runBattle(@[modularBotDir, spinBot], rounds = 10, timeout = 180000) + let (mb, en) = extractFinalScores(r, "SpinBot") + let winner = if mb > en: "ModularBot" else: "SpinBot" + echo fmt" ModularBot: {mb}, SpinBot: {en} → {winner} wins" + check mb > 0 or en > 0 + + test "vs Crazy (unpredictable)": + echo "\n[3/4] ModularBot vs Crazy" + let r = runBattle(@[modularBotDir, crazy], rounds = 10, timeout = 180000) + let (mb, en) = extractFinalScores(r, "Crazy") + let winner = if mb > en: "ModularBot" else: "Crazy" + echo fmt" ModularBot: {mb}, Crazy: {en} → {winner} wins" + check mb > 0 or en > 0 + + test "vs TrackFire (fires back)": + echo "\n[4/4] ModularBot vs TrackFire" + let r = runBattle(@[modularBotDir, trackFire], rounds = 10, timeout = 180000) + let (mb, en) = extractFinalScores(r, "TrackFire") + let winner = if mb > en: "ModularBot" else: "TrackFire" + echo fmt" ModularBot: {mb}, TrackFire: {en} → {winner} wins" + check mb > 0 or en > 0 diff --git a/ModularBot_garage/tests/test_gun_diversity.nim b/ModularBot_garage/tests/test_gun_diversity.nim new file mode 100644 index 0000000..43121a1 --- /dev/null +++ b/ModularBot_garage/tests/test_gun_diversity.nim @@ -0,0 +1,24 @@ +import std/os +import std/strutils +import test_framework/test_framework + +if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"): + echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set" + quit(0) + +let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage" +let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7" + +echo "\n=== BATTLE 1: ModularBot vs SpinBot (Circular Movement) ===" +let r1 = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 10) +echo "\nBattle 1 Results:" +for bot in r1.results: + echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank +echo "Winners: " & r1.winners.join(", ") + +echo "\n=== BATTLE 2: ModularBot vs Crazy (Unpredictable Movement) ===" +let r2 = runBattle(@[modularBotDir, sampleBotsDir / "Crazy"], rounds = 10) +echo "\nBattle 2 Results:" +for bot in r2.results: + echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank +echo "Winners: " & r2.winners.join(", ") diff --git a/ModularBot_garage/tests/test_gun_diversity_detailed.nim b/ModularBot_garage/tests/test_gun_diversity_detailed.nim new file mode 100644 index 0000000..bb3578d --- /dev/null +++ b/ModularBot_garage/tests/test_gun_diversity_detailed.nim @@ -0,0 +1,36 @@ +import std/os +import std/strutils +import test_framework/test_framework + +if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"): + echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set" + quit(0) + +let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage" +let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7" + +echo "\n" & "=".repeat(60) +echo "BATTLE 1: ModularBot vs SpinBot (Circular Movement)" +echo "=".repeat(60) +let r1 = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 10) +echo "\nRound Details:" +for i, round in r1.rounds: + echo " Round " & $i & ":" + for bot in round.results: + echo " " & bot.name & ": score=" & $bot.score & " rank=" & $bot.rank & " survived=" & $bot.survived +echo "\nFinal Scores:" +for bot in r1.results: + echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount + +echo "\n" & "=".repeat(60) +echo "BATTLE 2: ModularBot vs Crazy (Unpredictable Movement)" +echo "=".repeat(60) +let r2 = runBattle(@[modularBotDir, sampleBotsDir / "Crazy"], rounds = 10) +echo "\nRound Details:" +for i, round in r2.rounds: + echo " Round " & $i & ":" + for bot in round.results: + echo " " & bot.name & ": score=" & $bot.score & " rank=" & $bot.rank & " survived=" & $bot.survived +echo "\nFinal Scores:" +for bot in r2.results: + echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount diff --git a/ModularBot_garage/tests/test_gun_diversity_verbose.nim b/ModularBot_garage/tests/test_gun_diversity_verbose.nim new file mode 100644 index 0000000..b9d5388 --- /dev/null +++ b/ModularBot_garage/tests/test_gun_diversity_verbose.nim @@ -0,0 +1,34 @@ +import std/os +import std/strutils +import test_framework/test_framework + +if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"): + echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set" + quit(0) + +let modularBotDir = "/home/davide/Projects/SirRoboGarage/ModularBot_garage" +let sampleBotsDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7" + +echo "\n" & "=".repeat(60) +echo "BATTLE 1: ModularBot vs SpinBot (Circular Movement)" +echo "=".repeat(60) +let r1 = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 10) +echo "\nFinal Scores:" +for bot in r1.results: + echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount +echo "Winner: " & r1.winners[0] + +echo "\n" & "=".repeat(60) +echo "BATTLE 2: ModularBot vs Crazy (Unpredictable Movement)" +echo "=".repeat(60) +let r2 = runBattle(@[modularBotDir, sampleBotsDir / "Crazy"], rounds = 10) +echo "\nFinal Scores:" +for bot in r2.results: + echo " " & bot.name & ": score=" & $bot.totalScore & " rank=" & $bot.rank & " firstPlaces=" & $bot.firstPlaces & " survived=" & $bot.survivalCount +echo "Winner: " & r2.winners[0] + +echo "\n" & "=".repeat(60) +echo "SUMMARY" +echo "=".repeat(60) +echo "Battle 1 (vs SpinBot): " & (if "ModularBot" in r1.winners: "WON" else: "LOST") +echo "Battle 2 (vs Crazy): " & (if "ModularBot" in r2.winners: "WON" else: "LOST") diff --git a/ModularBot_garage/tests/test_gun_selection.nim b/ModularBot_garage/tests/test_gun_selection.nim new file mode 100644 index 0000000..24ef190 --- /dev/null +++ b/ModularBot_garage/tests/test_gun_selection.nim @@ -0,0 +1,39 @@ +## Unit test for ModularBot gun selection +import unittest +import gun_harness/virtual_bullets as vb +import gun_harness/selector +import gun_harness/gun_interface + +suite "Gun Selection": + test "selector picks best gun and power": + # Create a simple tracker with mock vbullets + var t = initTracker(4) + + # Spawn some bullets on gun 1 (linear) to simulate tracking history + let state = WorldState( + enemyX: 100, enemyY: 100, enemySpeed: 5.0, enemyHeading: 0.0, + selfX: 50, selfY: 50, selfSpeed: 0.0, selfHeading: 0.0, + selfRadarHeading: 0.0, selfEnergy: 100.0, + arenaWidth: 800, arenaHeight: 600, tick: 1 + ) + + # Gun 1 (linear) predictions + var linPreds: array[len(PowerBins), GunPrediction] + for i in 0.. 180.0: delta -= 360.0 + elif delta < -180.0: delta += 360.0 + abs(delta) <= AimThresholdDeg and gunHeat <= 0.0 + +proc selectShot*(t: VirtualTracker): (GunId, int, float) = + ## Returns (gunId, powerBinIdx, power) — the shot to take this tick. + let gunId = t.bestGun() + let (binIdx, power) = t.bestPower(gunId) + result = (gunId, binIdx, power) diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim new file mode 100644 index 0000000..89710dc --- /dev/null +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -0,0 +1,133 @@ +## Virtual bullet tracker. +## Spawns virtual bullets per gun×power bin every tick (no real firing). +## Resolves by travel distance. Rolling window fitness per gun×power. +## Calls onResult() on the owning gun when a bullet resolves. + +import std/math +import gun_interface + +const + PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed + WindowSize* = 50 ## rolling window ticks for fitness + MaxBullets* = 512 ## hard cap; ponytail: ring buffer, resize if more guns added + MinHitRate* = 0.40 ## 40% threshold for acceptable power selection + +type + GunId* = int ## index into the guns seq + + VirtualBullet* = object + gunId*: GunId + powerBin*: int ## index into PowerBins + fireX*, fireY*: float + aimX*, aimY*: float ## predicted target (absolute) + bulletSpeed*: float + travelDist*: float ## accumulated px so far + fireDist*: float ## distance to target at fire time + active*: bool + + FitnessWindow* = object + ## Ring buffer of hit booleans. + hits*: array[WindowSize, bool] + count*: int ## total samples so far (capped at WindowSize for rate) + head*: int + + GunFitness* = object + bins*: array[len(PowerBins), FitnessWindow] + + VirtualTracker* = object + bullets*: array[MaxBullets, VirtualBullet] + head*: int ## ring buffer head + fitness*: seq[GunFitness] ## indexed by GunId + +proc initTracker*(numGuns: int): VirtualTracker = + result.fitness = newSeq[GunFitness](numGuns) + +proc hitRate*(fw: FitnessWindow): float = + ## Returns fraction of hits in the rolling window. 0.0 when no data. + if fw.count == 0: return 0.0 + let n = min(fw.count, WindowSize) + var h = 0 + for i in 0..= MinHitRate. + ## Falls back to lowest power bin if nothing qualifies yet. + result = (0, PowerBins[0]) + for binIdx in countdown(len(PowerBins) - 1, 0): + let rate = t.fitness[gunId].bins[binIdx].hitRate() + if rate >= MinHitRate or t.fitness[gunId].bins[binIdx].count == 0: + return (binIdx, PowerBins[binIdx]) + +proc bestGun*(t: VirtualTracker): GunId = + ## Pick gun with highest hit rate across all power bins. + ## ponytail: O(n*bins), fine for small gun counts + var bestRate = -1.0 + result = 0 + for gunId in 0.. bestRate: + bestRate = r + result = gunId diff --git a/common_libs/guns/circular.nim b/common_libs/guns/circular.nim index a4a2f8f..c20f25f 100644 --- a/common_libs/guns/circular.nim +++ b/common_libs/guns/circular.nim @@ -6,9 +6,11 @@ import std/math import gun_harness/gun_interface type CircularGun* = object - prevHeading: float ## enemy heading from last frame - prevTick: int ## tick at last observation + prevHeading: float ## enemy heading from the tick before current + prevTick: int ## tick of prevHeading observation hasPrev: bool + cachedOmega: float ## omega (rad/tick) computed on first call this tick + cachedTick: int ## tick for which cachedOmega was computed proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPrediction = if bulletSpeed <= 0.0: @@ -22,36 +24,31 @@ proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPre if not g.hasPrev: # ponytail: first-frame fallback to head-on, needs 2 frames for turn rate g.prevHeading = state.enemyHeading - g.prevTick = state.tick - g.hasPrev = true + g.prevTick = state.tick + g.hasPrev = true return GunPrediction(x: state.enemyX, y: state.enemyY) - # Save old state before potential update (predict is called once per power bin per tick) - let oldHeading = g.prevHeading - let oldTick = g.prevTick - - # Update on new tick only + # On new tick: recompute cachedOmega and advance the heading window. + # On same-tick calls (multiple power bins): reuse cachedOmega so omega + # doesn't collapse to zero on bins 1+. if state.tick > g.prevTick: + var turnRate = state.enemyHeading - g.prevHeading + if turnRate > 180.0: turnRate -= 360.0 + elif turnRate < -180.0: turnRate += 360.0 + let tickDelta = max(1, state.tick - g.prevTick) + g.cachedOmega = degToRad(turnRate / tickDelta.float) + g.cachedTick = state.tick g.prevHeading = state.enemyHeading - g.prevTick = state.tick + g.prevTick = state.tick - # Compute turn rate using captured old state - var turnRate = state.enemyHeading - oldHeading - if turnRate > 180.0: turnRate -= 360.0 - elif turnRate < -180.0: turnRate += 360.0 - - # Divide by actual tick delta (scans may not be every tick) - let tickDelta = max(1, state.tick - oldTick) - turnRate = turnRate / tickDelta.float - - # Closed-form integrated trajectory (Robowiki circular targeting) - # 0°=East: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t) - # → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)] - # → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)] + let omega = g.cachedOmega let theta = degToRad(state.enemyHeading) - let omega = degToRad(turnRate) # rad/tick let v = state.enemySpeed + # Closed-form integrated trajectory (Robowiki circular targeting) + # 0°=East CCW+: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t) + # → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)] + # → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)] var ex = state.enemyX var ey = state.enemyY var t = ticks diff --git a/common_libs/guns/head_on.nim b/common_libs/guns/head_on.nim new file mode 100644 index 0000000..c33be38 --- /dev/null +++ b/common_libs/guns/head_on.nim @@ -0,0 +1,13 @@ +## Head-on gun: predict enemy stays at current position. +## Simplest possible Gun implementation — baseline reference. + +import gun_harness/gun_interface + +type HeadOnGun* = object + discard + +proc predict*(g: var HeadOnGun, state: WorldState, bulletSpeed: float): GunPrediction = + GunPrediction(x: state.enemyX, y: state.enemyY) + +proc onResult*(g: var HeadOnGun, e: FeedbackEvent) = + discard # analytical gun — no learning diff --git a/common_libs/guns/linear.nim b/common_libs/guns/linear.nim new file mode 100644 index 0000000..17500eb --- /dev/null +++ b/common_libs/guns/linear.nim @@ -0,0 +1,22 @@ +## Linear gun: predict enemy continues at current velocity and heading. +## Coordinate system: 0° = East, CCW positive (Tank Royale standard). + +import std/math +import gun_harness/gun_interface + +type LinearGun* = object + discard + +proc predict*(g: var LinearGun, state: WorldState, bulletSpeed: float): GunPrediction = + let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) + let ticksToArrive = dist / bulletSpeed + let headingRad = degToRad(state.enemyHeading) + var px = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive + var py = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive + # Clamp to arena bounds + px = clamp(px, 0.0, state.arenaWidth) + py = clamp(py, 0.0, state.arenaHeight) + GunPrediction(x: px, y: py) + +proc onResult*(g: var LinearGun, e: FeedbackEvent) = + discard # analytical gun — no learning diff --git a/common_libs/guns/tsetlin.nim b/common_libs/guns/tsetlin.nim new file mode 100644 index 0000000..8fb2ce6 --- /dev/null +++ b/common_libs/guns/tsetlin.nim @@ -0,0 +1,286 @@ +## Tsetlin Machine gun — regression TM for aiming correction on top of linear extrapolation. +## Self-contained: includes binary encoding and TM predictor inline. +## Implements Gun interface: predict(state, bulletSpeed) → GunPrediction, onResult(FeedbackEvent). + +import std/[math, random] +import gun_harness/gun_interface + +# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ───── + +const + TM_FRAME_BITS = 83 + TM_SELF_BITS = 40 + TM_WINDOW_SIZE = 10 + TM_TOTAL_BITS* = TM_FRAME_BITS * TM_WINDOW_SIZE + TM_SELF_BITS # 870 + TM_MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena + +type + TmBinaryVector = array[TM_TOTAL_BITS, uint8] + TmFrameEncoded = array[TM_FRAME_BITS, uint8] + TmSelfEncoded = array[TM_SELF_BITS, uint8] + +proc tmToGray(value: int): int = value xor (value shr 1) + +proc tmToBits(value: int, bits: int): seq[uint8] = + result = newSeq[uint8](bits) + let gray = tmToGray(value) + for i in 0.. 0: + hasIncluded = true + if lits[lit] == 0: return 0'u8 + return if hasIncluded: 1'u8 else: 0'u8 + +proc tmForwardWithCache(net: TmNet, input: TmBinaryVector, + cache: var TmClauseCache): (float, float) = + let lits = tmMakeLiterals(input) + var vx = 0.0; var vy = 0.0 + for c in 0..= pFeedback: continue + let pol = tmPolarity(c) + let cOut = cache[outIdx * TM_N_CLAUSES + c] + + if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0): + # Type I / Ib feedback + for lit in 0.. 0: + net.states[si] = int16(max(st - 1, -TM_N_STATES)) + +# ── 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 + +type + TmTrace = object + predX, predY: float # key: matches FeedbackEvent.prediction + input: TmBinaryVector + cache: TmClauseCache + alive: bool + + TsetlinGun* = object + net: TmNet + frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded] + bufferCount: int + traces: array[TM_TRACE_SLOTS, TmTrace] + traceHead: int + +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 + randomize() + +proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPrediction = + # Encode current frame and push into window + let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) + let bearing = radToDeg(arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX)) + + let frame = tmEncodeFrame( + bearing, dist, state.enemySpeed, state.enemyHeading, + state.arenaHeight - state.enemyY, state.enemyY, + 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 + + # Warm-up: until window is full, fall back to linear extrapolation + let ticksToArrive = if bulletSpeed > 0.0: dist / bulletSpeed else: 1.0 + let headingRad = degToRad(state.enemyHeading) + let linearX = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive + let linearY = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive + + if g.bufferCount < TM_WINDOW_SIZE: + return GunPrediction(x: clamp(linearX, 0.0, state.arenaWidth), + y: clamp(linearY, 0.0, state.arenaHeight)) + + let selfState = tmEncodeSelf( + state.arenaHeight - state.selfY, state.selfY, + state.arenaWidth - state.selfX, state.selfX, + state.selfEnergy, + true, # canFire not in WorldState; assume true + ) + let vec = tmEncodeFullVector(g.frameBuffer, selfState) + + var cache: TmClauseCache + let (cx, cy) = tmForwardWithCache(g.net, vec, cache) + + 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 + + GunPrediction(x: predX, y: predY) + +proc onResult*(g: var TsetlinGun, e: FeedbackEvent) = + # Find matching trace by prediction coords + for i in 0.. 0.01 or abs(t.predY - e.prediction.y) > 0.01: + continue + # Shaped reward: residual = (actual enemy pos) - (bullet impact pos) + # FeedbackEvent carries missDistance but not the direction. + # We reconstruct: aimed at (predX, predY); miss is distance to current enemy. + # Use miss distance as magnitude; direction unknown → scale back along aim vector. + # ponytail: zero-direction residual when miss=0 is fine; TM learns from magnitude via pFeedback + let missSign = if e.hit: 0.0 else: 1.0 + let residualX = (e.prediction.x - t.predX) * missSign # trivially 0; real signal is missDistance + # Better: treat miss distance as residual magnitude along (enemy - pred) direction + # We don't have enemy pos here directly, but we can scale correction proportionally. + # Simplest correct signal: pass missDistance as residual magnitude for both dims. + let rMag = e.missDistance * missSign + let lits = tmMakeLiterals(t.input) + # Apply residual equally to both axes (we don't know direction split) + # ponytail: split 50/50; upgrade to directional when FeedbackEvent carries enemy pos + let r = rMag / sqrt(2.0) + g.net.tmLearnOne(0, lits, t.cache, r) + g.net.tmLearnOne(1, lits, t.cache, r) + t.alive = false + break diff --git a/common_libs/movement_harness/movement_interface.nim b/common_libs/movement_harness/movement_interface.nim new file mode 100644 index 0000000..7412554 --- /dev/null +++ b/common_libs/movement_harness/movement_interface.nim @@ -0,0 +1,20 @@ +## Movement harness — shared types and MovementModule concept. +## Mirrors gun_interface.nim structure. + +import gun_harness/gun_interface + +export gun_interface.WorldState + +type + MoveCommand* = tuple[speed: float, turnRate: float] + ## speed: target speed in px/tick, clamped to ±8 by bot API + ## turnRate: body turn rate in degrees/tick + +## MovementModule concept — any type T implementing computeMove is valid. +template isMovementModule*(T: typedesc): bool = + compiles( + block: + var m: T + let ws = WorldState() + let cmd: MoveCommand = m.computeMove(ws) + ) diff --git a/common_libs/movements/oscillator.nim b/common_libs/movements/oscillator.nim new file mode 100644 index 0000000..edef994 --- /dev/null +++ b/common_libs/movements/oscillator.nim @@ -0,0 +1,50 @@ +## Oscillator movement — perpendicular strafing relative to enemy bearing. +## Reverses direction every PERIOD ticks; wall proximity triggers one reversal +## then locks out further wall-reversals for WALL_LOCKOUT ticks to prevent +## sign-flip every tick (which would zero net movement and park the bot). + +import std/math +import gun_harness/gun_interface +import movement_harness/movement_interface + +const + MaxSpeed* = 8.0 ## Tank Royale max speed + Period* = 40 ## ticks between reversals; ponytail: fixed, tune if evasion feels predictable + WallMargin* = 80.0 ## px from wall to trigger early reversal + WallLockout* = 20 ## ticks to suppress further wall-reversals after one fires + +type OscillatorModule* = object + sign: float ## +1 or -1, forward/backward relative to perp heading + elapsed: int ## ticks since last reversal + wallLockout: int ## remaining ticks where wall-reversal is suppressed + +proc initOscillator*(): OscillatorModule = + OscillatorModule(sign: 1.0, elapsed: 0, wallLockout: 0) + +proc computeMove*(m: var OscillatorModule, ws: WorldState): MoveCommand = + inc m.elapsed + if m.wallLockout > 0: dec m.wallLockout + + # Perpendicular heading to enemy: enemy bearing + 90° + let enemyBearing = arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * (180.0 / PI) + let perpHeading = (enemyBearing + 90.0) mod 360.0 + + let nearWall = ws.selfX < WallMargin or ws.selfX > ws.arenaWidth - WallMargin or + ws.selfY < WallMargin or ws.selfY > ws.arenaHeight - WallMargin + + if m.elapsed >= Period or (nearWall and m.wallLockout == 0): + m.sign *= -1.0 + m.elapsed = 0 + if nearWall: m.wallLockout = WallLockout + + # Turn rate = delta from current heading toward perpendicular + var delta = perpHeading - ws.selfHeading + while delta > 180.0: delta -= 360.0 + while delta < -180.0: delta += 360.0 + + # When moving backward, flip the turn to keep perpendicular + var normDelta = if m.sign < 0: delta - 180.0 else: delta + while normDelta > 180.0: normDelta -= 360.0 + while normDelta < -180.0: normDelta += 360.0 + + (speed: m.sign * MaxSpeed, turnRate: normDelta.clamp(-10.0, 10.0)) diff --git a/common_libs/movements/phantom_meteor.nim b/common_libs/movements/phantom_meteor.nim new file mode 100644 index 0000000..1a0fb52 --- /dev/null +++ b/common_libs/movements/phantom_meteor.nim @@ -0,0 +1,232 @@ +## phantom_meteor.nim — PhantomMeteor gravity engine as a MovementModule. +## Wraps gravity.nim (GravityEngine) to satisfy the MovementModule concept. +## Fire detection, phantom bullets, waves, and danger histogram are all internal. +## All angles in radians internally; interface outputs degrees for bot API. + +import std/math +import gun_harness/gun_interface +import movement_harness/movement_interface + +# Inline the gravity engine types and logic here to keep the module self-contained. +# We re-export nothing from gravity.nim — it's not on the common_libs path. +# ponytail: copy instead of import; if gravity.nim moves to common_libs, collapse. + +# ── Vec2 (local, unexported) ────────────────────────────────────────────────── + +type + Vec2 = object + x, y: float64 + +proc vec2(x, y: float64): Vec2 {.inline.} = Vec2(x: x, y: y) +proc `+`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x+b.x, a.y+b.y) +proc `-`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x-b.x, a.y-b.y) +proc `*`(a: Vec2, s: float64): Vec2 {.inline.} = vec2(a.x*s, a.y*s) +proc magnitude(v: Vec2): float64 {.inline.} = sqrt(v.x*v.x + v.y*v.y) +proc normalize(v: Vec2): Vec2 = + let m = v.magnitude + if m < 1e-9: vec2(0.0, 0.0) else: vec2(v.x/m, v.y/m) +proc dist(a, b: Vec2): float64 {.inline.} = (a-b).magnitude + +# ── Gravity engine types ────────────────────────────────────────────────────── + +const NumBins = 41 + +type + DangerHistogram = object + bins: array[NumBins, float64] + + PhantomBullet = object + pos, vel: Vec2 + weight: float64 + alive: bool + ticks: int + + Wave = object + origin: Vec2 + heading: float64 + speed: float64 + radius: float64 + startDist: float64 + + GravityEngine = object + histogram: DangerHistogram + phantoms: seq[PhantomBullet] + waves: seq[Wave] + prevEnemyEnergy: float64 + +# ── Gravity engine internals ────────────────────────────────────────────────── + +const + KBullet = 1500.0 + KWall = 4000.0 + KEnemy = 300.0 + PreferredDist = 400.0 + NumPhantoms = 25 + MinDist = 20.0 + WallMinDist = 40.0 + VeryClose = 40.0 + +proc initEngine(): GravityEngine = + var h: DangerHistogram + for i in 0..= 0.1 and drop <= 3.0: (true, drop) else: (false, 0.0) + +proc spawnPhantoms(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) = + let base = arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x) + let maxA = mea(bspeed) + for i in 0..= 50: eng.phantoms[i].alive = false + if eng.phantoms.len > 200: + var live: seq[PhantomBullet] + for p in eng.phantoms: + if p.alive: live.add p + eng.phantoms = live + +proc spawnWave(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) = + eng.waves.add Wave( + origin: enemyPos, + heading: arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x), + speed: bspeed, radius: 0.0, startDist: dist(enemyPos, botPos)) + +proc tickWaves(eng: var GravityEngine, botPos: Vec2) = + var i = 0 + while i < eng.waves.len: + eng.waves[i].radius += eng.waves[i].speed + if eng.waves[i].radius >= eng.waves[i].startDist: + let toBot = arctan2(botPos.y - eng.waves[i].origin.y, + botPos.x - eng.waves[i].origin.x) + var off = toBot - eng.waves[i].heading + while off > PI: off -= 2.0*PI + while off < -PI: off += 2.0*PI + let maxA = mea(eng.waves[i].speed) + if maxA >= 1e-9: + let gf = (off / maxA).clamp(-1.0, 1.0) + eng.histogram.bins[gfToBin(gf)] += 1.0 + eng.waves.del(i) + else: + inc i + +proc computeForces(eng: GravityEngine, botPos, enemyPos: Vec2, arenaW, arenaH: float64): Vec2 = + var total = vec2(0.0, 0.0) + + # Priority 1: nearby phantoms + var hasClose = false + for ph in eng.phantoms: + if ph.alive: + let d = dist(ph.pos, botPos) + if d < 150.0: + hasClose = true + total = total + normalize(botPos - ph.pos) * (KBullet * (1.0 - d/150.0)) + + # Priority 2: wall escape (hard override) + let minW = min(min(botPos.x, arenaW-botPos.x), min(botPos.y, arenaH-botPos.y)) + if minW < VeryClose: + var ex = 0.0; var ey = 0.0 + if botPos.x < VeryClose: ex = 1.0 + if arenaW - botPos.x < VeryClose: ex = -1.0 + if botPos.y < VeryClose: ey = 1.0 + if arenaH - botPos.y < VeryClose: ey = -1.0 + let m = sqrt(ex*ex + ey*ey) + if m > 0.1: return normalize(vec2(ex,ey)) * 500.0 + + # Priority 3: distance to enemy + let de = dist(enemyPos, botPos) + if not hasClose and de > 100.0: + if de < PreferredDist: + total = total + normalize(botPos - enemyPos) * KEnemy + elif de > PreferredDist + 100.0: + total = total + normalize(enemyPos - botPos) * (KEnemy * 0.3) + + # Priority 4: weak wall repulsion + let dL = max(botPos.x, WallMinDist) + let dR = max(arenaW - botPos.x, WallMinDist) + let dB = max(botPos.y, WallMinDist) + let dT = max(arenaH - botPos.y, WallMinDist) + total = total + vec2(KWall*0.5/(dL*dL) - KWall*0.5/(dR*dR), + KWall*0.5/(dB*dB) - KWall*0.5/(dT*dT)) + total + +# ── MovementModule wrapper ──────────────────────────────────────────────────── + +type PhantomMeteorModule* = object + engine: GravityEngine + +proc initPhantomMeteor*(): PhantomMeteorModule = + PhantomMeteorModule(engine: initEngine()) + +proc resetRound*(m: var PhantomMeteorModule) = + ## Clear per-round transients (phantoms, waves, energy baseline), keep histogram. + m.engine.phantoms = @[] + m.engine.waves = @[] + m.engine.prevEnemyEnergy = 100.0 + +proc computeMove*(m: var PhantomMeteorModule, ws: WorldState): MoveCommand = + let botPos = vec2(ws.selfX, ws.selfY) + let enemyPos = vec2(ws.enemyX, ws.enemyY) + + # Advance simulation + m.engine.tickPhantoms() + m.engine.tickWaves(botPos) + + # Fire detection → spawn phantoms + wave + let (fired, power) = m.engine.detectFire(ws.enemyEnergy) + if fired: + let bspeed = 20.0 - 3.0 * power + m.engine.spawnPhantoms(enemyPos, botPos, bspeed) + m.engine.spawnWave(enemyPos, botPos, bspeed) + + # Compute force vector + let force = m.engine.computeForces(botPos, enemyPos, ws.arenaWidth, ws.arenaHeight) + + if force.magnitude < 1e-9: + return (speed: 0.0, turnRate: 0.0) + + # Enemy bearing (radians, math convention: 0=East, CCW+) + let enemyBearingRad = arctan2(enemyPos.y - botPos.y, enemyPos.x - botPos.x) + + # Two perpendicular directions to enemy bearing (±90°) + let perpCCW = vec2(-sin(enemyBearingRad), cos(enemyBearingRad)) # +90° + let perpCW = vec2( sin(enemyBearingRad), -cos(enemyBearingRad)) # -90° + + # Pick perpendicular direction that aligns with force vector + let fn = force.normalize + let desiredDeg = + if fn.x * perpCCW.x + fn.y * perpCCW.y >= fn.x * perpCW.x + fn.y * perpCW.y: + radToDeg(arctan2(perpCCW.y, perpCCW.x)) + else: + radToDeg(arctan2(perpCW.y, perpCW.x)) + + # Delta from current heading + var delta = desiredDeg - ws.selfHeading + while delta > 180.0: delta -= 360.0 + while delta < -180.0: delta += 360.0 + + # Dot-product trick: if |delta| > 90 → reverse, less turning + let goForward = abs(delta) <= 90.0 + if not goForward: + delta = if delta >= 0.0: delta - 180.0 else: delta + 180.0 + + (speed: if goForward: 8.0 else: -8.0, + turnRate: delta.clamp(-10.0, 10.0)) diff --git a/common_libs/radar_harness/radar_interface.nim b/common_libs/radar_harness/radar_interface.nim new file mode 100644 index 0000000..e24077f --- /dev/null +++ b/common_libs/radar_harness/radar_interface.nim @@ -0,0 +1,15 @@ +## Radar harness — shared RadarModule concept. +## Coordinate system: 0° = East, CCW positive (Tank Royale standard). + +import ../gun_harness/gun_interface + +export gun_interface # re-export WorldState + +## Radar concept — any type T implementing computeScan is a valid radar module. +template isRadarModule*(T: typedesc): bool = + compiles( + block: + var r: T + let ws = WorldState() + let rate: float = r.computeScan(ws) + ) diff --git a/common_libs/radars/radar_lock_module.nim b/common_libs/radars/radar_lock_module.nim new file mode 100644 index 0000000..d8f5c69 --- /dev/null +++ b/common_libs/radars/radar_lock_module.nim @@ -0,0 +1,18 @@ +## RadarLock adapter — wraps radar_lock.doRadar() as a RadarModule. + +import std/math +import ../radar_harness/radar_interface +import ../radar_lock/radar_lock as radar_lock_impl + +export radar_interface + +type RadarLockModule* = object + +proc computeScan*(m: RadarLockModule, state: WorldState): float = + ## Returns radar turn rate (deg/tick) to lock onto enemy. + let enemyBearing = arctan2(state.enemyY - state.selfY, + state.enemyX - state.selfX).radToDeg + radar_lock_impl.doRadar(state.selfRadarHeading, enemyBearing) + +proc init*(m: var RadarLockModule) = + radar_lock_impl.init() diff --git a/tools/battle_runner/RunBNNBattle.java b/tools/battle_runner/RunBNNBattle.java new file mode 100644 index 0000000..37a3421 --- /dev/null +++ b/tools/battle_runner/RunBNNBattle.java @@ -0,0 +1,81 @@ +import dev.robocode.tankroyale.runner.*; +import dev.robocode.tankroyale.client.model.*; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * BNNBot validation battle: BNNBot vs Target (1 round, embedded server). + * + * Env vars: + * BNN_BOT_DIR — path to BNNBot directory (must contain BNNBot.json + BNNBot.sh) + * SAMPLE_BOTS_DIR — path to sample bots archive + * BNNBOT_CSV — forwarded to bot process: "1" enables CSV output + * ENEMY_BOT — name of enemy bot subfolder under SAMPLE_BOTS_DIR (default: Target) + * NUM_ROUNDS — number of rounds to run (default: 5) + * + * Usage: java -cp ".:$JAR" RunBNNBattle + */ +public class RunBNNBattle { + + static final Map idToName = new ConcurrentHashMap<>(); + + public static void main(String[] args) { + Logger.getLogger("dev.robocode.tankroyale").setLevel(Level.WARNING); + + String bnnBotDir = requireEnv("BNN_BOT_DIR"); + String sampleBots = requireEnv("SAMPLE_BOTS_DIR"); + String enemyBot = System.getenv().getOrDefault("ENEMY_BOT", "Target"); + int numRounds = Integer.parseInt(System.getenv().getOrDefault("NUM_ROUNDS", "5")); + + System.out.println("Enemy: " + enemyBot + " Rounds: " + numRounds); + + try (var runner = BattleRunner.create(b -> b.embeddedServer().suppressServerOutput())) { + var setup = BattleSetup.classic(s -> s.setNumberOfRounds(numRounds)); + var bots = List.of( + BotEntry.of(bnnBotDir), + BotEntry.of(sampleBots + "/" + enemyBot) + ); + + var owner = new Object(); + try (var handle = runner.startBattleAsync(setup, bots)) { + + handle.getOnGameStarted().on(owner, event -> { + System.out.println("=== GAME STARTED ==="); + for (var p : event.getParticipants()) { + idToName.put(p.getId(), p.getName()); + System.out.printf(" #%d %s%n", p.getId(), p.getName()); + } + System.out.printf("%-6s %-12s %-10s%n", "Turn", "Name", "RadarDir"); + System.out.println("-".repeat(40)); + }); + + handle.getOnTickEvent().on(owner, event -> { + int turn = event.getTurnNumber(); + for (var state : event.getBotStates()) + if (state.getName() != null) idToName.putIfAbsent(state.getId(), state.getName()); + }); + + handle.getOnRoundEnded().on(owner, event -> + System.out.printf("%n=== ROUND %d ENDED (turn %d) ===%n", + event.getRoundNumber(), event.getTurnNumber())); + + var results = handle.awaitResults(); + System.out.printf("%n=== RESULTS (%d rounds) ===%n", results.getNumberOfRounds()); + for (var r : results.getResults()) + System.out.printf(" #%d %-25s %d pts%n", r.getRank(), r.getName(), r.getTotalScore()); + } + } + } + + static String requireEnv(String name) { + var v = System.getenv(name); + if (v == null || v.isBlank()) { + System.err.println("Error: " + name + " env var not set"); + System.exit(1); + } + return v; + } +}