4cd5618435
TASK 2 - THE FLAKY ACCEPTANCE TEST, root-caused. It was NOT a live/offline boundary race as suspected. The replay spawned gun 13 (TMSelect) while live has EnableTmSelector = false and never does. The shared VirtualTracker ring is ORDER-SENSITIVE, so gun 13's extra 4 bullets/tick shift the ring head and permute the per-tick RESOLUTION ORDER of every other gun. The learning guns append observations in resolution order, so their predictions shifted and produced small hit deltas that moved between runs. Evidence: the first KNN divergence was at rtick=174 with the SAME resolution set merely reordered (live ft133,138,139,142,148,150,151 vs offline ft150,151,133,138,139,142,148); after closing gun 13's ready gate offline the live and offline KNN traces became BYTE-IDENTICAL (diff empty, 904/904 lines). Fix: mirror the live rack in the replay. No tick exclusion, no tolerance loosening. Stability: 5/5 consecutive runs now report 12/12 exact, each with enemyDied=true - the death boundary is included, not excluded. The proof is now real rather than a lucky run. TASK 1 - the tie-break was not random. randomize() was only reached incidentally through initTsetlinGun(), so a rack without Tsetlin had a fixed rand() stream and ties always resolved the same way across process restarts. Added seedSelectorRng() after gun construction, honouring GUN_SELECTOR_SEED. Evidence: unseeded, 6 separate processes gave different pick sequences; with GUN_SELECTOR_SEED=42, 3 processes gave identical sequences. TASK 3 - PRUNING DOES NOT HELP; keep the full rack. 15 PAIRED runs per variant vs DrussGT, 8 rounds, identical seeds: baseline 3238 shots 6.18% (events 6.16%) 200 dmg/run Tsetlin disabled 3522 shots 5.76% (events 5.71%) 197 dmg/run Tsetlin+Displace 3478 shots 5.46% (events 5.37%) 183 dmg/run Paired permutation tests: -0.34pp p=0.57 and -0.70pp p=0.21. Per-run distributions completely overlap (baseline range [2.68, 10.00]; 15/15 and 14/15 runs inside it). A Crazy control showed no separation either. So removing the measured-worst real performers is neutral-to-slightly-negative, and with sd ~1.8pp a definitive claim either way would need far more runs. CORRECTION TO A CLAIM I MADE: the 'virtual metric is INVERTED' finding does NOT reproduce. Job-24 measured Spearman -0.374; this job measures +0.335 over the same 13 guns with a different but equally defensible aggregation. Two opposite signs means the correlation is NOT robustly negative - it is WEAK AND SIGN-UNSTABLE. The honest statement is that virtual hit rate is a poor ranker, not an inverted one. The docs assert the inversion and need correcting. Also adds per-process GUN_STATS_PATH/GUN_SHOTLOG_PATH so concurrent A/B runs do not clobber each other, and an env-gated GUN_RACK_DISABLE for rack A/Bs. All default behaviour is unchanged when the env vars are unset.
801 lines
34 KiB
Nim
801 lines
34 KiB
Nim
## ModularBot — plugin gun architecture tracer bullet.
|
|
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13) via GunHarness.
|
|
## Radar: RadarLockModule (1v1) / 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..<len(vb.PowerBins):
|
|
let fw = fit[gid].bins[binIdx]
|
|
let n = min(fw.count, vb.WindowSize)
|
|
totalShots += n
|
|
for k in 0..<n:
|
|
if fw.hits[k]: inc totalHits
|
|
let hitRate = if totalShots > 0: totalHits.float / totalShots.float else: 0.0
|
|
let rShots = bot.gunRealShots[gid]
|
|
let rHits = bot.gunRealHits[gid]
|
|
let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0
|
|
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..<len(PowerBins):
|
|
headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if EnableTmSelector:
|
|
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
|
|
bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
|
|
if not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
|
|
if not gunDisabled(2) and bot.tsetlin.isWarmedUp():
|
|
bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid)
|
|
if not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
|
|
if not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
|
|
if not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
|
|
if not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
|
|
if not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
|
|
if not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
|
|
if not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
|
|
if not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
|
|
if not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
|
|
if not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
|
|
if EnableTmSelector and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
|
|
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
|
|
|
|
# Build slim enemy table for tickBullets
|
|
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
|
|
for id, es in bot.enemyTracker.enemies:
|
|
enemyPositions[id] = (x: es.x, y: es.y, lastSeenTick: es.lastSeenTick, alive: es.alive)
|
|
|
|
let st = bot.lastState
|
|
bot.tracker.tickBullets(st, enemyPositions, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) =
|
|
case gunId
|
|
of 0: bot.headOn.onResult(fe)
|
|
of 1: bot.linear.onResult(fe)
|
|
of 2: bot.tsetlin.onResult(fe)
|
|
of 3:
|
|
bot.circular.onResult(fe)
|
|
when DebugCircular:
|
|
let ax = st.enemyX
|
|
let ay = st.enemyY
|
|
echo fmt"[circ-vb] predicted=({fe.prediction.x:.0f},{fe.prediction.y:.0f}) actual=({ax:.0f},{ay:.0f}) miss={fe.missDistance:.1f}px hit={fe.hit}"
|
|
of 4: bot.guessFactor.onResult(fe)
|
|
of 5: bot.patternMatcher.onResult(fe)
|
|
of 6: bot.wallBounce.onResult(fe)
|
|
of 7: bot.accelGun.onResult(fe)
|
|
of 8: bot.stopShot.onResult(fe)
|
|
of 9: bot.displace.onResult(fe)
|
|
of 10: bot.avgLead.onResult(fe)
|
|
of 11: bot.decayGF.onResult(fe)
|
|
of 12: bot.knnGun.onResult(fe)
|
|
of 13: bot.tmSelector.onResult(fe)
|
|
else: discard
|
|
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
|
|
when DebugVBullets:
|
|
let total = bot.virtualHits + bot.virtualMiss
|
|
let pct = if total > 0: bot.virtualHits.float / total.float * 100.0 else: 0.0
|
|
echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)"
|
|
)
|
|
|
|
# Gun selection + fire
|
|
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)
|