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:
@@ -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)
|
||||
Reference in New Issue
Block a user