## ModularBot — plugin gun architecture tracer bullet. ## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13), TmPatternGun (14), TmHorizonGun (15), LeadGainGun (16), BitbrainNetGun (17) via GunHarness. ## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick. ## Movement: OscillatorModule (perpendicular strafing). import env_boot # MUST be first: applies the .env file before any other # module's top-level `let` reads the environment. import std/[math, os, strformat, tables, sets, json, random, strutils] import robocode_tankroyale_botapi import module_switches import radar_harness/radar_interface import radars/radar_lock_module import radars/adaptive_melee_radar import gun_harness/gun_interface import gun_harness/virtual_bullets as vb import gun_harness/selector import guns/head_on import guns/linear import guns/circular import guns/tsetlin import guns/guess_factor import guns/pattern_matcher import guns/wall_bounce import guns/accel_predictor import guns/stop_shot import guns/displacement import guns/averaged_lead import guns/decay_gf import guns/knn_gun import guns/tm_selector import guns/tm_pattern import guns/tm_horizon import guns/lead_gain import movements/phantom_meteor import movements/rammer import movements/ram_decision import movements/the_floor_is_lava import movements/the_floor_is_lava_ring import movements/strafe import movements/wave_surfer import movements/learned_surfer import movement_harness/virtual_bodies as mvb import movement_harness/bullet_shadows import targeting/enemy_tracker import targeting/target_selector import env_report import vbullet_draw import geo_overlay const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" const DebugVBullets = false const DebugCircular = false ## Registration switch for the TM selector gun (id 13). Set false to build a ## rack without it (A/B runs); the tracker still reserves gun id 13 so the other ## ids and per-gun stats are unchanged. ## ## VERDICT: left FALSE. The TM gate underperforms its own experts on every ## offline fixture (energy-threshold 63% vs Circular 100%, random-walk 45% vs ## WallBounce 68%, Corners 25% vs Accel 34%) and the DrussGT A/B showed real hit ## rate 5.59% vs 7.47% for the power-fix-only rack. Flip to true to re-enable. const EnableTmSelector = false ## Task A instrumentation: log every real shot + its eventual outcome to ## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out. const ShotLog = true ## Offline range recorder: when the TR_RECORD_WORLDSTATE env var is set, append ## the exact WorldState the bot builds each tick to /tmp/worldstate_record.jsonl ## so it can be replayed offline. This is a RUNTIME switch (not a compile-time ## const) so the normal build never writes a fixture, while the acceptance test ## can enable recording for just the battle it spawns by exporting the env var. let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE") const WorldStateRecordPath = "/tmp/worldstate_record.jsonl" ## Radar measurement switches (all RUNTIME, read once at process start): ## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full ## spin (always 45 deg/tick). This reproduces the ## replaced `melee_scan` on the IDENTICAL binary, so an ## OLD-vs-NEW scan-rate A/B changes only the radar. ## TR_RADAR_SCANLOG=1 append one per-round JSON line of scan events and ## coverage to /tmp/radar_scan_log.jsonl. let RadarForceSpin* = existsEnv("TR_RADAR_FORCE_SPIN") let RadarScanLog* = existsEnv("TR_RADAR_SCANLOG") let RadarScanLogPath = getEnv("TR_RADAR_SCAN_LOG_PATH", "/tmp/radar_scan_log.jsonl") ## Enemy-tracker probe (TR_TRACKER_PROBE=1): append one JSON line per tick ## (tracker state vs server enemy count) and one per event dispatch (the queue ## BEFORE `removeOldEvents` deletes dropped events) to TR_TRACKER_PROBE_PATH. ## Measurement-only; the default build writes nothing. let TrackerProbe* = existsEnv("TR_TRACKER_PROBE") let TrackerProbePath = getEnv("TR_TRACKER_PROBE_PATH", "/tmp/tracker_probe.jsonl") ## Runtime rack pruning for MEASURED A/B runs: comma-separated gun ids to remove ## from the virtual-bullet rack. A disabled gun never spawns virtual bullets, so ## its fitness window stays empty and the selector can never pick it (chooseFromFit ## skips guns with no observations). Read once at process start so a SINGLE frozen ## binary can be A/B'd by exporting GUN_RACK_DISABLE. Empty/unset = full rack. let DisabledGuns = block: var s: HashSet[int] for tok in getEnv("GUN_RACK_DISABLE", "").split(','): let t = tok.strip() if t.len > 0: try: s.incl parseInt(t) except ValueError: discard s proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns ## ── virtual-bullet spawn gate (TR_VBULLET_ADMIT_ONLY) ────────────────────── ## Rack membership filters SELECTION; this knob extends it to SPAWNING. With it ## set (default 1) a gun the current rack does not admit is skipped ENTIRELY in ## the live aim block — no `predict()`, no `spawnBullets()` — so 13 unselected ## guns stop computing a fitness table nobody reads (~40-46% of the per-tick ## budget in the shipped onlyPattern rack). Set to 0 to restore the pre-change ## behaviour (every non-TMPATTERN gun spawns) on the SAME frozen binary. ## `onResult` feedback is NEVER gated (see the gated aim block in `run`). let VBulletAdmitOnly* = block: let v = getEnv("TR_VBULLET_ADMIT_ONLY", "1").strip().toLowerAscii() not (v == "0" or v == "false" or v == "no" or v == "off") ## ── Movement rack (runtime switch) ───────────────────────────────────────── ## `TR_MOVEMENT` selects the movement engine, mirroring the gun rack's ## env-driven construction. Both engines are always constructed, so the switch ## needs no rebuild: ## strafe (DEFAULT since the gate-v2 fresh-data confirmation, 2026-09-26) — ## the perpendicular-strafe mover and the movement campaign's ## measured champion (docs/movement_campaign.md). It wins rounds by ## dodging (incoming hit rate ~13% vs tfil ~17%), not by dealing ## more damage. ## tfil — the long-shipped mover; still fully reachable as an explicit ## override: `TR_MOVEMENT=tfil`. ## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring). ## UNPROVEN: it must never become the default silently. let MovementName* = getEnv("TR_MOVEMENT", "strafe").strip().toLowerAscii() ## The engine the tick ACTUALLY runs after the per-engine on/off switches ## (`TR_MODULE_MOVE_*`, default all on) are applied. With every switch unset this ## equals `MovementName`, so the dispatch below is byte-identical to before. let EffectiveMovementName* = resolveMoveEngine(MovementName) ## TEMPORARY j134 diagnostic (TASK B): when `TR_FIRE_DIAG` is set, print the tick ## of every enemy-hit / our-hit EVENT so it can be checked against the energy ## reading tick logged by the mover. OFF by default; observability only. let FireDiag* = existsEnv("TR_FIRE_DIAG") ## Per-process output paths so concurrent A/B runs do not clobber each other. let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl") let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl") ## Energy-aware power policy observability (TR_POWER_LOG=1): emit ONE line per ## CHANGE of the (power, cap, reason) decision — not per tick — so the GUI log ## shows why the cap moved. The policy itself lives in the shared gun harness ## (`applyPowerPolicy`), so both live and offline paths see the same rule. let PowerLog = existsEnv("TR_POWER_LOG") ## Round-outcome observability (TR_RESULT_LOG): one `[result]` line per round ## (win / our death / mutual kill / timeout) plus one at battle end. Default ON; ## only the explicit off-values disable it. Read once at process start. let ResultLog* = block: let v = getEnv("TR_RESULT_LOG", "1").strip().toLowerAscii() not (v == "0" or v == "false" or v == "no" or v == "off") const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon", "LeadGain"] ## Rack id of the new TM pattern gun. It defaults to `TR_RACK_TMPATTERN=off`; ## unlike the other guns, its virtual-bullet spawn is gated on rack admission ## (see the aim block in `run`), so the shipped default never spawns it and the ## shared VirtualTracker ring head — hence every other gun's learning order and ## the default selection sequence — is byte-for-byte unchanged. const TmPatternId = 14 ## Rack id of the horizon-based TM corrector. Like TMPATTERN it defaults to ## `TR_RACK_TMHORIZON=off` and its virtual-bullet spawn is gated on rack ## admission, so the shipped default never spawns it and the shared tracker ring ## head — hence every other gun's learning order — is byte-for-byte unchanged. const TmHorizonId = 15 ## Rack id of the LEADGAIN per-range-band lead-gain corrector (it used to be ## called BitBrain, before the ADE+SBC network was removed from it; the rack id ## is unchanged). It defaults to `TR_RACK_LEADGAIN=off` and its virtual-bullet ## spawn is gated on rack admission, so the shipped default never spawns it and ## the shared tracker ring head — hence every other gun's learning order — is ## byte-for-byte unchanged. `TR_RACK_BITBRAIN` still selects it unconditionally ## (backward compatibility; see `gun_harness/selector`). const LeadGainId = 16 ## ── virtual-bullet debug overlay (TR_VBULLET_DEBUG*) ─────────────────────── ## OFF by default: the shipped bot draws nothing. When on, the tracker's ## virtual bullets are overlaid so the training signal the selector learns ## from is visible (travelled path, predicted aim point, miss vector). The ## colour table is shared with the turret (see `vbullet_draw.gunColors`). ## Default filter: ONLY the currently selected gun, to stay legible; set ## `TR_VBULLET_DEBUG_GUN=all` or name a gun (e.g. `Pattern`). let VBulletDebugOn = vBulletDebugEnabled() let VBulletDebugMax = vBulletDebugMaxBullets() let VBulletDebugGun = resolveGunFilter(getEnv(VBulletDebugGunEnv, ""), GunNames) ## ── global debug-draw switch (TR_DEBUG_DRAW, default ON) ─────────────────── ## When 0/false/no/off, every mover is built with `debugGraphics = false` and ## the virtual-bullet overlay is skipped, so nothing but the (independent, ## default-off) TR_GEO_DEBUG geometry overlay is left on screen. When on, every ## per-element knob behaves exactly as before. let DebugDrawOn = debugDrawEnabled() ## ── geometry overlay (TR_GEO_DEBUG, default OFF) ─────────────────────────── ## Draw-only visual aid (see `geo_overlay.nim`): the two bots as the endpoints ## of a circle diameter, each heading line, and each line's intersection points ## with the circle. No decision, movement or gun change. let GeoDebugOn = geoDebugEnabled() const CLR_GUN = "\e[33m" # yellow CLR_MOVE = "\e[36m" # cyan CLR_FIRE = "\e[31m" # red CLR_RADAR = "\e[32m" # green CLR_CHANGE = "\e[32m" # green (highlight for changed module) CLR_ROUND = "\e[1;37m" # bold white CLR_RST = "\e[0m" # reset type PendingShot = object ## Metadata for one real shot, captured at setFire() time and resolved when ## the server reports the bullet's fate (hit / wall / bullet). gunId: int angleErr: float ## |aim error| (deg) at fire time — the value the gate used distPx: float ## distance (px) to the aim point at fire time power: float tick: int targetId: int mode: vb.RackMode ## rack in force at fire time (per-mode hit stats) ModularBot = ref object of Bot hasContact: bool enemyBearing: float lastState: WorldState radar: RadarLockModule meleeRadar: AdaptiveMeleeRadarModule enemyCount: int initialEnemyCount: int radarMode: int # 0 = lock, 1 = melee tracker: VirtualTracker headOn: HeadOnGun linear: LinearGun circular: CircularGun tsetlin: TsetlinGun guessFactor: GFGun patternMatcher: PatternMatcherGun wallBounce: WallBounceGun accelGun: AccelGun stopShot: StopShotGun displace: DisplacementGun avgLead: AveragedLeadGun decayGF: DecayGFGun knnGun: KNNGun tmSelector: TmSelectorGun tmPattern: TmPatternGun tmHorizon: TmHorizonGun leadGain: LeadGainGun mover: TFILModule ringMover: TFILRingModule strafeMover: StrafeModule surfMover: WaveSurferModule learnedMover: LearnedSurferModule rammer: RammerModule isRamming: bool ramDurationTicks: int ramCooldownTicks: int ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage ramDmgHead: int ramDmgAccum: float ## damage accumulated this turn, flushed by the window lastRamLogKey: string ## TR_RAM_LOG change detector (state|reason) moveTracker: VirtualBodyTracker virtualHits: int virtualMiss: int tick: int currentGun: int currentTargetId: int targetSwitchTick: int enemyTracker: EnemyTracker prevGun: int prevRadar: int prevTarget: int cfgDirty: bool ## gun/target/radar changed this tick; emit one config line at tick end roundNumber: int realShotsFired: int realHits: int gunRealShots: array[NumRackGuns, int] gunRealHits: array[NumRackGuns, int] # Same real-shot accounting split by the rack in force at fire time, so a # later data-driven pass can rank guns per mode (1v1 vs melee). # These four arrays and `gunSelectionCount` are indexed by SELECTED GUN ID, # so their width is `NumRackGuns`, not a literal. A literal `array[17, int]` # survived the addition of rack id 17 (BITBRAIN) and indexed one past the end # on the first tick that gun was selected — an IndexDefect in a debug build # and silent corruption of the adjacent `lastPowerLogKey` string in the # shipped `-d:release` binary, i.e. a bot that aims, moves and never fires. gunRealShotsByMode: array[vb.RackMode, array[NumRackGuns, int]] gunRealHitsByMode: array[vb.RackMode, array[NumRackGuns, int]] pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution bulletMode: Table[int, vb.RackMode] ## bulletId -> rack at fire time bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log) pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp gunSelectionCount: array[NumRackGuns, int] lastPowerLogKey: string ## change detector for the TR_POWER_LOG line lastKnownTargetId: int ## persists through death, used for round-end stats # Radar measurement instrumentation (only touched when RadarScanLog is set). radarScanCounts: Table[int, int] ## onScannedBot calls per enemy id radarAliveTicks: Table[int, int] ## ticks each enemy was known-alive radarFresh8: Table[int, int] ## ticks each enemy was seen within 8 radarFresh16: Table[int, int] ## ticks each enemy was seen within 16 arcWidthHist: array[12, int] ## covering-arc width buckets of 30 deg radarAcquireTicks: int radarTrackTicks: int radarMeleeActive: bool # Rack (melee vs 1v1) state. `rackMode` is derived from SERVER truth once # per tick in run(); `lastRackLogKey` is the change detector for the # once-per-change `[rack]` log line. rackMode: vb.RackMode lastRackLogKey: string # Round-outcome logging (TR_RESULT_LOG). Pure observability: these fields # never feed a movement/gun/radar decision. Reset per round in # onRoundStarted (and the counters per battle in onGameStarted). battleRounds: int ## rounds configured for this battle (GameSetup) roundsWon: int ## cumulative rounds won this battle roundsPlayed: int ## cumulative rounds fully accounted for roundCounted: bool ## roundsPlayed already bumped for this round weDiedThisRound: bool enemyDiedThisRound: bool ## an enemy BotDeath was observed this round wonRoundSeen: bool ## onWonRound fired (authoritative "last alive") roundOutcomePrinted: bool ## a `[result]` line already covered this round roundPrintedLabel: string ## its outcome label (for reconciliation) proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) = ## Task A: append one JSON line per resolved real shot. The write is fully ## guarded so a full disk / bad path can never take the bot down. when ShotLog: let row = %*{ "tick": shot.tick, "targetId": shot.targetId, "gunId": shot.gunId, "angleErr": shot.angleErr, "distPx": shot.distPx, "power": shot.power, "hit": hit, "unresolved": unresolved } try: let f = open(ShotLogPath, fmAppend) f.writeLine($row) f.close() except CatchableError: discard # logging must never crash the bot proc resolveShotLog(bot: ModularBot, bulletId: int, hit: bool) = ## Look up the shot metadata for a resolved bullet and log its fate once. ## No-op when ShotLog is off (bulletShot is then never populated). if bot.bulletShot.hasKey(bulletId): writeShotLog(bot.bulletShot[bulletId], hit, false) bot.bulletShot.del(bulletId) proc registerOwnBullet(bot: ModularBot, bulletId: int) = ## onBulletFired: the server assigns a unique bulletId per round. Stamp the ## oldest pending fire with it, then credit that gun with a real shot. ## The FIFO stays aligned because we only enqueue when setFire() succeeds ## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard). if bot.pendingFires.len == 0: return # unexpected: no pending fire to stamp let shot = bot.pendingFires[0] delete(bot.pendingFires, 0) bot.bulletGun[bulletId] = shot.gunId bot.bulletMode[bulletId] = shot.mode inc bot.gunRealShots[shot.gunId] inc bot.gunRealShotsByMode[shot.mode][shot.gunId] when ShotLog: bot.bulletShot[bulletId] = shot # Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and # BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first. # If so, credit the hit now and drop the (already resolved) map entry. if bulletId in bot.pendingHitBullets: inc bot.gunRealHits[shot.gunId] inc bot.gunRealHitsByMode[shot.mode][shot.gunId] bot.pendingHitBullets.excl(bulletId) bot.bulletGun.del(bulletId) bot.bulletMode.del(bulletId) bot.resolveShotLog(bulletId, true) proc resolveOwnBullet(bot: ModularBot, bulletId: int): int = ## Look up the gun that fired bulletId and drop it from the in-flight map. ## Returns the gun id, or -1 if the bullet was not attributed. result = bot.bulletGun.getOrDefault(bulletId, -1) if result >= 0: bot.bulletGun.del(bulletId) bot.bulletMode.del(bulletId) proc printConfig(bot: ModularBot, forceAll: bool = false) = ## One line describing the config in use for the tick being reported. Only the ## fields that changed since the previous line are highlighted green. The gun ## field is omitted until a gun has actually been selected this round, so a ## round-start line never claims a gun that was not chosen. let rst = CLR_RST let gc = if bot.currentGun != bot.prevGun or forceAll: CLR_CHANGE else: "" let rc = if bot.radarMode != bot.prevRadar or forceAll: CLR_CHANGE else: "" let tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: "" let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee" let moveName = if EffectiveMovementName == "strafe": if bot.isRamming: "strafe(ram)" else: "strafe" elif EffectiveMovementName == "tfil_ring": if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring" elif EffectiveMovementName == "surf": if bot.isRamming: "surf(ram)" else: "surf" elif EffectiveMovementName == "learned": if bot.isRamming: "learned(ram)" else: "learned" elif bot.isRamming: "rammer" else: "tfil" var line = "[config] " if bot.currentGun >= 0 and bot.currentGun < GunNames.len: line &= gc & "gun=" & GunNames[bot.currentGun] & (if gc != "": rst else: "") & " | " line &= "move=" & moveName & " | " & rc & "radar=" & radarName & (if rc != "": rst else: "") & " | enemies=" & $bot.enemyTracker.allAlive().len & " target=" & tc & "#" & $bot.currentTargetId & (if tc != "": rst else: "") echo line bot.prevGun = bot.currentGun bot.prevRadar = bot.radarMode bot.prevTarget = bot.currentTargetId proc logRackChange(bot: ModularBot, force = false) = ## Emit ONE `[rack]` line per mode / membership change (never per tick), ## behind the existing config-log mechanism. The membership table is frozen at ## process start, so in practice this fires on the 1v1 <-> melee transition; ## `force` also prints the round-start state. let over = rackOverrides(ActiveRackMembership) let key = rackModeName(bot.rackMode) & "|" & over if not force and key == bot.lastRackLogKey: return bot.lastRackLogKey = key echo "[rack] mode=" & rackModeName(bot.rackMode) & " active=" & rackActive(ActiveRackMembership, bot.rackMode) & (if over.len > 0: " overrides=" & over else: " overrides=none") proc startWorldStateRecord(bot: ModularBot) = ## Truncate the fixture and write the meta line at round start. if RecordWorldState: try: let f = open(WorldStateRecordPath, fmWrite) f.writeLine($(%*{"meta": { "adversary": "live-recorded", "source": "live", "perfect_info": false, "arena": {"w": getArenaWidth().float, "h": getArenaHeight().float}, "note": "exact WorldState the bot built, with tracker lastSeenTick", }})) f.close() except CatchableError: discard proc finishWorldStateRecord(bot: ModularBot) = ## Append the end marker so the offline replay can reproduce the live ## resolver's final-tick behaviour: if the target died during the last go() ## the live aim block was skipped, so no bullet resolved on that tick. if RecordWorldState: var died = false if bot.lastKnownTargetId >= 0 and bot.enemyTracker.enemies.contains(bot.lastKnownTargetId): died = not bot.enemyTracker.enemies[bot.lastKnownTargetId].alive try: let f = open(WorldStateRecordPath, fmAppend) f.writeLine($(%*{"end": {"enemy_died": died}})) f.close() except CatchableError: discard proc recordWorldState(bot: ModularBot, ws: WorldState) = ## Append one tick of the state the bot ACTUALLY built (including the stale ## tracker positions between radar scans) so the offline replay sees the same ## information the guns saw online. if RecordWorldState: let tid = bot.currentTargetId var lst = -1 if tid >= 0 and bot.enemyTracker.enemies.contains(tid): lst = bot.enemyTracker.enemies[tid].lastSeenTick var row = %*{ "tick": ws.tick, "ex": ws.enemyX, "ey": ws.enemyY, "eh": ws.enemyHeading, "es": ws.enemySpeed, "ee": ws.enemyEnergy, "sx": ws.selfX, "sy": ws.selfY, "sh": ws.selfHeading, "ss": ws.selfSpeed, "se": ws.selfEnergy, "eid": tid, } if lst >= 0: row["lst"] = %lst try: let f = open(WorldStateRecordPath, fmAppend) f.writeLine($row) f.close() except CatchableError: discard proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState = var ei: seq[EnemyInfo] for es in bot.enemyTracker.allAlive(): ei.add EnemyInfo(id: es.id, x: es.x, y: es.y, heading: es.heading, speed: es.speed, energy: es.energy, lastSeenTick: es.lastSeenTick) result = WorldState( enemyX: ex, enemyY: ey, enemySpeed: espeed, enemyHeading: eheading, enemyEnergy: eenergy, selfX: getX(), selfY: getY(), selfSpeed: getSpeed(), selfHeading: getDirection(), selfRadarHeading: getRadarDirection(), selfEnergy: getEnergy(), arenaWidth: getArenaWidth().float, arenaHeight: getArenaHeight().float, tick: bot.tick, enemies: ei, ) if RecordWorldState: bot.recordWorldState(result) ## ── Enemy-tracker probe (TR_TRACKER_PROBE) ───────────────────────────────── var gProbeFile: File var gProbeReady = false var gProbeEventRegistered = false proc probeWrite(row: JsonNode) = if not gProbeReady: try: gProbeFile = open(TrackerProbePath, fmWrite) gProbeReady = true except CatchableError: return try: gProbeFile.writeLine($row) gProbeFile.flushFile() except CatchableError: discard proc probeQueue(): bool = ## Registered as a custom event. `addCustomEvents` runs it AFTER the tick's ## events are queued but BEFORE `removeOldEvents` deletes them, so this is the ## only place that can observe a `BotDeathEvent` the API is about to drop. ## Always returns false, so it is never dispatched. if TrackerProbe: var evs = newJArray() var dropped = 0 var maxTurn = -1 let turn = getTurn() for e in getEvents(): let drop = e.turnNumber < turn - MAX_EVENTS_AGE and not e.isCritical if drop: inc dropped if e.turnNumber > maxTurn: maxTurn = e.turnNumber evs.add(%*{"k": $e.kind, "t": e.turnNumber, "drop": drop}) probeWrite(%*{ "probe": "queue", "round": getRound(), "turn": turn, "queue_turn": maxTurn, "dispatch_lag": turn - maxTurn, "events": evs, "dropped": dropped, }) return false proc probeTracker(bot: ModularBot) = ## Per-tick tracker snapshot (tracker alive-count vs the server's ## `getEnemyCount()`). The `pruned` field is kept for schema compatibility ## with the corpse measurement tooling; it is always empty now that ## reconciliation is gone (see docs/tracker_death_events.md). if not TrackerProbe: return var alive = newJObject() var dead = newJObject() for id, es in bot.enemyTracker.enemies: if es.alive: alive[$id] = %es.lastSeenTick else: dead[$id] = %es.lastSeenTick let current = bot.currentTargetId probeWrite(%*{ "probe": "tracker", "round": bot.roundNumber, "my_id": getMyId(), "turn": getTurn(), "bot_tick": bot.tick, "lag": getTurn() - bot.tick, "server_ec": getEnemyCount(), "tracker_alive": bot.enemyTracker.aliveCount(), "alive": alive, "dead": dead, "pruned": newJArray(), "target": current, "target_alive": bot.enemyTracker.isAlive(current), "radar_mode": bot.radarMode, "ramming": bot.isRamming, }) proc recordRadarStats(bot: ModularBot) = ## Per-tick radar coverage sampler (no-op unless TR_RADAR_SCANLOG is set). ## "Fresh within N" means the tracker scanned that enemy at most N ticks ago. ## The covering-arc width is recomputed here from live bearings purely for the ## histogram; it does not influence the radar. ## ## Only SERVER-confirmed melee ticks are sampled (2+ enemies alive), so the ## 1v1 lock mode cannot dilute the melee numbers. There is no corpse filter: ## the tracker's `alive` flag is reliable (measured 0 corpse ticks — see ## docs/tracker_death_events.md), so dead enemies are excluded by `es.alive` ## alone. if not RadarScanLog: return bot.radarMeleeActive = getEnemyCount() >= 2 if not bot.radarMeleeActive: return if bot.radarMode == 1: if bot.meleeRadar.phase == rpAcquire: inc bot.radarAcquireTicks else: inc bot.radarTrackTicks var bearings: seq[float] for id, es in bot.enemyTracker.enemies: if not es.alive: continue let age = bot.tick - es.lastSeenTick bearings.add normalizeDeg(arctan2(es.y - getY(), es.x - getX()).radToDeg) bot.radarAliveTicks[id] = bot.radarAliveTicks.getOrDefault(id) + 1 if age <= 8: bot.radarFresh8[id] = bot.radarFresh8.getOrDefault(id) + 1 if age <= 16: bot.radarFresh16[id] = bot.radarFresh16.getOrDefault(id) + 1 if bearings.len > 0: let (_, _, width) = minimalCoveringArc(bearings) inc bot.arcWidthHist[min(11, int(width / 30.0))] method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) = bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick) bot.hasContact = true if RadarScanLog and bot.radarMeleeActive: bot.radarScanCounts[e.scannedBotId] = bot.radarScanCounts.getOrDefault(e.scannedBotId) + 1 method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) = inc bot.realShotsFired bot.registerOwnBullet(e.bullet.bulletId) bot.moveTracker.shadows.addBullet(e.bullet.x, e.bullet.y, degToRad(e.bullet.direction), e.bullet.power) method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) = inc bot.realHits # j134 fire-detection fix (shared by EVERY mover): our damage to the enemy # inflates the enemy's energy drop this tick (it can push the enemy's own # fire past the 3.0 power cap and get it rejected). Each mover's detector # undoes it. bot.mover.noteDamageDealt(e.damage) bot.ringMover.noteDamageDealt(e.damage) bot.strafeMover.noteDamageDealt(e.damage) bot.surfMover.noteDamageDealt(e.damage) bot.learnedMover.noteDamageDealt(e.damage) if FireDiag: echo "[firediag] EV dmg tick=", bot.tick, " getTurn=", getTurn(), " damage=", e.damage # Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot. let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1) # onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim. let gunId = bot.resolveOwnBullet(e.bullet.bulletId) if gunId >= 0: inc bot.gunRealHits[gunId] inc bot.gunRealHitsByMode[hitMode][gunId] bot.resolveShotLog(e.bullet.bulletId, true) else: bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) = discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot bot.resolveShotLog(e.bullet.bulletId, false) bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.surfMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.learnedMover.removeBulletNear(e.bullet.x, e.bullet.y) # Real wall endpoint of an ENEMY bullet. On the running server (0.35.5) a # BulletHitWallEvent is delivered only to the bullet's OWNER # (`addPrivateBotEvent(bullet.botId, ...)`), so this branch is a no-op live; # it is wired so a server that exposes enemy wall hit-points feeds the # exact-geometry wave resolution instead of the arrival-deadline proxy. if e.bullet.ownerId != getMyId(): discard bot.learnedMover.resolveEnemyBullet( e.bullet.x, e.bullet.y, degToRad(e.bullet.direction), e.bullet.ownerId, bot.tick, false) method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot bot.resolveShotLog(e.bullet.bulletId, false) bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.surfMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.learnedMover.removeBulletNear(e.bullet.x, e.bullet.y) # `e.bullet` is OUR bullet; `e.hitBullet` is the bullet it struck. When that # is an ENEMY bullet, the event exposes its real endpoint + heading - a real # resolution for a wave that would otherwise wait for the deadline. if e.hitBullet.ownerId != getMyId() and e.hitBullet.bulletId != 0: discard bot.learnedMover.resolveEnemyBullet( e.hitBullet.x, e.hitBullet.y, degToRad(e.hitBullet.direction), e.hitBullet.ownerId, bot.tick, false) method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) = # j134 fire-detection fix (shared by EVERY mover): server rules.kt gives the # SHOOTER a `3*power` energy bonus when its bullet hits us, which masks the # cost of a fire in the same tick. Forward the enemy bullet's power to every # detector, which adds it back before classifying the energy delta. bot.mover.noteEnemyBulletHit(e.bullet.power) bot.ringMover.noteEnemyBulletHit(e.bullet.power) bot.strafeMover.noteEnemyBulletHit(e.bullet.power) bot.surfMover.noteEnemyBulletHit(e.bullet.power) bot.learnedMover.noteEnemyBulletHit(e.bullet.power) if FireDiag: echo "[firediag] EV hit tick=", bot.tick, " getTurn=", getTurn(), " power=", e.bullet.power # Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's # 4p/6p-2 damage), not raw firepower, so `damageRatePerTurn` is a real # energy/turn rate (see movements/ram_decision.bulletDamage). bot.ramDmgAccum += bulletDamage(e.bullet.power) bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY()) # A real ENEMY bullet hit us: its endpoint (our impact point) + heading give # the wave's EXACT straight line, so resolve and drop that live wave now. discard bot.learnedMover.resolveEnemyBullet( e.bullet.x, e.bullet.y, degToRad(e.bullet.direction), e.bullet.ownerId, bot.tick, true) proc enemyEnergyForReport(bot: ModularBot): float = ## Last-scanned energy of the enemy the report is about (current target, ## else the last known target, else any enemy in the tracker). Returns -1.0 ## when nothing has been scanned yet, so a report never invents a number; ## returns 0.0 when that enemy is already known dead. let tid = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId if tid >= 0 and bot.enemyTracker.enemies.contains(tid): let es = bot.enemyTracker.enemies[tid] return (if es.alive: es.energy else: 0.0) for _, es in bot.enemyTracker.enemies: return (if es.alive: es.energy else: 0.0) -1.0 proc emitRoundResult(bot: ModularBot, label, detail: string, ticks: int, usDead, themDead: bool) = ## The ONE `[result]` line for one round. Every line starts with the greppable ## `[result]` prefix; the outcome word is left-padded so successive lines ## align. `rounds won X/Y` is the cumulative count including this round. if not ResultLog: return let rn = if bot.battleRounds > 0: $bot.roundNumber & "/" & $bot.battleRounds else: $bot.roundNumber let usE = if usDead: 0.0 else: getEnergy() var themE = enemyEnergyForReport(bot) if themDead: themE = 0.0 let themStr = if themE < 0.0: "?" else: fmt"{themE:.1f}" let total = if bot.battleRounds > 0: bot.battleRounds else: bot.roundsPlayed echo "[result] round ", rn, " ", alignLeft(label, 10), "(", detail, ")", " | us ", fmt"{usE:.1f}", " energy, them ", themStr, ", ", ticks, " ticks | rounds won ", bot.roundsWon, "/", total bot.roundOutcomePrinted = true bot.roundPrintedLabel = label method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = ## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round). # Round outcome: `e.results.rank` (1 = winner) is the server's authoritative # win/lose signal and does not depend on BotDeathEvent. Death observations # (our `onDeath`, enemy `onBotDeath`) and `onWonRound` refine it into the four # distinct outcomes. If our death already printed this round's line (immediate # report), only reprint when the final label CHANGED (e.g. a mutual kill). if ResultLog: let rank1 = e.results.rank == 1 if not bot.roundCounted: inc bot.roundsPlayed bot.roundCounted = true if rank1: inc bot.roundsWon let weDied = bot.weDiedThisRound let enemyGone = bot.enemyDiedThisRound or bot.wonRoundSeen let decided = ": " & (if rank1: "WE WON" else: "WE LOST") var label, detail: string if weDied and enemyGone: label = "BOTH DIED"; detail = "score decided" & decided elif weDied: label = "WE DIED"; detail = "killed" elif enemyGone: label = "WE WON"; detail = "enemy destroyed" else: label = "TIMEOUT"; detail = "score decided" & decided if not bot.roundOutcomePrinted or bot.roundPrintedLabel != label: bot.emitRoundResult(label, detail, e.turnNumber, weDied, enemyGone) if RecordWorldState: bot.finishWorldStateRecord() # Per-round TM-horizon summary (change-gated behind TR_TMHORIZON_LOG=1) so the # user can watch it learn across the round. bot.tmHorizon.roundSummary() # Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId # Per-target fitness when known, else a deterministic recency-weighted aggregate # over all enemies (see VirtualTracker.fitnessFor). Using the shared proc keeps # this stats dump identical to what the gun selector sees. let fit = bot.tracker.fitnessFor(targetId) var gunsArr = newJArray() for gid in 0.. 0: totalHits.float / totalShots.float else: 0.0 let rShots = bot.gunRealShots[gid] let rHits = bot.gunRealHits[gid] let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0 # Real hit rate split by the rack in force when each shot was fired, so a # later data-driven pass can rank guns per mode without a rebuild. let rShots1 = bot.gunRealShotsByMode[vb.rm1v1][gid] let rHits1 = bot.gunRealHitsByMode[vb.rm1v1][gid] let rRate1 = if rShots1 > 0: rHits1.float * 100.0 / rShots1.float else: 0.0 let rShotsM = bot.gunRealShotsByMode[vb.rmMelee][gid] let rHitsM = bot.gunRealHitsByMode[vb.rmMelee][gid] let rRateM = if rShotsM > 0: rHitsM.float * 100.0 / rShotsM.float else: 0.0 gunsArr.add(%*{ "id": gid, "name": GunNames[gid], "vShots": totalShots, "vHits": totalHits, "hitRate": (hitRate * 100.0).int, "selected": bot.gunSelectionCount[gid], "realShots": rShots, "realHits": rHits, "realHitRate": rRate.round(1), "realShots1v1": rShots1, "realHits1v1": rHits1, "realHitRate1v1": rRate1.round(1), "realShotsMelee": rShotsM, "realHitsMelee": rHitsM, "realHitRateMelee": rRateM.round(1) }) let row = %*{ "round": bot.roundNumber, "targetId": targetId, "guns": gunsArr, "realShots": bot.realShotsFired, "realHits": bot.realHits, "score": e.results.totalScore, "bulletDmg": e.results.bulletDamage, "vDropped": bot.tracker.droppedBullets, "vStarved": bot.guessFactor.waveStarved + bot.decayGF.waveStarved + bot.knnGun.waveStarved } let f = open(GunStatsPath, fmAppend) f.writeLine($row) f.close() # Task A: shots still in flight at round end never resolved, so count them as # misses for the shot log (matching how realHits/realShots treats them). # Shots fired but never stamped by onBulletFired are not counted in realShots # either, so they are dropped rather than logged. when ShotLog: for _, shot in bot.bulletShot: writeShotLog(shot, false, true) bot.bulletShot.clear() bot.pendingFires.setLen(0) if RadarScanLog: var scans = newJObject() var alive = newJObject() var fresh8 = newJObject() var fresh16 = newJObject() for id, n in bot.radarScanCounts: scans[$id] = %n for id, n in bot.radarAliveTicks: alive[$id] = %n for id, n in bot.radarFresh8: fresh8[$id] = %n for id, n in bot.radarFresh16: fresh16[$id] = %n var hist = newJArray() for c in bot.arcWidthHist: hist.add(%c) let rrow = %*{ "round": bot.roundNumber, "ticks": bot.tick, "scans": scans, "aliveTicks": alive, "fresh8": fresh8, "fresh16": fresh16, "arcHist": hist, "acquireTicks": bot.radarAcquireTicks, "trackTicks": bot.radarTrackTicks, "enemyCount": getEnemyCount(), } try: let rf = open(RadarScanLogPath, fmAppend) rf.writeLine($rrow) rf.close() except CatchableError: discard method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = bot.roundNumber = e.roundNumber if FireDiag: # j147 timeline anchor: the (bot.tick -> server getTurn) offset, once per # round, so a recorded capture's event sidecar can be aligned to the bot's # own tick stream without guessing. echo "[firediag] ROUND round=", getRound(), " getTurn=", getTurn() # Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1 # reports and a target/enemy change mid-round cannot leak a stale flag. bot.weDiedThisRound = false bot.enemyDiedThisRound = false bot.wonRoundSeen = false bot.roundCounted = false bot.roundOutcomePrinted = false bot.roundPrintedLabel = "" if RecordWorldState: bot.startWorldStateRecord() bot.realShotsFired = 0 bot.realHits = 0 bot.lastKnownTargetId = -1 bot.pendingFires.setLen(0) bot.bulletGun.clear() bot.bulletMode.clear() bot.bulletShot.clear() bot.pendingHitBullets.clear() if RadarScanLog: bot.radarScanCounts.clear() bot.radarAliveTicks.clear() bot.radarFresh8.clear() bot.radarFresh16.clear() for i in 0 ..< bot.arcWidthHist.len: bot.arcWidthHist[i] = 0 bot.radarAcquireTicks = 0 bot.radarTrackTicks = 0 bot.radarMeleeActive = false for i in 0.. melee change. At round start # `bot.enemyCount` (from the game setup) is reliable where a raw # getEnemyCount() call may not be yet, and run() refreshes it every tick. bot.rackMode = vb.rackMode(bot.enemyCount) bot.lastRackLogKey = "" bot.logRackChange(force = true) # tracker fitness persists across rounds (rolling window carries over) method onBotDeath*(bot: ModularBot, e: BotDeathEvent) = ## An ENEMY died (`ekBotDeath`; our own death goes to `onDeath`). bot.enemyDiedThisRound = true echo "[death] victimId=", e.victimId, " wasTarget=", (e.victimId == bot.currentTargetId), " trackerAlive=", (if bot.enemyTracker.enemies.contains(e.victimId): $bot.enemyTracker.enemies[e.victimId].alive else: "notInTracker") echo CLR_MOVE & "[death] victimId=" & $e.victimId & " wasTarget=" & $(e.victimId == bot.currentTargetId) & CLR_RST # `markDead` is the lone death path. The API-level drop mechanism is real # (`ekBotDeath` is not `isCritical`), but it was MEASURED never to fire at # this workload: 0 dropped BotDeath events and 0 corpse ticks over 151 deaths # on two server versions. See docs/tracker_death_events.md. bot.enemyTracker.markDead(e.victimId) if e.victimId == bot.currentTargetId: bot.isRamming = false bot.currentTargetId = -1 method onDeath*(bot: ModularBot, e: BotDeathEvent) = ## OUR OWN death (`ekDeath`, victimId == myId). Reported the instant it ## happens so the user tailing the GUI log sees it without waiting for the ## round-end event (which may not arrive). If the enemy is already known dead, ## the round is a mutual kill and is labelled as such (rank/score only arrives ## at round end and is reconciled there if it contradicts this line). bot.weDiedThisRound = true inc bot.roundsPlayed bot.roundCounted = true if bot.enemyDiedThisRound or bot.wonRoundSeen: bot.emitRoundResult("BOTH DIED", "score decided", e.turnNumber, true, true) else: bot.emitRoundResult("WE DIED", "killed", e.turnNumber, true, false) method onWonRound*(bot: ModularBot, e: WonRoundEvent) = ## The server says we were the last bot alive: authoritative "enemy ## destroyed", independent of whether a BotDeathEvent was delivered. bot.wonRoundSeen = true method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) = # A NEW BATTLE begins: wipe the horizon TM's machines and learned statistics # (no persistence across battles). `onRoundStarted` only clears per-round # state, so this is the only place the learning is dropped at a battle # boundary. The round-1 fallback in `onRoundStarted` covers a missed callback. bot.tmHorizon.resetLearning("game_start") bot.leadGain.resetLearning("game_start") # minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1 bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1 # New battle: reset the cumulative outcome counters and remember how many # rounds the server will run (0 = unlimited; the report then shows played count). bot.battleRounds = e.gameSetup.numberOfRounds bot.roundsWon = 0 bot.roundsPlayed = 0 # The custom event must be registered AFTER `start()` ran `initGlobals()`, # which replaces the event queue (and its conditions). onGameStarted is the # first bot callback that runs after that, still before any round/tick. if TrackerProbe and not gProbeEventRegistered: addCustomEvent("tracker_queue_probe", probeQueue) gProbeEventRegistered = true method onGameEnded*(bot: ModularBot, e: GameEndedEventForBot) = ## Battle summary, ONE line, after the server reports the game over. if not ResultLog: return let total = if e.numberOfRounds > 0: e.numberOfRounds elif bot.battleRounds > 0: bot.battleRounds else: bot.roundsPlayed echo "[result] battle END: rounds won ", bot.roundsWon, "/", total proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool = ## Hysteresis: only switch when there is a clear reason. if bot.currentTargetId < 0: return true let cur = bot.enemyTracker.enemies.getOrDefault(bot.currentTargetId) if not cur.alive: return true if bot.tick - cur.lastSeenTick >= 20: return true # stale if bot.tick - bot.targetSwitchTick < 10: return false # cooldown if candidateId == bot.currentTargetId: return false let cand = bot.enemyTracker.enemies.getOrDefault(candidateId) let sx = getX(); let sy = getY() let curDist = hypot(cur.x - sx, cur.y - sy) let candDist = hypot(cand.x - sx, cand.y - sy) if candDist < curDist * 0.7: return true # significantly closer if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity return false proc pushRamDamageWindow(bot: ModularBot) = ## Advance the incoming-damage window once per turn. Damage accumulated by ## `onHitByBullet` during the preceding event dispatch lands in the slot being ## written. Called before the contact check so the window always advances. bot.ramDmgWindow[bot.ramDmgHead] = bot.ramDmgAccum bot.ramDmgHead = (bot.ramDmgHead + 1) mod RamDamageWindow bot.ramDmgAccum = 0.0 proc logRamChange(bot: ModularBot, on: bool, reason: RamReason, dist: float, ws: WorldState, dmgRate: float, offReason = "") = ## TR_RAM_LOG: ONE `[ram]` line per state/reason CHANGE (never per tick). The ## cap is 3.0 by construction while ramming — `applyPowerPolicy`'s ram ## exemption returns `cap: 3.0`; the ring band is `[0,50]` on the same ## `shouldRam` flag. Pure observability; no effect when TR_RAM_LOG is unset. if not RamLog: return let key = if on: "ON|" & reasonName(reason) else: "OFF|" & offReason if key == bot.lastRamLogKey: return bot.lastRamLogKey = key if on: echo fmt"[ram] ON reason={reasonName(reason)} dist={dist.int} " & fmt"selfE={ws.selfEnergy.int} enemyE={ws.enemyEnergy.int} cap=3.0 " & "band=[0,50]" else: echo fmt"[ram] OFF reason={offReason} dmgRate={dmgRate:.2f}/turn" method run*(bot: ModularBot) = while isRunning(): inc bot.tick if TrackerProbe: bot.probeTracker() # `enemyCount` is derived from server truth every tick, not hand-kept. bot.enemyCount = getEnemyCount() if RadarScanLog: bot.recordRadarStats() # Ram cooldown countdown if bot.ramCooldownTicks > 0: dec bot.ramCooldownTicks # Advance the incoming-damage window once per turn (hits accumulated by # onHitByBullet during the preceding dispatch). bot.pushRamDamageWindow() # Safety: reset invalid target before any logic if bot.currentTargetId >= 0: if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or not bot.enemyTracker.enemies[bot.currentTargetId].alive: echo CLR_MOVE & "[target-invalid] id=" & $bot.currentTargetId & " resetting" & CLR_RST bot.currentTargetId = -1 bot.isRamming = false if not bot.hasContact: setTargetSpeed(0.0) setTurnRate(0.0) setRadarTurnRate(45.0) go() continue # --- Target selection (sticky) --- let candidateId = selectTarget(bot.enemyTracker, getX(), getY(), tmClosest) if bot.shouldSwitchTarget(candidateId): if candidateId != bot.currentTargetId: bot.currentTargetId = candidateId bot.targetSwitchTick = bot.tick bot.cfgDirty = true # emit at tick end, after gun selection # The horizon TM learns ONE enemy at a time: wipe its machines when the # target changes to a different bot id (gated by # TR_TMHORIZON_RESET_ON_TARGET, default on; no-op in 1v1). discard bot.tmHorizon.targetChanged(candidateId) discard bot.leadGain.targetChanged(candidateId) if bot.currentTargetId >= 0: bot.lastKnownTargetId = bot.currentTargetId # --- Refresh target state FIRST, before ram decision --- let tid = bot.currentTargetId if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and bot.enemyTracker.enemies[tid].alive: let tgt = bot.enemyTracker.getEnemy(tid) bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y) bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy) # Ram decision — harness decides, not the movement module (uses fresh lastState). # Triggers + abort live in the pure `ram_decision` module so they can be # swept/unit-tested; the cooldown/duration machinery stays here. let ws = bot.lastState let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY) let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow) # Change-of-plan signal: the selected gun's pooled virtual hit rate. Only # consulted when the (default-off) plan trigger is enabled. var gunHitRate = 0.0 if RamPlanEnabled and bot.currentGun >= 0 and tid >= 0: gunHitRate = vb.gunRate(bot.tracker.fitnessFor(tid)[bot.currentGun], pooled = true) let ramReason = ramTrigger(RamInputs( dist: ramDist, selfEnergy: ws.selfEnergy, enemyEnergy: ws.enemyEnergy, gunHitRate: gunHitRate)) let triggerRam = ModuleRam and bot.currentTargetId >= 0 and bot.ramCooldownTicks == 0 and ramReason != rrNone # Abort an IN-PROGRESS ram on sustained incoming fire. The trigger itself is # unaffected, so a firefight before the approach never vetoes the start. let abortRam = shouldAbortRam(bot.isRamming, ramDmgRate) let shouldRam = triggerRam and not abortRam if shouldRam and not bot.isRamming: bot.isRamming = true bot.ramDurationTicks = 0 elif abortRam and bot.isRamming: # Bullet rain: drop the ram, return to the normal range band, KEEP the # target. The cooldown stops an immediate re-trigger while fire persists. bot.isRamming = false bot.ramDurationTicks = 0 bot.ramCooldownTicks = 30 bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "bulletRain") elif not shouldRam and bot.isRamming: bot.isRamming = false bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone") # Only the 60-tick duration cap can end a ram by itself. The old `stuck` # counter required dist < 5px on consecutive ticks, but contact happens at # ~36px (two 18px radii) and positions rewind, so it could never increment; # it has been removed (see docs/ramming_negative_result.md). if bot.isRamming: inc bot.ramDurationTicks if bot.ramDurationTicks > 60: bot.isRamming = false bot.currentTargetId = -1 bot.ramDurationTicks = 0 bot.ramCooldownTicks = 30 bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "duration") # TR_RAM_LOG: the change detector dedupes on (state|reason), so this emits # once per transition — including a reason change while already ramming. if bot.isRamming: bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate) # Movement dispatch. `TR_MOVEMENT=strafe` (this module) routes holding # through the perpendicular-strafe engine and still lets the rammer take # over on a ram trigger; `tfil_ring` routes BOTH holding and ramming # through the single ring engine; the default `tfil` keeps the old # two-engine behaviour (tfil when holding, rammer when ramming). var spd, tr: float if EffectiveMovementName == "strafe" and not shouldRam: (spd, tr) = bot.strafeMover.computeMove(ws) elif EffectiveMovementName == "surf" and not shouldRam: (spd, tr) = bot.surfMover.computeMove(ws) elif EffectiveMovementName == "learned" and not shouldRam: (spd, tr) = bot.learnedMover.computeMove(ws) elif EffectiveMovementName == "tfil_ring": bot.ringMover.band = if shouldRam: (lo: 0.0, hi: 50.0) else: (lo: RangeLo, hi: RangeHi) (spd, tr) = bot.ringMover.computeMove(ws) elif shouldRam: bot.mover.clearGraphics() (spd, tr) = bot.rammer.computeMove(ws) else: (spd, tr) = bot.mover.computeMove(ws) setTargetSpeed(spd) setTurnRate(tr) # Auto-switch radar based on the SERVER's live enemy count. Using the # tracker's known-enemy count here was wrong in melee: before the second # enemy had been scanned the bot saw 1 and locked to 1v1, then never scanned # the rest. getEnemyCount() is the server's alive-enemy count (scanned or # not), so melee mode persists until only one enemy is actually left. let liveEnemyCount = getEnemyCount() let targetMode = if liveEnemyCount == 1: 0 else: 1 # ONE definition of the rack mode: the SAME server-truth count the radar # uses above (`vb.rackMode` maps it to 1v1/melee). The tracker's known-enemy # count must not be used — see the radar note. Logged once on change. bot.rackMode = vb.rackMode(liveEnemyCount) bot.logRackChange() if targetMode != bot.radarMode: bot.radarMode = targetMode if bot.radarMode == 0: bot.radar.init() # re-lock onto survivor setRadarColor("#004444") setScanColor("#0D4D4D") else: bot.meleeRadar.init() # fresh acquisition of the remaining crowd setRadarColor("#443300") setScanColor("#4D3D0D") bot.cfgDirty = true # emit at tick end, after gun selection let radarRate = if bot.radarMode == 0: bot.radar.computeScan(bot.lastState) elif RadarForceSpin: adaptive_melee_radar.MaxRadarTurnRate # A/B: exact old melee_scan behaviour else: bot.meleeRadar.setExpectedEnemies(getEnemyCount()) bot.meleeRadar.computeScan(bot.lastState) setRadarTurnRate(radarRate) go() # --- Aim + fire from tracked state (not scan event) --- if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and bot.enemyTracker.enemies[tid].alive: # Spawn virtual bullets for all guns var headsUp: array[len(PowerBins), GunPrediction] var linPreds: array[len(PowerBins), GunPrediction] var tmPreds: array[len(PowerBins), GunPrediction] var circPreds: array[len(PowerBins), GunPrediction] var gfPreds: array[len(PowerBins), GunPrediction] var pmPreds: array[len(PowerBins), GunPrediction] var wbPreds: array[len(PowerBins), GunPrediction] var acPreds: array[len(PowerBins), GunPrediction] var ssPreds: array[len(PowerBins), GunPrediction] var dsPreds: array[len(PowerBins), GunPrediction] var alPreds: array[len(PowerBins), GunPrediction] var dgPreds: array[len(PowerBins), GunPrediction] var knnPreds: array[len(PowerBins), GunPrediction] var tmselPreds: array[len(PowerBins), GunPrediction] var tmpPreds: array[len(PowerBins), GunPrediction] var tmhPreds: array[len(PowerBins), GunPrediction] var lgPreds: array[len(PowerBins), GunPrediction] # ── TR_VBULLET_ADMIT_ONLY gate ────────────────────────────────────── # Rack membership used to filter only SELECTION, so every unselected gun # still ran predict()+spawnBullets() each tick to feed a fitness table the # selector would never read (~40-46% of the per-tick budget). `admit` # skips BOTH calls for a gun the current rack does not admit. It is # recomputed each tick because the rack mode can change mid-round (a melee # thinning to 1v1). Bullets already in flight are NOT cancelled: they are # resolved by `tickBullets` and the feedback `case` below still calls the # owning gun's onResult, so attribution survives a mid-round rack change. # TMPATTERN and TMHORIZON keep their own admission gate even when the knob # is 0, so TR_VBULLET_ADMIT_ONLY=0 reproduces the exact pre-change rack. var admit: array[NumRackGuns, bool] for gi in 0.. 0: bot.virtualHits.float / total.float * 100.0 else: 0.0 echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)" ) # Gun selection + fire. `bot.tick` drives the selector's dwell window. # The energy-aware power cap uses the current target distance and our own # energy; `shouldRam` (the movement code's decision) exempts it. let (selectedGun, _, power, pdec) = selectShotPolicy( bot.tracker, tid, bot.tick, dist = ramDist, selfEnergy = ws.selfEnergy, enemyEnergy = ws.enemyEnergy, ramming = shouldRam, rackMode = bot.rackMode, membership = ActiveRackMembership) if PowerLog: let pkey = fmt"{power:.1f}|{pdec.cap:.1f}|{pdec.reason}" if pkey != bot.lastPowerLogKey: bot.lastPowerLogKey = pkey echo fmt"[power] p={power:.1f} cap={pdec.cap:.1f} " & fmt"reason={powerReasonName(pdec.reason)} " & fmt"dist={ramDist:.0f} selfE={ws.selfEnergy:.0f} " & fmt"enemyE={ws.enemyEnergy:.0f} gun={GunNames[selectedGun]}" bot.gunSelectionCount[selectedGun] += 1 if selectedGun != bot.currentGun: bot.currentGun = selectedGun bot.cfgDirty = true # gun switch: emit the config line at tick end # Per-gun colours live in `vbullet_draw.gunColors`, shared with the # virtual-bullet overlay so turret and overlay can never drift apart. let (turretColor, bulletColor) = gunColors(selectedGun) setTurretColor(turretColor) setBulletColor(bulletColor) let pred = case selectedGun of 1: bot.linear.predict(bot.lastState, bulletSpeed(power)) of 2: bot.tsetlin.predict(bot.lastState, bulletSpeed(power)) of 3: bot.circular.predict(bot.lastState, bulletSpeed(power)) of 4: bot.guessFactor.predict(bot.lastState, bulletSpeed(power)) of 5: bot.patternMatcher.predict(bot.lastState, bulletSpeed(power)) of 6: bot.wallBounce.predict(bot.lastState, bulletSpeed(power)) of 7: bot.accelGun.predict(bot.lastState, bulletSpeed(power)) of 8: bot.stopShot.predict(bot.lastState, bulletSpeed(power)) of 9: bot.displace.predict(bot.lastState, bulletSpeed(power)) of 10: bot.avgLead.predict(bot.lastState, bulletSpeed(power)) of 11: bot.decayGF.predict(bot.lastState, bulletSpeed(power)) of 12: bot.knnGun.predict(bot.lastState, bulletSpeed(power)) of 13: bot.tmSelector.predict(bot.lastState, bulletSpeed(power)) of TmPatternId: bot.tmPattern.predict(bot.lastState, bulletSpeed(power)) of TmHorizonId: bot.tmHorizon.predict(bot.lastState, bulletSpeed(power)) of LeadGainId: bot.leadGain.predict(bot.lastState, bulletSpeed(power)) else: bot.headOn.predict(bot.lastState, bulletSpeed(power)) let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y) let gunDir = getGunDirection() let gunHeat = getGunHeat() let gunDelta = (aimTarget - gunDir) mod 360.0 var normDelta = gunDelta if normDelta > 180.0: normDelta -= 360.0 elif normDelta < -180.0: normDelta += 360.0 # Distance to the aim point drives the range-aware gate (Task B) and is # recorded per shot (Task A). let distPx = hypot(pred.x - getX(), pred.y - getY()) if shouldFire(gunDir, aimTarget, gunHeat, distPx): # j160 FIRING FLOOR: at/below TR_RAM_FLOOR_ENERGY self energy we hold # the reserve for the ram instead of spending it on a shot. Off by # default (`RamFloorEnergy = 0.0`), and `ramming` (the exhaustion # trigger) wins the conflict, so an engaged ram never starves itself. let floorBlocks = fireFloorBlocks(RamFloorEnergy, getEnergy(), shouldRam) # Enqueue the selected gun so onBulletFired can stamp the server's bulletId. # getEnergy() > power mirrors the server's "bot.energy <= firepower" reject. if not floorBlocks and setFire(power) and getEnergy() > power: bot.pendingFires.add(PendingShot( gunId: selectedGun, angleErr: abs(normDelta), distPx: distPx, power: power, tick: bot.tick, targetId: tid, mode: bot.rackMode, )) setGunTurnRate(normDelta) # Virtual-bullet overlay (default off; suppressed by TR_DEBUG_DRAW=0). # Drawn AFTER selection so the default "selected gun only" filter uses # this tick's choice. if DebugDrawOn and VBulletDebugOn: discard drawVirtualBullets(bot.tracker, resolvedMarks, selectedGun, VBulletDebugGun, VBulletDebugMax, GunNames) # Geometry overlay (TR_GEO_DEBUG, default off). Independent of # TR_DEBUG_DRAW: this is the one thing left when debug drawing is off. if GeoDebugOn: discard drawGeoOverlay(ws.selfX, ws.selfY, ws.selfHeading, ws.enemyX, ws.enemyY, ws.enemyHeading) # Emit exactly one config line per tick, at the END of the tick, so the gun # field reflects the selection made above rather than the previous tick's. if bot.cfgDirty: bot.printConfig() bot.cfgDirty = false proc seedSelectorRng() = ## Seed the process-global RNG exactly once at bot startup so the gun ## selector's "random" tie-break (`chooseFromFit` -> rand) actually varies ## across process restarts. Previously the only `randomize()` call reached on ## the live path was incidental, inside the Tsetlin gun's constructor, so a ## rack without Tsetlin produced a FIXED tie-break sequence forever. ## ## `GUN_SELECTOR_SEED`, when set to an integer, pins the stream so A/B runs are ## reproducible; otherwise time+pid seeding makes each process independent. let s = getEnv("GUN_SELECTOR_SEED", "") if s.len > 0: try: randomize(parseInt(s.strip())) except ValueError: randomize() else: randomize() when isMainModule: var bot = ModularBot( tracker: vb.initTracker(NumRackGuns), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect, 14: TMPattern, 15: TMHorizon, 16: LeadGain headOn: HeadOnGun(), linear: LinearGun(), circular: CircularGun(), tsetlin: initTsetlinGun(), guessFactor: initGFGun(), patternMatcher: PatternMatcherGun(), wallBounce: initWallBounceGun(), accelGun: initAccelGun(), stopShot: initStopShotGun(), displace: initDisplacementGun(), avgLead: initAveragedLeadGun(), decayGF: initDecayGFGun(), knnGun: initKNNGun(), tmSelector: initTmSelectorGun(), tmPattern: initTmRadialGun(), tmHorizon: initTmHorizonGun(), leadGain: initLeadGainGun(), radar: RadarLockModule(), meleeRadar: initAdaptiveMeleeRadar(), mover: TFILModule(debugGraphics: DebugDrawOn), ringMover: TFILRingModule(debugGraphics: DebugDrawOn), strafeMover: StrafeModule(debugGraphics: DebugDrawOn), surfMover: initWaveSurfer(), learnedMover: initLearnedSurfer(), rammer: initRammer(), moveTracker: mvb.initVirtualBodyTracker(1), currentGun: -1, ) # Must run AFTER the gun constructors (Tsetlin/TMSelector call randomize() # unconditionally) so the explicit seed override is the value that survives. seedSelectorRng() # Boot-time environment report (once per process, stdout). Prints what env # this process actually inherited, the resolved effective value of every # documented knob, and the build identity behind `grep '^\[env\]'`. printEnvReport(EnvReportContext( movementName: MovementName, vBulletAdmitOnly: VBulletAdmitOnly, vBulletDebug: VBulletDebugOn, vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""), vBulletDebugMax: VBulletDebugMax, recordWorldState: RecordWorldState, geoDebug: GeoDebugOn, debugDraw: DebugDrawOn, radarForceSpin: RadarForceSpin, radarScanLog: RadarScanLog, radarScanLogPath: RadarScanLogPath, trackerProbe: TrackerProbe, trackerProbePath: TrackerProbePath, powerLog: PowerLog, resultLog: ResultLog, gunStatsPath: GunStatsPath, shotLogPath: ShotLogPath, disabledGuns: DisabledGuns, tmHorizon: bot.tmHorizon, patternMatcher: bot.patternMatcher, leadGain: bot.leadGain, )) start(bot, botJsonPath)