dc071b83f3
One greppable '[result]' line per round plus one at battle end, stdout only: [result] round 3/7 WE WON (enemy destroyed) | us 42.1 energy, them 0.0, 812 ticks | rounds won 3/7 [result] battle END: rounds won 4/7 Outcome is authoritative from RoundEndedEventForBot.results.rank (1 = winner); death observations (our onDeath, enemy onBotDeath) and onWonRound refine it into WE WON (enemy destroyed) / WE DIED (killed) / BOTH DIED (score decided) / TIMEOUT (score decided). Our own death is reported the instant it happens. TR_RESULT_LOG registers in the boot env report; default on, only explicit off-values disable it. No behaviour change - logging/state only.
1441 lines
67 KiB
Nim
1441 lines
67 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), TmPatternGun (14), TmHorizonGun (15), BitBrainGun (16) via GunHarness.
|
|
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
|
|
## Movement: OscillatorModule (perpendicular strafing).
|
|
|
|
import std/[math, os, strformat, tables, sets, json, random, strutils]
|
|
import robocode_tankroyale_botapi
|
|
import radar_harness/radar_interface
|
|
import radars/radar_lock_module
|
|
import radars/adaptive_melee_radar
|
|
import gun_harness/gun_interface
|
|
import gun_harness/virtual_bullets as vb
|
|
import gun_harness/selector
|
|
import guns/head_on
|
|
import guns/linear
|
|
import guns/circular
|
|
import guns/tsetlin
|
|
import guns/guess_factor
|
|
import guns/pattern_matcher
|
|
import guns/wall_bounce
|
|
import guns/accel_predictor
|
|
import guns/stop_shot
|
|
import guns/displacement
|
|
import guns/averaged_lead
|
|
import guns/decay_gf
|
|
import guns/knn_gun
|
|
import guns/tm_selector
|
|
import guns/tm_pattern
|
|
import guns/tm_horizon
|
|
import guns/bitbrain_gun
|
|
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 movement_harness/virtual_bodies as mvb
|
|
import movement_harness/bullet_shadows
|
|
import targeting/enemy_tracker
|
|
import targeting/target_selector
|
|
import env_report
|
|
|
|
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:
|
|
## tfil (DEFAULT) — today's behaviour, byte-for-byte, including the
|
|
## separate rammer dispatch.
|
|
## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring).
|
|
## UNPROVEN: it must never become the default silently.
|
|
let MovementName* = getEnv("TR_MOVEMENT", "tfil").strip().toLowerAscii()
|
|
## Per-process output paths so concurrent A/B runs do not clobber each other.
|
|
let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl")
|
|
let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl")
|
|
## Energy-aware power policy observability (TR_POWER_LOG=1): emit ONE line per
|
|
## CHANGE of the (power, cap, reason) decision — not per tick — so the GUI log
|
|
## shows why the cap moved. The policy itself lives in the shared gun harness
|
|
## (`applyPowerPolicy`), so both live and offline paths see the same rule.
|
|
let PowerLog = existsEnv("TR_POWER_LOG")
|
|
## 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", "BitBrain"]
|
|
|
|
## 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 BitBrain ADE+SBC fine-grained aim corrector. It defaults to
|
|
## `TR_RACK_BITBRAIN=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 BitBrainId = 16
|
|
|
|
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
|
|
bitbrain: BitBrainGun
|
|
mover: TFILModule
|
|
ringMover: TFILRingModule
|
|
strafeMover: StrafeModule
|
|
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[17, int]
|
|
gunRealHits: array[17, int]
|
|
# Same real-shot accounting split by the rack in force at fire time, so a
|
|
# later data-driven pass can rank guns per mode (1v1 vs melee).
|
|
gunRealShotsByMode: array[vb.RackMode, array[17, int]]
|
|
gunRealHitsByMode: array[vb.RackMode, array[17, 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[17, 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 MovementName == "strafe":
|
|
if bot.isRamming: "strafe(ram)" else: "strafe"
|
|
elif MovementName == "tfil_ring":
|
|
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
|
|
elif bot.isRamming: "rammer"
|
|
else: "tfil"
|
|
var line = "[config] "
|
|
if bot.currentGun >= 0 and bot.currentGun < GunNames.len:
|
|
line &= gc & "gun=" & GunNames[bot.currentGun] & (if gc != "": rst else: "") & " | "
|
|
line &= "move=" & moveName &
|
|
" | " & rc & "radar=" & radarName & (if rc != "": rst else: "") &
|
|
" | enemies=" & $bot.enemyTracker.allAlive().len &
|
|
" target=" & tc & "#" & $bot.currentTargetId & (if tc != "": rst else: "")
|
|
echo line
|
|
bot.prevGun = bot.currentGun
|
|
bot.prevRadar = bot.radarMode
|
|
bot.prevTarget = bot.currentTargetId
|
|
|
|
proc logRackChange(bot: ModularBot, force = false) =
|
|
## Emit ONE `[rack]` line per mode / membership change (never per tick),
|
|
## behind the existing config-log mechanism. The membership table is frozen at
|
|
## process start, so in practice this fires on the 1v1 <-> melee transition;
|
|
## `force` also prints the round-start state.
|
|
let over = rackOverrides(ActiveRackMembership)
|
|
let key = rackModeName(bot.rackMode) & "|" & over
|
|
if not force and key == bot.lastRackLogKey: return
|
|
bot.lastRackLogKey = key
|
|
echo "[rack] mode=" & rackModeName(bot.rackMode) &
|
|
" active=" & rackActive(ActiveRackMembership, bot.rackMode) &
|
|
(if over.len > 0: " overrides=" & over else: " overrides=none")
|
|
|
|
proc startWorldStateRecord(bot: ModularBot) =
|
|
## Truncate the fixture and write the meta line at round start.
|
|
if RecordWorldState:
|
|
try:
|
|
let f = open(WorldStateRecordPath, fmWrite)
|
|
f.writeLine($(%*{"meta": {
|
|
"adversary": "live-recorded",
|
|
"source": "live",
|
|
"perfect_info": false,
|
|
"arena": {"w": getArenaWidth().float, "h": getArenaHeight().float},
|
|
"note": "exact WorldState the bot built, with tracker lastSeenTick",
|
|
}}))
|
|
f.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc finishWorldStateRecord(bot: ModularBot) =
|
|
## Append the end marker so the offline replay can reproduce the live
|
|
## resolver's final-tick behaviour: if the target died during the last go()
|
|
## the live aim block was skipped, so no bullet resolved on that tick.
|
|
if RecordWorldState:
|
|
var died = false
|
|
if bot.lastKnownTargetId >= 0 and bot.enemyTracker.enemies.contains(bot.lastKnownTargetId):
|
|
died = not bot.enemyTracker.enemies[bot.lastKnownTargetId].alive
|
|
try:
|
|
let f = open(WorldStateRecordPath, fmAppend)
|
|
f.writeLine($(%*{"end": {"enemy_died": died}}))
|
|
f.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc recordWorldState(bot: ModularBot, ws: WorldState) =
|
|
## Append one tick of the state the bot ACTUALLY built (including the stale
|
|
## tracker positions between radar scans) so the offline replay sees the same
|
|
## information the guns saw online.
|
|
if RecordWorldState:
|
|
let tid = bot.currentTargetId
|
|
var lst = -1
|
|
if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
|
|
lst = bot.enemyTracker.enemies[tid].lastSeenTick
|
|
var row = %*{
|
|
"tick": ws.tick,
|
|
"ex": ws.enemyX, "ey": ws.enemyY,
|
|
"eh": ws.enemyHeading, "es": ws.enemySpeed, "ee": ws.enemyEnergy,
|
|
"sx": ws.selfX, "sy": ws.selfY,
|
|
"sh": ws.selfHeading, "ss": ws.selfSpeed, "se": ws.selfEnergy,
|
|
"eid": tid,
|
|
}
|
|
if lst >= 0: row["lst"] = %lst
|
|
try:
|
|
let f = open(WorldStateRecordPath, fmAppend)
|
|
f.writeLine($row)
|
|
f.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState =
|
|
var ei: seq[EnemyInfo]
|
|
for es in bot.enemyTracker.allAlive():
|
|
ei.add EnemyInfo(id: es.id, x: es.x, y: es.y,
|
|
heading: es.heading, speed: es.speed, energy: es.energy,
|
|
lastSeenTick: es.lastSeenTick)
|
|
result = WorldState(
|
|
enemyX: ex,
|
|
enemyY: ey,
|
|
enemySpeed: espeed,
|
|
enemyHeading: eheading,
|
|
enemyEnergy: eenergy,
|
|
selfX: getX(),
|
|
selfY: getY(),
|
|
selfSpeed: getSpeed(),
|
|
selfHeading: getDirection(),
|
|
selfRadarHeading: getRadarDirection(),
|
|
selfEnergy: getEnergy(),
|
|
arenaWidth: getArenaWidth().float,
|
|
arenaHeight: getArenaHeight().float,
|
|
tick: bot.tick,
|
|
enemies: ei,
|
|
)
|
|
if RecordWorldState:
|
|
bot.recordWorldState(result)
|
|
|
|
## ── Enemy-tracker probe (TR_TRACKER_PROBE) ─────────────────────────────────
|
|
var gProbeFile: File
|
|
var gProbeReady = false
|
|
var gProbeEventRegistered = false
|
|
|
|
proc probeWrite(row: JsonNode) =
|
|
if not gProbeReady:
|
|
try:
|
|
gProbeFile = open(TrackerProbePath, fmWrite)
|
|
gProbeReady = true
|
|
except CatchableError:
|
|
return
|
|
try:
|
|
gProbeFile.writeLine($row)
|
|
gProbeFile.flushFile()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc probeQueue(): bool =
|
|
## Registered as a custom event. `addCustomEvents` runs it AFTER the tick's
|
|
## events are queued but BEFORE `removeOldEvents` deletes them, so this is the
|
|
## only place that can observe a `BotDeathEvent` the API is about to drop.
|
|
## Always returns false, so it is never dispatched.
|
|
if TrackerProbe:
|
|
var evs = newJArray()
|
|
var dropped = 0
|
|
var maxTurn = -1
|
|
let turn = getTurn()
|
|
for e in getEvents():
|
|
let drop = e.turnNumber < turn - MAX_EVENTS_AGE and not e.isCritical
|
|
if drop: inc dropped
|
|
if e.turnNumber > maxTurn: maxTurn = e.turnNumber
|
|
evs.add(%*{"k": $e.kind, "t": e.turnNumber, "drop": drop})
|
|
probeWrite(%*{
|
|
"probe": "queue",
|
|
"round": getRound(),
|
|
"turn": turn,
|
|
"queue_turn": maxTurn,
|
|
"dispatch_lag": turn - maxTurn,
|
|
"events": evs,
|
|
"dropped": dropped,
|
|
})
|
|
return false
|
|
|
|
proc probeTracker(bot: ModularBot) =
|
|
## Per-tick tracker snapshot (tracker alive-count vs the server's
|
|
## `getEnemyCount()`). The `pruned` field is kept for schema compatibility
|
|
## with the corpse measurement tooling; it is always empty now that
|
|
## reconciliation is gone (see docs/tracker_death_events.md).
|
|
if not TrackerProbe: return
|
|
var alive = newJObject()
|
|
var dead = newJObject()
|
|
for id, es in bot.enemyTracker.enemies:
|
|
if es.alive: alive[$id] = %es.lastSeenTick
|
|
else: dead[$id] = %es.lastSeenTick
|
|
let current = bot.currentTargetId
|
|
probeWrite(%*{
|
|
"probe": "tracker",
|
|
"round": bot.roundNumber,
|
|
"my_id": getMyId(),
|
|
"turn": getTurn(),
|
|
"bot_tick": bot.tick,
|
|
"lag": getTurn() - bot.tick,
|
|
"server_ec": getEnemyCount(),
|
|
"tracker_alive": bot.enemyTracker.aliveCount(),
|
|
"alive": alive,
|
|
"dead": dead,
|
|
"pruned": newJArray(),
|
|
"target": current,
|
|
"target_alive": bot.enemyTracker.isAlive(current),
|
|
"radar_mode": bot.radarMode,
|
|
"ramming": bot.isRamming,
|
|
})
|
|
|
|
proc recordRadarStats(bot: ModularBot) =
|
|
## Per-tick radar coverage sampler (no-op unless TR_RADAR_SCANLOG is set).
|
|
## "Fresh within N" means the tracker scanned that enemy at most N ticks ago.
|
|
## The covering-arc width is recomputed here from live bearings purely for the
|
|
## histogram; it does not influence the radar.
|
|
##
|
|
## Only SERVER-confirmed melee ticks are sampled (2+ enemies alive), so the
|
|
## 1v1 lock mode cannot dilute the melee numbers. There is no corpse filter:
|
|
## the tracker's `alive` flag is reliable (measured 0 corpse ticks — see
|
|
## docs/tracker_death_events.md), so dead enemies are excluded by `es.alive`
|
|
## alone.
|
|
if not RadarScanLog: return
|
|
bot.radarMeleeActive = getEnemyCount() >= 2
|
|
if not bot.radarMeleeActive: return
|
|
if bot.radarMode == 1:
|
|
if bot.meleeRadar.phase == rpAcquire: inc bot.radarAcquireTicks
|
|
else: inc bot.radarTrackTicks
|
|
var bearings: seq[float]
|
|
for id, es in bot.enemyTracker.enemies:
|
|
if not es.alive: continue
|
|
let age = bot.tick - es.lastSeenTick
|
|
bearings.add normalizeDeg(arctan2(es.y - getY(), es.x - getX()).radToDeg)
|
|
bot.radarAliveTicks[id] = bot.radarAliveTicks.getOrDefault(id) + 1
|
|
if age <= 8:
|
|
bot.radarFresh8[id] = bot.radarFresh8.getOrDefault(id) + 1
|
|
if age <= 16:
|
|
bot.radarFresh16[id] = bot.radarFresh16.getOrDefault(id) + 1
|
|
if bearings.len > 0:
|
|
let (_, _, width) = minimalCoveringArc(bearings)
|
|
inc bot.arcWidthHist[min(11, int(width / 30.0))]
|
|
|
|
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
|
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
|
|
bot.hasContact = true
|
|
if RadarScanLog and bot.radarMeleeActive:
|
|
bot.radarScanCounts[e.scannedBotId] =
|
|
bot.radarScanCounts.getOrDefault(e.scannedBotId) + 1
|
|
|
|
|
|
method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) =
|
|
inc bot.realShotsFired
|
|
bot.registerOwnBullet(e.bullet.bulletId)
|
|
bot.moveTracker.shadows.addBullet(e.bullet.x, e.bullet.y,
|
|
degToRad(e.bullet.direction), e.bullet.power)
|
|
|
|
method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
|
|
inc bot.realHits
|
|
# Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
|
|
let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1)
|
|
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
|
|
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
|
|
if gunId >= 0:
|
|
inc bot.gunRealHits[gunId]
|
|
inc bot.gunRealHitsByMode[hitMode][gunId]
|
|
bot.resolveShotLog(e.bullet.bulletId, true)
|
|
else:
|
|
bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
|
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
|
|
method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
|
|
discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot
|
|
bot.resolveShotLog(e.bullet.bulletId, false)
|
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
|
|
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
|
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
|
|
bot.resolveShotLog(e.bullet.bulletId, false)
|
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
|
|
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
|
# 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())
|
|
|
|
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..<17:
|
|
var totalShots = 0
|
|
var totalHits = 0
|
|
for binIdx in 0..<len(vb.PowerBins):
|
|
let fw = fit[gid].bins[binIdx]
|
|
let n = min(fw.count, vb.WindowSize)
|
|
totalShots += n
|
|
for k in 0..<n:
|
|
if fw.hits[k]: inc totalHits
|
|
let hitRate = if totalShots > 0: totalHits.float / totalShots.float else: 0.0
|
|
let rShots = bot.gunRealShots[gid]
|
|
let rHits = bot.gunRealHits[gid]
|
|
let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0
|
|
# Real hit rate split by the rack in force when each shot was fired, so a
|
|
# later data-driven pass can rank guns per mode without a rebuild.
|
|
let rShots1 = bot.gunRealShotsByMode[vb.rm1v1][gid]
|
|
let rHits1 = bot.gunRealHitsByMode[vb.rm1v1][gid]
|
|
let rRate1 = if rShots1 > 0: rHits1.float * 100.0 / rShots1.float else: 0.0
|
|
let rShotsM = bot.gunRealShotsByMode[vb.rmMelee][gid]
|
|
let rHitsM = bot.gunRealHitsByMode[vb.rmMelee][gid]
|
|
let rRateM = if rShotsM > 0: rHitsM.float * 100.0 / rShotsM.float else: 0.0
|
|
gunsArr.add(%*{
|
|
"id": gid,
|
|
"name": GunNames[gid],
|
|
"vShots": totalShots,
|
|
"vHits": totalHits,
|
|
"hitRate": (hitRate * 100.0).int,
|
|
"selected": bot.gunSelectionCount[gid],
|
|
"realShots": rShots,
|
|
"realHits": rHits,
|
|
"realHitRate": rRate.round(1),
|
|
"realShots1v1": rShots1,
|
|
"realHits1v1": rHits1,
|
|
"realHitRate1v1": rRate1.round(1),
|
|
"realShotsMelee": rShotsM,
|
|
"realHitsMelee": rHitsM,
|
|
"realHitRateMelee": rRateM.round(1)
|
|
})
|
|
|
|
let row = %*{
|
|
"round": bot.roundNumber,
|
|
"targetId": targetId,
|
|
"guns": gunsArr,
|
|
"realShots": bot.realShotsFired,
|
|
"realHits": bot.realHits,
|
|
"score": e.results.totalScore,
|
|
"bulletDmg": e.results.bulletDamage,
|
|
"vDropped": bot.tracker.droppedBullets,
|
|
"vStarved": bot.guessFactor.waveStarved + bot.decayGF.waveStarved + bot.knnGun.waveStarved
|
|
}
|
|
let f = open(GunStatsPath, fmAppend)
|
|
f.writeLine($row)
|
|
f.close()
|
|
# Task A: shots still in flight at round end never resolved, so count them as
|
|
# misses for the shot log (matching how realHits/realShots treats them).
|
|
# Shots fired but never stamped by onBulletFired are not counted in realShots
|
|
# either, so they are dropped rather than logged.
|
|
when ShotLog:
|
|
for _, shot in bot.bulletShot:
|
|
writeShotLog(shot, false, true)
|
|
bot.bulletShot.clear()
|
|
bot.pendingFires.setLen(0)
|
|
if RadarScanLog:
|
|
var scans = newJObject()
|
|
var alive = newJObject()
|
|
var fresh8 = newJObject()
|
|
var fresh16 = newJObject()
|
|
for id, n in bot.radarScanCounts: scans[$id] = %n
|
|
for id, n in bot.radarAliveTicks: alive[$id] = %n
|
|
for id, n in bot.radarFresh8: fresh8[$id] = %n
|
|
for id, n in bot.radarFresh16: fresh16[$id] = %n
|
|
var hist = newJArray()
|
|
for c in bot.arcWidthHist: hist.add(%c)
|
|
let rrow = %*{
|
|
"round": bot.roundNumber,
|
|
"ticks": bot.tick,
|
|
"scans": scans,
|
|
"aliveTicks": alive,
|
|
"fresh8": fresh8,
|
|
"fresh16": fresh16,
|
|
"arcHist": hist,
|
|
"acquireTicks": bot.radarAcquireTicks,
|
|
"trackTicks": bot.radarTrackTicks,
|
|
"enemyCount": getEnemyCount(),
|
|
}
|
|
try:
|
|
let rf = open(RadarScanLogPath, fmAppend)
|
|
rf.writeLine($rrow)
|
|
rf.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
|
bot.roundNumber = e.roundNumber
|
|
# 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..<17:
|
|
bot.gunSelectionCount[i] = 0
|
|
bot.gunRealShots[i] = 0
|
|
bot.gunRealHits[i] = 0
|
|
for m in vb.RackMode:
|
|
for i in 0..<17:
|
|
bot.gunRealShotsByMode[m][i] = 0
|
|
bot.gunRealHitsByMode[m][i] = 0
|
|
# Reset per-round integrity counters so each /tmp/gun_stats.jsonl line reports
|
|
# that round's numbers (summing the lines gives the session total).
|
|
bot.tracker.droppedBullets = 0
|
|
bot.guessFactor.wavePushes = 0
|
|
bot.guessFactor.waveStarved = 0
|
|
bot.decayGF.wavePushes = 0
|
|
bot.decayGF.waveStarved = 0
|
|
bot.knnGun.wavePushes = 0
|
|
bot.knnGun.waveStarved = 0
|
|
setAdjustGunForBodyTurn(true)
|
|
setAdjustRadarForBodyTurn(true)
|
|
setAdjustRadarForGunTurn(true)
|
|
setBodyColor("#FF9900") # PhantomMeteor: deep orange/gold
|
|
bot.radar.init()
|
|
bot.meleeRadar.init()
|
|
bot.mover.resetRound()
|
|
bot.ringMover.resetRound()
|
|
bot.strafeMover.resetRound()
|
|
bot.enemyTracker.resetRound()
|
|
# The TM pattern gun is cold every round (no cross-battle persistence): wipe
|
|
# its clause teams and motion history explicitly, so a same-id opponent in the
|
|
# next round cannot inherit the previous round's overfit net.
|
|
bot.tmPattern.resetLearning()
|
|
# The horizon TM corrector KEEPS its machines across rounds: only the
|
|
# per-round observation ring and deferred labels are cleared (bots teleport
|
|
# between rounds, so old positions are meaningless). The machines are wiped on
|
|
# a NEW BATTLE in onGameStarted, with a round-1 fallback here in case that
|
|
# callback does not fire in this harness.
|
|
bot.tmHorizon.resetRoundState()
|
|
if e.roundNumber <= 1:
|
|
bot.tmHorizon.resetLearning("round1")
|
|
# BitBrain: per-round wipe always clears observations/labels; `perRound` mode
|
|
# also wipes its SBCs (the gate test's winning regime).
|
|
bot.bitbrain.resetRoundState()
|
|
if e.roundNumber <= 1:
|
|
bot.bitbrain.resetLearning("round1")
|
|
bot.isRamming = false
|
|
bot.ramDurationTicks = 0
|
|
bot.ramCooldownTicks = 0
|
|
for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0
|
|
bot.ramDmgHead = 0
|
|
bot.ramDmgAccum = 0.0
|
|
bot.lastRamLogKey = ""
|
|
bot.hasContact = false
|
|
bot.tick = 0
|
|
bot.currentGun = -1
|
|
bot.currentTargetId = -1
|
|
bot.targetSwitchTick = 0
|
|
# Defensive fallback: if onGameStarted hasn't run, use live enemy count from API
|
|
if bot.initialEnemyCount == 0:
|
|
bot.initialEnemyCount = getEnemyCount()
|
|
bot.enemyCount = bot.initialEnemyCount
|
|
# ponytail: default to melee until confirmed 1v1 via scans, avoids stale enemyCount issue
|
|
bot.radarMode = 1 # Start in adaptive_melee; will switch to radar_lock (0) if tracker confirms 1v1
|
|
bot.moveTracker.resetRound(getX(), getY(), getDirection(), getSpeed())
|
|
# currentGun stays -1 ("no gun chosen yet") until run() picks one; printConfig
|
|
# omits the gun field while it is negative.
|
|
# Apply radar colors for initial mode
|
|
if bot.radarMode == 0:
|
|
setRadarColor("#004444")
|
|
setScanColor("#0D4D4D")
|
|
else:
|
|
setRadarColor("#443300")
|
|
setScanColor("#4D3D0D")
|
|
bot.cfgDirty = false
|
|
bot.printConfig(forceAll = true)
|
|
# Rack mode from server truth (same transition the radar uses), logged once
|
|
# per round start and again on every 1v1 <-> melee change. At round start
|
|
# `bot.enemyCount` (from the game setup) is reliable where a raw
|
|
# getEnemyCount() call may not be yet, and run() refreshes it every tick.
|
|
bot.rackMode = vb.rackMode(bot.enemyCount)
|
|
bot.lastRackLogKey = ""
|
|
bot.logRackChange(force = true)
|
|
# tracker fitness persists across rounds (rolling window carries over)
|
|
|
|
method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
|
|
## 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.bitbrain.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.bitbrain.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 = 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 MovementName == "strafe" and not shouldRam:
|
|
(spd, tr) = bot.strafeMover.computeMove(ws)
|
|
elif MovementName == "tfil_ring":
|
|
bot.ringMover.band =
|
|
if shouldRam: (lo: 0.0, hi: 50.0)
|
|
else: (lo: RangeLo, hi: RangeHi)
|
|
(spd, tr) = bot.ringMover.computeMove(ws)
|
|
elif shouldRam:
|
|
bot.mover.clearGraphics()
|
|
(spd, tr) = bot.rammer.computeMove(ws)
|
|
else:
|
|
(spd, tr) = bot.mover.computeMove(ws)
|
|
setTargetSpeed(spd)
|
|
setTurnRate(tr)
|
|
|
|
# Auto-switch radar based on the SERVER's live enemy count. Using the
|
|
# tracker's known-enemy count here was wrong in melee: before the second
|
|
# enemy had been scanned the bot saw 1 and locked to 1v1, then never scanned
|
|
# the rest. getEnemyCount() is the server's alive-enemy count (scanned or
|
|
# not), so melee mode persists until only one enemy is actually left.
|
|
let liveEnemyCount = getEnemyCount()
|
|
let targetMode = if liveEnemyCount == 1: 0 else: 1
|
|
# ONE definition of the rack mode: the SAME server-truth count the radar
|
|
# uses above (`vb.rackMode` maps it to 1v1/melee). The tracker's known-enemy
|
|
# count must not be used — see the radar note. Logged once on change.
|
|
bot.rackMode = vb.rackMode(liveEnemyCount)
|
|
bot.logRackChange()
|
|
if targetMode != bot.radarMode:
|
|
bot.radarMode = targetMode
|
|
if bot.radarMode == 0:
|
|
bot.radar.init() # re-lock onto survivor
|
|
setRadarColor("#004444")
|
|
setScanColor("#0D4D4D")
|
|
else:
|
|
bot.meleeRadar.init() # fresh acquisition of the remaining crowd
|
|
setRadarColor("#443300")
|
|
setScanColor("#4D3D0D")
|
|
bot.cfgDirty = true # emit at tick end, after gun selection
|
|
|
|
let radarRate = if bot.radarMode == 0:
|
|
bot.radar.computeScan(bot.lastState)
|
|
elif RadarForceSpin:
|
|
adaptive_melee_radar.MaxRadarTurnRate # A/B: exact old melee_scan behaviour
|
|
else:
|
|
bot.meleeRadar.setExpectedEnemies(getEnemyCount())
|
|
bot.meleeRadar.computeScan(bot.lastState)
|
|
setRadarTurnRate(radarRate)
|
|
go()
|
|
|
|
# --- Aim + fire from tracked state (not scan event) ---
|
|
if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and
|
|
bot.enemyTracker.enemies[tid].alive:
|
|
# Spawn virtual bullets for all guns
|
|
var headsUp: array[len(PowerBins), GunPrediction]
|
|
var linPreds: array[len(PowerBins), GunPrediction]
|
|
var tmPreds: array[len(PowerBins), GunPrediction]
|
|
var circPreds: array[len(PowerBins), GunPrediction]
|
|
var gfPreds: array[len(PowerBins), GunPrediction]
|
|
var pmPreds: array[len(PowerBins), GunPrediction]
|
|
var wbPreds: array[len(PowerBins), GunPrediction]
|
|
var acPreds: array[len(PowerBins), GunPrediction]
|
|
var ssPreds: array[len(PowerBins), GunPrediction]
|
|
var dsPreds: array[len(PowerBins), GunPrediction]
|
|
var alPreds: array[len(PowerBins), GunPrediction]
|
|
var dgPreds: array[len(PowerBins), GunPrediction]
|
|
var knnPreds: array[len(PowerBins), GunPrediction]
|
|
var tmselPreds: array[len(PowerBins), GunPrediction]
|
|
var tmpPreds: array[len(PowerBins), GunPrediction]
|
|
var tmhPreds: array[len(PowerBins), GunPrediction]
|
|
var bbPreds: 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[17, bool]
|
|
for gi in 0..<17:
|
|
admit[gi] = vBulletAdmitted(gi, bot.rackMode, ActiveRackMembership,
|
|
VBulletAdmitOnly or gi == TmPatternId or
|
|
gi == TmHorizonId or gi == BitBrainId)
|
|
for i in 0..<len(PowerBins):
|
|
if admit[0]: headsUp[i] = bot.headOn.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[1]: linPreds[i] = bot.linear.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[2]: tmPreds[i] = bot.tsetlin.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[3]: circPreds[i] = bot.circular.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[4]: gfPreds[i] = bot.guessFactor.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[5]: pmPreds[i] = bot.patternMatcher.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[6]: wbPreds[i] = bot.wallBounce.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[7]: acPreds[i] = bot.accelGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[8]: ssPreds[i] = bot.stopShot.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[9]: dsPreds[i] = bot.displace.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[10]: alPreds[i] = bot.avgLead.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[11]: dgPreds[i] = bot.decayGF.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if admit[12]: knnPreds[i] = bot.knnGun.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
if EnableTmSelector and admit[13]:
|
|
tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
# TMPATTERN keeps its own rack-admission gate (see `admit` above).
|
|
if admit[TmPatternId]:
|
|
tmpPreds[i] = bot.tmPattern.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
# TMHORIZON likewise: Pattern base + horizon TM correction.
|
|
if admit[TmHorizonId]:
|
|
tmhPreds[i] = bot.tmHorizon.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
# BITBRAIN: Pattern base + ADE/SBC fine-grained angular correction.
|
|
if admit[BitBrainId]:
|
|
bbPreds[i] = bot.bitbrain.predict(bot.lastState, bulletSpeed(PowerBins[i]))
|
|
|
|
if admit[0]: bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid)
|
|
if admit[1] and not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid)
|
|
if admit[2] and not gunDisabled(2) and bot.tsetlin.isWarmedUp():
|
|
bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid)
|
|
if admit[3] and not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid)
|
|
if admit[4] and not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid)
|
|
if admit[5] and not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid)
|
|
if admit[6] and not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid)
|
|
if admit[7] and not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid)
|
|
if admit[8] and not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid)
|
|
if admit[9] and not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid)
|
|
if admit[10] and not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid)
|
|
if admit[11] and not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid)
|
|
if admit[12] and not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid)
|
|
if EnableTmSelector and admit[13] and not gunDisabled(13) and bot.tmSelector.isWarmedUp():
|
|
bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid)
|
|
if admit[TmPatternId] and not gunDisabled(TmPatternId):
|
|
bot.tracker.spawnBullets(TmPatternId, tmpPreds, bot.lastState, tid)
|
|
if admit[TmHorizonId] and not gunDisabled(TmHorizonId):
|
|
bot.tracker.spawnBullets(TmHorizonId, tmhPreds, bot.lastState, tid)
|
|
if admit[BitBrainId] and not gunDisabled(BitBrainId):
|
|
bot.tracker.spawnBullets(BitBrainId, bbPreds, 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)
|
|
of TmPatternId: bot.tmPattern.onResult(fe)
|
|
of TmHorizonId: bot.tmHorizon.onResult(fe)
|
|
of BitBrainId: bot.bitbrain.onResult(fe)
|
|
else: discard
|
|
if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss
|
|
when DebugVBullets:
|
|
let total = bot.virtualHits + bot.virtualMiss
|
|
let pct = if total > 0: bot.virtualHits.float / total.float * 100.0 else: 0.0
|
|
echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)"
|
|
)
|
|
|
|
# Gun selection + fire. `bot.tick` drives the selector's dwell window.
|
|
# The energy-aware power cap uses the current target distance and our own
|
|
# energy; `shouldRam` (the movement code's decision) exempts it.
|
|
let (selectedGun, _, power, pdec) = selectShotPolicy(
|
|
bot.tracker, tid, bot.tick,
|
|
dist = ramDist, selfEnergy = ws.selfEnergy,
|
|
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
|
|
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")
|
|
of TmPatternId: setTurretColor("#AAFF00"); setBulletColor("#CCFF66")
|
|
of TmHorizonId: setTurretColor("#00AAFF"); setBulletColor("#66CCFF")
|
|
of BitBrainId: setTurretColor("#FF1493"); setBulletColor("#FF69B4")
|
|
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))
|
|
of TmPatternId: bot.tmPattern.predict(bot.lastState, bulletSpeed(power))
|
|
of TmHorizonId: bot.tmHorizon.predict(bot.lastState, bulletSpeed(power))
|
|
of BitBrainId: bot.bitbrain.predict(bot.lastState, bulletSpeed(power))
|
|
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
|
|
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
|
|
|
|
let gunDir = getGunDirection()
|
|
let gunHeat = getGunHeat()
|
|
let gunDelta = (aimTarget - gunDir) mod 360.0
|
|
var normDelta = gunDelta
|
|
if normDelta > 180.0: normDelta -= 360.0
|
|
elif normDelta < -180.0: normDelta += 360.0
|
|
|
|
# Distance to the aim point drives the range-aware gate (Task B) and is
|
|
# recorded per shot (Task A).
|
|
let distPx = hypot(pred.x - getX(), pred.y - getY())
|
|
|
|
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
|
|
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
|
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
|
if setFire(power) and getEnergy() > power:
|
|
bot.pendingFires.add(PendingShot(
|
|
gunId: selectedGun,
|
|
angleErr: abs(normDelta),
|
|
distPx: distPx,
|
|
power: power,
|
|
tick: bot.tick,
|
|
targetId: tid,
|
|
mode: bot.rackMode,
|
|
))
|
|
|
|
setGunTurnRate(normDelta)
|
|
|
|
# Emit exactly one config line per tick, at the END of the tick, so the gun
|
|
# field reflects the selection made above rather than the previous tick's.
|
|
if bot.cfgDirty:
|
|
bot.printConfig()
|
|
bot.cfgDirty = false
|
|
|
|
proc seedSelectorRng() =
|
|
## Seed the process-global RNG exactly once at bot startup so the gun
|
|
## selector's "random" tie-break (`chooseFromFit` -> rand) actually varies
|
|
## across process restarts. Previously the only `randomize()` call reached on
|
|
## the live path was incidental, inside the Tsetlin gun's constructor, so a
|
|
## rack without Tsetlin produced a FIXED tie-break sequence forever.
|
|
##
|
|
## `GUN_SELECTOR_SEED`, when set to an integer, pins the stream so A/B runs are
|
|
## reproducible; otherwise time+pid seeding makes each process independent.
|
|
let s = getEnv("GUN_SELECTOR_SEED", "")
|
|
if s.len > 0:
|
|
try: randomize(parseInt(s.strip()))
|
|
except ValueError: randomize()
|
|
else:
|
|
randomize()
|
|
|
|
when isMainModule:
|
|
var bot = ModularBot(
|
|
tracker: vb.initTracker(17), # 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: BitBrain
|
|
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(),
|
|
bitbrain: initBitBrainGun(),
|
|
radar: RadarLockModule(),
|
|
meleeRadar: initAdaptiveMeleeRadar(),
|
|
mover: TFILModule(debugGraphics: true),
|
|
ringMover: TFILRingModule(debugGraphics: true),
|
|
strafeMover: StrafeModule(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()
|
|
# 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,
|
|
recordWorldState: RecordWorldState,
|
|
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,
|
|
bitbrain: bot.bitbrain,
|
|
))
|
|
start(bot, botJsonPath)
|