Files
SirRoboGarage/ModularBot_garage/src/ModularBot.nim
T
SirStone a73de13458 racks: separate melee and 1v1 gun racks, plus per-mode real hit-rate data
The user's plan: "separate racks for melee and 1v1, so the bot switches from
those based on the situation, and we can put the guns we want in one or both
racks."

MECHANISM
- `RackMode` (rm1v1/rmMelee) derived from SERVER TRUTH: `rackMode(enemyCount)`
  = 1v1 when the count is 1, melee otherwise. This is the SAME `getEnemyCount()`
  value the radar already uses, so there is now ONE definition of the mode.
  (Using the tracker's known-enemy count was a previous bug in the radar: it
  read 1 before the second enemy was scanned.)
- `RackMembership` per gun: both (default) | 1v1 | melee | off.
- The selector ranks only admitted guns - including the floor path and the
  incumbent-hysteresis path.
- Empty filtered set FALLS BACK to the full rack, so the bot can never end up
  with no gun.
- Env-overridable at process start, no rebuild: `TR_RACK_<GUN>` for all 14 guns
  (TR_RACK_HEADON, TR_RACK_LINEAR, ... TR_RACK_TMSELECT), values
  both|1v1|melee|off. Empty/unknown -> both + a stderr warning, never fatal.
- `[rack] mode=<1v1|melee> active=<guns> overrides=<...>` logged once per mode
  change, never per tick.

DEFAULT IS UNCHANGED: every gun ships `rmBoth`, so behaviour is byte-identical
until the user re-racks anything. Verified by the unit test's default-config
selection parity (RNG draw for RNG draw) and by `test_gun_harness` 39 and
acceptance 12/12. `chooseFromFit` iterates the admitted list in ascending id
order, so the random tie-break draws are unchanged.

NO TUNING DONE, deliberately: we had no per-gun melee hit-rate data, and an
earlier 15-paired-run experiment found pruning neutral-to-negative on hit rate
(p=0.57/0.21). So all guns stay `both` and the membership pass waits for data.

PER-MODE DATA PLUMBING (this is what unblocks that pass): per-gun real shot
accounting is now split by the rack in force at fire time, adding to
gun_stats.jsonl: realShots1v1, realHits1v1, realHitRate1v1, realShotsMelee,
realHitsMelee, realHitRateMelee.

Verification: test_rack_membership 38/38 (new, pure, no battle); test_gun_harness
39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28;
acceptance_offline_vs_online 12/12 VERDICT PASS; ModularBot compiles. The live
`[rack]` line was observed switching 1v1 -> melee when the enemy died.

The offline range never calls the selector (only spawnBullets/tickBullets/
reportFor), so mode filtering cannot change the offline result and no offline
mode parameter was needed - confirmed by reasoning over the source and by 12/12.
2026-09-22 00:45:10 +02:00

1183 lines
52 KiB
Nim

## ModularBot — plugin gun architecture tracer bullet.
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13) via GunHarness.
## Radar: RadarLockModule (1v1) / 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 movements/phantom_meteor
import movements/rammer
import movements/ram_decision
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import movement_harness/virtual_bodies as mvb
import movement_harness/bullet_shadows
import targeting/enemy_tracker
import targeting/target_selector
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
const DebugVBullets = false
const DebugCircular = false
## Registration switch for the TM selector gun (id 13). Set false to build a
## rack without it (A/B runs); the tracker still reserves gun id 13 so the other
## ids and per-gun stats are unchanged.
##
## VERDICT: left FALSE. The TM gate underperforms its own experts on every
## offline fixture (energy-threshold 63% vs Circular 100%, random-walk 45% vs
## WallBounce 68%, Corners 25% vs Accel 34%) and the DrussGT A/B showed real hit
## rate 5.59% vs 7.47% for the power-fix-only rack. Flip to true to re-enable.
const EnableTmSelector = false
## Task A instrumentation: log every real shot + its eventual outcome to
## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out.
const ShotLog = true
## Offline range recorder: when the TR_RECORD_WORLDSTATE env var is set, append
## the exact WorldState the bot builds each tick to /tmp/worldstate_record.jsonl
## so it can be replayed offline. This is a RUNTIME switch (not a compile-time
## const) so the normal build never writes a fixture, while the acceptance test
## can enable recording for just the battle it spawns by exporting the env var.
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
## 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
## ── 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")
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"]
const
CLR_GUN = "\e[33m" # yellow
CLR_MOVE = "\e[36m" # cyan
CLR_FIRE = "\e[31m" # red
CLR_RADAR = "\e[32m" # green
CLR_CHANGE = "\e[32m" # green (highlight for changed module)
CLR_ROUND = "\e[1;37m" # bold white
CLR_RST = "\e[0m" # reset
type
PendingShot = object
## Metadata for one real shot, captured at setFire() time and resolved when
## the server reports the bullet's fate (hit / wall / bullet).
gunId: int
angleErr: float ## |aim error| (deg) at fire time — the value the gate used
distPx: float ## distance (px) to the aim point at fire time
power: float
tick: int
targetId: int
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
mover: TFILModule
ringMover: TFILRingModule
rammer: RammerModule
isRamming: bool
ramStuckTicks: int
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[14, int]
gunRealHits: array[14, 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[14, int]]
gunRealHitsByMode: array[vb.RackMode, array[14, int]]
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
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[14, 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
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 == "tfil_ring":
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
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)
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)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
bot.ramDmgAccum += e.bullet.power # feeds the ram bullet-rain abort window
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round).
if RecordWorldState:
bot.finishWorldStateRecord()
# Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end
let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId
# Per-target fitness when known, else a deterministic recency-weighted aggregate
# over all enemies (see VirtualTracker.fitnessFor). Using the shared proc keeps
# this stats dump identical to what the gun selector sees.
let fit = bot.tracker.fitnessFor(targetId)
var gunsArr = newJArray()
for gid in 0..<14:
var totalShots = 0
var totalHits = 0
for binIdx in 0..<len(vb.PowerBins):
let fw = fit[gid].bins[binIdx]
let n = min(fw.count, vb.WindowSize)
totalShots += n
for k in 0..<n:
if fw.hits[k]: inc totalHits
let hitRate = if totalShots > 0: totalHits.float / totalShots.float else: 0.0
let rShots = bot.gunRealShots[gid]
let rHits = bot.gunRealHits[gid]
let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0
# 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 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..<14:
bot.gunSelectionCount[i] = 0
bot.gunRealShots[i] = 0
bot.gunRealHits[i] = 0
for m in vb.RackMode:
for i in 0..<14:
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.enemyTracker.resetRound()
bot.isRamming = false
bot.ramStuckTicks = 0
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) =
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 onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
# 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
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
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/stuck 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.ramStuckTicks = 0
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.ramStuckTicks = 0
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")
if bot.isRamming:
inc bot.ramDurationTicks
if ramDist < 5.0:
inc bot.ramStuckTicks
else:
bot.ramStuckTicks = 0
if bot.ramStuckTicks > 10:
bot.isRamming = false
bot.currentTargetId = -1
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "stuck")
elif bot.ramDurationTicks > 60:
bot.isRamming = false
bot.currentTargetId = -1
bot.ramStuckTicks = 0
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=tfil_ring` routes BOTH holding and
# ramming through the single ring engine (ram is just band [0, 50]);
# `shouldRam` already accounts for the bullet-rain abort. The default `tfil`
# keeps the old two-engine behaviour (tfil when holding, rammer when ramming).
var spd, tr: float
if 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]
for i in 0..<len(PowerBins):
headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
if EnableTmSelector:
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
if not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
if not gunDisabled(2) and bot.tsetlin.isWarmedUp():
bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid)
if not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
if not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
if not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
if not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
if not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
if not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
if not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
if not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
if not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
if not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
if EnableTmSelector and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
# Build slim enemy table for tickBullets
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
for id, es in bot.enemyTracker.enemies:
enemyPositions[id] = (x: es.x, y: es.y, lastSeenTick: es.lastSeenTick, alive: es.alive)
let st = bot.lastState
bot.tracker.tickBullets(st, enemyPositions, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) =
case gunId
of 0: bot.headOn.onResult(fe)
of 1: bot.linear.onResult(fe)
of 2: bot.tsetlin.onResult(fe)
of 3:
bot.circular.onResult(fe)
when DebugCircular:
let ax = st.enemyX
let ay = st.enemyY
echo fmt"[circ-vb] predicted=({fe.prediction.x:.0f},{fe.prediction.y:.0f}) actual=({ax:.0f},{ay:.0f}) miss={fe.missDistance:.1f}px hit={fe.hit}"
of 4: bot.guessFactor.onResult(fe)
of 5: bot.patternMatcher.onResult(fe)
of 6: bot.wallBounce.onResult(fe)
of 7: bot.accelGun.onResult(fe)
of 8: bot.stopShot.onResult(fe)
of 9: bot.displace.onResult(fe)
of 10: bot.avgLead.onResult(fe)
of 11: bot.decayGF.onResult(fe)
of 12: bot.knnGun.onResult(fe)
of 13: bot.tmSelector.onResult(fe)
else: discard
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
when DebugVBullets:
let total = bot.virtualHits + bot.virtualMiss
let pct = if total > 0: bot.virtualHits.float / total.float * 100.0 else: 0.0
echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)"
)
# Gun selection + fire. `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, 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} 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")
else: discard
let pred = case selectedGun
of 1: bot.linear.predict(bot.lastState, bulletSpeed(power))
of 2: bot.tsetlin.predict(bot.lastState, bulletSpeed(power))
of 3: bot.circular.predict(bot.lastState, bulletSpeed(power))
of 4: bot.guessFactor.predict(bot.lastState, bulletSpeed(power))
of 5: bot.patternMatcher.predict(bot.lastState, bulletSpeed(power))
of 6: bot.wallBounce.predict(bot.lastState, bulletSpeed(power))
of 7: bot.accelGun.predict(bot.lastState, bulletSpeed(power))
of 8: bot.stopShot.predict(bot.lastState, bulletSpeed(power))
of 9: bot.displace.predict(bot.lastState, bulletSpeed(power))
of 10: bot.avgLead.predict(bot.lastState, bulletSpeed(power))
of 11: bot.decayGF.predict(bot.lastState, bulletSpeed(power))
of 12: bot.knnGun.predict(bot.lastState, bulletSpeed(power))
of 13: bot.tmSelector.predict(bot.lastState, bulletSpeed(power))
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
let gunDir = getGunDirection()
let gunHeat = getGunHeat()
let gunDelta = (aimTarget - gunDir) mod 360.0
var normDelta = gunDelta
if normDelta > 180.0: normDelta -= 360.0
elif normDelta < -180.0: normDelta += 360.0
# Distance to the aim point drives the range-aware gate (Task B) and is
# recorded per shot (Task A).
let distPx = hypot(pred.x - getX(), pred.y - getY())
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
if setFire(power) and getEnergy() > power:
bot.pendingFires.add(PendingShot(
gunId: selectedGun,
angleErr: abs(normDelta),
distPx: distPx,
power: power,
tick: bot.tick,
targetId: tid,
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(14), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect
headOn: HeadOnGun(),
linear: LinearGun(),
circular: CircularGun(),
tsetlin: initTsetlinGun(),
guessFactor: initGFGun(),
patternMatcher: PatternMatcherGun(),
wallBounce: initWallBounceGun(),
accelGun: initAccelGun(),
stopShot: initStopShotGun(),
displace: initDisplacementGun(),
avgLead: initAveragedLeadGun(),
decayGF: initDecayGFGun(),
knnGun: initKNNGun(),
tmSelector: initTmSelectorGun(),
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: true),
ringMover: TFILRingModule(debugGraphics: true),
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()
start(bot, botJsonPath)