Files
SirRoboGarage/ModularBot_garage/src/ModularBot.nim
T
SirStone 23bce2dad5 j160 (default-off): the energy-reserve FIRING FLOOR + ENEMY-EXHAUSTION ram trigger
TR_RAM_FLOOR_ENERGY (0.0 = off): at/below this self energy we start no NEW
shot, holding back the reserve for a final ram exchange. Justified by the only
energy gain in the game being +3*power per bullet hit LANDED, so not firing
denies the enemy its only refill. Blocks only NEW shots (gunHeat already gates
committed ones) and is bypassed while ramming.

TR_RAM_ENEMY_ENERGY (0.0 = off): last-scanned enemy energy <= this -> ram
mode. Enemy energy IS observable (ScannedBotEvent.energy, schemas.nim:306),
1-8 ticks stale. This is the shipped finisher with its energy tolerance
promoted to a knob, keeping the self>enemy surplus guard because RAM_DAMAGE
0.6 applies to BOTH bots on every contact tick.

Open-loop measurement (measure_ramfloor_energy, 8149 recordings / 29871
rounds / 33.8M ticks): 'both low' is COMMON (10.4% of ticks below 20, 15.1%
below 25) but neither side goes low first (enemy 52.7% / us 47.3%), and the
owner's literal trigger - enemy so low it cannot fire (energy <= 1.95) - is
only 2.5% of ticks, 1.1% while we are healthy.

Guards 136 -> 147 in test_tfil_commit_env.nim, all green. A/B PRE-REGISTERED
in docs/ram_floor_exhaustion_ab.md and NOT RUN.
2026-09-27 12:46:46 +02:00

1578 lines
75 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), LeadGainGun (16), BitbrainNetGun (17) via GunHarness.
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing).
import env_boot # MUST be first: applies the .env file before any other
# module's top-level `let` reads the environment.
import std/[math, os, strformat, tables, sets, json, random, strutils]
import robocode_tankroyale_botapi
import module_switches
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/lead_gain
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 movements/learned_surfer
import movement_harness/virtual_bodies as mvb
import movement_harness/bullet_shadows
import targeting/enemy_tracker
import targeting/target_selector
import env_report
import vbullet_draw
import geo_overlay
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:
## strafe (DEFAULT since the gate-v2 fresh-data confirmation, 2026-09-26) —
## the perpendicular-strafe mover and the movement campaign's
## measured champion (docs/movement_campaign.md). It wins rounds by
## dodging (incoming hit rate ~13% vs tfil ~17%), not by dealing
## more damage.
## tfil — the long-shipped mover; still fully reachable as an explicit
## override: `TR_MOVEMENT=tfil`.
## 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", "strafe").strip().toLowerAscii()
## The engine the tick ACTUALLY runs after the per-engine on/off switches
## (`TR_MODULE_MOVE_*`, default all on) are applied. With every switch unset this
## equals `MovementName`, so the dispatch below is byte-identical to before.
let EffectiveMovementName* = resolveMoveEngine(MovementName)
## TEMPORARY j134 diagnostic (TASK B): when `TR_FIRE_DIAG` is set, print the tick
## of every enemy-hit / our-hit EVENT so it can be checked against the energy
## reading tick logged by the mover. OFF by default; observability only.
let FireDiag* = existsEnv("TR_FIRE_DIAG")
## 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", "LeadGain"]
## 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 LEADGAIN per-range-band lead-gain corrector (it used to be
## called BitBrain, before the ADE+SBC network was removed from it; the rack id
## is unchanged). It defaults to `TR_RACK_LEADGAIN=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. `TR_RACK_BITBRAIN` still selects it unconditionally
## (backward compatibility; see `gun_harness/selector`).
const LeadGainId = 16
## ── virtual-bullet debug overlay (TR_VBULLET_DEBUG*) ───────────────────────
## OFF by default: the shipped bot draws nothing. When on, the tracker's
## virtual bullets are overlaid so the training signal the selector learns
## from is visible (travelled path, predicted aim point, miss vector). The
## colour table is shared with the turret (see `vbullet_draw.gunColors`).
## Default filter: ONLY the currently selected gun, to stay legible; set
## `TR_VBULLET_DEBUG_GUN=all` or name a gun (e.g. `Pattern`).
let VBulletDebugOn = vBulletDebugEnabled()
let VBulletDebugMax = vBulletDebugMaxBullets()
let VBulletDebugGun = resolveGunFilter(getEnv(VBulletDebugGunEnv, ""), GunNames)
## ── global debug-draw switch (TR_DEBUG_DRAW, default ON) ───────────────────
## When 0/false/no/off, every mover is built with `debugGraphics = false` and
## the virtual-bullet overlay is skipped, so nothing but the (independent,
## default-off) TR_GEO_DEBUG geometry overlay is left on screen. When on, every
## per-element knob behaves exactly as before.
let DebugDrawOn = debugDrawEnabled()
## ── geometry overlay (TR_GEO_DEBUG, default OFF) ───────────────────────────
## Draw-only visual aid (see `geo_overlay.nim`): the two bots as the endpoints
## of a circle diameter, each heading line, and each line's intersection points
## with the circle. No decision, movement or gun change.
let GeoDebugOn = geoDebugEnabled()
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
leadGain: LeadGainGun
mover: TFILModule
ringMover: TFILRingModule
strafeMover: StrafeModule
surfMover: WaveSurferModule
learnedMover: LearnedSurferModule
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[NumRackGuns, int]
gunRealHits: array[NumRackGuns, 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).
# These four arrays and `gunSelectionCount` are indexed by SELECTED GUN ID,
# so their width is `NumRackGuns`, not a literal. A literal `array[17, int]`
# survived the addition of rack id 17 (BITBRAIN) and indexed one past the end
# on the first tick that gun was selected — an IndexDefect in a debug build
# and silent corruption of the adjacent `lastPowerLogKey` string in the
# shipped `-d:release` binary, i.e. a bot that aims, moves and never fires.
gunRealShotsByMode: array[vb.RackMode, array[NumRackGuns, int]]
gunRealHitsByMode: array[vb.RackMode, array[NumRackGuns, 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[NumRackGuns, 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 EffectiveMovementName == "strafe":
if bot.isRamming: "strafe(ram)" else: "strafe"
elif EffectiveMovementName == "tfil_ring":
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
elif EffectiveMovementName == "surf":
if bot.isRamming: "surf(ram)" else: "surf"
elif EffectiveMovementName == "learned":
if bot.isRamming: "learned(ram)" else: "learned"
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
# j134 fire-detection fix (shared by EVERY mover): our damage to the enemy
# inflates the enemy's energy drop this tick (it can push the enemy's own
# fire past the 3.0 power cap and get it rejected). Each mover's detector
# undoes it.
bot.mover.noteDamageDealt(e.damage)
bot.ringMover.noteDamageDealt(e.damage)
bot.strafeMover.noteDamageDealt(e.damage)
bot.surfMover.noteDamageDealt(e.damage)
bot.learnedMover.noteDamageDealt(e.damage)
if FireDiag:
echo "[firediag] EV dmg tick=", bot.tick, " getTurn=", getTurn(), " damage=", e.damage # 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)
bot.learnedMover.removeBulletNear(e.bullet.x, e.bullet.y)
# Real wall endpoint of an ENEMY bullet. On the running server (0.35.5) a
# BulletHitWallEvent is delivered only to the bullet's OWNER
# (`addPrivateBotEvent(bullet.botId, ...)`), so this branch is a no-op live;
# it is wired so a server that exposes enemy wall hit-points feeds the
# exact-geometry wave resolution instead of the arrival-deadline proxy.
if e.bullet.ownerId != getMyId():
discard bot.learnedMover.resolveEnemyBullet(
e.bullet.x, e.bullet.y, degToRad(e.bullet.direction),
e.bullet.ownerId, bot.tick, false)
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)
bot.learnedMover.removeBulletNear(e.bullet.x, e.bullet.y)
# `e.bullet` is OUR bullet; `e.hitBullet` is the bullet it struck. When that
# is an ENEMY bullet, the event exposes its real endpoint + heading - a real
# resolution for a wave that would otherwise wait for the deadline.
if e.hitBullet.ownerId != getMyId() and e.hitBullet.bulletId != 0:
discard bot.learnedMover.resolveEnemyBullet(
e.hitBullet.x, e.hitBullet.y, degToRad(e.hitBullet.direction),
e.hitBullet.ownerId, bot.tick, false)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
# j134 fire-detection fix (shared by EVERY mover): server rules.kt gives the
# SHOOTER a `3*power` energy bonus when its bullet hits us, which masks the
# cost of a fire in the same tick. Forward the enemy bullet's power to every
# detector, which adds it back before classifying the energy delta.
bot.mover.noteEnemyBulletHit(e.bullet.power)
bot.ringMover.noteEnemyBulletHit(e.bullet.power)
bot.strafeMover.noteEnemyBulletHit(e.bullet.power)
bot.surfMover.noteEnemyBulletHit(e.bullet.power)
bot.learnedMover.noteEnemyBulletHit(e.bullet.power)
if FireDiag:
echo "[firediag] EV hit tick=", bot.tick, " getTurn=", getTurn(), " power=", e.bullet.power
# 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())
# A real ENEMY bullet hit us: its endpoint (our impact point) + heading give
# the wave's EXACT straight line, so resolve and drop that live wave now.
discard bot.learnedMover.resolveEnemyBullet(
e.bullet.x, e.bullet.y, degToRad(e.bullet.direction),
e.bullet.ownerId, bot.tick, true)
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..<NumRackGuns:
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
if FireDiag:
# j147 timeline anchor: the (bot.tick -> server getTurn) offset, once per
# round, so a recorded capture's event sidecar can be aligned to the bot's
# own tick stream without guessing.
echo "[firediag] ROUND round=", getRound(), " getTurn=", getTurn()
# 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..<NumRackGuns:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
for m in vb.RackMode:
for i in 0..<NumRackGuns:
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.learnedMover.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")
# LEADGAIN: per-round wipe always clears observations/labels; `perRound` mode
# also wipes its SBCs (the gate test's winning regime).
bot.leadGain.resetRoundState()
if e.roundNumber <= 1:
bot.leadGain.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.leadGain.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.leadGain.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 = ModuleRam and 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 EffectiveMovementName == "strafe" and not shouldRam:
(spd, tr) = bot.strafeMover.computeMove(ws)
elif EffectiveMovementName == "surf" and not shouldRam:
(spd, tr) = bot.surfMover.computeMove(ws)
elif EffectiveMovementName == "learned" and not shouldRam:
(spd, tr) = bot.learnedMover.computeMove(ws)
elif EffectiveMovementName == "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 lgPreds: 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[NumRackGuns, bool]
for gi in 0..<NumRackGuns:
admit[gi] = ModuleVBullets and
vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership,
VBulletAdmitOnly or gi == TmPatternId or
gi == TmHorizonId or gi == LeadGainId)
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]))
# LEADGAIN: Pattern base, lead scaled by a per-band learned gain.
if admit[LeadGainId]:
lgPreds[i] = bot.leadGain.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[LeadGainId] and not gunDisabled(LeadGainId):
bot.tracker.spawnBullets(LeadGainId, lgPreds, 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)
# Debug-only: bullets that resolve during `tickBullets` below are cleared
# from the tracker in the same tick, so capture their outcome here for the
# overlay's miss vector. Empty (and unused) when the overlay is off.
var resolvedMarks: seq[VbResolved]
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 LeadGainId: bot.leadGain.onResult(fe)
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
if DebugDrawOn and VBulletDebugOn:
resolvedMarks.add VbResolved(
gunId: gunId, aimX: fe.prediction.x, aimY: fe.prediction.y,
actualX: fe.actualX, actualY: fe.actualY, hit: fe.hit)
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
# Per-gun colours live in `vbullet_draw.gunColors`, shared with the
# virtual-bullet overlay so turret and overlay can never drift apart.
let (turretColor, bulletColor) = gunColors(selectedGun)
setTurretColor(turretColor)
setBulletColor(bulletColor)
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 LeadGainId: bot.leadGain.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):
# j160 FIRING FLOOR: at/below TR_RAM_FLOOR_ENERGY self energy we hold
# the reserve for the ram instead of spending it on a shot. Off by
# default (`RamFloorEnergy = 0.0`), and `ramming` (the exhaustion
# trigger) wins the conflict, so an engaged ram never starves itself.
let floorBlocks = fireFloorBlocks(RamFloorEnergy, getEnergy(), shouldRam)
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
if not floorBlocks and 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)
# Virtual-bullet overlay (default off; suppressed by TR_DEBUG_DRAW=0).
# Drawn AFTER selection so the default "selected gun only" filter uses
# this tick's choice.
if DebugDrawOn and VBulletDebugOn:
discard drawVirtualBullets(bot.tracker, resolvedMarks, selectedGun,
VBulletDebugGun, VBulletDebugMax, GunNames)
# Geometry overlay (TR_GEO_DEBUG, default off). Independent of
# TR_DEBUG_DRAW: this is the one thing left when debug drawing is off.
if GeoDebugOn:
discard drawGeoOverlay(ws.selfX, ws.selfY, ws.selfHeading,
ws.enemyX, ws.enemyY, ws.enemyHeading)
# 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(NumRackGuns), # 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: LeadGain
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(),
leadGain: initLeadGainGun(),
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: DebugDrawOn),
ringMover: TFILRingModule(debugGraphics: DebugDrawOn),
strafeMover: StrafeModule(debugGraphics: DebugDrawOn),
surfMover: initWaveSurfer(),
learnedMover: initLearnedSurfer(),
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,
vBulletDebug: VBulletDebugOn,
vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""),
vBulletDebugMax: VBulletDebugMax,
recordWorldState: RecordWorldState,
geoDebug: GeoDebugOn,
debugDraw: DebugDrawOn,
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,
leadGain: bot.leadGain,
))
start(bot, botJsonPath)