feat(ModularBot): 6 guns, pattern matcher, melee modules, adversarial bots
- New guns: guess-factor (GF histogram), pattern-matcher (movement tape replay) - New modules: minimum-risk melee movement, spinning melee radar - New test bots: PatternMover, RandomMover, WaveSurfer - Fixed: FeedbackEvent now carries actualX/actualY for proper GF learning - Fixed: TM gun warmup gating + directional residuals - Fixed: circular gun integrated formula + multi-bin omega cache - Fixed: oscillator wall-bounce lockout - Fixed: phantom meteor perpendicular body orientation - 6/6 battle wins across all enemy types
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
## Guess-factor gun: statistical targeting via GF histogram.
|
||||
## Bins: 31, ranging GF -1 (max CW escape) to +1 (max CCW escape).
|
||||
## Learns from virtual bullet outcomes; caches wave state per-tick.
|
||||
|
||||
import std/math
|
||||
import gun_harness/gun_interface
|
||||
|
||||
const
|
||||
GFBins = 31
|
||||
GFPrior = 0.1
|
||||
|
||||
type
|
||||
Wave = object
|
||||
fireX, fireY: float
|
||||
fireBearing: float # atan2(enemyY-selfY, enemyX-selfX) at fire tick (rad)
|
||||
mea: float # max escape angle (rad)
|
||||
|
||||
GFGun* = object
|
||||
bins: array[GFBins, float]
|
||||
waves: seq[Wave] # pending unresolved waves
|
||||
# per-tick cache: store wave only once across multiple power-bin calls
|
||||
cachedTick: int
|
||||
cachedWaveStored: bool
|
||||
|
||||
proc initGFGun*(): GFGun =
|
||||
result.cachedTick = -1
|
||||
for i in 0..<GFBins:
|
||||
result.bins[i] = GFPrior
|
||||
|
||||
proc gfToIndex(gf: float): int {.inline.} =
|
||||
clamp(int(round((gf + 1.0) * 0.5 * float(GFBins - 1))), 0, GFBins - 1)
|
||||
|
||||
proc indexToGF(idx: int): float {.inline.} =
|
||||
float(idx) / float(GFBins - 1) * 2.0 - 1.0
|
||||
|
||||
proc peakBin(g: GFGun): int =
|
||||
var best = 0
|
||||
for i in 1..<GFBins:
|
||||
if g.bins[i] > g.bins[best]:
|
||||
best = i
|
||||
best
|
||||
|
||||
proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
if bulletSpeed <= 0.0:
|
||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||
|
||||
let dx = state.enemyX - state.selfX
|
||||
let dy = state.enemyY - state.selfY
|
||||
let dist = sqrt(dx*dx + dy*dy)
|
||||
let bearing = arctan2(dy, dx)
|
||||
let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0))
|
||||
|
||||
# Store one wave per tick regardless of how many power bins call us
|
||||
if state.tick != g.cachedTick:
|
||||
g.cachedTick = state.tick
|
||||
g.cachedWaveStored = false
|
||||
|
||||
if not g.cachedWaveStored:
|
||||
g.waves.add Wave(
|
||||
fireX: state.selfX,
|
||||
fireY: state.selfY,
|
||||
fireBearing: bearing,
|
||||
mea: mea,
|
||||
)
|
||||
g.cachedWaveStored = true
|
||||
|
||||
let gfAngle = bearing + indexToGF(g.peakBin()) * mea
|
||||
# Aim from self at gfAngle, at current dist (angular targeting)
|
||||
let px = state.selfX + cos(gfAngle) * dist
|
||||
let py = state.selfY + sin(gfAngle) * dist
|
||||
|
||||
GunPrediction(
|
||||
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||
y: clamp(py, BotRadius, state.arenaHeight - BotRadius),
|
||||
)
|
||||
|
||||
proc onResult*(g: var GFGun, e: FeedbackEvent) =
|
||||
## Called when a virtual bullet resolves. Match the wave by predicted point,
|
||||
## compute actual GF, and increment the histogram with a smoothing kernel.
|
||||
## We don't have the original wave tick here, so we use the prediction coords
|
||||
## to identify and remove the matching wave.
|
||||
## ponytail: O(n) scan over waves; waves list stays tiny (< a dozen at a time)
|
||||
if g.waves.len == 0:
|
||||
return
|
||||
|
||||
# Pop the oldest wave (FIFO matches bullet resolution order)
|
||||
let w = g.waves[0]
|
||||
g.waves.delete(0)
|
||||
|
||||
# Compute actual bearing from fire position to where the enemy actually was
|
||||
let actualDx = e.actualX - w.fireX
|
||||
let actualDy = e.actualY - w.fireY
|
||||
let actualBearing = arctan2(actualDy, actualDx)
|
||||
var bearingDelta = actualBearing - w.fireBearing
|
||||
# Normalize to [-PI, PI]
|
||||
while bearingDelta > PI: bearingDelta -= 2.0*PI
|
||||
while bearingDelta < -PI: bearingDelta += 2.0*PI
|
||||
|
||||
let gf = if w.mea > 1e-10: clamp(bearingDelta / w.mea, -1.0, 1.0) else: 0.0
|
||||
let centerIdx = gfToIndex(gf)
|
||||
|
||||
# Triangular smoothing kernel over adjacent bins
|
||||
for i in 0..<GFBins:
|
||||
let dist = abs(i - centerIdx)
|
||||
g.bins[i] += 1.0 / float(1 + dist)
|
||||
@@ -0,0 +1,154 @@
|
||||
## Pattern-matching gun: searches movement history for a matching sequence,
|
||||
## then plays it forward to predict future position.
|
||||
## Reference: https://robowiki.net/wiki/Pattern_Matching
|
||||
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||
|
||||
import std/math
|
||||
import gun_harness/gun_interface
|
||||
|
||||
const
|
||||
HistorySize* = 500
|
||||
PatternLen* = 10 # ticks used as search key; ponytail: fixed, expose if tuning needed
|
||||
|
||||
type
|
||||
MoveTick = object
|
||||
velocity: float ## signed speed (px/tick)
|
||||
headingDelta: float ## heading change in radians this tick
|
||||
|
||||
PatternMatcherGun* = object
|
||||
buf: array[HistorySize, MoveTick]
|
||||
head: int ## next write index (circular)
|
||||
count: int ## filled entries (capped at HistorySize)
|
||||
prevHeading: float
|
||||
prevSpeed: float
|
||||
prevTick: int
|
||||
hasPrev: bool
|
||||
# per-tick cache — avoid re-searching for multiple power bins
|
||||
cacheTick: int
|
||||
cacheX: float
|
||||
cacheY: float
|
||||
cacheValid: bool
|
||||
|
||||
# --- circular buffer helpers ---
|
||||
|
||||
proc write(g: var PatternMatcherGun, m: MoveTick) {.inline.} =
|
||||
g.buf[g.head] = m
|
||||
g.head = (g.head + 1) mod HistorySize
|
||||
if g.count < HistorySize: inc g.count
|
||||
|
||||
proc readAt(g: PatternMatcherGun, i: int): MoveTick {.inline.} =
|
||||
## i = 0 is oldest, i = count-1 is newest
|
||||
g.buf[(g.head - g.count + i + HistorySize * 2) mod HistorySize]
|
||||
|
||||
# --- linear fallback (same style as linear.nim) ---
|
||||
|
||||
proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) =
|
||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||
let t = dist / bulletSpeed
|
||||
let hRad = degToRad(state.enemyHeading)
|
||||
let ex = clamp(state.enemyX + cos(hRad) * state.enemySpeed * t,
|
||||
BotRadius, state.arenaWidth - BotRadius)
|
||||
let ey = clamp(state.enemyY + sin(hRad) * state.enemySpeed * t,
|
||||
BotRadius, state.arenaHeight - BotRadius)
|
||||
(ex, ey)
|
||||
|
||||
# --- pattern search + play-forward ---
|
||||
|
||||
proc searchAndProject(g: PatternMatcherGun, state: WorldState,
|
||||
bulletSpeed: float): (float, float) =
|
||||
## Returns projected (x, y). Falls back to linear if history too short.
|
||||
if g.count < PatternLen * 2:
|
||||
return linearPredict(state, bulletSpeed)
|
||||
|
||||
# key = last PatternLen entries
|
||||
let keyStart = g.count - PatternLen
|
||||
|
||||
# scan backwards for best match (exclude the key itself)
|
||||
var bestScore = Inf
|
||||
var bestMatch = -1
|
||||
let scanEnd = g.count - PatternLen - 1 # last valid match start
|
||||
for i in countdown(scanEnd, 0):
|
||||
var score = 0.0
|
||||
for k in 0 ..< PatternLen:
|
||||
let a = g.readAt(keyStart + k)
|
||||
let b = g.readAt(i + k)
|
||||
let dv = a.velocity - b.velocity
|
||||
let dh = a.headingDelta - b.headingDelta
|
||||
score += dv * dv + dh * dh
|
||||
if score < bestScore:
|
||||
bestScore = score
|
||||
bestMatch = i
|
||||
|
||||
if bestMatch < 0:
|
||||
return linearPredict(state, bulletSpeed)
|
||||
|
||||
# play forward from bestMatch + PatternLen
|
||||
let playStart = bestMatch + PatternLen
|
||||
let playAvail = g.count - 1 - playStart # ticks we can replay
|
||||
|
||||
# iterative time estimate
|
||||
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||
var t = dist0 / bulletSpeed
|
||||
var ex = state.enemyX
|
||||
var ey = state.enemyY
|
||||
|
||||
for _ in 0..4:
|
||||
let steps = min(int(t + 0.5), playAvail)
|
||||
ex = state.enemyX
|
||||
ey = state.enemyY
|
||||
var heading = degToRad(state.enemyHeading)
|
||||
var speed = state.enemySpeed
|
||||
for s in 0 ..< steps:
|
||||
let m = g.readAt(playStart + s)
|
||||
heading += m.headingDelta
|
||||
speed = m.velocity
|
||||
ex += cos(heading) * speed
|
||||
ey += sin(heading) * speed
|
||||
# if we ran out of replay data, coast linearly from last simulated pos
|
||||
let remaining = t - steps.float
|
||||
if remaining > 0.0:
|
||||
ex += cos(heading) * speed * remaining
|
||||
ey += sin(heading) * speed * remaining
|
||||
let ndx = ex - state.selfX
|
||||
let ndy = ey - state.selfY
|
||||
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
|
||||
|
||||
ex = clamp(ex, BotRadius, state.arenaWidth - BotRadius)
|
||||
ey = clamp(ey, BotRadius, state.arenaHeight - BotRadius)
|
||||
(ex, ey)
|
||||
|
||||
# --- Gun interface ---
|
||||
|
||||
proc predict*(g: var PatternMatcherGun, state: WorldState,
|
||||
bulletSpeed: float): GunPrediction =
|
||||
if bulletSpeed <= 0.0:
|
||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||
|
||||
# Update history once per tick
|
||||
if g.hasPrev and state.tick > g.prevTick:
|
||||
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
|
||||
# wrap to [-π, π]
|
||||
while dh > PI: dh -= 2.0 * PI
|
||||
while dh < -PI: dh += 2.0 * PI
|
||||
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
|
||||
|
||||
if not g.hasPrev or state.tick > g.prevTick:
|
||||
g.prevHeading = state.enemyHeading
|
||||
g.prevSpeed = state.enemySpeed
|
||||
g.prevTick = state.tick
|
||||
g.hasPrev = true
|
||||
g.cacheValid = false # new tick invalidates cache
|
||||
|
||||
# Return cached result for same-tick calls (multiple power bins)
|
||||
if g.cacheValid and state.tick == g.cacheTick:
|
||||
return GunPrediction(x: g.cacheX, y: g.cacheY)
|
||||
|
||||
let (px, py) = g.searchAndProject(state, bulletSpeed)
|
||||
g.cacheX = px
|
||||
g.cacheY = py
|
||||
g.cacheTick = state.tick
|
||||
g.cacheValid = true
|
||||
GunPrediction(x: px, y: py)
|
||||
|
||||
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
|
||||
discard # pattern matcher learns from movement observation, not feedback
|
||||
@@ -2,7 +2,7 @@
|
||||
## Self-contained: includes binary encoding and TM predictor inline.
|
||||
## Implements Gun interface: predict(state, bulletSpeed) → GunPrediction, onResult(FeedbackEvent).
|
||||
|
||||
import std/[math, random]
|
||||
import std/[math, random, strformat]
|
||||
import gun_harness/gun_interface
|
||||
|
||||
# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ─────
|
||||
@@ -190,6 +190,7 @@ proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
|
||||
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
|
||||
DebugTM* = false # set true to print [tm-dbg] lines per onResult call
|
||||
|
||||
type
|
||||
TmTrace = object
|
||||
@@ -204,12 +205,16 @@ type
|
||||
bufferCount: int
|
||||
traces: array[TM_TRACE_SLOTS, TmTrace]
|
||||
traceHead: int
|
||||
shotCount: int ## total onResult calls received
|
||||
|
||||
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 isWarmedUp*(g: TsetlinGun): bool {.inline.} =
|
||||
g.bufferCount >= TM_WINDOW_SIZE
|
||||
|
||||
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)
|
||||
@@ -259,28 +264,21 @@ proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPred
|
||||
GunPrediction(x: predX, y: predY)
|
||||
|
||||
proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
|
||||
inc g.shotCount
|
||||
# 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
|
||||
# Directional residual: actual enemy pos minus our prediction
|
||||
# On hit residual is 0 (we were right); on miss we push toward actual position.
|
||||
let rx = if e.hit: 0.0 else: clamp(e.actualX - t.predX, -TM_RESID_MAX, TM_RESID_MAX)
|
||||
let ry = if e.hit: 0.0 else: clamp(e.actualY - t.predY, -TM_RESID_MAX, TM_RESID_MAX)
|
||||
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)
|
||||
g.net.tmLearnOne(0, lits, t.cache, rx)
|
||||
g.net.tmLearnOne(1, lits, t.cache, ry)
|
||||
when DebugTM:
|
||||
echo fmt"[tm-dbg] shot={g.shotCount} miss={e.missDistance:.1f}px predicted=({t.predX:.0f},{t.predY:.0f}) actual=({e.actualX:.0f},{e.actualY:.0f}) rx={rx:.1f} ry={ry:.1f} hit={e.hit}"
|
||||
t.alive = false
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user