## 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.. 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.. 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)