Files
SirRoboGarage/ModularBot_garage/src/ModularBot.nim
T
SirStone 0f5cfe37b2 surf: wire the dormant wave surfer as TR_MOVEMENT=surf + fix 4 real defects
common_libs/movements/wave_surfer.nim was written in an early session and
never wired to the bot. This connects it exactly like tfil/strafe and fixes
the defects a full read found:

  1. the dodge direction was INVERTED: the perpendicular was built from the
     bot->enemy bearing while GF lives in the enemy->bot frame, so the bot
     moved toward MORE danger. Now built from the wave's origin->bot bearing:
     +90 provably increases GF.
  2. the danger histogram was never reset (resetRound cleared waves only), so
     it was a battle-long static average. Now reset to the uniform prior each
     round.
  3. fire detection tracked only the current target's energy via one scalar;
     now per-enemy (seq[(id,energy)]) so melee target switches cannot invent
     or hide waves.
  4. the wall penalty projected a point from the wave origin, not from the
     bot, making the wall test meaningless. Now projects the bot->candidate
     direction.

Wave speed uses the actual firepower (the one-tick energy drop IS the
firepower, so speed = 20 - 3*drop is exact). Adds TR_SURF_* knobs and
registers them in the env report. Shipped TR_MOVEMENT=tfil default untouched
(test_tfil_commit_env: 30/30 pass; test_env_report: pass).
2026-09-26 00:10:24 +02:00

1451 lines
68 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), TmPatternGun (14), TmHorizonGun (15), BitBrainGun (16) via GunHarness.
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
import std/[math, os, strformat, tables, sets, json, random, strutils]
import robocode_tankroyale_botapi
import radar_harness/radar_interface
import radars/radar_lock_module
import radars/adaptive_melee_radar
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 guns/tm_pattern
import guns/tm_horizon
import guns/bitbrain_gun
import movements/phantom_meteor
import movements/rammer
import movements/ram_decision
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import movements/strafe
import movements/wave_surfer
import movement_harness/virtual_bodies as mvb
import movement_harness/bullet_shadows
import targeting/enemy_tracker
import targeting/target_selector
import env_report
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"
## Radar measurement switches (all RUNTIME, read once at process start):
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
## spin (always 45 deg/tick). This reproduces the
## replaced `melee_scan` on the IDENTICAL binary, so an
## OLD-vs-NEW scan-rate A/B changes only the radar.
## TR_RADAR_SCANLOG=1 append one per-round JSON line of scan events and
## coverage to /tmp/radar_scan_log.jsonl.
let RadarForceSpin* = existsEnv("TR_RADAR_FORCE_SPIN")
let RadarScanLog* = existsEnv("TR_RADAR_SCANLOG")
let RadarScanLogPath = getEnv("TR_RADAR_SCAN_LOG_PATH", "/tmp/radar_scan_log.jsonl")
## Enemy-tracker probe (TR_TRACKER_PROBE=1): append one JSON line per tick
## (tracker state vs server enemy count) and one per event dispatch (the queue
## BEFORE `removeOldEvents` deletes dropped events) to TR_TRACKER_PROBE_PATH.
## Measurement-only; the default build writes nothing.
let TrackerProbe* = existsEnv("TR_TRACKER_PROBE")
let TrackerProbePath = getEnv("TR_TRACKER_PROBE_PATH", "/tmp/tracker_probe.jsonl")
## Runtime rack pruning for MEASURED A/B runs: comma-separated gun ids to remove
## from the virtual-bullet rack. A disabled gun never spawns virtual bullets, so
## its fitness window stays empty and the selector can never pick it (chooseFromFit
## skips guns with no observations). Read once at process start so a SINGLE frozen
## binary can be A/B'd by exporting GUN_RACK_DISABLE. Empty/unset = full rack.
let DisabledGuns =
block:
var s: HashSet[int]
for tok in getEnv("GUN_RACK_DISABLE", "").split(','):
let t = tok.strip()
if t.len > 0:
try: s.incl parseInt(t)
except ValueError: discard
s
proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns
## ── virtual-bullet spawn gate (TR_VBULLET_ADMIT_ONLY) ──────────────────────
## Rack membership filters SELECTION; this knob extends it to SPAWNING. With it
## set (default 1) a gun the current rack does not admit is skipped ENTIRELY in
## the live aim block — no `predict()`, no `spawnBullets()` — so 13 unselected
## guns stop computing a fitness table nobody reads (~40-46% of the per-tick
## budget in the shipped onlyPattern rack). Set to 0 to restore the pre-change
## behaviour (every non-TMPATTERN gun spawns) on the SAME frozen binary.
## `onResult` feedback is NEVER gated (see the gated aim block in `run`).
let VBulletAdmitOnly* =
block:
let v = getEnv("TR_VBULLET_ADMIT_ONLY", "1").strip().toLowerAscii()
not (v == "0" or v == "false" or v == "no" or v == "off")
## ── Movement rack (runtime switch) ─────────────────────────────────────────
## `TR_MOVEMENT` selects the movement engine, mirroring the gun rack's
## env-driven construction. Both engines are always constructed, so the switch
## needs no rebuild:
## tfil (DEFAULT) — today's behaviour, byte-for-byte, including the
## separate rammer dispatch.
## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring).
## UNPROVEN: it must never become the default silently.
let MovementName* = getEnv("TR_MOVEMENT", "tfil").strip().toLowerAscii()
## Per-process output paths so concurrent A/B runs do not clobber each other.
let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl")
let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl")
## Energy-aware power policy observability (TR_POWER_LOG=1): emit ONE line per
## CHANGE of the (power, cap, reason) decision — not per tick — so the GUI log
## shows why the cap moved. The policy itself lives in the shared gun harness
## (`applyPowerPolicy`), so both live and offline paths see the same rule.
let PowerLog = existsEnv("TR_POWER_LOG")
## Round-outcome observability (TR_RESULT_LOG): one `[result]` line per round
## (win / our death / mutual kill / timeout) plus one at battle end. Default ON;
## only the explicit off-values disable it. Read once at process start.
let ResultLog* =
block:
let v = getEnv("TR_RESULT_LOG", "1").strip().toLowerAscii()
not (v == "0" or v == "false" or v == "no" or v == "off")
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon", "BitBrain"]
## Rack id of the new TM pattern gun. It defaults to `TR_RACK_TMPATTERN=off`;
## unlike the other guns, its virtual-bullet spawn is gated on rack admission
## (see the aim block in `run`), so the shipped default never spawns it and the
## shared VirtualTracker ring head — hence every other gun's learning order and
## the default selection sequence — is byte-for-byte unchanged.
const TmPatternId = 14
## Rack id of the horizon-based TM corrector. Like TMPATTERN it defaults to
## `TR_RACK_TMHORIZON=off` and its virtual-bullet spawn is gated on rack
## admission, so the shipped default never spawns it and the shared tracker ring
## head — hence every other gun's learning order — is byte-for-byte unchanged.
const TmHorizonId = 15
## Rack id of the BitBrain ADE+SBC fine-grained aim corrector. It defaults to
## `TR_RACK_BITBRAIN=off` and its virtual-bullet spawn is gated on rack
## admission, so the shipped default never spawns it and the shared tracker ring
## head — hence every other gun's learning order — is byte-for-byte unchanged.
const BitBrainId = 16
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
mode: vb.RackMode ## rack in force at fire time (per-mode hit stats)
ModularBot = ref object of Bot
hasContact: bool
enemyBearing: float
lastState: WorldState
radar: RadarLockModule
meleeRadar: AdaptiveMeleeRadarModule
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
tmPattern: TmPatternGun
tmHorizon: TmHorizonGun
bitbrain: BitBrainGun
mover: TFILModule
ringMover: TFILRingModule
strafeMover: StrafeModule
surfMover: WaveSurferModule
rammer: RammerModule
isRamming: bool
ramDurationTicks: int
ramCooldownTicks: int
ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage
ramDmgHead: int
ramDmgAccum: float ## damage accumulated this turn, flushed by the window
lastRamLogKey: string ## TR_RAM_LOG change detector (state|reason)
moveTracker: VirtualBodyTracker
virtualHits: int
virtualMiss: int
tick: int
currentGun: int
currentTargetId: int
targetSwitchTick: int
enemyTracker: EnemyTracker
prevGun: int
prevRadar: int
prevTarget: int
cfgDirty: bool ## gun/target/radar changed this tick; emit one config line at tick end
roundNumber: int
realShotsFired: int
realHits: int
gunRealShots: array[17, int]
gunRealHits: array[17, int]
# Same real-shot accounting split by the rack in force at fire time, so a
# later data-driven pass can rank guns per mode (1v1 vs melee).
gunRealShotsByMode: array[vb.RackMode, array[17, int]]
gunRealHitsByMode: array[vb.RackMode, array[17, 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
bulletMode: Table[int, vb.RackMode] ## bulletId -> rack at fire time
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
gunSelectionCount: array[17, int]
lastPowerLogKey: string ## change detector for the TR_POWER_LOG line
lastKnownTargetId: int ## persists through death, used for round-end stats
# Radar measurement instrumentation (only touched when RadarScanLog is set).
radarScanCounts: Table[int, int] ## onScannedBot calls per enemy id
radarAliveTicks: Table[int, int] ## ticks each enemy was known-alive
radarFresh8: Table[int, int] ## ticks each enemy was seen within 8
radarFresh16: Table[int, int] ## ticks each enemy was seen within 16
arcWidthHist: array[12, int] ## covering-arc width buckets of 30 deg
radarAcquireTicks: int
radarTrackTicks: int
radarMeleeActive: bool
# Rack (melee vs 1v1) state. `rackMode` is derived from SERVER truth once
# per tick in run(); `lastRackLogKey` is the change detector for the
# once-per-change `[rack]` log line.
rackMode: vb.RackMode
lastRackLogKey: string
# Round-outcome logging (TR_RESULT_LOG). Pure observability: these fields
# never feed a movement/gun/radar decision. Reset per round in
# onRoundStarted (and the counters per battle in onGameStarted).
battleRounds: int ## rounds configured for this battle (GameSetup)
roundsWon: int ## cumulative rounds won this battle
roundsPlayed: int ## cumulative rounds fully accounted for
roundCounted: bool ## roundsPlayed already bumped for this round
weDiedThisRound: bool
enemyDiedThisRound: bool ## an enemy BotDeath was observed this round
wonRoundSeen: bool ## onWonRound fired (authoritative "last alive")
roundOutcomePrinted: bool ## a `[result]` line already covered this round
roundPrintedLabel: string ## its outcome label (for reconciliation)
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(ShotLogPath, 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
bot.bulletMode[bulletId] = shot.mode
inc bot.gunRealShots[shot.gunId]
inc bot.gunRealShotsByMode[shot.mode][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]
inc bot.gunRealHitsByMode[shot.mode][shot.gunId]
bot.pendingHitBullets.excl(bulletId)
bot.bulletGun.del(bulletId)
bot.bulletMode.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)
bot.bulletMode.del(bulletId)
proc printConfig(bot: ModularBot, forceAll: bool = false) =
## One line describing the config in use for the tick being reported. Only the
## fields that changed since the previous line are highlighted green. The gun
## field is omitted until a gun has actually been selected this round, so a
## round-start line never claims a gun that was not chosen.
let rst = CLR_RST
let gc = if bot.currentGun != bot.prevGun or forceAll: CLR_CHANGE else: ""
let rc = if bot.radarMode != bot.prevRadar or forceAll: CLR_CHANGE else: ""
let tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: ""
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
let moveName =
if MovementName == "strafe":
if bot.isRamming: "strafe(ram)" else: "strafe"
elif MovementName == "tfil_ring":
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
elif MovementName == "surf":
if bot.isRamming: "surf(ram)" else: "surf"
elif bot.isRamming: "rammer"
else: "tfil"
var line = "[config] "
if bot.currentGun >= 0 and bot.currentGun < GunNames.len:
line &= gc & "gun=" & GunNames[bot.currentGun] & (if gc != "": rst else: "") & " | "
line &= "move=" & moveName &
" | " & rc & "radar=" & radarName & (if rc != "": rst else: "") &
" | enemies=" & $bot.enemyTracker.allAlive().len &
" target=" & tc & "#" & $bot.currentTargetId & (if tc != "": rst else: "")
echo line
bot.prevGun = bot.currentGun
bot.prevRadar = bot.radarMode
bot.prevTarget = bot.currentTargetId
proc logRackChange(bot: ModularBot, force = false) =
## Emit ONE `[rack]` line per mode / membership change (never per tick),
## behind the existing config-log mechanism. The membership table is frozen at
## process start, so in practice this fires on the 1v1 <-> melee transition;
## `force` also prints the round-start state.
let over = rackOverrides(ActiveRackMembership)
let key = rackModeName(bot.rackMode) & "|" & over
if not force and key == bot.lastRackLogKey: return
bot.lastRackLogKey = key
echo "[rack] mode=" & rackModeName(bot.rackMode) &
" active=" & rackActive(ActiveRackMembership, bot.rackMode) &
(if over.len > 0: " overrides=" & over else: " overrides=none")
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,
lastSeenTick: es.lastSeenTick)
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)
## ── Enemy-tracker probe (TR_TRACKER_PROBE) ─────────────────────────────────
var gProbeFile: File
var gProbeReady = false
var gProbeEventRegistered = false
proc probeWrite(row: JsonNode) =
if not gProbeReady:
try:
gProbeFile = open(TrackerProbePath, fmWrite)
gProbeReady = true
except CatchableError:
return
try:
gProbeFile.writeLine($row)
gProbeFile.flushFile()
except CatchableError:
discard
proc probeQueue(): bool =
## Registered as a custom event. `addCustomEvents` runs it AFTER the tick's
## events are queued but BEFORE `removeOldEvents` deletes them, so this is the
## only place that can observe a `BotDeathEvent` the API is about to drop.
## Always returns false, so it is never dispatched.
if TrackerProbe:
var evs = newJArray()
var dropped = 0
var maxTurn = -1
let turn = getTurn()
for e in getEvents():
let drop = e.turnNumber < turn - MAX_EVENTS_AGE and not e.isCritical
if drop: inc dropped
if e.turnNumber > maxTurn: maxTurn = e.turnNumber
evs.add(%*{"k": $e.kind, "t": e.turnNumber, "drop": drop})
probeWrite(%*{
"probe": "queue",
"round": getRound(),
"turn": turn,
"queue_turn": maxTurn,
"dispatch_lag": turn - maxTurn,
"events": evs,
"dropped": dropped,
})
return false
proc probeTracker(bot: ModularBot) =
## Per-tick tracker snapshot (tracker alive-count vs the server's
## `getEnemyCount()`). The `pruned` field is kept for schema compatibility
## with the corpse measurement tooling; it is always empty now that
## reconciliation is gone (see docs/tracker_death_events.md).
if not TrackerProbe: return
var alive = newJObject()
var dead = newJObject()
for id, es in bot.enemyTracker.enemies:
if es.alive: alive[$id] = %es.lastSeenTick
else: dead[$id] = %es.lastSeenTick
let current = bot.currentTargetId
probeWrite(%*{
"probe": "tracker",
"round": bot.roundNumber,
"my_id": getMyId(),
"turn": getTurn(),
"bot_tick": bot.tick,
"lag": getTurn() - bot.tick,
"server_ec": getEnemyCount(),
"tracker_alive": bot.enemyTracker.aliveCount(),
"alive": alive,
"dead": dead,
"pruned": newJArray(),
"target": current,
"target_alive": bot.enemyTracker.isAlive(current),
"radar_mode": bot.radarMode,
"ramming": bot.isRamming,
})
proc recordRadarStats(bot: ModularBot) =
## Per-tick radar coverage sampler (no-op unless TR_RADAR_SCANLOG is set).
## "Fresh within N" means the tracker scanned that enemy at most N ticks ago.
## The covering-arc width is recomputed here from live bearings purely for the
## histogram; it does not influence the radar.
##
## Only SERVER-confirmed melee ticks are sampled (2+ enemies alive), so the
## 1v1 lock mode cannot dilute the melee numbers. There is no corpse filter:
## the tracker's `alive` flag is reliable (measured 0 corpse ticks — see
## docs/tracker_death_events.md), so dead enemies are excluded by `es.alive`
## alone.
if not RadarScanLog: return
bot.radarMeleeActive = getEnemyCount() >= 2
if not bot.radarMeleeActive: return
if bot.radarMode == 1:
if bot.meleeRadar.phase == rpAcquire: inc bot.radarAcquireTicks
else: inc bot.radarTrackTicks
var bearings: seq[float]
for id, es in bot.enemyTracker.enemies:
if not es.alive: continue
let age = bot.tick - es.lastSeenTick
bearings.add normalizeDeg(arctan2(es.y - getY(), es.x - getX()).radToDeg)
bot.radarAliveTicks[id] = bot.radarAliveTicks.getOrDefault(id) + 1
if age <= 8:
bot.radarFresh8[id] = bot.radarFresh8.getOrDefault(id) + 1
if age <= 16:
bot.radarFresh16[id] = bot.radarFresh16.getOrDefault(id) + 1
if bearings.len > 0:
let (_, _, width) = minimalCoveringArc(bearings)
inc bot.arcWidthHist[min(11, int(width / 30.0))]
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
if RadarScanLog and bot.radarMeleeActive:
bot.radarScanCounts[e.scannedBotId] =
bot.radarScanCounts.getOrDefault(e.scannedBotId) + 1
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
# Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1)
# 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]
inc bot.gunRealHitsByMode[hitMode][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)
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.surfMover.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)
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.surfMover.removeBulletNear(e.bullet.x, e.bullet.y)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
# Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's
# 4p/6p-2 damage), not raw firepower, so `damageRatePerTurn` is a real
# energy/turn rate (see movements/ram_decision.bulletDamage).
bot.ramDmgAccum += bulletDamage(e.bullet.power)
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
proc enemyEnergyForReport(bot: ModularBot): float =
## Last-scanned energy of the enemy the report is about (current target,
## else the last known target, else any enemy in the tracker). Returns -1.0
## when nothing has been scanned yet, so a report never invents a number;
## returns 0.0 when that enemy is already known dead.
let tid = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId
if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
let es = bot.enemyTracker.enemies[tid]
return (if es.alive: es.energy else: 0.0)
for _, es in bot.enemyTracker.enemies:
return (if es.alive: es.energy else: 0.0)
-1.0
proc emitRoundResult(bot: ModularBot, label, detail: string, ticks: int,
usDead, themDead: bool) =
## The ONE `[result]` line for one round. Every line starts with the greppable
## `[result]` prefix; the outcome word is left-padded so successive lines
## align. `rounds won X/Y` is the cumulative count including this round.
if not ResultLog: return
let rn = if bot.battleRounds > 0: $bot.roundNumber & "/" & $bot.battleRounds
else: $bot.roundNumber
let usE = if usDead: 0.0 else: getEnergy()
var themE = enemyEnergyForReport(bot)
if themDead: themE = 0.0
let themStr = if themE < 0.0: "?" else: fmt"{themE:.1f}"
let total = if bot.battleRounds > 0: bot.battleRounds else: bot.roundsPlayed
echo "[result] round ", rn, " ", alignLeft(label, 10), "(", detail, ")",
" | us ", fmt"{usE:.1f}", " energy, them ", themStr, ", ",
ticks, " ticks | rounds won ", bot.roundsWon, "/", total
bot.roundOutcomePrinted = true
bot.roundPrintedLabel = label
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round).
# Round outcome: `e.results.rank` (1 = winner) is the server's authoritative
# win/lose signal and does not depend on BotDeathEvent. Death observations
# (our `onDeath`, enemy `onBotDeath`) and `onWonRound` refine it into the four
# distinct outcomes. If our death already printed this round's line (immediate
# report), only reprint when the final label CHANGED (e.g. a mutual kill).
if ResultLog:
let rank1 = e.results.rank == 1
if not bot.roundCounted:
inc bot.roundsPlayed
bot.roundCounted = true
if rank1: inc bot.roundsWon
let weDied = bot.weDiedThisRound
let enemyGone = bot.enemyDiedThisRound or bot.wonRoundSeen
let decided = ": " & (if rank1: "WE WON" else: "WE LOST")
var label, detail: string
if weDied and enemyGone:
label = "BOTH DIED"; detail = "score decided" & decided
elif weDied:
label = "WE DIED"; detail = "killed"
elif enemyGone:
label = "WE WON"; detail = "enemy destroyed"
else:
label = "TIMEOUT"; detail = "score decided" & decided
if not bot.roundOutcomePrinted or bot.roundPrintedLabel != label:
bot.emitRoundResult(label, detail, e.turnNumber, weDied, enemyGone)
if RecordWorldState:
bot.finishWorldStateRecord()
# Per-round TM-horizon summary (change-gated behind TR_TMHORIZON_LOG=1) so the
# user can watch it learn across the round.
bot.tmHorizon.roundSummary()
# 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..<17:
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
# Real hit rate split by the rack in force when each shot was fired, so a
# later data-driven pass can rank guns per mode without a rebuild.
let rShots1 = bot.gunRealShotsByMode[vb.rm1v1][gid]
let rHits1 = bot.gunRealHitsByMode[vb.rm1v1][gid]
let rRate1 = if rShots1 > 0: rHits1.float * 100.0 / rShots1.float else: 0.0
let rShotsM = bot.gunRealShotsByMode[vb.rmMelee][gid]
let rHitsM = bot.gunRealHitsByMode[vb.rmMelee][gid]
let rRateM = if rShotsM > 0: rHitsM.float * 100.0 / rShotsM.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),
"realShots1v1": rShots1,
"realHits1v1": rHits1,
"realHitRate1v1": rRate1.round(1),
"realShotsMelee": rShotsM,
"realHitsMelee": rHitsM,
"realHitRateMelee": rRateM.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(GunStatsPath, 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)
if RadarScanLog:
var scans = newJObject()
var alive = newJObject()
var fresh8 = newJObject()
var fresh16 = newJObject()
for id, n in bot.radarScanCounts: scans[$id] = %n
for id, n in bot.radarAliveTicks: alive[$id] = %n
for id, n in bot.radarFresh8: fresh8[$id] = %n
for id, n in bot.radarFresh16: fresh16[$id] = %n
var hist = newJArray()
for c in bot.arcWidthHist: hist.add(%c)
let rrow = %*{
"round": bot.roundNumber,
"ticks": bot.tick,
"scans": scans,
"aliveTicks": alive,
"fresh8": fresh8,
"fresh16": fresh16,
"arcHist": hist,
"acquireTicks": bot.radarAcquireTicks,
"trackTicks": bot.radarTrackTicks,
"enemyCount": getEnemyCount(),
}
try:
let rf = open(RadarScanLogPath, fmAppend)
rf.writeLine($rrow)
rf.close()
except CatchableError:
discard
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.roundNumber = e.roundNumber
# Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1
# reports and a target/enemy change mid-round cannot leak a stale flag.
bot.weDiedThisRound = false
bot.enemyDiedThisRound = false
bot.wonRoundSeen = false
bot.roundCounted = false
bot.roundOutcomePrinted = false
bot.roundPrintedLabel = ""
if RecordWorldState:
bot.startWorldStateRecord()
bot.realShotsFired = 0
bot.realHits = 0
bot.lastKnownTargetId = -1
bot.pendingFires.setLen(0)
bot.bulletGun.clear()
bot.bulletMode.clear()
bot.bulletShot.clear()
bot.pendingHitBullets.clear()
if RadarScanLog:
bot.radarScanCounts.clear()
bot.radarAliveTicks.clear()
bot.radarFresh8.clear()
bot.radarFresh16.clear()
for i in 0 ..< bot.arcWidthHist.len: bot.arcWidthHist[i] = 0
bot.radarAcquireTicks = 0
bot.radarTrackTicks = 0
bot.radarMeleeActive = false
for i in 0..<17:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
for m in vb.RackMode:
for i in 0..<17:
bot.gunRealShotsByMode[m][i] = 0
bot.gunRealHitsByMode[m][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.meleeRadar.init()
bot.mover.resetRound()
bot.ringMover.resetRound()
bot.strafeMover.resetRound()
bot.surfMover.resetRound()
bot.enemyTracker.resetRound()
# The TM pattern gun is cold every round (no cross-battle persistence): wipe
# its clause teams and motion history explicitly, so a same-id opponent in the
# next round cannot inherit the previous round's overfit net.
bot.tmPattern.resetLearning()
# The horizon TM corrector KEEPS its machines across rounds: only the
# per-round observation ring and deferred labels are cleared (bots teleport
# between rounds, so old positions are meaningless). The machines are wiped on
# a NEW BATTLE in onGameStarted, with a round-1 fallback here in case that
# callback does not fire in this harness.
bot.tmHorizon.resetRoundState()
if e.roundNumber <= 1:
bot.tmHorizon.resetLearning("round1")
# BitBrain: per-round wipe always clears observations/labels; `perRound` mode
# also wipes its SBCs (the gate test's winning regime).
bot.bitbrain.resetRoundState()
if e.roundNumber <= 1:
bot.bitbrain.resetLearning("round1")
bot.isRamming = false
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 0
for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0
bot.ramDmgHead = 0
bot.ramDmgAccum = 0.0
bot.lastRamLogKey = ""
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 adaptive_melee; will switch to radar_lock (0) if tracker confirms 1v1
bot.moveTracker.resetRound(getX(), getY(), getDirection(), getSpeed())
# currentGun stays -1 ("no gun chosen yet") until run() picks one; printConfig
# omits the gun field while it is negative.
# Apply radar colors for initial mode
if bot.radarMode == 0:
setRadarColor("#004444")
setScanColor("#0D4D4D")
else:
setRadarColor("#443300")
setScanColor("#4D3D0D")
bot.cfgDirty = false
bot.printConfig(forceAll = true)
# Rack mode from server truth (same transition the radar uses), logged once
# per round start and again on every 1v1 <-> melee change. At round start
# `bot.enemyCount` (from the game setup) is reliable where a raw
# getEnemyCount() call may not be yet, and run() refreshes it every tick.
bot.rackMode = vb.rackMode(bot.enemyCount)
bot.lastRackLogKey = ""
bot.logRackChange(force = true)
# tracker fitness persists across rounds (rolling window carries over)
method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
## An ENEMY died (`ekBotDeath`; our own death goes to `onDeath`).
bot.enemyDiedThisRound = true
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
# `markDead` is the lone death path. The API-level drop mechanism is real
# (`ekBotDeath` is not `isCritical`), but it was MEASURED never to fire at
# this workload: 0 dropped BotDeath events and 0 corpse ticks over 151 deaths
# on two server versions. See docs/tracker_death_events.md.
bot.enemyTracker.markDead(e.victimId)
if e.victimId == bot.currentTargetId:
bot.isRamming = false
bot.currentTargetId = -1
method onDeath*(bot: ModularBot, e: BotDeathEvent) =
## OUR OWN death (`ekDeath`, victimId == myId). Reported the instant it
## happens so the user tailing the GUI log sees it without waiting for the
## round-end event (which may not arrive). If the enemy is already known dead,
## the round is a mutual kill and is labelled as such (rank/score only arrives
## at round end and is reconciled there if it contradicts this line).
bot.weDiedThisRound = true
inc bot.roundsPlayed
bot.roundCounted = true
if bot.enemyDiedThisRound or bot.wonRoundSeen:
bot.emitRoundResult("BOTH DIED", "score decided", e.turnNumber, true, true)
else:
bot.emitRoundResult("WE DIED", "killed", e.turnNumber, true, false)
method onWonRound*(bot: ModularBot, e: WonRoundEvent) =
## The server says we were the last bot alive: authoritative "enemy
## destroyed", independent of whether a BotDeathEvent was delivered.
bot.wonRoundSeen = true
method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# A NEW BATTLE begins: wipe the horizon TM's machines and learned statistics
# (no persistence across battles). `onRoundStarted` only clears per-round
# state, so this is the only place the learning is dropped at a battle
# boundary. The round-1 fallback in `onRoundStarted` covers a missed callback.
bot.tmHorizon.resetLearning("game_start")
bot.bitbrain.resetLearning("game_start")
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
# New battle: reset the cumulative outcome counters and remember how many
# rounds the server will run (0 = unlimited; the report then shows played count).
bot.battleRounds = e.gameSetup.numberOfRounds
bot.roundsWon = 0
bot.roundsPlayed = 0
# The custom event must be registered AFTER `start()` ran `initGlobals()`,
# which replaces the event queue (and its conditions). onGameStarted is the
# first bot callback that runs after that, still before any round/tick.
if TrackerProbe and not gProbeEventRegistered:
addCustomEvent("tracker_queue_probe", probeQueue)
gProbeEventRegistered = true
method onGameEnded*(bot: ModularBot, e: GameEndedEventForBot) =
## Battle summary, ONE line, after the server reports the game over.
if not ResultLog: return
let total = if e.numberOfRounds > 0: e.numberOfRounds
elif bot.battleRounds > 0: bot.battleRounds
else: bot.roundsPlayed
echo "[result] battle END: rounds won ", bot.roundsWon, "/", total
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
proc pushRamDamageWindow(bot: ModularBot) =
## Advance the incoming-damage window once per turn. Damage accumulated by
## `onHitByBullet` during the preceding event dispatch lands in the slot being
## written. Called before the contact check so the window always advances.
bot.ramDmgWindow[bot.ramDmgHead] = bot.ramDmgAccum
bot.ramDmgHead = (bot.ramDmgHead + 1) mod RamDamageWindow
bot.ramDmgAccum = 0.0
proc logRamChange(bot: ModularBot, on: bool, reason: RamReason,
dist: float, ws: WorldState, dmgRate: float,
offReason = "") =
## TR_RAM_LOG: ONE `[ram]` line per state/reason CHANGE (never per tick). The
## cap is 3.0 by construction while ramming — `applyPowerPolicy`'s ram
## exemption returns `cap: 3.0`; the ring band is `[0,50]` on the same
## `shouldRam` flag. Pure observability; no effect when TR_RAM_LOG is unset.
if not RamLog: return
let key = if on: "ON|" & reasonName(reason) else: "OFF|" & offReason
if key == bot.lastRamLogKey: return
bot.lastRamLogKey = key
if on:
echo fmt"[ram] ON reason={reasonName(reason)} dist={dist.int} " &
fmt"selfE={ws.selfEnergy.int} enemyE={ws.enemyEnergy.int} cap=3.0 " &
"band=[0,50]"
else:
echo fmt"[ram] OFF reason={offReason} dmgRate={dmgRate:.2f}/turn"
method run*(bot: ModularBot) =
while isRunning():
inc bot.tick
if TrackerProbe: bot.probeTracker()
# `enemyCount` is derived from server truth every tick, not hand-kept.
bot.enemyCount = getEnemyCount()
if RadarScanLog: bot.recordRadarStats()
# Ram cooldown countdown
if bot.ramCooldownTicks > 0:
dec bot.ramCooldownTicks
# Advance the incoming-damage window once per turn (hits accumulated by
# onHitByBullet during the preceding dispatch).
bot.pushRamDamageWindow()
# 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.cfgDirty = true # emit at tick end, after gun selection
# The horizon TM learns ONE enemy at a time: wipe its machines when the
# target changes to a different bot id (gated by
# TR_TMHORIZON_RESET_ON_TARGET, default on; no-op in 1v1).
discard bot.tmHorizon.targetChanged(candidateId)
discard bot.bitbrain.targetChanged(candidateId)
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).
# Triggers + abort live in the pure `ram_decision` module so they can be
# swept/unit-tested; the cooldown/duration machinery stays here.
let ws = bot.lastState
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow)
# Change-of-plan signal: the selected gun's pooled virtual hit rate. Only
# consulted when the (default-off) plan trigger is enabled.
var gunHitRate = 0.0
if RamPlanEnabled and bot.currentGun >= 0 and tid >= 0:
gunHitRate = vb.gunRate(bot.tracker.fitnessFor(tid)[bot.currentGun], pooled = true)
let ramReason = ramTrigger(RamInputs(
dist: ramDist, selfEnergy: ws.selfEnergy,
enemyEnergy: ws.enemyEnergy, gunHitRate: gunHitRate))
let triggerRam = bot.currentTargetId >= 0 and bot.ramCooldownTicks == 0 and
ramReason != rrNone
# Abort an IN-PROGRESS ram on sustained incoming fire. The trigger itself is
# unaffected, so a firefight before the approach never vetoes the start.
let abortRam = shouldAbortRam(bot.isRamming, ramDmgRate)
let shouldRam = triggerRam and not abortRam
if shouldRam and not bot.isRamming:
bot.isRamming = true
bot.ramDurationTicks = 0
elif abortRam and bot.isRamming:
# Bullet rain: drop the ram, return to the normal range band, KEEP the
# target. The cooldown stops an immediate re-trigger while fire persists.
bot.isRamming = false
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "bulletRain")
elif not shouldRam and bot.isRamming:
bot.isRamming = false
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone")
# Only the 60-tick duration cap can end a ram by itself. The old `stuck`
# counter required dist < 5px on consecutive ticks, but contact happens at
# ~36px (two 18px radii) and positions rewind, so it could never increment;
# it has been removed (see docs/ramming_negative_result.md).
if bot.isRamming:
inc bot.ramDurationTicks
if bot.ramDurationTicks > 60:
bot.isRamming = false
bot.currentTargetId = -1
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "duration")
# TR_RAM_LOG: the change detector dedupes on (state|reason), so this emits
# once per transition — including a reason change while already ramming.
if bot.isRamming:
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
# Movement dispatch. `TR_MOVEMENT=strafe` (this module) routes holding
# through the perpendicular-strafe engine and still lets the rammer take
# over on a ram trigger; `tfil_ring` routes BOTH holding and ramming
# through the single ring engine; the default `tfil` keeps the old
# two-engine behaviour (tfil when holding, rammer when ramming).
var spd, tr: float
if MovementName == "strafe" and not shouldRam:
(spd, tr) = bot.strafeMover.computeMove(ws)
elif MovementName == "surf" and not shouldRam:
(spd, tr) = bot.surfMover.computeMove(ws)
elif MovementName == "tfil_ring":
bot.ringMover.band =
if shouldRam: (lo: 0.0, hi: 50.0)
else: (lo: RangeLo, hi: RangeHi)
(spd, tr) = bot.ringMover.computeMove(ws)
elif shouldRam:
bot.mover.clearGraphics()
(spd, tr) = bot.rammer.computeMove(ws)
else:
(spd, tr) = bot.mover.computeMove(ws)
setTargetSpeed(spd)
setTurnRate(tr)
# Auto-switch radar based on the SERVER's live enemy count. Using the
# tracker's known-enemy count here was wrong in melee: before the second
# enemy had been scanned the bot saw 1 and locked to 1v1, then never scanned
# the rest. getEnemyCount() is the server's alive-enemy count (scanned or
# not), so melee mode persists until only one enemy is actually left.
let liveEnemyCount = getEnemyCount()
let targetMode = if liveEnemyCount == 1: 0 else: 1
# ONE definition of the rack mode: the SAME server-truth count the radar
# uses above (`vb.rackMode` maps it to 1v1/melee). The tracker's known-enemy
# count must not be used — see the radar note. Logged once on change.
bot.rackMode = vb.rackMode(liveEnemyCount)
bot.logRackChange()
if targetMode != bot.radarMode:
bot.radarMode = targetMode
if bot.radarMode == 0:
bot.radar.init() # re-lock onto survivor
setRadarColor("#004444")
setScanColor("#0D4D4D")
else:
bot.meleeRadar.init() # fresh acquisition of the remaining crowd
setRadarColor("#443300")
setScanColor("#4D3D0D")
bot.cfgDirty = true # emit at tick end, after gun selection
let radarRate = if bot.radarMode == 0:
bot.radar.computeScan(bot.lastState)
elif RadarForceSpin:
adaptive_melee_radar.MaxRadarTurnRate # A/B: exact old melee_scan behaviour
else:
bot.meleeRadar.setExpectedEnemies(getEnemyCount())
bot.meleeRadar.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]
var tmpPreds: array[len(PowerBins), GunPrediction]
var tmhPreds: array[len(PowerBins), GunPrediction]
var bbPreds: array[len(PowerBins), GunPrediction]
# ── TR_VBULLET_ADMIT_ONLY gate ──────────────────────────────────────
# Rack membership used to filter only SELECTION, so every unselected gun
# still ran predict()+spawnBullets() each tick to feed a fitness table the
# selector would never read (~40-46% of the per-tick budget). `admit`
# skips BOTH calls for a gun the current rack does not admit. It is
# recomputed each tick because the rack mode can change mid-round (a melee
# thinning to 1v1). Bullets already in flight are NOT cancelled: they are
# resolved by `tickBullets` and the feedback `case` below still calls the
# owning gun's onResult, so attribution survives a mid-round rack change.
# TMPATTERN and TMHORIZON keep their own admission gate even when the knob
# is 0, so TR_VBULLET_ADMIT_ONLY=0 reproduces the exact pre-change rack.
var admit: array[17, bool]
for gi in 0..<17:
admit[gi] = vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership,
VBulletAdmitOnly or gi == TmPatternId or
gi == TmHorizonId or gi == BitBrainId)
for i in 0..<len(PowerBins):
if admit[0]: headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[1]: linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[2]: tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[3]: circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[4]: gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[5]: pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[6]: wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[7]: acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[8]: ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[9]: dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[10]: alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[11]: dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[12]: knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector and admit[13]:
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# TMPATTERN keeps its own rack-admission gate (see `admit` above).
if admit[TmPatternId]:
tmpPreds[i] = bot.tmPattern.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# TMHORIZON likewise: Pattern base + horizon TM correction.
if admit[TmHorizonId]:
tmhPreds[i] = bot.tmHorizon.predict(bot.lastState, bulletSpeed(PowerBins[i]))
# BITBRAIN: Pattern base + ADE/SBC fine-grained angular correction.
if admit[BitBrainId]:
bbPreds[i] = bot.bitbrain.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if admit[0]: bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if admit[1] and not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
if admit[2] and not gunDisabled(2) and bot.tsetlin.isWarmedUp():
bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid)
if admit[3] and not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
if admit[4] and not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
if admit[5] and not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
if admit[6] and not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
if admit[7] and not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
if admit[8] and not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
if admit[9] and not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
if admit[10] and not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
if admit[11] and not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
if admit[12] and not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and admit[13] and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
if admit[TmPatternId] and not gunDisabled(TmPatternId):
bot.tracker.spawnBullets(TmPatternId, tmpPreds, bot.lastState, tid)
if admit[TmHorizonId] and not gunDisabled(TmHorizonId):
bot.tracker.spawnBullets(TmHorizonId, tmhPreds, bot.lastState, tid)
if admit[BitBrainId] and not gunDisabled(BitBrainId):
bot.tracker.spawnBullets(BitBrainId, bbPreds, 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)
of TmPatternId: bot.tmPattern.onResult(fe)
of TmHorizonId: bot.tmHorizon.onResult(fe)
of BitBrainId: bot.bitbrain.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. `bot.tick` drives the selector's dwell window.
# The energy-aware power cap uses the current target distance and our own
# energy; `shouldRam` (the movement code's decision) exempts it.
let (selectedGun, _, power, pdec) = selectShotPolicy(
bot.tracker, tid, bot.tick,
dist = ramDist, selfEnergy = ws.selfEnergy,
enemyEnergy = ws.enemyEnergy, ramming = shouldRam,
rackMode = bot.rackMode, membership = ActiveRackMembership)
if PowerLog:
let pkey = fmt"{power:.1f}|{pdec.cap:.1f}|{pdec.reason}"
if pkey != bot.lastPowerLogKey:
bot.lastPowerLogKey = pkey
echo fmt"[power] p={power:.1f} cap={pdec.cap:.1f} " &
fmt"reason={powerReasonName(pdec.reason)} " &
fmt"dist={ramDist:.0f} selfE={ws.selfEnergy:.0f} " &
fmt"enemyE={ws.enemyEnergy:.0f} gun={GunNames[selectedGun]}"
bot.gunSelectionCount[selectedGun] += 1
if selectedGun != bot.currentGun:
bot.currentGun = selectedGun
bot.cfgDirty = true # gun switch: emit the config line at tick end
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")
of TmPatternId: setTurretColor("#AAFF00"); setBulletColor("#CCFF66")
of TmHorizonId: setTurretColor("#00AAFF"); setBulletColor("#66CCFF")
of BitBrainId: setTurretColor("#FF1493"); setBulletColor("#FF69B4")
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))
of TmPatternId: bot.tmPattern.predict(bot.lastState, bulletSpeed(power))
of TmHorizonId: bot.tmHorizon.predict(bot.lastState, bulletSpeed(power))
of BitBrainId: bot.bitbrain.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,
mode: bot.rackMode,
))
setGunTurnRate(normDelta)
# Emit exactly one config line per tick, at the END of the tick, so the gun
# field reflects the selection made above rather than the previous tick's.
if bot.cfgDirty:
bot.printConfig()
bot.cfgDirty = false
proc seedSelectorRng() =
## Seed the process-global RNG exactly once at bot startup so the gun
## selector's "random" tie-break (`chooseFromFit` -> rand) actually varies
## across process restarts. Previously the only `randomize()` call reached on
## the live path was incidental, inside the Tsetlin gun's constructor, so a
## rack without Tsetlin produced a FIXED tie-break sequence forever.
##
## `GUN_SELECTOR_SEED`, when set to an integer, pins the stream so A/B runs are
## reproducible; otherwise time+pid seeding makes each process independent.
let s = getEnv("GUN_SELECTOR_SEED", "")
if s.len > 0:
try: randomize(parseInt(s.strip()))
except ValueError: randomize()
else:
randomize()
when isMainModule:
var bot = ModularBot(
tracker: vb.initTracker(17), # 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, 14: TMPattern, 15: TMHorizon, 16: BitBrain
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(),
tmPattern: initTmRadialGun(),
tmHorizon: initTmHorizonGun(),
bitbrain: initBitBrainGun(),
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: true),
ringMover: TFILRingModule(debugGraphics: true),
strafeMover: StrafeModule(debugGraphics: true),
surfMover: initWaveSurfer(),
rammer: initRammer(),
moveTracker: mvb.initVirtualBodyTracker(1),
currentGun: -1,
)
# Must run AFTER the gun constructors (Tsetlin/TMSelector call randomize()
# unconditionally) so the explicit seed override is the value that survives.
seedSelectorRng()
# Boot-time environment report (once per process, stdout). Prints what env
# this process actually inherited, the resolved effective value of every
# documented knob, and the build identity behind `grep '^\[env\]'`.
printEnvReport(EnvReportContext(
movementName: MovementName,
vBulletAdmitOnly: VBulletAdmitOnly,
recordWorldState: RecordWorldState,
radarForceSpin: RadarForceSpin,
radarScanLog: RadarScanLog,
radarScanLogPath: RadarScanLogPath,
trackerProbe: TrackerProbe,
trackerProbePath: TrackerProbePath,
powerLog: PowerLog,
resultLog: ResultLog,
gunStatsPath: GunStatsPath,
shotLogPath: ShotLogPath,
disabledGuns: DisabledGuns,
tmHorizon: bot.tmHorizon,
patternMatcher: bot.patternMatcher,
bitbrain: bot.bitbrain,
))
start(bot, botJsonPath)