feat(radar): adaptive melee radar sweeps only the arc containing all enemies
Replaces melee_scan in the rack. melee_scan spun the radar at the 45 deg/tick cap unconditionally, so a full 360 deg revolution took 8 ticks and every enemy was scanned roughly every 8 ticks. The new module starts with the same full spin, and once it is SURE it has covered every enemy it sweeps back and forth over only the minimal covering arc of all enemy bearings. MEASURED, real melees via the bridge, per-enemy onScannedBot counts: 3-bot melee (2 enemies): 29.6 -> 61.1 scans/100 melee-ticks (2.06x) 4-bot melee (3 enemies): 36.0 -> 75.7 scans/100 melee-ticks (2.10x) Covering-arc widths observed: mostly <90 deg in the 2-enemy case, up to 240 deg in the 3-enemy case, so the gain shrinks as the arc widens - and at the ExitTrackWidthDeg=300 fallback it degenerates to exactly the old full spin, so there is no loss when narrowing would not help. TRADEOFF, recorded rather than hidden: a wider arc legitimately takes longer to traverse, so the freshness window costs 5-7 points (fresh<=16: 93-95% vs 98-100%) and more at fresh<=8 (75-76% vs 97-100%). More scans per enemy, at slightly staler individual fixes. DESIGN: acquisition spins 360 until every live known enemy was seen within FreshnessTicks=16 (two revolutions of slack), no new id appeared, and the live count matches getEnemyCount(); that must hold FreshStreakTicks=3 consecutive ticks. Tracking then bang-bang sweeps the wraparound-aware covering arc (350+10 -> 20 through 0) widened by MarginDeg=20 each end, at up to 45 deg/tick. Fallbacks return to acquisition: any stale enemy, any new id, or an arc >= 300 deg. Enter 270 / Exit 300 gives 30 deg of hysteresis so it cannot flap. Adds EnemyInfo.lastSeenTick (additive) so coverage is judged on staleness, not mere knowledge - without it an enemy that slipped behind the sweep would keep contributing its own stale bearing, which is self-confirming. The offline range now round-trips that field from the fixture 'lst'. COMPANION FIX, and it matters: the radar-mode switch used the TRACKER's known enemy count, so in melee the bot saw one enemy before scanning the second, locked to 1v1, and the melee radar never ran at all. Now uses getEnemyCount() (server truth), so melee mode persists until one enemy is genuinely left. 41 new unit checks (wraparound arcs, straddle at 0/360, single/empty enemies, the 45 deg/tick cap, every phase transition and fallback). melee_scan is kept but marked DEPRECATED; nothing in the rack imports it. Non-regression: 33 gun-harness checks, vbullet metric, power selection, and 12/12 offline==online acceptance all pass.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
## ModularBot — plugin gun architecture tracer bullet.
|
||||
## Guns: HeadOnGun (0), LinearGun (1), TsetlinGun (2), CircularGun (3), GFGun (4), PatternMatcherGun (5), WallBounceGun (6), AccelGun (7), StopShotGun (8), DisplacementGun (9), AveragedLeadGun (10), DecayGFGun (11), KNNGun (12), TmSelectorGun (13) via GunHarness.
|
||||
## Radar: RadarLockModule (1v1) / MeleeScanModule (2+ enemies), auto-switched per tick.
|
||||
## 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/melee_scan
|
||||
import radars/adaptive_melee_radar
|
||||
import gun_harness/gun_interface
|
||||
import gun_harness/virtual_bullets as vb
|
||||
import gun_harness/selector
|
||||
@@ -55,6 +55,16 @@ const ShotLog = true
|
||||
## 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")
|
||||
## 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
|
||||
@@ -101,7 +111,7 @@ type
|
||||
enemyBearing: float
|
||||
lastState: WorldState
|
||||
radar: RadarLockModule
|
||||
meleeScan: MeleeScanModule
|
||||
meleeRadar: AdaptiveMeleeRadarModule
|
||||
enemyCount: int
|
||||
initialEnemyCount: int
|
||||
radarMode: int # 0 = lock, 1 = melee
|
||||
@@ -149,6 +159,15 @@ type
|
||||
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
|
||||
gunSelectionCount: array[14, int]
|
||||
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
|
||||
|
||||
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
|
||||
## Task A: append one JSON line per resolved real shot. The write is fully
|
||||
@@ -215,7 +234,7 @@ proc printConfig(bot: ModularBot, forceAll: bool = false) =
|
||||
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: "melee_scan"
|
||||
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
|
||||
let moveName = if bot.isRamming: "rammer" else: "tfil"
|
||||
var line = "[config] "
|
||||
if bot.currentGun >= 0 and bot.currentGun < GunNames.len:
|
||||
@@ -289,7 +308,8 @@ proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): Worl
|
||||
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)
|
||||
heading: es.heading, speed: es.speed, energy: es.energy,
|
||||
lastSeenTick: es.lastSeenTick)
|
||||
result = WorldState(
|
||||
enemyX: ex,
|
||||
enemyY: ey,
|
||||
@@ -310,9 +330,44 @@ proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): Worl
|
||||
if RecordWorldState:
|
||||
bot.recordWorldState(result)
|
||||
|
||||
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. Enemies unseen for > 60
|
||||
## ticks are treated as dead-but-unmarked and dropped from the coverage
|
||||
## denominator (BotDeathEvent does not reach this bot in the current API, so
|
||||
## the tracker keeps corpses alive forever).
|
||||
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
|
||||
if age > 60: continue # dead-but-unmarked corpse: exclude from coverage
|
||||
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) =
|
||||
@@ -407,6 +462,35 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
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
|
||||
@@ -419,6 +503,15 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.bulletGun.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
|
||||
@@ -437,6 +530,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
setAdjustRadarForGunTurn(true)
|
||||
setBodyColor("#FF9900") # PhantomMeteor: deep orange/gold
|
||||
bot.radar.init()
|
||||
bot.meleeRadar.init()
|
||||
bot.mover.resetRound()
|
||||
bot.enemyTracker.resetRound()
|
||||
bot.isRamming = false
|
||||
@@ -453,7 +547,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.initialEnemyCount = getEnemyCount()
|
||||
bot.enemyCount = bot.initialEnemyCount
|
||||
# ponytail: default to melee until confirmed 1v1 via scans, avoids stale enemyCount issue
|
||||
bot.radarMode = 1 # Start in melee_scan; will switch to radar_lock (0) if tracker confirms 1v1
|
||||
bot.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.
|
||||
@@ -502,6 +596,7 @@ proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
|
||||
method run*(bot: ModularBot) =
|
||||
while isRunning():
|
||||
inc bot.tick
|
||||
if RadarScanLog: bot.recordRadarStats()
|
||||
|
||||
# Ram cooldown countdown
|
||||
if bot.ramCooldownTicks > 0:
|
||||
@@ -590,8 +685,12 @@ method run*(bot: ModularBot) =
|
||||
setTargetSpeed(spd)
|
||||
setTurnRate(tr)
|
||||
|
||||
# Auto-switch radar based on live enemy count from tracker (ground truth from scans)
|
||||
let liveEnemyCount = bot.enemyTracker.allAlive().len
|
||||
# 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
|
||||
if targetMode != bot.radarMode:
|
||||
bot.radarMode = targetMode
|
||||
@@ -600,14 +699,18 @@ method run*(bot: ModularBot) =
|
||||
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.meleeScan.computeScan(bot.lastState)
|
||||
bot.meleeRadar.setExpectedEnemies(getEnemyCount())
|
||||
bot.meleeRadar.computeScan(bot.lastState)
|
||||
setRadarTurnRate(radarRate)
|
||||
go()
|
||||
|
||||
@@ -698,8 +801,8 @@ method run*(bot: ModularBot) =
|
||||
echo fmt"[vbullet] gun={gunId} bin={binIdx} miss={fe.missDistance:.1f}px hit={fe.hit} | total hits={bot.virtualHits}/{total} ({pct:.1f}%)"
|
||||
)
|
||||
|
||||
# Gun selection + fire
|
||||
let (selectedGun, _, power) = selectShot(bot.tracker, tid)
|
||||
# Gun selection + fire. `bot.tick` drives the selector's dwell window.
|
||||
let (selectedGun, _, power) = selectShot(bot.tracker, tid, bot.tick)
|
||||
bot.gunSelectionCount[selectedGun] += 1
|
||||
if selectedGun != bot.currentGun:
|
||||
bot.currentGun = selectedGun
|
||||
@@ -804,7 +907,7 @@ when isMainModule:
|
||||
knnGun: initKNNGun(),
|
||||
tmSelector: initTmSelectorGun(),
|
||||
radar: RadarLockModule(),
|
||||
meleeScan: initMeleeScan(),
|
||||
meleeRadar: initAdaptiveMeleeRadar(),
|
||||
mover: TFILModule(debugGraphics: true),
|
||||
rammer: initRammer(),
|
||||
moveTracker: mvb.initVirtualBodyTracker(1),
|
||||
|
||||
Reference in New Issue
Block a user