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:
2026-09-20 00:37:10 +02:00
parent c9191b7afb
commit 254c7dc997
39 changed files with 2630 additions and 30 deletions
+49
View File
@@ -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)
)
+26
View File
@@ -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)
+133
View File
@@ -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