Files
SirRoboGarage/ModularBot_garage/src/ModularBot.nim
T
SirStone 57b2ac3849 feat(guns): scale-aware power selection (+52% damage); TM classifier gun built, measured, DISABLED
TASK 2 - power selection, a clear win. bestPower used an ABSOLUTE
MinHitRate = 0.40 bar. Measured per-bin virtual rates (rolling-100 fraction)
show no bin ever clears 40%, so 11 of 14 guns were stuck at bin 0 (power 1.0)
even where higher bins were comparable:
  Linear  p1.0 44% p1.5 39% p2.0 30% p3.0 29%   old bin 0 -> new bin 3
  Accel   p1.0 44% p1.5 40% p2.0 26% p3.0 29%   old bin 1 -> new bin 3
  Pattern p1.0 50% p1.5 40% p2.0 27% p3.0 12%   old bin 1 -> new bin 2
Replaced with a scale-aware PowerBarFrac = 0.50 (a dimensionless FRACTION of
the gun's own best bin rate). 13 of 14 selections now pick heavier bullets.
Real effect vs DrussGT (8 rounds x 3 runs): hit rate unchanged (7.56% ->
7.47%) but damage dealt +52% (157 -> 239 per run) and rounds end faster.
Same accuracy, half the shots, half again more damage.

TASK 1 - the TM pattern-classifier gun does NOT earn its slot. It was built as
a mixture of experts with a corrected-Granmo TM as a multi-class gate over
HeadOn/Linear/Circular/WallBounce/Accel, labelled by which expert's prediction
was closest to the actual enemy position (an exact, supervised, per-shot
label - no delayed credit). Offline it loses to the best of its OWN experts on
essentially every fixture, and against DrussGT it cost real performance:
  baseline (path+relative)  7.56% real hit rate, damage 157
  + power fix               7.47%,                 damage 239
  + power fix + TM gun      5.59%,                 damage 133
The gun was selected on 806 ticks and fired 24 real shots at 4.2%.
So the tree ships with EnableTmSelector = false: code and wiring kept intact
for re-enabling, but it is not in the active rack.

Worth recording from the clause dump: the gate DOES latch onto meaningful
structure. On energy-threshold-turner, HeadOn's clauses key on the energy bits
(the rule's own driving variable) while Circular keys on distance/velocity. So
the TM is learning something real and interpretable - it simply cannot beat
'always pick the best expert'. Root cause (INFERRED): the closest-expert label
is noisy because several experts are near-tied, and under the path metric the
winner varies by power bin while the gate sees one shared per-tick input, so a
one-vs-rest gate over a saturated 870-bit clause space has no margin to exploit.
(Zero-padding the 2-frame window was tried first and saturated every clause at
256-755 included literals; alternating the two real frames fixed that.)

Also factors the corrected feedback into an exported tmLearnDir and exports the
encoding/TM primitives; the Tsetlin tests still reproduce the documented
mean=13.8 included literals, so the refactor is behaviour-preserving.

Verified: 33/33 guard checks, tsetlin tests green, metric checks green, new
power-selection guard green (13/14 selections change; relative bar still picks
bin 1 and not bin 3 for a [30,25,12,5]% profile), 12/12 offline==online
acceptance under the shipped default.
2026-09-21 05:19:07 +02:00

763 lines
32 KiB
Nim

## ModularBot — plugin gun architecture tracer bullet.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13) via GunHarness.
## Radar: RadarLockModule (1v1) / MeleeScanModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
import std/[math, os, strformat, tables, sets, json]
import robocode_tankroyale_botapi
import radar_harness/radar_interface
import radars/radar_lock_module
import radars/melee_scan
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 guns/guess_factor
import guns/pattern_matcher
import guns/wall_bounce
import guns/accel_predictor
import guns/stop_shot
import guns/displacement
import guns/averaged_lead
import guns/decay_gf
import guns/knn_gun
import guns/tm_selector
import movements/phantom_meteor
import movements/rammer
import movements/the_floor_is_lava
import movement_harness/virtual_bodies as mvb
import movement_harness/bullet_shadows
import targeting/enemy_tracker
import targeting/target_selector
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
const DebugVBullets = false
const DebugCircular = false
## Registration switch for the TM selector gun (id 13). Set false to build a
## rack without it (A/B runs); the tracker still reserves gun id 13 so the other
## ids and per-gun stats are unchanged.
##
## VERDICT: left FALSE. The TM gate underperforms its own experts on every
## offline fixture (energy-threshold 63% vs Circular 100%, random-walk 45% vs
## WallBounce 68%, Corners 25% vs Accel 34%) and the DrussGT A/B showed real hit
## rate 5.59% vs 7.47% for the power-fix-only rack. Flip to true to re-enable.
const EnableTmSelector = false
## Task A instrumentation: log every real shot + its eventual outcome to
## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out.
const ShotLog = true
## Offline range recorder: when the TR_RECORD_WORLDSTATE env var is set, append
## the exact WorldState the bot builds each tick to /tmp/worldstate_record.jsonl
## so it can be replayed offline. This is a RUNTIME switch (not a compile-time
## const) so the normal build never writes a fixture, while the acceptance test
## can enable recording for just the battle it spawns by exporting the env var.
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"]
const
CLR_GUN = "\e[33m" # yellow
CLR_MOVE = "\e[36m" # cyan
CLR_FIRE = "\e[31m" # red
CLR_RADAR = "\e[32m" # green
CLR_CHANGE = "\e[32m" # green (highlight for changed module)
CLR_ROUND = "\e[1;37m" # bold white
CLR_RST = "\e[0m" # reset
type
PendingShot = object
## Metadata for one real shot, captured at setFire() time and resolved when
## the server reports the bullet's fate (hit / wall / bullet).
gunId: int
angleErr: float ## |aim error| (deg) at fire time — the value the gate used
distPx: float ## distance (px) to the aim point at fire time
power: float
tick: int
targetId: int
ModularBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastState: WorldState
radar: RadarLockModule
meleeScan: MeleeScanModule
enemyCount: int
initialEnemyCount: int
radarMode: int # 0 = lock, 1 = melee
tracker: VirtualTracker
headOn: HeadOnGun
linear: LinearGun
circular: CircularGun
tsetlin: TsetlinGun
guessFactor: GFGun
patternMatcher: PatternMatcherGun
wallBounce: WallBounceGun
accelGun: AccelGun
stopShot: StopShotGun
displace: DisplacementGun
avgLead: AveragedLeadGun
decayGF: DecayGFGun
knnGun: KNNGun
tmSelector: TmSelectorGun
mover: TFILModule
rammer: RammerModule
isRamming: bool
ramStuckTicks: int
ramDurationTicks: int
ramCooldownTicks: int
moveTracker: VirtualBodyTracker
virtualHits: int
virtualMiss: int
tick: int
currentGun: int
currentTargetId: int
targetSwitchTick: int
enemyTracker: EnemyTracker
prevGun: int
prevRadar: int
prevTarget: int
roundNumber: int
realShotsFired: int
realHits: int
gunRealShots: array[14, int]
gunRealHits: array[14, int]
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
gunSelectionCount: array[14, int]
lastKnownTargetId: int ## persists through death, used for round-end stats
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
## Task A: append one JSON line per resolved real shot. The write is fully
## guarded so a full disk / bad path can never take the bot down.
when ShotLog:
let row = %*{
"tick": shot.tick,
"targetId": shot.targetId,
"gunId": shot.gunId,
"angleErr": shot.angleErr,
"distPx": shot.distPx,
"power": shot.power,
"hit": hit,
"unresolved": unresolved
}
try:
let f = open("/tmp/shot_log.jsonl", fmAppend)
f.writeLine($row)
f.close()
except CatchableError:
discard # logging must never crash the bot
proc resolveShotLog(bot: ModularBot, bulletId: int, hit: bool) =
## Look up the shot metadata for a resolved bullet and log its fate once.
## No-op when ShotLog is off (bulletShot is then never populated).
if bot.bulletShot.hasKey(bulletId):
writeShotLog(bot.bulletShot[bulletId], hit, false)
bot.bulletShot.del(bulletId)
proc registerOwnBullet(bot: ModularBot, bulletId: int) =
## onBulletFired: the server assigns a unique bulletId per round. Stamp the
## oldest pending fire with it, then credit that gun with a real shot.
## The FIFO stays aligned because we only enqueue when setFire() succeeds
## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard).
if bot.pendingFires.len == 0: return # unexpected: no pending fire to stamp
let shot = bot.pendingFires[0]
delete(bot.pendingFires, 0)
bot.bulletGun[bulletId] = shot.gunId
inc bot.gunRealShots[shot.gunId]
when ShotLog:
bot.bulletShot[bulletId] = shot
# Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and
# BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first.
# If so, credit the hit now and drop the (already resolved) map entry.
if bulletId in bot.pendingHitBullets:
inc bot.gunRealHits[shot.gunId]
bot.pendingHitBullets.excl(bulletId)
bot.bulletGun.del(bulletId)
bot.resolveShotLog(bulletId, true)
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
## Look up the gun that fired bulletId and drop it from the in-flight map.
## Returns the gun id, or -1 if the bullet was not attributed.
result = bot.bulletGun.getOrDefault(bulletId, -1)
if result >= 0:
bot.bulletGun.del(bulletId)
proc printConfig(bot: ModularBot, changed: string = "") =
let gunName = GunNames[bot.currentGun]
let radarName = if bot.radarMode == 0: "radar_lock" else: "melee_scan"
let moveName = if bot.isRamming: "rammer" else: "tfil"
let gc = if bot.currentGun != bot.prevGun or changed == "all": CLR_CHANGE else: ""
let rc = if bot.radarMode != bot.prevRadar or changed == "all": CLR_CHANGE else: ""
let tc = if bot.currentTargetId != bot.prevTarget or changed == "all": CLR_CHANGE else: ""
let rst = CLR_RST
echo "[config] " & gc & "gun=" & gunName & (if gc != "": rst else: "") &
" | move=" & moveName &
" | " & rc & "radar=" & radarName & (if rc != "": rst else: "") &
" | enemies=" & $bot.enemyTracker.allAlive().len &
" target=" & tc & "#" & $bot.currentTargetId & (if tc != "": rst else: "")
bot.prevGun = bot.currentGun
bot.prevRadar = bot.radarMode
bot.prevTarget = bot.currentTargetId
proc startWorldStateRecord(bot: ModularBot) =
## Truncate the fixture and write the meta line at round start.
if RecordWorldState:
try:
let f = open(WorldStateRecordPath, fmWrite)
f.writeLine($(%*{"meta": {
"adversary": "live-recorded",
"source": "live",
"perfect_info": false,
"arena": {"w": getArenaWidth().float, "h": getArenaHeight().float},
"note": "exact WorldState the bot built, with tracker lastSeenTick",
}}))
f.close()
except CatchableError:
discard
proc finishWorldStateRecord(bot: ModularBot) =
## Append the end marker so the offline replay can reproduce the live
## resolver's final-tick behaviour: if the target died during the last go()
## the live aim block was skipped, so no bullet resolved on that tick.
if RecordWorldState:
var died = false
if bot.lastKnownTargetId >= 0 and bot.enemyTracker.enemies.contains(bot.lastKnownTargetId):
died = not bot.enemyTracker.enemies[bot.lastKnownTargetId].alive
try:
let f = open(WorldStateRecordPath, fmAppend)
f.writeLine($(%*{"end": {"enemy_died": died}}))
f.close()
except CatchableError:
discard
proc recordWorldState(bot: ModularBot, ws: WorldState) =
## Append one tick of the state the bot ACTUALLY built (including the stale
## tracker positions between radar scans) so the offline replay sees the same
## information the guns saw online.
if RecordWorldState:
let tid = bot.currentTargetId
var lst = -1
if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
lst = bot.enemyTracker.enemies[tid].lastSeenTick
var row = %*{
"tick": ws.tick,
"ex": ws.enemyX, "ey": ws.enemyY,
"eh": ws.enemyHeading, "es": ws.enemySpeed, "ee": ws.enemyEnergy,
"sx": ws.selfX, "sy": ws.selfY,
"sh": ws.selfHeading, "ss": ws.selfSpeed, "se": ws.selfEnergy,
"eid": tid,
}
if lst >= 0: row["lst"] = %lst
try:
let f = open(WorldStateRecordPath, fmAppend)
f.writeLine($row)
f.close()
except CatchableError:
discard
proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState =
var ei: seq[EnemyInfo]
for es in bot.enemyTracker.allAlive():
ei.add EnemyInfo(id: es.id, x: es.x, y: es.y,
heading: es.heading, speed: es.speed, energy: es.energy)
result = 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,
enemies: ei,
)
if RecordWorldState:
bot.recordWorldState(result)
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
bot.hasContact = true
method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) =
inc bot.realShotsFired
bot.registerOwnBullet(e.bullet.bulletId)
bot.moveTracker.shadows.addBullet(e.bullet.x, e.bullet.y,
degToRad(e.bullet.direction), e.bullet.power)
method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
inc bot.realHits
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
if gunId >= 0:
inc bot.gunRealHits[gunId]
bot.resolveShotLog(e.bullet.bulletId, true)
else:
bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot
bot.resolveShotLog(e.bullet.bulletId, false)
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
bot.resolveShotLog(e.bullet.bulletId, false)
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round).
if RecordWorldState:
bot.finishWorldStateRecord()
# Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end
let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId
# Per-target fitness when known, else a deterministic recency-weighted aggregate
# over all enemies (see VirtualTracker.fitnessFor). Using the shared proc keeps
# this stats dump identical to what the gun selector sees.
let fit = bot.tracker.fitnessFor(targetId)
var gunsArr = newJArray()
for gid in 0..<14:
var totalShots = 0
var totalHits = 0
for binIdx in 0..<len(vb.PowerBins):
let fw = fit[gid].bins[binIdx]
let n = min(fw.count, vb.WindowSize)
totalShots += n
for k in 0..<n:
if fw.hits[k]: inc totalHits
let hitRate = if totalShots > 0: totalHits.float / totalShots.float else: 0.0
let rShots = bot.gunRealShots[gid]
let rHits = bot.gunRealHits[gid]
let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0
gunsArr.add(%*{
"id": gid,
"name": GunNames[gid],
"vShots": totalShots,
"vHits": totalHits,
"hitRate": (hitRate * 100.0).int,
"selected": bot.gunSelectionCount[gid],
"realShots": rShots,
"realHits": rHits,
"realHitRate": rRate.round(1)
})
let row = %*{
"round": bot.roundNumber,
"targetId": targetId,
"guns": gunsArr,
"realShots": bot.realShotsFired,
"realHits": bot.realHits,
"score": e.results.totalScore,
"bulletDmg": e.results.bulletDamage,
"vDropped": bot.tracker.droppedBullets,
"vStarved": bot.guessFactor.waveStarved + bot.decayGF.waveStarved + bot.knnGun.waveStarved
}
let f = open("/tmp/gun_stats.jsonl", fmAppend)
f.writeLine($row)
f.close()
# Task A: shots still in flight at round end never resolved, so count them as
# misses for the shot log (matching how realHits/realShots treats them).
# Shots fired but never stamped by onBulletFired are not counted in realShots
# either, so they are dropped rather than logged.
when ShotLog:
for _, shot in bot.bulletShot:
writeShotLog(shot, false, true)
bot.bulletShot.clear()
bot.pendingFires.setLen(0)
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.roundNumber = e.roundNumber
if RecordWorldState:
bot.startWorldStateRecord()
bot.realShotsFired = 0
bot.realHits = 0
bot.lastKnownTargetId = -1
bot.pendingFires.setLen(0)
bot.bulletGun.clear()
bot.bulletShot.clear()
bot.pendingHitBullets.clear()
for i in 0..<14:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
# Reset per-round integrity counters so each /tmp/gun_stats.jsonl line reports
# that round's numbers (summing the lines gives the session total).
bot.tracker.droppedBullets = 0
bot.guessFactor.wavePushes = 0
bot.guessFactor.waveStarved = 0
bot.decayGF.wavePushes = 0
bot.decayGF.waveStarved = 0
bot.knnGun.wavePushes = 0
bot.knnGun.waveStarved = 0
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
setBodyColor("#FF9900") # PhantomMeteor: deep orange/gold
bot.radar.init()
bot.mover.resetRound()
bot.enemyTracker.resetRound()
bot.isRamming = false
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 0
bot.hasContact = false
bot.tick = 0
bot.currentGun = -1
bot.currentTargetId = -1
bot.targetSwitchTick = 0
# Defensive fallback: if onGameStarted hasn't run, use live enemy count from API
if bot.initialEnemyCount == 0:
bot.initialEnemyCount = getEnemyCount()
bot.enemyCount = bot.initialEnemyCount
# ponytail: default to melee until confirmed 1v1 via scans, avoids stale enemyCount issue
bot.radarMode = 1 # Start in melee_scan; will switch to radar_lock (0) if tracker confirms 1v1
bot.moveTracker.resetRound(getX(), getY(), getDirection(), getSpeed())
bot.currentGun = 0
# Apply radar colors for initial mode
if bot.radarMode == 0:
setRadarColor("#004444")
setScanColor("#0D4D4D")
else:
setRadarColor("#443300")
setScanColor("#4D3D0D")
bot.printConfig("all")
# tracker fitness persists across rounds (rolling window carries over)
method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
echo "[death] victimId=", e.victimId, " wasTarget=", (e.victimId == bot.currentTargetId),
" trackerAlive=", (if bot.enemyTracker.enemies.contains(e.victimId): $bot.enemyTracker.enemies[e.victimId].alive else: "notInTracker")
echo CLR_MOVE & "[death] victimId=" & $e.victimId & " wasTarget=" & $(e.victimId == bot.currentTargetId) & CLR_RST
bot.enemyTracker.markDead(e.victimId)
if bot.enemyCount > 0:
dec bot.enemyCount
if e.victimId == bot.currentTargetId:
bot.isRamming = false
bot.currentTargetId = -1
method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
## Hysteresis: only switch when there is a clear reason.
if bot.currentTargetId < 0: return true
let cur = bot.enemyTracker.enemies.getOrDefault(bot.currentTargetId)
if not cur.alive: return true
if bot.tick - cur.lastSeenTick >= 20: return true # stale
if bot.tick - bot.targetSwitchTick < 10: return false # cooldown
if candidateId == bot.currentTargetId: return false
let cand = bot.enemyTracker.enemies.getOrDefault(candidateId)
let sx = getX(); let sy = getY()
let curDist = hypot(cur.x - sx, cur.y - sy)
let candDist = hypot(cand.x - sx, cand.y - sy)
if candDist < curDist * 0.7: return true # significantly closer
if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity
return false
method run*(bot: ModularBot) =
while isRunning():
inc bot.tick
# Ram cooldown countdown
if bot.ramCooldownTicks > 0:
dec bot.ramCooldownTicks
# Safety: reset invalid target before any logic
if bot.currentTargetId >= 0:
if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or
not bot.enemyTracker.enemies[bot.currentTargetId].alive:
echo CLR_MOVE & "[target-invalid] id=" & $bot.currentTargetId & " resetting" & CLR_RST
bot.currentTargetId = -1
bot.isRamming = false
if not bot.hasContact:
setTargetSpeed(0.0)
setTurnRate(0.0)
setRadarTurnRate(45.0)
go()
continue
# --- Target selection (sticky) ---
let candidateId = selectTarget(bot.enemyTracker, getX(), getY(), tmClosest)
if bot.shouldSwitchTarget(candidateId):
if candidateId != bot.currentTargetId:
bot.currentTargetId = candidateId
bot.targetSwitchTick = bot.tick
bot.printConfig("target")
if bot.currentTargetId >= 0:
bot.lastKnownTargetId = bot.currentTargetId
# --- Refresh target state FIRST, before ram decision ---
let tid = bot.currentTargetId
if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and
bot.enemyTracker.enemies[tid].alive:
let tgt = bot.enemyTracker.getEnemy(tid)
bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y)
bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy)
# Ram decision — harness decides, not the movement module (uses fresh lastState)
let ws = bot.lastState
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
let ramFinisher = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 300.0 and ws.enemyEnergy < 20.0 and ws.selfEnergy > ws.enemyEnergy
let ramOpportunity = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 50.0 and ws.selfEnergy > ws.enemyEnergy + 30.0
let ramDesperation = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ws.selfEnergy < 5.0 and ws.enemyEnergy < 5.0 and ramDist < 150.0
let shouldRam = bot.currentTargetId >= 0 and
(ramFinisher or ramOpportunity or ramDesperation)
if shouldRam and not bot.isRamming:
bot.isRamming = true
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
if ramFinisher:
echo "[ram:enter] reason=finisher dist=", ramDist.int, " selfEnergy=", ws.selfEnergy.int, " enemyEnergy=", ws.enemyEnergy.int
elif ramOpportunity:
echo "[ram:enter] reason=opportunity dist=", ramDist.int, " energyAdv=", (ws.selfEnergy - ws.enemyEnergy).int
elif ramDesperation:
echo "[ram:enter] reason=desperation selfEnergy=", ws.selfEnergy.int
elif not shouldRam and bot.isRamming:
bot.isRamming = false
if bot.isRamming:
inc bot.ramDurationTicks
if ramDist < 5.0:
inc bot.ramStuckTicks
else:
bot.ramStuckTicks = 0
if bot.ramStuckTicks > 10:
bot.isRamming = false
bot.currentTargetId = -1
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
elif bot.ramDurationTicks > 60:
bot.isRamming = false
bot.currentTargetId = -1
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
let (spd, tr) = if shouldRam:
bot.mover.clearGraphics()
bot.rammer.computeMove(ws)
else:
bot.mover.computeMove(ws)
setTargetSpeed(spd)
setTurnRate(tr)
# Auto-switch radar based on live enemy count from tracker (ground truth from scans)
let liveEnemyCount = bot.enemyTracker.allAlive().len
let targetMode = if liveEnemyCount == 1: 0 else: 1
if targetMode != bot.radarMode:
bot.radarMode = targetMode
if bot.radarMode == 0:
bot.radar.init() # re-lock onto survivor
setRadarColor("#004444")
setScanColor("#0D4D4D")
else:
setRadarColor("#443300")
setScanColor("#4D3D0D")
bot.printConfig("radar")
let radarRate = if bot.radarMode == 0:
bot.radar.computeScan(bot.lastState)
else:
bot.meleeScan.computeScan(bot.lastState)
setRadarTurnRate(radarRate)
go()
# --- Aim + fire from tracked state (not scan event) ---
if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and
bot.enemyTracker.enemies[tid].alive:
# Spawn virtual bullets for 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]
var gfPreds: array[len(PowerBins), GunPrediction]
var pmPreds: array[len(PowerBins), GunPrediction]
var wbPreds: array[len(PowerBins), GunPrediction]
var acPreds: array[len(PowerBins), GunPrediction]
var ssPreds: array[len(PowerBins), GunPrediction]
var dsPreds: array[len(PowerBins), GunPrediction]
var alPreds: array[len(PowerBins), GunPrediction]
var dgPreds: array[len(PowerBins), GunPrediction]
var knnPreds: array[len(PowerBins), GunPrediction]
var tmselPreds: 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]))
gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector:
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
if bot.tsetlin.isWarmedUp():
bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid)
bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and bot.tmSelector.isWarmedUp():
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
for id, es in bot.enemyTracker.enemies:
enemyPositions[id] = (x: es.x, y: es.y, lastSeenTick: es.lastSeenTick, alive: es.alive)
let st = bot.lastState
bot.tracker.tickBullets(st, enemyPositions, 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}"
of 4: bot.guessFactor.onResult(fe)
of 5: bot.patternMatcher.onResult(fe)
of 6: bot.wallBounce.onResult(fe)
of 7: bot.accelGun.onResult(fe)
of 8: bot.stopShot.onResult(fe)
of 9: bot.displace.onResult(fe)
of 10: bot.avgLead.onResult(fe)
of 11: bot.decayGF.onResult(fe)
of 12: bot.knnGun.onResult(fe)
of 13: bot.tmSelector.onResult(fe)
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, tid)
bot.gunSelectionCount[selectedGun] += 1
if selectedGun != bot.currentGun:
bot.currentGun = selectedGun
case selectedGun
of 0: setTurretColor("#FF3333"); setBulletColor("#FF6666")
of 1: setTurretColor("#3366FF"); setBulletColor("#6699FF")
of 2: setTurretColor("#9933FF"); setBulletColor("#CC66FF")
of 3: setTurretColor("#33CC33"); setBulletColor("#66FF66")
of 4: setTurretColor("#FFCC00"); setBulletColor("#FFE066")
of 5: setTurretColor("#FF6600"); setBulletColor("#FF9944")
of 6: setTurretColor("#CCCCCC"); setBulletColor("#EEEEEE")
of 7: setTurretColor("#FF00FF"); setBulletColor("#FF66FF")
of 8: setTurretColor("#990000"); setBulletColor("#CC3333")
of 9: setTurretColor("#006600"); setBulletColor("#009900")
of 10: setTurretColor("#996633"); setBulletColor("#CC9966")
of 11: setTurretColor("#008888"); setBulletColor("#00AAAA")
of 12: setTurretColor("#CC00CC"); setBulletColor("#FF44FF")
of 13: setTurretColor("#00FFCC"); setBulletColor("#66FFDD")
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))
of 4: bot.guessFactor.predict(bot.lastState, bulletSpeed(power))
of 5: bot.patternMatcher.predict(bot.lastState, bulletSpeed(power))
of 6: bot.wallBounce.predict(bot.lastState, bulletSpeed(power))
of 7: bot.accelGun.predict(bot.lastState, bulletSpeed(power))
of 8: bot.stopShot.predict(bot.lastState, bulletSpeed(power))
of 9: bot.displace.predict(bot.lastState, bulletSpeed(power))
of 10: bot.avgLead.predict(bot.lastState, bulletSpeed(power))
of 11: bot.decayGF.predict(bot.lastState, bulletSpeed(power))
of 12: bot.knnGun.predict(bot.lastState, bulletSpeed(power))
of 13: bot.tmSelector.predict(bot.lastState, bulletSpeed(power))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
let gunDir = getGunDirection()
let gunHeat = getGunHeat()
let gunDelta = (aimTarget - gunDir) mod 360.0
var normDelta = gunDelta
if normDelta > 180.0: normDelta -= 360.0
elif normDelta < -180.0: normDelta += 360.0
# Distance to the aim point drives the range-aware gate (Task B) and is
# recorded per shot (Task A).
let distPx = hypot(pred.x - getX(), pred.y - getY())
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
if setFire(power) and getEnergy() > power:
bot.pendingFires.add(PendingShot(
gunId: selectedGun,
angleErr: abs(normDelta),
distPx: distPx,
power: power,
tick: bot.tick,
targetId: tid,
))
setGunTurnRate(normDelta)
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(14), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
tsetlin: initTsetlinGun(),
guessFactor: initGFGun(),
patternMatcher: PatternMatcherGun(),
wallBounce: initWallBounceGun(),
accelGun: initAccelGun(),
stopShot: initStopShotGun(),
displace: initDisplacementGun(),
avgLead: initAveragedLeadGun(),
decayGF: initDecayGFGun(),
knnGun: initKNNGun(),
tmSelector: initTmSelectorGun(),
radar: RadarLockModule(),
meleeScan: initMeleeScan(),
mover: TFILModule(debugGraphics: true),
rammer: initRammer(),
moveTracker: mvb.initVirtualBodyTracker(1),
currentGun: -1,
)
start(bot, botJsonPath)