a73de13458
The user's plan: "separate racks for melee and 1v1, so the bot switches from those based on the situation, and we can put the guns we want in one or both racks." MECHANISM - `RackMode` (rm1v1/rmMelee) derived from SERVER TRUTH: `rackMode(enemyCount)` = 1v1 when the count is 1, melee otherwise. This is the SAME `getEnemyCount()` value the radar already uses, so there is now ONE definition of the mode. (Using the tracker's known-enemy count was a previous bug in the radar: it read 1 before the second enemy was scanned.) - `RackMembership` per gun: both (default) | 1v1 | melee | off. - The selector ranks only admitted guns - including the floor path and the incumbent-hysteresis path. - Empty filtered set FALLS BACK to the full rack, so the bot can never end up with no gun. - Env-overridable at process start, no rebuild: `TR_RACK_<GUN>` for all 14 guns (TR_RACK_HEADON, TR_RACK_LINEAR, ... TR_RACK_TMSELECT), values both|1v1|melee|off. Empty/unknown -> both + a stderr warning, never fatal. - `[rack] mode=<1v1|melee> active=<guns> overrides=<...>` logged once per mode change, never per tick. DEFAULT IS UNCHANGED: every gun ships `rmBoth`, so behaviour is byte-identical until the user re-racks anything. Verified by the unit test's default-config selection parity (RNG draw for RNG draw) and by `test_gun_harness` 39 and acceptance 12/12. `chooseFromFit` iterates the admitted list in ascending id order, so the random tie-break draws are unchanged. NO TUNING DONE, deliberately: we had no per-gun melee hit-rate data, and an earlier 15-paired-run experiment found pruning neutral-to-negative on hit rate (p=0.57/0.21). So all guns stay `both` and the membership pass waits for data. PER-MODE DATA PLUMBING (this is what unblocks that pass): per-gun real shot accounting is now split by the rack in force at fire time, adding to gun_stats.jsonl: realShots1v1, realHits1v1, realHitRate1v1, realShotsMelee, realHitsMelee, realHitRateMelee. Verification: test_rack_membership 38/38 (new, pure, no battle); test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28; acceptance_offline_vs_online 12/12 VERDICT PASS; ModularBot compiles. The live `[rack]` line was observed switching 1v1 -> melee when the enemy died. The offline range never calls the selector (only spawnBullets/tickBullets/ reportFor), so mode filtering cannot change the offline result and no offline mode parameter was needed - confirmed by reasoning over the source and by 12/12.
1183 lines
52 KiB
Nim
1183 lines
52 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) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
|
|
## Movement: OscillatorModule (perpendicular strafing).
|
|
|
|
import std/[math, os, strformat, tables, sets, json, random, strutils]
|
|
import robocode_tankroyale_botapi
|
|
import radar_harness/radar_interface
|
|
import radars/radar_lock_module
|
|
import radars/adaptive_melee_radar
|
|
import gun_harness/gun_interface
|
|
import gun_harness/virtual_bullets as vb
|
|
import gun_harness/selector
|
|
import guns/head_on
|
|
import guns/linear
|
|
import guns/circular
|
|
import guns/tsetlin
|
|
import guns/guess_factor
|
|
import guns/pattern_matcher
|
|
import guns/wall_bounce
|
|
import guns/accel_predictor
|
|
import guns/stop_shot
|
|
import guns/displacement
|
|
import guns/averaged_lead
|
|
import guns/decay_gf
|
|
import guns/knn_gun
|
|
import guns/tm_selector
|
|
import movements/phantom_meteor
|
|
import movements/rammer
|
|
import movements/ram_decision
|
|
import movements/the_floor_is_lava
|
|
import movements/the_floor_is_lava_ring
|
|
import movement_harness/virtual_bodies as mvb
|
|
import movement_harness/bullet_shadows
|
|
import targeting/enemy_tracker
|
|
import targeting/target_selector
|
|
|
|
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
|
|
const DebugVBullets = false
|
|
const DebugCircular = false
|
|
## Registration switch for the TM selector gun (id 13). Set false to build a
|
|
## rack without it (A/B runs); the tracker still reserves gun id 13 so the other
|
|
## ids and per-gun stats are unchanged.
|
|
##
|
|
## VERDICT: left FALSE. The TM gate underperforms its own experts on every
|
|
## offline fixture (energy-threshold 63% vs Circular 100%, random-walk 45% vs
|
|
## WallBounce 68%, Corners 25% vs Accel 34%) and the DrussGT A/B showed real hit
|
|
## rate 5.59% vs 7.47% for the power-fix-only rack. Flip to true to re-enable.
|
|
const EnableTmSelector = false
|
|
## Task A instrumentation: log every real shot + its eventual outcome to
|
|
## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out.
|
|
const ShotLog = true
|
|
## Offline range recorder: when the TR_RECORD_WORLDSTATE env var is set, append
|
|
## the exact WorldState the bot builds each tick to /tmp/worldstate_record.jsonl
|
|
## so it can be replayed offline. This is a RUNTIME switch (not a compile-time
|
|
## const) so the normal build never writes a fixture, while the acceptance test
|
|
## can enable recording for just the battle it spawns by exporting the env var.
|
|
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
|
|
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
|
|
## Radar measurement switches (all RUNTIME, read once at process start):
|
|
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
|
|
## spin (always 45 deg/tick). This reproduces the
|
|
## replaced `melee_scan` on the IDENTICAL binary, so an
|
|
## OLD-vs-NEW scan-rate A/B changes only the radar.
|
|
## TR_RADAR_SCANLOG=1 append one per-round JSON line of scan events and
|
|
## coverage to /tmp/radar_scan_log.jsonl.
|
|
let RadarForceSpin* = existsEnv("TR_RADAR_FORCE_SPIN")
|
|
let RadarScanLog* = existsEnv("TR_RADAR_SCANLOG")
|
|
let RadarScanLogPath = getEnv("TR_RADAR_SCAN_LOG_PATH", "/tmp/radar_scan_log.jsonl")
|
|
## Enemy-tracker probe (TR_TRACKER_PROBE=1): append one JSON line per tick
|
|
## (tracker state vs server enemy count) and one per event dispatch (the queue
|
|
## BEFORE `removeOldEvents` deletes dropped events) to TR_TRACKER_PROBE_PATH.
|
|
## Measurement-only; the default build writes nothing.
|
|
let TrackerProbe* = existsEnv("TR_TRACKER_PROBE")
|
|
let TrackerProbePath = getEnv("TR_TRACKER_PROBE_PATH", "/tmp/tracker_probe.jsonl")
|
|
## Runtime rack pruning for MEASURED A/B runs: comma-separated gun ids to remove
|
|
## from the virtual-bullet rack. A disabled gun never spawns virtual bullets, so
|
|
## its fitness window stays empty and the selector can never pick it (chooseFromFit
|
|
## skips guns with no observations). Read once at process start so a SINGLE frozen
|
|
## binary can be A/B'd by exporting GUN_RACK_DISABLE. Empty/unset = full rack.
|
|
let DisabledGuns =
|
|
block:
|
|
var s: HashSet[int]
|
|
for tok in getEnv("GUN_RACK_DISABLE", "").split(','):
|
|
let t = tok.strip()
|
|
if t.len > 0:
|
|
try: s.incl parseInt(t)
|
|
except ValueError: discard
|
|
s
|
|
|
|
proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns
|
|
## ── Movement rack (runtime switch) ─────────────────────────────────────────
|
|
## `TR_MOVEMENT` selects the movement engine, mirroring the gun rack's
|
|
## env-driven construction. Both engines are always constructed, so the switch
|
|
## needs no rebuild:
|
|
## tfil (DEFAULT) — today's behaviour, byte-for-byte, including the
|
|
## separate rammer dispatch.
|
|
## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring).
|
|
## UNPROVEN: it must never become the default silently.
|
|
let MovementName* = getEnv("TR_MOVEMENT", "tfil").strip().toLowerAscii()
|
|
## Per-process output paths so concurrent A/B runs do not clobber each other.
|
|
let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl")
|
|
let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl")
|
|
## Energy-aware power policy observability (TR_POWER_LOG=1): emit ONE line per
|
|
## CHANGE of the (power, cap, reason) decision — not per tick — so the GUI log
|
|
## shows why the cap moved. The policy itself lives in the shared gun harness
|
|
## (`applyPowerPolicy`), so both live and offline paths see the same rule.
|
|
let PowerLog = existsEnv("TR_POWER_LOG")
|
|
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"]
|
|
|
|
const
|
|
CLR_GUN = "\e[33m" # yellow
|
|
CLR_MOVE = "\e[36m" # cyan
|
|
CLR_FIRE = "\e[31m" # red
|
|
CLR_RADAR = "\e[32m" # green
|
|
CLR_CHANGE = "\e[32m" # green (highlight for changed module)
|
|
CLR_ROUND = "\e[1;37m" # bold white
|
|
CLR_RST = "\e[0m" # reset
|
|
|
|
type
|
|
PendingShot = object
|
|
## Metadata for one real shot, captured at setFire() time and resolved when
|
|
## the server reports the bullet's fate (hit / wall / bullet).
|
|
gunId: int
|
|
angleErr: float ## |aim error| (deg) at fire time — the value the gate used
|
|
distPx: float ## distance (px) to the aim point at fire time
|
|
power: float
|
|
tick: int
|
|
targetId: int
|
|
mode: vb.RackMode ## rack in force at fire time (per-mode hit stats)
|
|
|
|
ModularBot = ref object of Bot
|
|
hasContact: bool
|
|
enemyBearing: float
|
|
lastState: WorldState
|
|
radar: RadarLockModule
|
|
meleeRadar: AdaptiveMeleeRadarModule
|
|
enemyCount: int
|
|
initialEnemyCount: int
|
|
radarMode: int # 0 = lock, 1 = melee
|
|
tracker: VirtualTracker
|
|
headOn: HeadOnGun
|
|
linear: LinearGun
|
|
circular: CircularGun
|
|
tsetlin: TsetlinGun
|
|
guessFactor: GFGun
|
|
patternMatcher: PatternMatcherGun
|
|
wallBounce: WallBounceGun
|
|
accelGun: AccelGun
|
|
stopShot: StopShotGun
|
|
displace: DisplacementGun
|
|
avgLead: AveragedLeadGun
|
|
decayGF: DecayGFGun
|
|
knnGun: KNNGun
|
|
tmSelector: TmSelectorGun
|
|
mover: TFILModule
|
|
ringMover: TFILRingModule
|
|
rammer: RammerModule
|
|
isRamming: bool
|
|
ramStuckTicks: int
|
|
ramDurationTicks: int
|
|
ramCooldownTicks: int
|
|
ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage
|
|
ramDmgHead: int
|
|
ramDmgAccum: float ## damage accumulated this turn, flushed by the window
|
|
lastRamLogKey: string ## TR_RAM_LOG change detector (state|reason)
|
|
moveTracker: VirtualBodyTracker
|
|
virtualHits: int
|
|
virtualMiss: int
|
|
tick: int
|
|
currentGun: int
|
|
currentTargetId: int
|
|
targetSwitchTick: int
|
|
enemyTracker: EnemyTracker
|
|
prevGun: int
|
|
prevRadar: int
|
|
prevTarget: int
|
|
cfgDirty: bool ## gun/target/radar changed this tick; emit one config line at tick end
|
|
roundNumber: int
|
|
realShotsFired: int
|
|
realHits: int
|
|
gunRealShots: array[14, int]
|
|
gunRealHits: array[14, int]
|
|
# Same real-shot accounting split by the rack in force at fire time, so a
|
|
# later data-driven pass can rank guns per mode (1v1 vs melee).
|
|
gunRealShotsByMode: array[vb.RackMode, array[14, int]]
|
|
gunRealHitsByMode: array[vb.RackMode, array[14, int]]
|
|
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
|
|
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
|
|
bulletMode: Table[int, vb.RackMode] ## bulletId -> rack at fire time
|
|
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
|
|
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
|
|
gunSelectionCount: array[14, int]
|
|
lastPowerLogKey: string ## change detector for the TR_POWER_LOG line
|
|
lastKnownTargetId: int ## persists through death, used for round-end stats
|
|
# Radar measurement instrumentation (only touched when RadarScanLog is set).
|
|
radarScanCounts: Table[int, int] ## onScannedBot calls per enemy id
|
|
radarAliveTicks: Table[int, int] ## ticks each enemy was known-alive
|
|
radarFresh8: Table[int, int] ## ticks each enemy was seen within 8
|
|
radarFresh16: Table[int, int] ## ticks each enemy was seen within 16
|
|
arcWidthHist: array[12, int] ## covering-arc width buckets of 30 deg
|
|
radarAcquireTicks: int
|
|
radarTrackTicks: int
|
|
radarMeleeActive: bool
|
|
# Rack (melee vs 1v1) state. `rackMode` is derived from SERVER truth once
|
|
# per tick in run(); `lastRackLogKey` is the change detector for the
|
|
# once-per-change `[rack]` log line.
|
|
rackMode: vb.RackMode
|
|
lastRackLogKey: string
|
|
|
|
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
|
|
## Task A: append one JSON line per resolved real shot. The write is fully
|
|
## guarded so a full disk / bad path can never take the bot down.
|
|
when ShotLog:
|
|
let row = %*{
|
|
"tick": shot.tick,
|
|
"targetId": shot.targetId,
|
|
"gunId": shot.gunId,
|
|
"angleErr": shot.angleErr,
|
|
"distPx": shot.distPx,
|
|
"power": shot.power,
|
|
"hit": hit,
|
|
"unresolved": unresolved
|
|
}
|
|
try:
|
|
let f = open(ShotLogPath, fmAppend)
|
|
f.writeLine($row)
|
|
f.close()
|
|
except CatchableError:
|
|
discard # logging must never crash the bot
|
|
|
|
proc resolveShotLog(bot: ModularBot, bulletId: int, hit: bool) =
|
|
## Look up the shot metadata for a resolved bullet and log its fate once.
|
|
## No-op when ShotLog is off (bulletShot is then never populated).
|
|
if bot.bulletShot.hasKey(bulletId):
|
|
writeShotLog(bot.bulletShot[bulletId], hit, false)
|
|
bot.bulletShot.del(bulletId)
|
|
|
|
proc registerOwnBullet(bot: ModularBot, bulletId: int) =
|
|
## onBulletFired: the server assigns a unique bulletId per round. Stamp the
|
|
## oldest pending fire with it, then credit that gun with a real shot.
|
|
## The FIFO stays aligned because we only enqueue when setFire() succeeds
|
|
## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard).
|
|
if bot.pendingFires.len == 0: return # unexpected: no pending fire to stamp
|
|
let shot = bot.pendingFires[0]
|
|
delete(bot.pendingFires, 0)
|
|
bot.bulletGun[bulletId] = shot.gunId
|
|
bot.bulletMode[bulletId] = shot.mode
|
|
inc bot.gunRealShots[shot.gunId]
|
|
inc bot.gunRealShotsByMode[shot.mode][shot.gunId]
|
|
when ShotLog:
|
|
bot.bulletShot[bulletId] = shot
|
|
# Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and
|
|
# BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first.
|
|
# If so, credit the hit now and drop the (already resolved) map entry.
|
|
if bulletId in bot.pendingHitBullets:
|
|
inc bot.gunRealHits[shot.gunId]
|
|
inc bot.gunRealHitsByMode[shot.mode][shot.gunId]
|
|
bot.pendingHitBullets.excl(bulletId)
|
|
bot.bulletGun.del(bulletId)
|
|
bot.bulletMode.del(bulletId)
|
|
bot.resolveShotLog(bulletId, true)
|
|
|
|
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
|
|
## Look up the gun that fired bulletId and drop it from the in-flight map.
|
|
## Returns the gun id, or -1 if the bullet was not attributed.
|
|
result = bot.bulletGun.getOrDefault(bulletId, -1)
|
|
if result >= 0:
|
|
bot.bulletGun.del(bulletId)
|
|
bot.bulletMode.del(bulletId)
|
|
|
|
proc printConfig(bot: ModularBot, forceAll: bool = false) =
|
|
## One line describing the config in use for the tick being reported. Only the
|
|
## fields that changed since the previous line are highlighted green. The gun
|
|
## field is omitted until a gun has actually been selected this round, so a
|
|
## round-start line never claims a gun that was not chosen.
|
|
let rst = CLR_RST
|
|
let gc = if bot.currentGun != bot.prevGun or forceAll: CLR_CHANGE else: ""
|
|
let rc = if bot.radarMode != bot.prevRadar or forceAll: CLR_CHANGE else: ""
|
|
let tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: ""
|
|
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
|
|
let moveName =
|
|
if MovementName == "tfil_ring":
|
|
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
|
|
elif bot.isRamming: "rammer"
|
|
else: "tfil"
|
|
var line = "[config] "
|
|
if bot.currentGun >= 0 and bot.currentGun < GunNames.len:
|
|
line &= gc & "gun=" & GunNames[bot.currentGun] & (if gc != "": rst else: "") & " | "
|
|
line &= "move=" & moveName &
|
|
" | " & rc & "radar=" & radarName & (if rc != "": rst else: "") &
|
|
" | enemies=" & $bot.enemyTracker.allAlive().len &
|
|
" target=" & tc & "#" & $bot.currentTargetId & (if tc != "": rst else: "")
|
|
echo line
|
|
bot.prevGun = bot.currentGun
|
|
bot.prevRadar = bot.radarMode
|
|
bot.prevTarget = bot.currentTargetId
|
|
|
|
proc logRackChange(bot: ModularBot, force = false) =
|
|
## Emit ONE `[rack]` line per mode / membership change (never per tick),
|
|
## behind the existing config-log mechanism. The membership table is frozen at
|
|
## process start, so in practice this fires on the 1v1 <-> melee transition;
|
|
## `force` also prints the round-start state.
|
|
let over = rackOverrides(ActiveRackMembership)
|
|
let key = rackModeName(bot.rackMode) & "|" & over
|
|
if not force and key == bot.lastRackLogKey: return
|
|
bot.lastRackLogKey = key
|
|
echo "[rack] mode=" & rackModeName(bot.rackMode) &
|
|
" active=" & rackActive(ActiveRackMembership, bot.rackMode) &
|
|
(if over.len > 0: " overrides=" & over else: " overrides=none")
|
|
|
|
proc startWorldStateRecord(bot: ModularBot) =
|
|
## Truncate the fixture and write the meta line at round start.
|
|
if RecordWorldState:
|
|
try:
|
|
let f = open(WorldStateRecordPath, fmWrite)
|
|
f.writeLine($(%*{"meta": {
|
|
"adversary": "live-recorded",
|
|
"source": "live",
|
|
"perfect_info": false,
|
|
"arena": {"w": getArenaWidth().float, "h": getArenaHeight().float},
|
|
"note": "exact WorldState the bot built, with tracker lastSeenTick",
|
|
}}))
|
|
f.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc finishWorldStateRecord(bot: ModularBot) =
|
|
## Append the end marker so the offline replay can reproduce the live
|
|
## resolver's final-tick behaviour: if the target died during the last go()
|
|
## the live aim block was skipped, so no bullet resolved on that tick.
|
|
if RecordWorldState:
|
|
var died = false
|
|
if bot.lastKnownTargetId >= 0 and bot.enemyTracker.enemies.contains(bot.lastKnownTargetId):
|
|
died = not bot.enemyTracker.enemies[bot.lastKnownTargetId].alive
|
|
try:
|
|
let f = open(WorldStateRecordPath, fmAppend)
|
|
f.writeLine($(%*{"end": {"enemy_died": died}}))
|
|
f.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc recordWorldState(bot: ModularBot, ws: WorldState) =
|
|
## Append one tick of the state the bot ACTUALLY built (including the stale
|
|
## tracker positions between radar scans) so the offline replay sees the same
|
|
## information the guns saw online.
|
|
if RecordWorldState:
|
|
let tid = bot.currentTargetId
|
|
var lst = -1
|
|
if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
|
|
lst = bot.enemyTracker.enemies[tid].lastSeenTick
|
|
var row = %*{
|
|
"tick": ws.tick,
|
|
"ex": ws.enemyX, "ey": ws.enemyY,
|
|
"eh": ws.enemyHeading, "es": ws.enemySpeed, "ee": ws.enemyEnergy,
|
|
"sx": ws.selfX, "sy": ws.selfY,
|
|
"sh": ws.selfHeading, "ss": ws.selfSpeed, "se": ws.selfEnergy,
|
|
"eid": tid,
|
|
}
|
|
if lst >= 0: row["lst"] = %lst
|
|
try:
|
|
let f = open(WorldStateRecordPath, fmAppend)
|
|
f.writeLine($row)
|
|
f.close()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState =
|
|
var ei: seq[EnemyInfo]
|
|
for es in bot.enemyTracker.allAlive():
|
|
ei.add EnemyInfo(id: es.id, x: es.x, y: es.y,
|
|
heading: es.heading, speed: es.speed, energy: es.energy,
|
|
lastSeenTick: es.lastSeenTick)
|
|
result = WorldState(
|
|
enemyX: ex,
|
|
enemyY: ey,
|
|
enemySpeed: espeed,
|
|
enemyHeading: eheading,
|
|
enemyEnergy: eenergy,
|
|
selfX: getX(),
|
|
selfY: getY(),
|
|
selfSpeed: getSpeed(),
|
|
selfHeading: getDirection(),
|
|
selfRadarHeading: getRadarDirection(),
|
|
selfEnergy: getEnergy(),
|
|
arenaWidth: getArenaWidth().float,
|
|
arenaHeight: getArenaHeight().float,
|
|
tick: bot.tick,
|
|
enemies: ei,
|
|
)
|
|
if RecordWorldState:
|
|
bot.recordWorldState(result)
|
|
|
|
## ── Enemy-tracker probe (TR_TRACKER_PROBE) ─────────────────────────────────
|
|
var gProbeFile: File
|
|
var gProbeReady = false
|
|
var gProbeEventRegistered = false
|
|
|
|
proc probeWrite(row: JsonNode) =
|
|
if not gProbeReady:
|
|
try:
|
|
gProbeFile = open(TrackerProbePath, fmWrite)
|
|
gProbeReady = true
|
|
except CatchableError:
|
|
return
|
|
try:
|
|
gProbeFile.writeLine($row)
|
|
gProbeFile.flushFile()
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc probeQueue(): bool =
|
|
## Registered as a custom event. `addCustomEvents` runs it AFTER the tick's
|
|
## events are queued but BEFORE `removeOldEvents` deletes them, so this is the
|
|
## only place that can observe a `BotDeathEvent` the API is about to drop.
|
|
## Always returns false, so it is never dispatched.
|
|
if TrackerProbe:
|
|
var evs = newJArray()
|
|
var dropped = 0
|
|
var maxTurn = -1
|
|
let turn = getTurn()
|
|
for e in getEvents():
|
|
let drop = e.turnNumber < turn - MAX_EVENTS_AGE and not e.isCritical
|
|
if drop: inc dropped
|
|
if e.turnNumber > maxTurn: maxTurn = e.turnNumber
|
|
evs.add(%*{"k": $e.kind, "t": e.turnNumber, "drop": drop})
|
|
probeWrite(%*{
|
|
"probe": "queue",
|
|
"round": getRound(),
|
|
"turn": turn,
|
|
"queue_turn": maxTurn,
|
|
"dispatch_lag": turn - maxTurn,
|
|
"events": evs,
|
|
"dropped": dropped,
|
|
})
|
|
return false
|
|
|
|
proc probeTracker(bot: ModularBot) =
|
|
## Per-tick tracker snapshot (tracker alive-count vs the server's
|
|
## `getEnemyCount()`). The `pruned` field is kept for schema compatibility
|
|
## with the corpse measurement tooling; it is always empty now that
|
|
## reconciliation is gone (see docs/tracker_death_events.md).
|
|
if not TrackerProbe: return
|
|
var alive = newJObject()
|
|
var dead = newJObject()
|
|
for id, es in bot.enemyTracker.enemies:
|
|
if es.alive: alive[$id] = %es.lastSeenTick
|
|
else: dead[$id] = %es.lastSeenTick
|
|
let current = bot.currentTargetId
|
|
probeWrite(%*{
|
|
"probe": "tracker",
|
|
"round": bot.roundNumber,
|
|
"my_id": getMyId(),
|
|
"turn": getTurn(),
|
|
"bot_tick": bot.tick,
|
|
"lag": getTurn() - bot.tick,
|
|
"server_ec": getEnemyCount(),
|
|
"tracker_alive": bot.enemyTracker.aliveCount(),
|
|
"alive": alive,
|
|
"dead": dead,
|
|
"pruned": newJArray(),
|
|
"target": current,
|
|
"target_alive": bot.enemyTracker.isAlive(current),
|
|
"radar_mode": bot.radarMode,
|
|
"ramming": bot.isRamming,
|
|
})
|
|
|
|
proc recordRadarStats(bot: ModularBot) =
|
|
## Per-tick radar coverage sampler (no-op unless TR_RADAR_SCANLOG is set).
|
|
## "Fresh within N" means the tracker scanned that enemy at most N ticks ago.
|
|
## The covering-arc width is recomputed here from live bearings purely for the
|
|
## histogram; it does not influence the radar.
|
|
##
|
|
## Only SERVER-confirmed melee ticks are sampled (2+ enemies alive), so the
|
|
## 1v1 lock mode cannot dilute the melee numbers. There is no corpse filter:
|
|
## the tracker's `alive` flag is reliable (measured 0 corpse ticks — see
|
|
## docs/tracker_death_events.md), so dead enemies are excluded by `es.alive`
|
|
## alone.
|
|
if not RadarScanLog: return
|
|
bot.radarMeleeActive = getEnemyCount() >= 2
|
|
if not bot.radarMeleeActive: return
|
|
if bot.radarMode == 1:
|
|
if bot.meleeRadar.phase == rpAcquire: inc bot.radarAcquireTicks
|
|
else: inc bot.radarTrackTicks
|
|
var bearings: seq[float]
|
|
for id, es in bot.enemyTracker.enemies:
|
|
if not es.alive: continue
|
|
let age = bot.tick - es.lastSeenTick
|
|
bearings.add normalizeDeg(arctan2(es.y - getY(), es.x - getX()).radToDeg)
|
|
bot.radarAliveTicks[id] = bot.radarAliveTicks.getOrDefault(id) + 1
|
|
if age <= 8:
|
|
bot.radarFresh8[id] = bot.radarFresh8.getOrDefault(id) + 1
|
|
if age <= 16:
|
|
bot.radarFresh16[id] = bot.radarFresh16.getOrDefault(id) + 1
|
|
if bearings.len > 0:
|
|
let (_, _, width) = minimalCoveringArc(bearings)
|
|
inc bot.arcWidthHist[min(11, int(width / 30.0))]
|
|
|
|
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
|
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
|
|
bot.hasContact = true
|
|
if RadarScanLog and bot.radarMeleeActive:
|
|
bot.radarScanCounts[e.scannedBotId] =
|
|
bot.radarScanCounts.getOrDefault(e.scannedBotId) + 1
|
|
|
|
|
|
method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) =
|
|
inc bot.realShotsFired
|
|
bot.registerOwnBullet(e.bullet.bulletId)
|
|
bot.moveTracker.shadows.addBullet(e.bullet.x, e.bullet.y,
|
|
degToRad(e.bullet.direction), e.bullet.power)
|
|
|
|
method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
|
|
inc bot.realHits
|
|
# Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
|
|
let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1)
|
|
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
|
|
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
|
|
if gunId >= 0:
|
|
inc bot.gunRealHits[gunId]
|
|
inc bot.gunRealHitsByMode[hitMode][gunId]
|
|
bot.resolveShotLog(e.bullet.bulletId, true)
|
|
else:
|
|
bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
|
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
|
|
method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
|
|
discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot
|
|
bot.resolveShotLog(e.bullet.bulletId, false)
|
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
|
|
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
|
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
|
|
bot.resolveShotLog(e.bullet.bulletId, false)
|
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
|
|
|
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
|
bot.ramDmgAccum += e.bullet.power # feeds the ram bullet-rain abort window
|
|
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
|
|
|
|
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
|
## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round).
|
|
if RecordWorldState:
|
|
bot.finishWorldStateRecord()
|
|
# Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end
|
|
let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId
|
|
# Per-target fitness when known, else a deterministic recency-weighted aggregate
|
|
# over all enemies (see VirtualTracker.fitnessFor). Using the shared proc keeps
|
|
# this stats dump identical to what the gun selector sees.
|
|
let fit = bot.tracker.fitnessFor(targetId)
|
|
|
|
var gunsArr = newJArray()
|
|
for gid in 0..<14:
|
|
var totalShots = 0
|
|
var totalHits = 0
|
|
for binIdx in 0..<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
|
|
if RecordWorldState:
|
|
bot.startWorldStateRecord()
|
|
bot.realShotsFired = 0
|
|
bot.realHits = 0
|
|
bot.lastKnownTargetId = -1
|
|
bot.pendingFires.setLen(0)
|
|
bot.bulletGun.clear()
|
|
bot.bulletMode.clear()
|
|
bot.bulletShot.clear()
|
|
bot.pendingHitBullets.clear()
|
|
if RadarScanLog:
|
|
bot.radarScanCounts.clear()
|
|
bot.radarAliveTicks.clear()
|
|
bot.radarFresh8.clear()
|
|
bot.radarFresh16.clear()
|
|
for i in 0 ..< bot.arcWidthHist.len: bot.arcWidthHist[i] = 0
|
|
bot.radarAcquireTicks = 0
|
|
bot.radarTrackTicks = 0
|
|
bot.radarMeleeActive = false
|
|
for i in 0..<14:
|
|
bot.gunSelectionCount[i] = 0
|
|
bot.gunRealShots[i] = 0
|
|
bot.gunRealHits[i] = 0
|
|
for m in vb.RackMode:
|
|
for i in 0..<14:
|
|
bot.gunRealShotsByMode[m][i] = 0
|
|
bot.gunRealHitsByMode[m][i] = 0
|
|
# Reset per-round integrity counters so each /tmp/gun_stats.jsonl line reports
|
|
# that round's numbers (summing the lines gives the session total).
|
|
bot.tracker.droppedBullets = 0
|
|
bot.guessFactor.wavePushes = 0
|
|
bot.guessFactor.waveStarved = 0
|
|
bot.decayGF.wavePushes = 0
|
|
bot.decayGF.waveStarved = 0
|
|
bot.knnGun.wavePushes = 0
|
|
bot.knnGun.waveStarved = 0
|
|
setAdjustGunForBodyTurn(true)
|
|
setAdjustRadarForBodyTurn(true)
|
|
setAdjustRadarForGunTurn(true)
|
|
setBodyColor("#FF9900") # PhantomMeteor: deep orange/gold
|
|
bot.radar.init()
|
|
bot.meleeRadar.init()
|
|
bot.mover.resetRound()
|
|
bot.ringMover.resetRound()
|
|
bot.enemyTracker.resetRound()
|
|
bot.isRamming = false
|
|
bot.ramStuckTicks = 0
|
|
bot.ramDurationTicks = 0
|
|
bot.ramCooldownTicks = 0
|
|
for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0
|
|
bot.ramDmgHead = 0
|
|
bot.ramDmgAccum = 0.0
|
|
bot.lastRamLogKey = ""
|
|
bot.hasContact = false
|
|
bot.tick = 0
|
|
bot.currentGun = -1
|
|
bot.currentTargetId = -1
|
|
bot.targetSwitchTick = 0
|
|
# Defensive fallback: if onGameStarted hasn't run, use live enemy count from API
|
|
if bot.initialEnemyCount == 0:
|
|
bot.initialEnemyCount = getEnemyCount()
|
|
bot.enemyCount = bot.initialEnemyCount
|
|
# ponytail: default to melee until confirmed 1v1 via scans, avoids stale enemyCount issue
|
|
bot.radarMode = 1 # Start in adaptive_melee; will switch to radar_lock (0) if tracker confirms 1v1
|
|
bot.moveTracker.resetRound(getX(), getY(), getDirection(), getSpeed())
|
|
# currentGun stays -1 ("no gun chosen yet") until run() picks one; printConfig
|
|
# omits the gun field while it is negative.
|
|
# Apply radar colors for initial mode
|
|
if bot.radarMode == 0:
|
|
setRadarColor("#004444")
|
|
setScanColor("#0D4D4D")
|
|
else:
|
|
setRadarColor("#443300")
|
|
setScanColor("#4D3D0D")
|
|
bot.cfgDirty = false
|
|
bot.printConfig(forceAll = true)
|
|
# Rack mode from server truth (same transition the radar uses), logged once
|
|
# per round start and again on every 1v1 <-> melee change. At round start
|
|
# `bot.enemyCount` (from the game setup) is reliable where a raw
|
|
# getEnemyCount() call may not be yet, and run() refreshes it every tick.
|
|
bot.rackMode = vb.rackMode(bot.enemyCount)
|
|
bot.lastRackLogKey = ""
|
|
bot.logRackChange(force = true)
|
|
# tracker fitness persists across rounds (rolling window carries over)
|
|
|
|
method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
|
|
echo "[death] victimId=", e.victimId, " wasTarget=", (e.victimId == bot.currentTargetId),
|
|
" trackerAlive=", (if bot.enemyTracker.enemies.contains(e.victimId): $bot.enemyTracker.enemies[e.victimId].alive else: "notInTracker")
|
|
echo CLR_MOVE & "[death] victimId=" & $e.victimId & " wasTarget=" & $(e.victimId == bot.currentTargetId) & CLR_RST
|
|
# `markDead` is the lone death path. The API-level drop mechanism is real
|
|
# (`ekBotDeath` is not `isCritical`), but it was MEASURED never to fire at
|
|
# this workload: 0 dropped BotDeath events and 0 corpse ticks over 151 deaths
|
|
# on two server versions. See docs/tracker_death_events.md.
|
|
bot.enemyTracker.markDead(e.victimId)
|
|
if e.victimId == bot.currentTargetId:
|
|
bot.isRamming = false
|
|
bot.currentTargetId = -1
|
|
|
|
method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
|
|
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
|
|
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
|
|
# The custom event must be registered AFTER `start()` ran `initGlobals()`,
|
|
# which replaces the event queue (and its conditions). onGameStarted is the
|
|
# first bot callback that runs after that, still before any round/tick.
|
|
if TrackerProbe and not gProbeEventRegistered:
|
|
addCustomEvent("tracker_queue_probe", probeQueue)
|
|
gProbeEventRegistered = true
|
|
|
|
proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
|
|
## Hysteresis: only switch when there is a clear reason.
|
|
if bot.currentTargetId < 0: return true
|
|
let cur = bot.enemyTracker.enemies.getOrDefault(bot.currentTargetId)
|
|
if not cur.alive: return true
|
|
if bot.tick - cur.lastSeenTick >= 20: return true # stale
|
|
if bot.tick - bot.targetSwitchTick < 10: return false # cooldown
|
|
if candidateId == bot.currentTargetId: return false
|
|
let cand = bot.enemyTracker.enemies.getOrDefault(candidateId)
|
|
let sx = getX(); let sy = getY()
|
|
let curDist = hypot(cur.x - sx, cur.y - sy)
|
|
let candDist = hypot(cand.x - sx, cand.y - sy)
|
|
if candDist < curDist * 0.7: return true # significantly closer
|
|
if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity
|
|
return false
|
|
|
|
proc pushRamDamageWindow(bot: ModularBot) =
|
|
## Advance the incoming-damage window once per turn. Damage accumulated by
|
|
## `onHitByBullet` during the preceding event dispatch lands in the slot being
|
|
## written. Called before the contact check so the window always advances.
|
|
bot.ramDmgWindow[bot.ramDmgHead] = bot.ramDmgAccum
|
|
bot.ramDmgHead = (bot.ramDmgHead + 1) mod RamDamageWindow
|
|
bot.ramDmgAccum = 0.0
|
|
|
|
proc logRamChange(bot: ModularBot, on: bool, reason: RamReason,
|
|
dist: float, ws: WorldState, dmgRate: float,
|
|
offReason = "") =
|
|
## TR_RAM_LOG: ONE `[ram]` line per state/reason CHANGE (never per tick). The
|
|
## cap is 3.0 by construction while ramming — `applyPowerPolicy`'s ram
|
|
## exemption returns `cap: 3.0`; the ring band is `[0,50]` on the same
|
|
## `shouldRam` flag. Pure observability; no effect when TR_RAM_LOG is unset.
|
|
if not RamLog: return
|
|
let key = if on: "ON|" & reasonName(reason) else: "OFF|" & offReason
|
|
if key == bot.lastRamLogKey: return
|
|
bot.lastRamLogKey = key
|
|
if on:
|
|
echo fmt"[ram] ON reason={reasonName(reason)} dist={dist.int} " &
|
|
fmt"selfE={ws.selfEnergy.int} enemyE={ws.enemyEnergy.int} cap=3.0 " &
|
|
"band=[0,50]"
|
|
else:
|
|
echo fmt"[ram] OFF reason={offReason} dmgRate={dmgRate:.2f}/turn"
|
|
|
|
method run*(bot: ModularBot) =
|
|
while isRunning():
|
|
inc bot.tick
|
|
if TrackerProbe: bot.probeTracker()
|
|
# `enemyCount` is derived from server truth every tick, not hand-kept.
|
|
bot.enemyCount = getEnemyCount()
|
|
if RadarScanLog: bot.recordRadarStats()
|
|
|
|
# Ram cooldown countdown
|
|
if bot.ramCooldownTicks > 0:
|
|
dec bot.ramCooldownTicks
|
|
|
|
# Advance the incoming-damage window once per turn (hits accumulated by
|
|
# onHitByBullet during the preceding dispatch).
|
|
bot.pushRamDamageWindow()
|
|
|
|
# Safety: reset invalid target before any logic
|
|
if bot.currentTargetId >= 0:
|
|
if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or
|
|
not bot.enemyTracker.enemies[bot.currentTargetId].alive:
|
|
echo CLR_MOVE & "[target-invalid] id=" & $bot.currentTargetId & " resetting" & CLR_RST
|
|
bot.currentTargetId = -1
|
|
bot.isRamming = false
|
|
|
|
|
|
if not bot.hasContact:
|
|
setTargetSpeed(0.0)
|
|
setTurnRate(0.0)
|
|
setRadarTurnRate(45.0)
|
|
go()
|
|
continue
|
|
|
|
# --- Target selection (sticky) ---
|
|
let candidateId = selectTarget(bot.enemyTracker, getX(), getY(), tmClosest)
|
|
if bot.shouldSwitchTarget(candidateId):
|
|
if candidateId != bot.currentTargetId:
|
|
bot.currentTargetId = candidateId
|
|
bot.targetSwitchTick = bot.tick
|
|
bot.cfgDirty = true # emit at tick end, after gun selection
|
|
if bot.currentTargetId >= 0:
|
|
bot.lastKnownTargetId = bot.currentTargetId
|
|
|
|
# --- Refresh target state FIRST, before ram decision ---
|
|
let tid = bot.currentTargetId
|
|
if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and
|
|
bot.enemyTracker.enemies[tid].alive:
|
|
let tgt = bot.enemyTracker.getEnemy(tid)
|
|
bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y)
|
|
bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy)
|
|
|
|
# Ram decision — harness decides, not the movement module (uses fresh lastState).
|
|
# Triggers + abort live in the pure `ram_decision` module so they can be
|
|
# swept/unit-tested; the cooldown/duration/stuck machinery stays here.
|
|
let ws = bot.lastState
|
|
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
|
|
let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow)
|
|
# Change-of-plan signal: the selected gun's pooled virtual hit rate. Only
|
|
# consulted when the (default-off) plan trigger is enabled.
|
|
var gunHitRate = 0.0
|
|
if RamPlanEnabled and bot.currentGun >= 0 and tid >= 0:
|
|
gunHitRate = vb.gunRate(bot.tracker.fitnessFor(tid)[bot.currentGun], pooled = true)
|
|
let ramReason = ramTrigger(RamInputs(
|
|
dist: ramDist, selfEnergy: ws.selfEnergy,
|
|
enemyEnergy: ws.enemyEnergy, gunHitRate: gunHitRate))
|
|
let triggerRam = bot.currentTargetId >= 0 and bot.ramCooldownTicks == 0 and
|
|
ramReason != rrNone
|
|
# Abort an IN-PROGRESS ram on sustained incoming fire. The trigger itself is
|
|
# unaffected, so a firefight before the approach never vetoes the start.
|
|
let abortRam = shouldAbortRam(bot.isRamming, ramDmgRate)
|
|
let shouldRam = triggerRam and not abortRam
|
|
|
|
if shouldRam and not bot.isRamming:
|
|
bot.isRamming = true
|
|
bot.ramStuckTicks = 0
|
|
bot.ramDurationTicks = 0
|
|
elif abortRam and bot.isRamming:
|
|
# Bullet rain: drop the ram, return to the normal range band, KEEP the
|
|
# target. The cooldown stops an immediate re-trigger while fire persists.
|
|
bot.isRamming = false
|
|
bot.ramStuckTicks = 0
|
|
bot.ramDurationTicks = 0
|
|
bot.ramCooldownTicks = 30
|
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "bulletRain")
|
|
elif not shouldRam and bot.isRamming:
|
|
bot.isRamming = false
|
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone")
|
|
|
|
if bot.isRamming:
|
|
inc bot.ramDurationTicks
|
|
if ramDist < 5.0:
|
|
inc bot.ramStuckTicks
|
|
else:
|
|
bot.ramStuckTicks = 0
|
|
if bot.ramStuckTicks > 10:
|
|
bot.isRamming = false
|
|
bot.currentTargetId = -1
|
|
bot.ramStuckTicks = 0
|
|
bot.ramDurationTicks = 0
|
|
bot.ramCooldownTicks = 30
|
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "stuck")
|
|
elif bot.ramDurationTicks > 60:
|
|
bot.isRamming = false
|
|
bot.currentTargetId = -1
|
|
bot.ramStuckTicks = 0
|
|
bot.ramDurationTicks = 0
|
|
bot.ramCooldownTicks = 30
|
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "duration")
|
|
|
|
# TR_RAM_LOG: the change detector dedupes on (state|reason), so this emits
|
|
# once per transition — including a reason change while already ramming.
|
|
if bot.isRamming:
|
|
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
|
|
|
|
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
|
|
# ramming through the single ring engine (ram is just band [0, 50]);
|
|
# `shouldRam` already accounts for the bullet-rain abort. The default `tfil`
|
|
# keeps the old two-engine behaviour (tfil when holding, rammer when ramming).
|
|
var spd, tr: float
|
|
if MovementName == "tfil_ring":
|
|
bot.ringMover.band =
|
|
if shouldRam: (lo: 0.0, hi: 50.0)
|
|
else: (lo: RangeLo, hi: RangeHi)
|
|
(spd, tr) = bot.ringMover.computeMove(ws)
|
|
elif shouldRam:
|
|
bot.mover.clearGraphics()
|
|
(spd, tr) = bot.rammer.computeMove(ws)
|
|
else:
|
|
(spd, tr) = bot.mover.computeMove(ws)
|
|
setTargetSpeed(spd)
|
|
setTurnRate(tr)
|
|
|
|
# Auto-switch radar based on the SERVER's live enemy count. Using the
|
|
# tracker's known-enemy count here was wrong in melee: before the second
|
|
# enemy had been scanned the bot saw 1 and locked to 1v1, then never scanned
|
|
# the rest. getEnemyCount() is the server's alive-enemy count (scanned or
|
|
# not), so melee mode persists until only one enemy is actually left.
|
|
let liveEnemyCount = getEnemyCount()
|
|
let targetMode = if liveEnemyCount == 1: 0 else: 1
|
|
# ONE definition of the rack mode: the SAME server-truth count the radar
|
|
# uses above (`vb.rackMode` maps it to 1v1/melee). The tracker's known-enemy
|
|
# count must not be used — see the radar note. Logged once on change.
|
|
bot.rackMode = vb.rackMode(liveEnemyCount)
|
|
bot.logRackChange()
|
|
if targetMode != bot.radarMode:
|
|
bot.radarMode = targetMode
|
|
if bot.radarMode == 0:
|
|
bot.radar.init() # re-lock onto survivor
|
|
setRadarColor("#004444")
|
|
setScanColor("#0D4D4D")
|
|
else:
|
|
bot.meleeRadar.init() # fresh acquisition of the remaining crowd
|
|
setRadarColor("#443300")
|
|
setScanColor("#4D3D0D")
|
|
bot.cfgDirty = true # emit at tick end, after gun selection
|
|
|
|
let radarRate = if bot.radarMode == 0:
|
|
bot.radar.computeScan(bot.lastState)
|
|
elif RadarForceSpin:
|
|
adaptive_melee_radar.MaxRadarTurnRate # A/B: exact old melee_scan behaviour
|
|
else:
|
|
bot.meleeRadar.setExpectedEnemies(getEnemyCount())
|
|
bot.meleeRadar.computeScan(bot.lastState)
|
|
setRadarTurnRate(radarRate)
|
|
go()
|
|
|
|
# --- Aim + fire from tracked state (not scan event) ---
|
|
if tid >= 0 and bot.enemyTracker.enemies.contains(tid) and
|
|
bot.enemyTracker.enemies[tid].alive:
|
|
# Spawn virtual bullets for all guns
|
|
var headsUp: array[len(PowerBins), GunPrediction]
|
|
var linPreds: array[len(PowerBins), GunPrediction]
|
|
var tmPreds: array[len(PowerBins), GunPrediction]
|
|
var circPreds: array[len(PowerBins), GunPrediction]
|
|
var gfPreds: array[len(PowerBins), GunPrediction]
|
|
var pmPreds: array[len(PowerBins), GunPrediction]
|
|
var wbPreds: array[len(PowerBins), GunPrediction]
|
|
var acPreds: array[len(PowerBins), GunPrediction]
|
|
var ssPreds: array[len(PowerBins), GunPrediction]
|
|
var dsPreds: array[len(PowerBins), GunPrediction]
|
|
var alPreds: array[len(PowerBins), GunPrediction]
|
|
var dgPreds: array[len(PowerBins), GunPrediction]
|
|
var knnPreds: array[len(PowerBins), GunPrediction]
|
|
var tmselPreds: array[len(PowerBins), GunPrediction]
|
|
for i in 0..<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. `bot.tick` drives the selector's dwell window.
|
|
# The energy-aware power cap uses the current target distance and our own
|
|
# energy; `shouldRam` (the movement code's decision) exempts it.
|
|
let (selectedGun, _, power, pdec) = selectShotPolicy(
|
|
bot.tracker, tid, bot.tick,
|
|
dist = ramDist, selfEnergy = ws.selfEnergy, ramming = shouldRam,
|
|
rackMode = bot.rackMode, membership = ActiveRackMembership)
|
|
if PowerLog:
|
|
let pkey = fmt"{power:.1f}|{pdec.cap:.1f}|{pdec.reason}"
|
|
if pkey != bot.lastPowerLogKey:
|
|
bot.lastPowerLogKey = pkey
|
|
echo fmt"[power] p={power:.1f} cap={pdec.cap:.1f} " &
|
|
fmt"reason={powerReasonName(pdec.reason)} " &
|
|
fmt"dist={ramDist:.0f} selfE={ws.selfEnergy:.0f} gun={GunNames[selectedGun]}"
|
|
bot.gunSelectionCount[selectedGun] += 1
|
|
if selectedGun != bot.currentGun:
|
|
bot.currentGun = selectedGun
|
|
bot.cfgDirty = true # gun switch: emit the config line at tick end
|
|
case selectedGun
|
|
of 0: setTurretColor("#FF3333"); setBulletColor("#FF6666")
|
|
of 1: setTurretColor("#3366FF"); setBulletColor("#6699FF")
|
|
of 2: setTurretColor("#9933FF"); setBulletColor("#CC66FF")
|
|
of 3: setTurretColor("#33CC33"); setBulletColor("#66FF66")
|
|
of 4: setTurretColor("#FFCC00"); setBulletColor("#FFE066")
|
|
of 5: setTurretColor("#FF6600"); setBulletColor("#FF9944")
|
|
of 6: setTurretColor("#CCCCCC"); setBulletColor("#EEEEEE")
|
|
of 7: setTurretColor("#FF00FF"); setBulletColor("#FF66FF")
|
|
of 8: setTurretColor("#990000"); setBulletColor("#CC3333")
|
|
of 9: setTurretColor("#006600"); setBulletColor("#009900")
|
|
of 10: setTurretColor("#996633"); setBulletColor("#CC9966")
|
|
of 11: setTurretColor("#008888"); setBulletColor("#00AAAA")
|
|
of 12: setTurretColor("#CC00CC"); setBulletColor("#FF44FF")
|
|
of 13: setTurretColor("#00FFCC"); setBulletColor("#66FFDD")
|
|
else: discard
|
|
|
|
let pred = case selectedGun
|
|
of 1: bot.linear.predict(bot.lastState, bulletSpeed(power))
|
|
of 2: bot.tsetlin.predict(bot.lastState, bulletSpeed(power))
|
|
of 3: bot.circular.predict(bot.lastState, bulletSpeed(power))
|
|
of 4: bot.guessFactor.predict(bot.lastState, bulletSpeed(power))
|
|
of 5: bot.patternMatcher.predict(bot.lastState, bulletSpeed(power))
|
|
of 6: bot.wallBounce.predict(bot.lastState, bulletSpeed(power))
|
|
of 7: bot.accelGun.predict(bot.lastState, bulletSpeed(power))
|
|
of 8: bot.stopShot.predict(bot.lastState, bulletSpeed(power))
|
|
of 9: bot.displace.predict(bot.lastState, bulletSpeed(power))
|
|
of 10: bot.avgLead.predict(bot.lastState, bulletSpeed(power))
|
|
of 11: bot.decayGF.predict(bot.lastState, bulletSpeed(power))
|
|
of 12: bot.knnGun.predict(bot.lastState, bulletSpeed(power))
|
|
of 13: bot.tmSelector.predict(bot.lastState, bulletSpeed(power))
|
|
else: bot.headOn.predict(bot.lastState, bulletSpeed(power))
|
|
let aimTarget = aimAngle(getX(), getY(), pred.x, pred.y)
|
|
|
|
let gunDir = getGunDirection()
|
|
let gunHeat = getGunHeat()
|
|
let gunDelta = (aimTarget - gunDir) mod 360.0
|
|
var normDelta = gunDelta
|
|
if normDelta > 180.0: normDelta -= 360.0
|
|
elif normDelta < -180.0: normDelta += 360.0
|
|
|
|
# Distance to the aim point drives the range-aware gate (Task B) and is
|
|
# recorded per shot (Task A).
|
|
let distPx = hypot(pred.x - getX(), pred.y - getY())
|
|
|
|
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
|
|
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
|
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
|
if setFire(power) and getEnergy() > power:
|
|
bot.pendingFires.add(PendingShot(
|
|
gunId: selectedGun,
|
|
angleErr: abs(normDelta),
|
|
distPx: distPx,
|
|
power: power,
|
|
tick: bot.tick,
|
|
targetId: tid,
|
|
mode: bot.rackMode,
|
|
))
|
|
|
|
setGunTurnRate(normDelta)
|
|
|
|
# Emit exactly one config line per tick, at the END of the tick, so the gun
|
|
# field reflects the selection made above rather than the previous tick's.
|
|
if bot.cfgDirty:
|
|
bot.printConfig()
|
|
bot.cfgDirty = false
|
|
|
|
proc seedSelectorRng() =
|
|
## Seed the process-global RNG exactly once at bot startup so the gun
|
|
## selector's "random" tie-break (`chooseFromFit` -> rand) actually varies
|
|
## across process restarts. Previously the only `randomize()` call reached on
|
|
## the live path was incidental, inside the Tsetlin gun's constructor, so a
|
|
## rack without Tsetlin produced a FIXED tie-break sequence forever.
|
|
##
|
|
## `GUN_SELECTOR_SEED`, when set to an integer, pins the stream so A/B runs are
|
|
## reproducible; otherwise time+pid seeding makes each process independent.
|
|
let s = getEnv("GUN_SELECTOR_SEED", "")
|
|
if s.len > 0:
|
|
try: randomize(parseInt(s.strip()))
|
|
except ValueError: randomize()
|
|
else:
|
|
randomize()
|
|
|
|
when isMainModule:
|
|
var bot = ModularBot(
|
|
tracker: vb.initTracker(14), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect
|
|
headOn: HeadOnGun(),
|
|
linear: LinearGun(),
|
|
circular: CircularGun(),
|
|
tsetlin: initTsetlinGun(),
|
|
guessFactor: initGFGun(),
|
|
patternMatcher: PatternMatcherGun(),
|
|
wallBounce: initWallBounceGun(),
|
|
accelGun: initAccelGun(),
|
|
stopShot: initStopShotGun(),
|
|
displace: initDisplacementGun(),
|
|
avgLead: initAveragedLeadGun(),
|
|
decayGF: initDecayGFGun(),
|
|
knnGun: initKNNGun(),
|
|
tmSelector: initTmSelectorGun(),
|
|
radar: RadarLockModule(),
|
|
meleeRadar: initAdaptiveMeleeRadar(),
|
|
mover: TFILModule(debugGraphics: true),
|
|
ringMover: TFILRingModule(debugGraphics: true),
|
|
rammer: initRammer(),
|
|
moveTracker: mvb.initVirtualBodyTracker(1),
|
|
currentGun: -1,
|
|
)
|
|
# Must run AFTER the gun constructors (Tsetlin/TMSelector call randomize()
|
|
# unconditionally) so the explicit seed override is the value that survives.
|
|
seedSelectorRng()
|
|
start(bot, botJsonPath)
|