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