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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Package
|
||||
version = "0.1.0"
|
||||
author = "Davide Cappellini"
|
||||
description = "Modular gun harness bot — plugin architecture tracer bullet"
|
||||
license = "MIT"
|
||||
srcDir = "src"
|
||||
bin = @["ModularBot"]
|
||||
|
||||
# Dependencies
|
||||
requires "nim >= 2.0.0"
|
||||
requires "robocode_tankroyale_botapi >= 1.0.7"
|
||||
# gun_harness and radar_lock are in common_libs, wired via config.nims --path
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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..<len(PowerBins):
|
||||
headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
||||
linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
||||
tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
||||
circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
||||
|
||||
bot.tracker.spawnBullets(0, headsUp, bot.lastState)
|
||||
bot.tracker.spawnBullets(1, linPreds, bot.lastState)
|
||||
bot.tracker.spawnBullets(2, tmPreds, bot.lastState)
|
||||
bot.tracker.spawnBullets(3, circPreds, bot.lastState)
|
||||
|
||||
# Resolve bullets that have travelled far enough
|
||||
let st = bot.lastState
|
||||
bot.tracker.tickBullets(st, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) =
|
||||
case gunId
|
||||
of 0: bot.headOn.onResult(fe)
|
||||
of 1: bot.linear.onResult(fe)
|
||||
of 2: bot.tsetlin.onResult(fe)
|
||||
of 3:
|
||||
bot.circular.onResult(fe)
|
||||
when DebugCircular:
|
||||
let ax = st.enemyX
|
||||
let ay = st.enemyY
|
||||
echo fmt"[circ-vb] predicted=({fe.prediction.x:.0f},{fe.prediction.y:.0f}) actual=({ax:.0f},{ay:.0f}) miss={fe.missDistance:.1f}px hit={fe.hit}"
|
||||
else: discard
|
||||
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
|
||||
when DebugVBullets:
|
||||
let total = bot.virtualHits + bot.virtualMiss
|
||||
let pct = if total > 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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
--path:"../../common_libs"
|
||||
@@ -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..<lines.len:
|
||||
echo lines[i]
|
||||
@@ -0,0 +1,58 @@
|
||||
import std/[os, unittest, strformat]
|
||||
import test_framework/test_framework
|
||||
|
||||
const
|
||||
modularBotDir = currentSourcePath().parentDir.parentDir
|
||||
samplesDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
|
||||
|
||||
target = samplesDir / "Target"
|
||||
spinBot = samplesDir / "SpinBot"
|
||||
crazy = samplesDir / "Crazy"
|
||||
trackFire = samplesDir / "TrackFire"
|
||||
|
||||
# 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)
|
||||
|
||||
proc extractFinalScores(r: BattleResult, enemyName: string): tuple[modularScore, enemyScore: int] =
|
||||
var modularScore = 0
|
||||
var enemyScore = 0
|
||||
for b in r.results:
|
||||
if b.name == "ModularBot": modularScore = b.totalScore
|
||||
if b.name == enemyName: enemyScore = b.totalScore
|
||||
(modularScore, enemyScore)
|
||||
|
||||
suite "ModularBot battle suite":
|
||||
test "vs Target (stationary)":
|
||||
echo "\n[1/4] ModularBot vs Target"
|
||||
let r = runBattle(@[modularBotDir, target], rounds = 10, timeout = 180000)
|
||||
let (mb, en) = extractFinalScores(r, "Target")
|
||||
let winner = if mb > 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
|
||||
@@ -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(", ")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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..<len(PowerBins):
|
||||
linPreds[i] = GunPrediction(x: 105.0 + i.float, y: 105.0 + i.float)
|
||||
|
||||
t.spawnBullets(1, linPreds, state)
|
||||
|
||||
# Simulate a hit on one of the linear gun bullets
|
||||
t.tickBullets(state, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) =
|
||||
if gunId == 1 and binIdx == 0:
|
||||
check fe.hit == false # Just verify callback works
|
||||
)
|
||||
|
||||
# Select best shot
|
||||
let (gunId, binIdx, power) = selectShot(t)
|
||||
|
||||
echo "[test] Selected gun=" & $gunId & " binIdx=" & $binIdx & " power=" & $power
|
||||
check gunId >= 0 and gunId < 4 # Should be one of 4 guns
|
||||
check binIdx >= 0 and binIdx < len(PowerBins) # Valid power bin
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 "\nSingle round: ModularBot vs SpinBot"
|
||||
let r = runBattle(@[modularBotDir, sampleBotsDir / "SpinBot"], rounds = 1)
|
||||
|
||||
for i, round in r.rounds:
|
||||
echo "\nRound " & $i & " details:"
|
||||
for bot in round.results:
|
||||
echo " " & bot.name & ": score=" & $bot.score & " survived=" & $bot.survived
|
||||
|
||||
echo "\nFinal: " & r.results[0].name & " " & $r.results[0].totalScore & " vs " & r.results[1].name & " " & $r.results[1].totalScore
|
||||
@@ -0,0 +1,70 @@
|
||||
import std/[os, unittest]
|
||||
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)
|
||||
|
||||
suite "ModularBot basic battle":
|
||||
test "beats SittingDuck in 1 round":
|
||||
# Debug: manually compile and check
|
||||
try:
|
||||
let compiledBots = compileBots(@[modularBotDir, sittingDuck])
|
||||
echo "Compiled bots: ", compiledBots
|
||||
for bot in compiledBots:
|
||||
if fileExists(bot):
|
||||
echo " ✓ ", bot
|
||||
else:
|
||||
echo " ✗ MISSING: ", bot
|
||||
except CatchableError as e:
|
||||
echo "Compilation error: ", e.msg
|
||||
raise
|
||||
|
||||
# Now run battle with longer timeout for ModularBot boot
|
||||
ensureServer()
|
||||
let stdout = runBattleRunner(getServerUrl(), @[modularBotDir, sittingDuck], 1, 180000, true)
|
||||
echo ""
|
||||
echo "Raw battle runner output:"
|
||||
echo stdout
|
||||
echo ""
|
||||
|
||||
let r = parseServerOutput(stdout)
|
||||
|
||||
echo ""
|
||||
echo "=== Battle Result ==="
|
||||
echo "Rounds: ", r.rounds.len
|
||||
echo "Bots: ", r.bots
|
||||
echo ""
|
||||
echo "Final standings:"
|
||||
for result in r.results:
|
||||
echo " ", result.name, ": rank=", result.rank, " score=", result.totalScore,
|
||||
" survived=", result.survivalCount, " first=", result.firstPlaces
|
||||
echo ""
|
||||
echo "Winners: ", r.winners
|
||||
echo ""
|
||||
|
||||
# Basic checks
|
||||
check r.rounds.len == 1
|
||||
check r.results.len == 2
|
||||
check "ModularBot" in r.bots
|
||||
check "SittingDuck" in r.bots
|
||||
|
||||
# ModularBot should win
|
||||
var modularResult: BotResult
|
||||
for b in r.results:
|
||||
if b.name == "ModularBot": modularResult = b
|
||||
check modularResult.name == "ModularBot"
|
||||
check modularResult.rank == 1
|
||||
check "ModularBot" in r.winners
|
||||
@@ -0,0 +1,49 @@
|
||||
## Gun harness — shared types and Gun concept.
|
||||
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||
## Bullet speed formula: 20 - 3 * power.
|
||||
|
||||
import std/math
|
||||
|
||||
const BotRadius* = 18.0 ## hit detection radius in px
|
||||
|
||||
type
|
||||
WorldState* = object
|
||||
## All raw data given to every gun every tick.
|
||||
# Enemy
|
||||
enemyX*, enemyY*: float
|
||||
enemySpeed*, enemyHeading*: float
|
||||
# Self
|
||||
selfX*, selfY*: float
|
||||
selfSpeed*, selfHeading*: float
|
||||
selfRadarHeading*: float
|
||||
selfEnergy*: float
|
||||
enemyEnergy*: float
|
||||
# Arena
|
||||
arenaWidth*, arenaHeight*: float
|
||||
# Meta
|
||||
tick*: int
|
||||
|
||||
GunPrediction* = object
|
||||
## Absolute (x, y) where the gun predicts the enemy will be.
|
||||
x*, y*: float
|
||||
|
||||
FeedbackEvent* = object
|
||||
## Outcome of a resolved virtual bullet.
|
||||
prediction*: GunPrediction
|
||||
bulletPower*: float
|
||||
missDistance*: float ## px; < BotRadius = hit
|
||||
hit*: bool
|
||||
|
||||
proc bulletSpeed*(power: float): float {.inline.} =
|
||||
20.0 - 3.0 * power
|
||||
|
||||
## Gun concept — any type T implementing these two procs is a valid gun.
|
||||
template isGun*(T: typedesc): bool =
|
||||
compiles(
|
||||
block:
|
||||
var g: T
|
||||
let ws = WorldState()
|
||||
let p: GunPrediction = g.predict(ws, 0.0)
|
||||
let fe = FeedbackEvent()
|
||||
g.onResult(fe)
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
## Gun selector — picks best gun×power, computes aim angle, gates firing.
|
||||
## Fires highest power with acceptable hit rate when gun is aimed within
|
||||
## threshold and gunHeat == 0.
|
||||
|
||||
import std/math
|
||||
import gun_interface
|
||||
import virtual_bullets
|
||||
|
||||
const AimThresholdDeg* = 2.0 ## max angle error to fire; ponytail: tune per bot
|
||||
|
||||
proc aimAngle*(selfX, selfY, targetX, targetY: float): float =
|
||||
## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY).
|
||||
result = radToDeg(arctan2(targetY - selfY, targetX - selfX))
|
||||
|
||||
proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool =
|
||||
## Returns true when gun is close enough and cool enough to fire.
|
||||
var delta = (targetAngle - currentGunDir) mod 360.0
|
||||
if delta > 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)
|
||||
@@ -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..<n: h += (if fw.hits[i]: 1 else: 0)
|
||||
result = h.float / n.float
|
||||
|
||||
proc record(fw: var FitnessWindow, hit: bool) =
|
||||
fw.hits[fw.head] = hit
|
||||
fw.head = (fw.head + 1) mod WindowSize
|
||||
inc fw.count
|
||||
|
||||
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||||
predictions: array[len(PowerBins), GunPrediction],
|
||||
state: WorldState) =
|
||||
## Call once per gun per tick with predictions for all power bins.
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let power = PowerBins[binIdx]
|
||||
let speed = bulletSpeed(power)
|
||||
let pred = predictions[binIdx]
|
||||
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
|
||||
let slot = t.head mod MaxBullets
|
||||
t.bullets[slot] = VirtualBullet(
|
||||
gunId: gunId,
|
||||
powerBin: binIdx,
|
||||
fireX: state.selfX,
|
||||
fireY: state.selfY,
|
||||
aimX: pred.x,
|
||||
aimY: pred.y,
|
||||
bulletSpeed: speed,
|
||||
travelDist: 0.0,
|
||||
fireDist: fireDist,
|
||||
active: true,
|
||||
)
|
||||
t.head = (t.head + 1) mod MaxBullets
|
||||
|
||||
proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
|
||||
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
|
||||
for i in 0..<MaxBullets:
|
||||
var b = addr t.bullets[i]
|
||||
if not b.active: continue
|
||||
b.travelDist += b.bulletSpeed
|
||||
if b.travelDist < b.fireDist: continue
|
||||
|
||||
# Resolved: compute miss distance against current enemy position
|
||||
# Direction from fire point to aim point
|
||||
let dx = b.aimX - b.fireX
|
||||
let dy = b.aimY - b.fireY
|
||||
let dist = hypot(dx, dy)
|
||||
let (bx, by) =
|
||||
if dist < 1e-6: (b.aimX, b.aimY)
|
||||
else: (b.fireX + dx / dist * b.travelDist,
|
||||
b.fireY + dy / dist * b.travelDist)
|
||||
let missDist = hypot(bx - state.enemyX, by - state.enemyY)
|
||||
let hit = missDist < BotRadius
|
||||
|
||||
t.fitness[b.gunId].bins[b.powerBin].record(hit)
|
||||
|
||||
let fe = FeedbackEvent(
|
||||
prediction: GunPrediction(x: b.aimX, y: b.aimY),
|
||||
bulletPower: PowerBins[b.powerBin],
|
||||
missDistance: missDist,
|
||||
hit: hit,
|
||||
)
|
||||
onResolved(b.gunId, b.powerBin, fe)
|
||||
b.active = false
|
||||
|
||||
proc bestPower*(t: VirtualTracker, gunId: GunId): (int, float) =
|
||||
## Returns (binIdx, power) with highest power that has >= 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..<t.fitness.len:
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let r = t.fitness[gunId].bins[binIdx].hitRate()
|
||||
if r > bestRate:
|
||||
bestRate = r
|
||||
result = gunId
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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..<bits:
|
||||
result[bits - 1 - i] = uint8((gray shr i) and 1)
|
||||
|
||||
proc tmEncodeFrame(bearing, distance, velocity, heading,
|
||||
wallN, wallS, wallE, wallW, energy: float): TmFrameEncoded =
|
||||
var offset = 0
|
||||
|
||||
# bearing sin: 8 bits
|
||||
let bSin = tmToBits(clamp(int((sin(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = bSin[i]
|
||||
offset += 8
|
||||
|
||||
# bearing cos: 8 bits
|
||||
let bCos = tmToBits(clamp(int((cos(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = bCos[i]
|
||||
offset += 8
|
||||
|
||||
# distance %: 7 bits
|
||||
let distBits = tmToBits(clamp(int(distance / TM_MAX_DISTANCE * 99.0), 0, 99), 7)
|
||||
for i in 0..<7: result[offset + i] = distBits[i]
|
||||
offset += 7
|
||||
|
||||
# velocity: 5 bits
|
||||
let velBits = tmToBits(clamp(int(velocity + 8.0), 0, 16), 5)
|
||||
for i in 0..<5: result[offset + i] = velBits[i]
|
||||
offset += 5
|
||||
|
||||
# heading sin: 8 bits
|
||||
let hSin = tmToBits(clamp(int((sin(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = hSin[i]
|
||||
offset += 8
|
||||
|
||||
# heading cos: 8 bits
|
||||
let hCos = tmToBits(clamp(int((cos(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = hCos[i]
|
||||
offset += 8
|
||||
|
||||
# wall distances: 4×7 bits
|
||||
for wall in [wallN, wallS, wallE, wallW]:
|
||||
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
|
||||
for i in 0..<7: result[offset + i] = wBits[i]
|
||||
offset += 7
|
||||
|
||||
# energy: 11 bits
|
||||
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
|
||||
for i in 0..<11: result[offset + i] = eBits[i]
|
||||
|
||||
proc tmEncodeSelf(wallN, wallS, wallE, wallW, energy: float, canFire: bool): TmSelfEncoded =
|
||||
var offset = 0
|
||||
for wall in [wallN, wallS, wallE, wallW]:
|
||||
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
|
||||
for i in 0..<7: result[offset + i] = wBits[i]
|
||||
offset += 7
|
||||
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
|
||||
for i in 0..<11: result[offset + i] = eBits[i]
|
||||
offset += 11
|
||||
result[offset] = if canFire: 1'u8 else: 0'u8
|
||||
|
||||
proc tmEncodeFullVector(window: array[TM_WINDOW_SIZE, TmFrameEncoded],
|
||||
self: TmSelfEncoded): TmBinaryVector =
|
||||
var offset = 0
|
||||
for i in 0..<TM_WINDOW_SIZE:
|
||||
for j in 0..<TM_FRAME_BITS:
|
||||
result[offset] = window[i][j]; inc offset
|
||||
for j in 0..<TM_SELF_BITS:
|
||||
result[offset] = self[j]; inc offset
|
||||
|
||||
# ── Tsetlin Machine (adapted from BNNBot_garage/src/tsetlin_predictor.nim) ───
|
||||
|
||||
const
|
||||
TM_N_IN = TM_TOTAL_BITS # 870
|
||||
TM_N_OUT = 2 # cx, cy pixel corrections
|
||||
TM_N_LITERALS = TM_N_IN * 2 # 1740
|
||||
TM_N_CLAUSES = 50 # per output; issue #184 default
|
||||
TM_HALF = TM_N_CLAUSES div 2
|
||||
TM_N_STATES = 32 # automaton range [-32..32]
|
||||
TM_T = float(TM_HALF) # vote clamped to [-T, T]
|
||||
TM_S = 1.5 # specificity
|
||||
TM_RESID_MAX = 80.0 # pixel correction range
|
||||
# ponytail: TM_N_STATES=32 needs int16 (int8 only fits ≤127, fine here); raise N_CLAUSES if underfitting
|
||||
|
||||
type
|
||||
TmClauseCache = array[TM_N_OUT * TM_N_CLAUSES, uint8]
|
||||
|
||||
TmNet = object
|
||||
states: array[TM_N_OUT * TM_N_CLAUSES * TM_N_LITERALS, int16]
|
||||
# ponytail: int16 to safely hold [-32..32]; TM_N_STATES=32 fits int8 too but int16 is safer
|
||||
|
||||
proc tmStateIdx(outIdx, clause, lit: int): int {.inline.} =
|
||||
(outIdx * TM_N_CLAUSES + clause) * TM_N_LITERALS + lit
|
||||
|
||||
proc tmPolarity(clause: int): float {.inline.} =
|
||||
if clause < TM_HALF: 1.0 else: -1.0
|
||||
|
||||
proc tmMakeLiterals(input: TmBinaryVector): array[TM_N_LITERALS, uint8] =
|
||||
for i in 0..<TM_N_IN:
|
||||
result[i] = input[i]
|
||||
result[i + TM_N_IN] = 1'u8 - input[i]
|
||||
|
||||
proc tmEvalClause(net: TmNet, outIdx, clause: int,
|
||||
lits: array[TM_N_LITERALS, uint8]): uint8 =
|
||||
var hasIncluded = false
|
||||
for lit in 0..<TM_N_LITERALS:
|
||||
let s = net.states[tmStateIdx(outIdx, clause, lit)]
|
||||
if s > 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..<TM_N_CLAUSES:
|
||||
let o = tmEvalClause(net, 0, c, lits)
|
||||
cache[c] = o
|
||||
vx += tmPolarity(c) * float(o)
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
let o = tmEvalClause(net, 1, c, lits)
|
||||
cache[TM_N_CLAUSES + c] = o
|
||||
vy += tmPolarity(c) * float(o)
|
||||
vx = clamp(vx, -TM_T, TM_T)
|
||||
vy = clamp(vy, -TM_T, TM_T)
|
||||
(vx / TM_T * TM_RESID_MAX, vy / TM_T * TM_RESID_MAX)
|
||||
|
||||
proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
|
||||
cache: TmClauseCache, residual: float) =
|
||||
var vote = 0.0
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
vote += tmPolarity(c) * float(cache[outIdx * TM_N_CLAUSES + c])
|
||||
vote = clamp(vote, -TM_T, TM_T)
|
||||
let predicted = vote / TM_T * TM_RESID_MAX
|
||||
let error = residual - predicted
|
||||
let pFeedback = min(1.0, abs(error) / (2.0 * TM_RESID_MAX))
|
||||
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
if pFeedback <= 0.0: continue
|
||||
if rand(1.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..<TM_N_LITERALS:
|
||||
let si = tmStateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if lits[lit] == 1'u8:
|
||||
if rand(1.0) < (TM_S - 1.0) / TM_S: st = min(st + 1, TM_N_STATES)
|
||||
else:
|
||||
if rand(1.0) < 1.0 / TM_S: st = max(st - 1, -TM_N_STATES)
|
||||
net.states[si] = int16(st)
|
||||
else:
|
||||
# Type II: shrink false literals in include range
|
||||
if cOut == 1'u8:
|
||||
for lit in 0..<TM_N_LITERALS:
|
||||
if lits[lit] == 0'u8:
|
||||
let si = tmStateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if st > 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..<TM_TRACE_SLOTS:
|
||||
var t = addr g.traces[i]
|
||||
if not t.alive: continue
|
||||
if abs(t.predX - e.prediction.x) > 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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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..<NumBins: h.bins[i] = 1.0
|
||||
GravityEngine(histogram: h, phantoms: @[], waves: @[], prevEnemyEnergy: 100.0)
|
||||
|
||||
proc gfToBin(gf: float64): int =
|
||||
int(((gf.clamp(-1.0,1.0) + 1.0) / 2.0 * float64(NumBins-1)).round).clamp(0, NumBins-1)
|
||||
|
||||
proc mea(speed: float64): float64 = arcsin(min(8.0/speed, 1.0))
|
||||
|
||||
proc detectFire(eng: var GravityEngine, energy: float64): tuple[fired: bool; power: float64] =
|
||||
let drop = eng.prevEnemyEnergy - energy
|
||||
eng.prevEnemyEnergy = energy
|
||||
if drop >= 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..<NumPhantoms:
|
||||
let gf = if NumPhantoms == 1: 0.0 else: -1.0 + float64(i)/float64(NumPhantoms-1)*2.0
|
||||
let angle = base + gf * maxA
|
||||
let weight = eng.histogram.bins[gfToBin(gf)]
|
||||
eng.phantoms.add PhantomBullet(
|
||||
pos: enemyPos, vel: vec2(bspeed*cos(angle), bspeed*sin(angle)),
|
||||
weight: weight, alive: true, ticks: 0)
|
||||
|
||||
proc tickPhantoms(eng: var GravityEngine) =
|
||||
for i in 0..<eng.phantoms.len:
|
||||
if not eng.phantoms[i].alive: continue
|
||||
eng.phantoms[i].pos = eng.phantoms[i].pos + eng.phantoms[i].vel
|
||||
inc eng.phantoms[i].ticks
|
||||
if eng.phantoms[i].ticks >= 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))
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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<Integer, String> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user