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:
2026-09-20 00:59:53 +02:00
parent 254c7dc997
commit 1ed7797cb6
184 changed files with 80149 additions and 21 deletions
@@ -30,6 +30,7 @@ type
FeedbackEvent* = object
## Outcome of a resolved virtual bullet.
prediction*: GunPrediction
actualX*, actualY*: float ## actual enemy position at resolution time
bulletPower*: float
missDistance*: float ## px; < BotRadius = hit
hit*: bool
@@ -104,6 +104,8 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
let fe = FeedbackEvent(
prediction: GunPrediction(x: b.aimX, y: b.aimY),
actualX: state.enemyX,
actualY: state.enemyY,
bulletPower: PowerBins[b.powerBin],
missDistance: missDist,
hit: hit,
+105
View File
@@ -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)
+154
View File
@@ -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
+15 -17
View File
@@ -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
+145
View File
@@ -0,0 +1,145 @@
## minimum_risk.nim — Minimum-risk point movement for melee (multiple enemies).
## Generates candidate points, scores each by threat proximity, wall/corner risk,
## and travel distance, then drives toward the lowest-risk point.
## Uses the same perpendicular-body trick as phantom_meteor.nim.
import std/math
import gun_harness/gun_interface
import movement_harness/movement_interface
# TODO: WorldState only carries one enemy. Extend WorldState with a seq of
# threat positions (enemies) and update scoreCandidates to iterate all of them.
# For now we treat the single enemy as the only threat point.
const
CandidateRadius = 150.0 # px radius for ring candidates
NumRingPoints = 16 # ring of 16 + 4 random = 20 candidates
NumRandPoints = 4
WallMargin = 80.0 # below this dist-to-wall = risk
CornerMargin = 150.0 # below this dist-to-corner = risk
RecalcInterval = 10 # ticks between full recalculations
KEnemy = 1.0 # inverse-square weight for enemy threat
KWall = 0.5 # linear wall penalty weight
KCorner = 0.8 # corner penalty weight
KTravel = 0.003 # penalty per pixel of travel distance
type
Vec2 = object
x, y: float
proc vec2(x, y: float): Vec2 {.inline.} = Vec2(x: x, y: y)
proc dist(a, b: Vec2): float {.inline.} =
let dx = a.x - b.x; let dy = a.y - b.y
sqrt(dx*dx + dy*dy)
type MinimumRiskModule* = object
targetX, targetY: float
ticksSinceCalc: int
hasTarget: bool
proc initMinimumRisk*(): MinimumRiskModule =
MinimumRiskModule(hasTarget: false, ticksSinceCalc: RecalcInterval)
proc wallRisk(p: Vec2, w, h: float): float {.inline.} =
## Linear penalty that ramps up inside WallMargin.
let dL = p.x
let dR = w - p.x
let dB = p.y
let dT = h - p.y
let minD = min(min(dL, dR), min(dB, dT))
if minD >= WallMargin: 0.0
else: KWall * (1.0 - minD / WallMargin)
proc cornerRisk(p: Vec2, w, h: float): float {.inline.} =
## Penalty for proximity to any of the four corners.
let corners = [vec2(0.0,0.0), vec2(w,0.0), vec2(0.0,h), vec2(w,h)]
var worst = 0.0
for c in corners:
let d = dist(p, c)
if d < CornerMargin:
worst = max(worst, KCorner * (1.0 - d / CornerMargin))
worst
proc scorePoint(p, bot: Vec2, threats: openArray[Vec2], w, h: float): float =
var risk = 0.0
# Inverse-square enemy threat
for t in threats:
let d = max(dist(p, t), 1.0)
risk += KEnemy / (d * d) * 1e4 # scale so numbers are comparable
risk += wallRisk(p, w, h)
risk += cornerRisk(p, w, h)
risk += KTravel * dist(p, bot)
risk
proc clampToArena(p: Vec2, w, h: float): Vec2 {.inline.} =
vec2(p.x.clamp(WallMargin, w - WallMargin),
p.y.clamp(WallMargin, h - WallMargin))
proc recalcTarget(m: var MinimumRiskModule, ws: WorldState) =
let bot = vec2(ws.selfX, ws.selfY)
let w = ws.arenaWidth
let h = ws.arenaHeight
# Single threat for now; TODO: replace with ws.enemies when available
let threats = [vec2(ws.enemyX, ws.enemyY)]
# Build candidates: ring + random (deterministic via tick-seeded offsets)
var best = bot # fallback: stay put
var bestRisk = scorePoint(bot, bot, threats, w, h)
for i in 0..<NumRingPoints:
let angle = float(i) / float(NumRingPoints) * 2.0 * PI
let p = clampToArena(
vec2(bot.x + CandidateRadius * cos(angle),
bot.y + CandidateRadius * sin(angle)), w, h)
let r = scorePoint(p, bot, threats, w, h)
if r < bestRisk:
bestRisk = r
best = p
# 4 random-ish candidates via golden-angle spread (no RNG state needed)
for i in 0..<NumRandPoints:
let angle = float(i) * 2.399963 # golden angle ~137.5°
let radius = CandidateRadius * 0.5 * (1.0 + float(i) / float(NumRandPoints))
let p = clampToArena(vec2(bot.x + radius * cos(angle),
bot.y + radius * sin(angle)), w, h)
let r = scorePoint(p, bot, threats, w, h)
if r < bestRisk:
bestRisk = r
best = p
m.targetX = best.x
m.targetY = best.y
m.hasTarget = true
proc computeMove*(m: var MinimumRiskModule, ws: WorldState): MoveCommand =
inc m.ticksSinceCalc
if m.ticksSinceCalc >= RecalcInterval or not m.hasTarget:
m.recalcTarget(ws)
m.ticksSinceCalc = 0
let bot = vec2(ws.selfX, ws.selfY)
let target = vec2(m.targetX, m.targetY)
let d = dist(bot, target)
if d < 5.0:
# Already at target — force recalc next tick
m.hasTarget = false
return (speed: 0.0, turnRate: 0.0)
# Direction to target (math convention: 0=East, CCW+)
let toTargetRad = arctan2(target.y - bot.y, target.x - bot.x)
let toTargetDeg = radToDeg(toTargetRad)
# Delta from current body heading
var delta = toTargetDeg - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
# Dot-product trick: reverse if |delta| > 90 to save 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))
+17
View File
@@ -0,0 +1,17 @@
## melee_scan.nim — Continuously spinning radar for melee.
## Always returns max radar turn rate so all enemies get scanned periodically.
## No state needed; stateless spin guarantees coverage regardless of enemy count.
import ../radar_harness/radar_interface
export radar_interface
const MaxRadarTurnRate* = 45.0 ## deg/tick — Tank Royale hard limit
type MeleeScanModule* = object
proc initMeleeScan*(): MeleeScanModule = MeleeScanModule()
proc computeScan*(m: MeleeScanModule, state: WorldState): float =
## Always spin at max rate — guarantees full arena coverage every 8 ticks.
MaxRadarTurnRate