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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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..<WINDOW_SIZE:
|
||||
for fn in ["bearing_sin", "bearing_cos", "distance", "velocity",
|
||||
"heading_sin", "heading_cos", "enemy_x", "enemy_y", "enemy_energy"]:
|
||||
result &= fmt",f{fr}_{fn}"
|
||||
|
||||
proc openCsv(bot: BNNBot) =
|
||||
if not csvEnabled: return
|
||||
createDir(csvPath(bot.roundNum).parentDir)
|
||||
bot.csvFile = open(csvPath(bot.roundNum), fmWrite)
|
||||
bot.csvOpen = true
|
||||
bot.csvFile.writeLine(buildHeader())
|
||||
|
||||
proc closeCsv(bot: BNNBot) =
|
||||
if bot.csvOpen:
|
||||
bot.csvFile.close()
|
||||
bot.csvOpen = false
|
||||
|
||||
proc writeRow(bot: BNNBot) =
|
||||
if not bot.csvOpen: return
|
||||
var line = $bot.tick
|
||||
for i in 0..<WINDOW_SIZE:
|
||||
let f = bot.frameRawBuf[i]
|
||||
line &= fmt",{f.bSin},{f.bCos},{f.dist},{f.vel},{f.hSin},{f.hCos},{f.ex:.2f},{f.ey:.2f},{f.energy:.2f}"
|
||||
bot.csvFile.writeLine(line)
|
||||
|
||||
# ── Bot methods ──────────────────────────────────────────────────────────────
|
||||
|
||||
method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
|
||||
let bx = getX(); let by = getY()
|
||||
@@ -66,10 +119,23 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
|
||||
)
|
||||
let encoded = encodeFrame(frame)
|
||||
|
||||
# Shift window — index 0 = newest
|
||||
for i in countdown(WINDOW_SIZE - 1, 1):
|
||||
bot.frameBuffer[i] = bot.frameBuffer[i - 1]
|
||||
bot.frameRawBuf[i] = bot.frameRawBuf[i - 1]
|
||||
bot.frameBuffer[0] = encoded
|
||||
|
||||
# Build raw decimal record for this frame
|
||||
let bSin = int((sin(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
|
||||
let bCos = int((cos(degToRad(bot.enemyBearing)) + 1.0) / 2.0 * 199.0)
|
||||
let dist = int(clamp(bot.distance / 1414.0 * 99.0, 0.0, 99.0))
|
||||
let vel = int(clamp(bot.velocity + 8.0, 0.0, 16.0))
|
||||
let hSin = int((sin(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
|
||||
let hCos = int((cos(degToRad(bot.heading)) + 1.0) / 2.0 * 199.0)
|
||||
bot.frameRawBuf[0] = FrameRaw(bSin: bSin, bCos: bCos, dist: dist, vel: vel,
|
||||
hSin: hSin, hCos: hCos,
|
||||
ex: e.x, ey: e.y, energy: e.energy)
|
||||
|
||||
if bot.bufferCount < WINDOW_SIZE:
|
||||
inc bot.bufferCount
|
||||
if bot.bufferCount < WINDOW_SIZE:
|
||||
@@ -99,11 +165,9 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
|
||||
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
|
||||
|
||||
# Shaped reward: +1.0 for perfect hit, decays toward -1.0 as miss distance grows
|
||||
# Using exponential decay: reward = 2.0 * exp(-missDistance / 36.0) - 1.0
|
||||
let reward = 2.0 * exp(-missDistance / 36.0) - 1.0
|
||||
bot.net.learn(b.trace, reward)
|
||||
|
||||
# Track hit/miss for display: hit if within 36px, miss otherwise
|
||||
if missDistance < 36.0:
|
||||
inc bot.virtualHits
|
||||
else:
|
||||
@@ -134,6 +198,9 @@ method onScannedBot*(bot: BNNBot, e: ScannedBotEvent) =
|
||||
active: true,
|
||||
)
|
||||
|
||||
# ── CSV row ───────────────────────────────────────────────────────────
|
||||
bot.writeRow()
|
||||
|
||||
# ── stats & echo ─────────────────────────────────────────────────────
|
||||
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
|
||||
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
|
||||
@@ -159,8 +226,13 @@ method onRoundStarted*(bot: BNNBot, e: RoundStartedEvent) =
|
||||
bot.tick = 0
|
||||
bot.hasPrev = false
|
||||
bot.bufferCount = 0
|
||||
inc bot.roundNum
|
||||
bot.openCsv()
|
||||
# net and epsilon persist across rounds (learning carries over)
|
||||
|
||||
method onRoundEnded*(bot: BNNBot, e: RoundEndedEventForBot) =
|
||||
bot.closeCsv()
|
||||
|
||||
method onGameStarted*(bot: BNNBot, e: GameStartedEventForBot) =
|
||||
discard
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "TsetlinBot",
|
||||
"version": "0.1.0",
|
||||
"authors": ["Davide Cappellini"],
|
||||
"description": "Regression Tsetlin Machine bot — learns aiming with virtual bullet feedback",
|
||||
"homepage": "",
|
||||
"countryCodes": ["IT"],
|
||||
"gameTypes": ["classic", "1v1"],
|
||||
"platform": "Nim",
|
||||
"programmingLang": "Nim"
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
# TsetlinBot — Regression Tsetlin Machine for aiming.
|
||||
# Identical to BNNBot except uses tsetlin_predictor instead of hebbian.
|
||||
|
||||
import std/[math, os, strutils, random]
|
||||
import robocode_tankroyale_botapi
|
||||
import radar_lock/radar_lock as radar_lock
|
||||
import binary_encoding
|
||||
import tsetlin_predictor
|
||||
|
||||
const botJsonPath = currentSourcePath().parentDir / "TsetlinBot.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
|
||||
TsetlinBot = 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: TsetlinNet
|
||||
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: TsetlinBot, e: ScannedBotEvent) =
|
||||
let bx = getX(); let by = getY()
|
||||
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
|
||||
bot.distance = distanceTo(bx, by, e.x, e.y)
|
||||
bot.heading = e.direction
|
||||
bot.velocity = e.speed
|
||||
bot.lastEnemyX = e.x
|
||||
bot.lastEnemyY = e.y
|
||||
bot.hasLastPos = true
|
||||
bot.hasContact = true
|
||||
|
||||
let arenaW = getArenaWidth().float
|
||||
let arenaH = getArenaHeight().float
|
||||
|
||||
let frame = EnemyScanFrame(
|
||||
bearing: bot.enemyBearing,
|
||||
distance: bot.distance,
|
||||
velocity: bot.velocity,
|
||||
heading: bot.heading,
|
||||
enemyWallN: arenaH - e.y,
|
||||
enemyWallS: e.y,
|
||||
enemyWallE: arenaW - e.x,
|
||||
enemyWallW: e.x,
|
||||
enemyEnergy: e.energy,
|
||||
)
|
||||
let encoded = encodeFrame(frame)
|
||||
|
||||
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..<N_POWER:
|
||||
let s = bot.powerStats[pi]
|
||||
if s.total >= 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..<N_POWER:
|
||||
let spd = 20.0 - 3.0 * POWER_LEVELS[pi]
|
||||
let probeTicks = bot.distance / spd
|
||||
let probeExtraX = cos(degToRad(bot.heading)) * bot.velocity * probeTicks
|
||||
let probeExtraY = sin(degToRad(bot.heading)) * bot.velocity * probeTicks
|
||||
let probeTargetX = bot.lastEnemyX + probeExtraX + corrX
|
||||
let probeTargetY = bot.lastEnemyY + probeExtraY + corrY
|
||||
let probeAimAngle = directionTo(bx2, by2, probeTargetX, probeTargetY)
|
||||
bot.probes[pi] = ProbeSlot(
|
||||
active: true,
|
||||
powerIdx: pi,
|
||||
fireX: bx2,
|
||||
fireY: by2,
|
||||
aimAngleDeg: probeAimAngle,
|
||||
fireDist: bot.distance,
|
||||
bulletSpeed: spd,
|
||||
age: 0,
|
||||
)
|
||||
|
||||
# ── stats & echo ─────────────────────────────────────────────────────
|
||||
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
|
||||
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
|
||||
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
|
||||
let totalVirtual = bot.virtualHits + bot.virtualMiss
|
||||
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
|
||||
echo align($bot.tick, 4), " ",
|
||||
hamming, " ", similarity, " ", overlap, " ",
|
||||
formatFloat(aimOffset, ffDecimal, 2), " ",
|
||||
bot.virtualHits, " ", bot.virtualMiss, " ",
|
||||
formatFloat(hitPct, ffDecimal, 1), "% ",
|
||||
"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..<BULLET_SLOTS:
|
||||
bot.bullets[i].active = false
|
||||
for i in 0..<N_POWER:
|
||||
bot.probes[i].active = false
|
||||
bot.bulletHead = 0
|
||||
# net and epsilon persist across rounds
|
||||
|
||||
method onGameStarted*(bot: TsetlinBot, e: GameStartedEventForBot) =
|
||||
discard
|
||||
|
||||
method run*(bot: TsetlinBot) =
|
||||
while isRunning():
|
||||
inc bot.tick
|
||||
setTargetSpeed(0.0)
|
||||
setTurnRate(0.0)
|
||||
|
||||
if not bot.hasContact:
|
||||
setRadarTurnRate(45.0)
|
||||
go()
|
||||
continue
|
||||
|
||||
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
|
||||
|
||||
# ── age & settle virtual bullets (game ticks) ─────────────────────
|
||||
if bot.hasLastPos:
|
||||
for idx in 0..<BULLET_SLOTS:
|
||||
var b = addr bot.bullets[idx]
|
||||
if not b.active: continue
|
||||
inc b.trace.age
|
||||
let bulletDist = b.bulletSpeed * float(b.trace.age)
|
||||
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
|
||||
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
|
||||
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
|
||||
let 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..<N_POWER:
|
||||
var p = addr bot.probes[pi]
|
||||
if not p.active: continue
|
||||
inc p.age
|
||||
let pd = p.bulletSpeed * float(p.age)
|
||||
if pd >= 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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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..<N_POWER:
|
||||
let s = bot.powerStats[pi]
|
||||
if s.total >= 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..<N_POWER:
|
||||
let probeSpeed = 20.0 - 3.0 * POWER_LEVELS[pi]
|
||||
let probeTicks = bot.distance / probeSpeed
|
||||
let probeExtrapolX = cos(degToRad(bot.heading)) * bot.velocity * probeTicks
|
||||
let probeExtrapolY = sin(degToRad(bot.heading)) * bot.velocity * probeTicks
|
||||
let probeTargetX = bot.lastEnemyX + probeExtrapolX + cx
|
||||
let probeTargetY = bot.lastEnemyY + probeExtrapolY + cy
|
||||
let probeAimAngle = directionTo(bx2, by2, probeTargetX, probeTargetY)
|
||||
bot.probes[pi] = ProbeSlot(
|
||||
active: true,
|
||||
powerIdx: pi,
|
||||
fireX: bx2,
|
||||
fireY: by2,
|
||||
aimAngleDeg: probeAimAngle,
|
||||
fireDist: bot.distance,
|
||||
bulletSpeed: probeSpeed,
|
||||
age: 0,
|
||||
)
|
||||
|
||||
# ── stats & echo ─────────────────────────────────────────────────────
|
||||
let hamming = if bot.hasPrev: hammingDistance(bot.prevVec, vec) else: 0
|
||||
let similarity = if bot.hasPrev: TOTAL_BITS - hamming else: 0
|
||||
let overlap = if bot.hasPrev: popcount(bitwiseAnd(bot.prevVec, vec)) else: 0
|
||||
let totalVirtual = bot.virtualHits + bot.virtualMiss
|
||||
let hitPct = if totalVirtual > 0: bot.virtualHits.float / totalVirtual.float * 100.0 else: 0.0
|
||||
echo align($bot.tick, 4), " ",
|
||||
hamming, " ", similarity, " ", overlap, " ",
|
||||
formatFloat(aimOffset, ffDecimal, 2), " ",
|
||||
bot.virtualHits, " ", bot.virtualMiss, " ",
|
||||
formatFloat(hitPct, ffDecimal, 1), "% ",
|
||||
"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..<BULLET_SLOTS:
|
||||
bot.bullets[i].active = false
|
||||
for i in 0..<N_POWER:
|
||||
bot.probes[i].active = false
|
||||
bot.bulletHead = 0
|
||||
# net and epsilon persist across rounds
|
||||
|
||||
method onGameStarted*(bot: WiSARDBot, e: GameStartedEventForBot) =
|
||||
discard
|
||||
|
||||
method run*(bot: WiSARDBot) =
|
||||
while isRunning():
|
||||
inc bot.tick
|
||||
setTargetSpeed(0.0)
|
||||
setTurnRate(0.0)
|
||||
|
||||
# ── age & settle virtual bullets every game tick ──────────────────
|
||||
if bot.hasLastPos:
|
||||
for idx in 0..<BULLET_SLOTS:
|
||||
var b = addr bot.bullets[idx]
|
||||
if not b.active: continue
|
||||
inc b.trace.age
|
||||
let bulletDist = b.bulletSpeed * float(b.trace.age)
|
||||
if bulletDist >= b.fireDist or b.trace.age >= TRACE_MAX_AGE:
|
||||
let bulletX = b.fireX + cos(degToRad(b.aimAngleDeg)) * bulletDist
|
||||
let bulletY = b.fireY + sin(degToRad(b.aimAngleDeg)) * bulletDist
|
||||
let missDistance = hypot(bulletX - bot.lastEnemyX, bulletY - bot.lastEnemyY)
|
||||
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..<N_POWER:
|
||||
var p = addr bot.probes[pi]
|
||||
if not p.active: continue
|
||||
inc p.age
|
||||
let pd = p.bulletSpeed * float(p.age)
|
||||
if pd >= 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)
|
||||
@@ -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..<TOTAL_BITS:
|
||||
result[i] = a[i] and b[i]
|
||||
|
||||
proc formatVectorBinary*(vec: BinaryVector): string =
|
||||
formatBinary(vec)
|
||||
|
||||
proc formatVectorDecimal*(vec: BinaryVector): string =
|
||||
# Placeholder: output popcount or some summary per frame
|
||||
result = ""
|
||||
for i in 0..<WINDOW_SIZE:
|
||||
var cnt = 0
|
||||
for j in 0..<FRAME_BITS:
|
||||
cnt += int(vec[i * FRAME_BITS + j])
|
||||
if i > 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
|
||||
|
||||
@@ -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
|
||||
@@ -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..<N_LITERALS:
|
||||
let s = net.states[stateIdx(outIdx, clause, lit)]
|
||||
if s > 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..<N_IN:
|
||||
result[i] = input[i]
|
||||
result[i + N_IN] = 1'u8 - input[i]
|
||||
|
||||
proc computeVote(net: TsetlinNet, outIdx: int,
|
||||
literals: array[N_LITERALS, uint8]): float =
|
||||
for c in 0..<N_CLAUSES:
|
||||
result += clausePolarity(c) * float(evalClause(net, outIdx, c, literals))
|
||||
result = clamp(result, -T, T)
|
||||
|
||||
# ── public API ─────────────────────────────────────────────────────────
|
||||
|
||||
proc initTsetlinNet*(): TsetlinNet =
|
||||
# Bug 2 fix: init at 0 (boundary). One Type I step crosses into Include.
|
||||
for s in result.states.mitems:
|
||||
s = 0'i8
|
||||
|
||||
proc forward*(net: TsetlinNet, input: BinaryVector): (float, float) =
|
||||
## Returns (cx, cy) pixel corrections.
|
||||
let literals = makeLiterals(input)
|
||||
let cx = computeVote(net, 0, literals) / T * RESID_MAX
|
||||
let cy = computeVote(net, 1, literals) / T * RESID_MAX
|
||||
return (cx, cy)
|
||||
|
||||
proc forwardWithCache*(net: TsetlinNet, input: BinaryVector,
|
||||
cache: var ClauseCache): (float, float) =
|
||||
## Like forward but also populates cache for eligibility tracing.
|
||||
let literals = makeLiterals(input)
|
||||
var vx = 0.0
|
||||
var vy = 0.0
|
||||
for c in 0..<N_CLAUSES:
|
||||
let o = evalClause(net, 0, c, literals)
|
||||
cache[c] = o
|
||||
vx += clausePolarity(c) * float(o)
|
||||
for c in 0..<N_CLAUSES:
|
||||
let o = evalClause(net, 1, c, literals)
|
||||
cache[N_CLAUSES + c] = o
|
||||
vy += clausePolarity(c) * float(o)
|
||||
vx = clamp(vx, -T, T)
|
||||
vy = clamp(vy, -T, T)
|
||||
return (vx / T * RESID_MAX, vy / T * RESID_MAX)
|
||||
|
||||
proc learnOne(net: var TsetlinNet, outIdx: int, literals: array[N_LITERALS, uint8],
|
||||
clauseOut: ClauseCache, residual: float) =
|
||||
## Regression TM update for one output dimension.
|
||||
# Recompute predicted from cached clause outputs
|
||||
var vote = 0.0
|
||||
for c in 0..<N_CLAUSES:
|
||||
vote += clausePolarity(c) * float(clauseOut[outIdx * N_CLAUSES + c])
|
||||
vote = clamp(vote, -T, T)
|
||||
let predicted = vote / T * RESID_MAX
|
||||
let error = residual - predicted
|
||||
# Bug 1 fix: proper feedback probability gated on normalized error
|
||||
let pFeedback = min(1.0, abs(error) / (2.0 * RESID_MAX))
|
||||
|
||||
for c in 0..<N_CLAUSES:
|
||||
if pFeedback <= 0.0: continue
|
||||
if rand(1.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..<N_LITERALS:
|
||||
let si = stateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if literals[lit] == 1'u8:
|
||||
if rand(1.0) < (S - 1.0) / S:
|
||||
st = min(st + 1, N_STATES)
|
||||
else:
|
||||
if rand(1.0) < 1.0 / S:
|
||||
st = max(st - 1, -N_STATES)
|
||||
net.states[si] = int8(st)
|
||||
else:
|
||||
# Type Ib: clause silent, should fire — grow toward current input
|
||||
for lit in 0..<N_LITERALS:
|
||||
let si = stateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if literals[lit] == 1'u8:
|
||||
if rand(1.0) < (S - 1.0) / S:
|
||||
st = min(st + 1, N_STATES)
|
||||
else:
|
||||
if rand(1.0) < 1.0 / S:
|
||||
st = max(st - 1, -N_STATES)
|
||||
net.states[si] = int8(st)
|
||||
else:
|
||||
# Bug 3 fix: Type II — decrement (toward exclude) false literals in Include range
|
||||
if cOut == 1'u8:
|
||||
for lit in 0..<N_LITERALS:
|
||||
if literals[lit] == 0'u8:
|
||||
let si = stateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if st > 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)
|
||||
@@ -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..<TOTAL_BITS: result.perm[i] = i
|
||||
# Fisher-Yates shuffle over real bits only
|
||||
for i in countdown(TOTAL_BITS - 1, 1):
|
||||
let j = rng.rand(i)
|
||||
swap(result.perm[i], result.perm[j])
|
||||
# Padding slots get random valid indices (no bias toward bit 0)
|
||||
for i in TOTAL_BITS..<N_BITS_PAD:
|
||||
result.perm[i] = rng.rand(TOTAL_BITS - 1)
|
||||
|
||||
proc computeAddresses*(ws: WiSARDNet, vec: BinaryVector): array[N_NEURONS, int] =
|
||||
for n in 0..<N_NEURONS:
|
||||
var laddr = 0
|
||||
let bits = if n == N_NEURONS - 1: LAST_BITS else: K
|
||||
for b in 0..<bits:
|
||||
laddr = (laddr shl 1) or int(vec[ws.perm[n * K + b]])
|
||||
result[n] = laddr
|
||||
|
||||
proc predictCorrection*(ws: WiSARDNet, addrs: array[N_NEURONS, int]): tuple[cx, cy: float] =
|
||||
## Average (sumX/count, sumY/count) across neurons with count > BLEACH_THRESHOLD.
|
||||
var sx = 0.0; var sy = 0.0; var active = 0
|
||||
for n in 0..<N_NEURONS:
|
||||
let e = ws.luts[n][addrs[n]]
|
||||
if e.count > 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..<N_NEURONS:
|
||||
let a = addrs[n]
|
||||
ws.luts[n][a].sumX += dx
|
||||
ws.luts[n][a].sumY += dy
|
||||
ws.luts[n][a].count += 1
|
||||
Reference in New Issue
Block a user