From 68e0375be2bedeb52c3198ca182923365e723587 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Mon, 21 Sep 2026 21:35:22 +0200 Subject: [PATCH] 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. --- ModularBot_garage/src/ModularBot.nim | 127 ++++++- common_libs/gun_harness/gun_interface.nim | 2 + common_libs/gun_harness/offline_range.nim | 7 +- common_libs/radars/adaptive_melee_radar.nim | 249 +++++++++++++ common_libs/radars/melee_scan.nim | 7 +- common_libs/tests/test_adaptive_radar.nim | 382 ++++++++++++++++++++ 6 files changed, 759 insertions(+), 15 deletions(-) create mode 100644 common_libs/radars/adaptive_melee_radar.nim create mode 100644 common_libs/tests/test_adaptive_radar.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 8c61453..da2592b 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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), diff --git a/common_libs/gun_harness/gun_interface.nim b/common_libs/gun_harness/gun_interface.nim index 25b5f13..c250784 100644 --- a/common_libs/gun_harness/gun_interface.nim +++ b/common_libs/gun_harness/gun_interface.nim @@ -11,6 +11,8 @@ type id*: int x*, y*: float heading*, speed*, energy*: float + lastSeenTick*: int ## tracker tick this enemy was last scanned + ## (the radar freshness signal; 0 if unknown) WorldState* = object ## All raw data given to every gun every tick. diff --git a/common_libs/gun_harness/offline_range.nim b/common_libs/gun_harness/offline_range.nim index 3367295..97e882b 100644 --- a/common_libs/gun_harness/offline_range.nim +++ b/common_libs/gun_harness/offline_range.nim @@ -141,7 +141,9 @@ proc stateFromJson(node: JsonNode, arenaW, arenaH: float, enemies: @[EnemyInfo(id: enemyId, x: ex, y: ey, heading: node["eh"].getFloat(), speed: node["es"].getFloat(), - energy: node["ee"].getFloat())], + energy: node["ee"].getFloat(), + lastSeenTick: (if node.hasKey("lst"): node["lst"].getInt() + else: node["tick"].getInt()))], ) proc parseMeta(node: JsonNode): FixtureMeta = @@ -314,7 +316,8 @@ proc mkState(tick: int, ex, ey, eh, es, ee: float, selfX: selfX, selfY: selfY, selfSpeed: 0.0, selfHeading: 0.0, selfRadarHeading: 0.0, selfEnergy: selfEnergy, arenaWidth: arenaW, arenaHeight: arenaH, tick: tick, - enemies: @[EnemyInfo(id: enemyId, x: ex, y: ey, heading: eh, speed: es, energy: ee)], + enemies: @[EnemyInfo(id: enemyId, x: ex, y: ey, heading: eh, speed: es, energy: ee, + lastSeenTick: tick)], ) proc finish(states: seq[WorldState], adversary, note: string): Fixture = diff --git a/common_libs/radars/adaptive_melee_radar.nim b/common_libs/radars/adaptive_melee_radar.nim new file mode 100644 index 0000000..f3aed18 --- /dev/null +++ b/common_libs/radars/adaptive_melee_radar.nim @@ -0,0 +1,249 @@ +## adaptive_melee_radar.nim — Melee radar that narrows its sweep to the arc +## that actually contains the enemies. +## +## Why this beats a continuous spin: the radar turn rate is capped at +## `MaxRadarTurnRate` deg/tick, so the radar detects every enemy in the arc it +## sweeps during a tick. A full spin covers 360 deg in `360/45 = 8` ticks; a +## back-and-forth sweep over a swept width `W` covers the arc twice per +## `2*ceil(W/45)` ticks, i.e. each enemy is detected at a rate proportional to +## `45/W` instead of `45/360`. Narrower arc => more scan events per enemy. +## +## Phases +## ------ +## * `rpAcquire` — full 360 spin at max rate. This is the start of every round +## and the fallback. Leave it only when the coverage condition has held for +## `FreshStreakTicks` consecutive ticks. +## * `rpTrack` — sweep back and forth inside the minimal wraparound covering +## arc of the enemy bearings (plus `MarginDeg` on each end). +## +## Coverage condition (both phases share it): +## * at least one live enemy is known, +## * every live known enemy was seen within `FreshnessTicks`, +## * the number of live known enemies matches the expected alive count (when +## the caller supplies one — this closes the "enemy we have never scanned is +## invisible to the tracker" hole; see `setExpectedEnemies`), +## * no enemy id appeared for the first time this tick. +## +## Fallbacks (all send the module back to `rpAcquire`): +## * any live known enemy goes stale, +## * a new enemy id appears, +## * the swept arc grows to `ExitTrackWidthDeg` or more. Above ~315 deg a +## bang-bang sweep is no faster than a full spin once the integer-tick +## quantisation is accounted for (`ceil(315/45)=7` vs `8` for a revolution), +## so narrowing buys nothing and only adds reversal churn. The enter +## threshold is set lower than the exit threshold for hysteresis. +## +## State is per-module and must be reset each round with `init`. + +import std/[math, algorithm, sets] +import ../radar_harness/radar_interface + +export radar_interface + +const + MaxRadarTurnRate* = 45.0 ## deg/tick — Tank Royale hard limit + FreshnessTicks* = 16 ## ticks; an enemy is "fresh" if the tracker saw + ## it at most this many ticks ago. A full spin + ## re-scans every enemy every 8 ticks, so 16 is + ## two full revolutions of slack. + FreshStreakTicks* = 3 ## consecutive fresh ticks required to enter + ## tracking (hysteresis against a single lucky + ## fresh tick). + CorpseTicks* = 40 ## ticks; an enemy unseen for longer is treated as + ## dead and dropped from the arc/coverage. The + ## staleness fallback re-acquires a LIVE enemy + ## within FreshnessTicks + one full spin, so a + ## live enemy is never unseen this long. Needed + ## because BotDeathEvent does not reach this bot + ## in the current API, so the tracker keeps dead + ## enemies 'alive' forever. + MarginDeg* = 20.0 ## deg added to EACH end of the covering arc. An + ## enemy at 8 px/tick and 300 px changes bearing + ## by at most ~1.5 deg/tick; over a half-sweep + ## this covers the drift of a fast mover. + EnterTrackWidthDeg* = 270.0 ## sweep narrower than this to enter tracking. + ExitTrackWidthDeg* = 300.0 ## at/above this swept width fall back to full + ## spin. 30 deg of hysteresis prevents flapping. + +type + RadarPhase* = enum + rpAcquire, ## full 360 spin + rpTrack ## back-and-forth sweep inside the covering arc + + AdaptiveMeleeRadarModule* = object + phase*: RadarPhase + sweepDir*: int ## +1 = CCW (increasing deg), -1 = CW + knownIds*: HashSet[int] + freshStreak*: int + expectedEnemies*: int ## -1 = unknown; else gate acquisition on it + lastSweptWidth*: float ## last computed swept arc (deg); for logging + debugGraphics*: bool + +proc initAdaptiveMeleeRadar*(): AdaptiveMeleeRadarModule = + AdaptiveMeleeRadarModule( + phase: rpAcquire, + sweepDir: 1, + expectedEnemies: -1, + ) + +proc init*(m: var AdaptiveMeleeRadarModule) = + ## Per-round reset. Mirrors `RadarLockModule.init`. + m.phase = rpAcquire + m.sweepDir = 1 + m.knownIds.clear() + m.freshStreak = 0 + m.expectedEnemies = -1 + m.lastSweptWidth = 360.0 + +proc setExpectedEnemies*(m: var AdaptiveMeleeRadarModule, n: int) = + ## Tell the radar how many enemies are alive according to the server (all + ## bots, scanned or not). While more enemies are expected than have been + ## scanned the radar refuses to leave acquisition, so an enemy that has never + ## entered the sweep cannot be silently missed. Pass -1 to disable the gate. + m.expectedEnemies = n + +proc normalizeDeg*(a: float): float {.inline.} = + ## Wrap an angle in degrees to [0, 360). + result = a mod 360.0 + if result < 0.0: + result += 360.0 + +proc minimalCoveringArc*(bearings: openArray[float]): tuple[lo, hi, width: float] = + ## Minimal wraparound arc containing every bearing. + ## + ## `lo` and `hi` lie in [0, 360); the arc runs CCW from `lo` to `hi` and has + ## angular extent `width` in [0, 360). The trick is that the uncovered gap is + ## the largest cyclic gap between sorted bearings, so `width = 360 - maxGap`. + ## This is what makes enemies at 350 and 10 give a 20 deg arc through 0, not + ## a 340 deg arc the long way round. + ## + ## Empty input returns `(0, 0, 0)`. A single bearing collapses to width 0. + if bearings.len == 0: + return (0.0, 0.0, 0.0) + var b: seq[float] + b.setLen(bearings.len) + for i, x in bearings: + b[i] = normalizeDeg(x) + b.sort() + if b.len == 1: + return (b[0], b[0], 0.0) + var maxGap = -1.0 + var maxIdx = 0 + for i in 0 ..< b.len: + let nxt = if i + 1 < b.len: b[i + 1] else: b[0] + 360.0 + let gap = nxt - b[i] + if gap > maxGap: + maxGap = gap + maxIdx = i + let lo = b[(maxIdx + 1) mod b.len] + let hi = b[maxIdx] + var width = 360.0 - maxGap + if width < 0.0: width = 0.0 + if width > 360.0: width = 360.0 + (lo, hi, width) + +proc isInsideArc*(heading, lo, hi, width: float): bool = + ## True when `heading` lies on the CCW arc [lo, hi] of extent `width`. + if width >= 360.0: return true + normalizeDeg(heading - lo) <= width + +proc sweepRate*(heading, lo, hi, width, margin: float, + dir: int): tuple[rate: float, dir: int] = + ## One tick of a bang-bang sweep over the CCW arc [lo, hi] widened by + ## `margin` on both ends. Returns the radar turn rate (deg/tick, magnitude + ## capped at `MaxRadarTurnRate`) and the possibly-reversed direction. + ## + ## `dir > 0` means the radar is heading CCW toward `hi`; `dir <= 0` means it is + ## heading CW toward `lo`. When the radar is outside the (widened) arc — which + ## happens right after the arc is recomputed — it moves toward the nearer end. + let swept = width + 2.0 * margin + if swept >= 360.0: + return (MaxRadarTurnRate, 1) + let aLo = normalizeDeg(lo - margin) + let aHi = normalizeDeg(hi + margin) + var rate: float + var newDir = dir + let rel = normalizeDeg(heading - aLo) # heading position in [0, 360) + if rel > swept: + # Outside the arc: head for whichever endpoint is closer. + let cwToLo = 360.0 - rel # travel CW (decreasing) + let ccwToHi = normalizeDeg(aHi - heading) # travel CCW (increasing) + if cwToLo <= ccwToHi: + rate = -min(MaxRadarTurnRate, cwToLo) + newDir = 1 + else: + rate = min(MaxRadarTurnRate, ccwToHi) + newDir = -1 + elif dir > 0: + let d = normalizeDeg(aHi - heading) # CCW distance to the far end + if d <= MaxRadarTurnRate: + rate = d + newDir = -1 + else: + rate = MaxRadarTurnRate + else: + let d = normalizeDeg(heading - aLo) # CW distance to the far end + if d <= MaxRadarTurnRate: + rate = -d + newDir = 1 + else: + rate = -MaxRadarTurnRate + (rate, newDir) + +proc computeScan*(m: var AdaptiveMeleeRadarModule, state: WorldState): float = + ## Returns a radar turn rate in deg/tick (never exceeding `MaxRadarTurnRate`). + var bearings: seq[float] + var allFresh = true + var newId = false + for e in state.enemies: + if e.id notin m.knownIds: + m.knownIds.incl e.id + newId = true + let age = state.tick - e.lastSeenTick + if age > CorpseTicks: + continue # dead-but-unmarked corpse: ignore it entirely + bearings.add normalizeDeg( + arctan2(e.y - state.selfY, e.x - state.selfX).radToDeg) + if age > FreshnessTicks: + allFresh = false + + # No live enemy is known: never leave the full spin. + if bearings.len == 0: + m.phase = rpAcquire + m.freshStreak = 0 + m.sweepDir = 1 + m.lastSweptWidth = 360.0 + return MaxRadarTurnRate + + let (lo, hi, width) = minimalCoveringArc(bearings) + let swept = width + 2.0 * MarginDeg + m.lastSweptWidth = swept + + let expectedMet = m.expectedEnemies < 0 or + bearings.len >= m.expectedEnemies + let coverageOk = allFresh and not newId and expectedMet + + if m.phase == rpAcquire: + if coverageOk and swept < EnterTrackWidthDeg: + inc m.freshStreak + if m.freshStreak >= FreshStreakTicks: + m.phase = rpTrack + m.freshStreak = 0 + m.sweepDir = 1 + else: + m.freshStreak = 0 + if m.phase == rpAcquire: + return MaxRadarTurnRate + else: + # Tracking: any loss of coverage or a narrowing that is not worth it + # (wide arc) drops us back to the full spin. + if not coverageOk or swept >= ExitTrackWidthDeg: + m.phase = rpAcquire + m.freshStreak = 0 + m.sweepDir = 1 + return MaxRadarTurnRate + + let (rate, newDir) = sweepRate(state.selfRadarHeading, lo, hi, width, + MarginDeg, m.sweepDir) + m.sweepDir = newDir + rate diff --git a/common_libs/radars/melee_scan.nim b/common_libs/radars/melee_scan.nim index da6c981..ae8413d 100644 --- a/common_libs/radars/melee_scan.nim +++ b/common_libs/radars/melee_scan.nim @@ -1,4 +1,9 @@ -## melee_scan.nim — Continuously spinning radar for melee. +## melee_scan.nim — DEPRECATED / reference only. Superseded by +## `adaptive_melee_radar.nim`, which sweeps only the arc containing the enemies. +## Nothing in the rack imports this module any more; it is kept because it is the +## exact baseline the adaptive radar is measured against (always 45 deg/tick). +## +## Continuously spinning radar for melee. ## Always returns max radar turn rate so all enemies get scanned periodically. ## No state needed; stateless spin guarantees coverage regardless of enemy count. diff --git a/common_libs/tests/test_adaptive_radar.nim b/common_libs/tests/test_adaptive_radar.nim new file mode 100644 index 0000000..8887efc --- /dev/null +++ b/common_libs/tests/test_adaptive_radar.nim @@ -0,0 +1,382 @@ +## Offline unit tests for the adaptive melee radar's geometry and phase logic. +## +## No Java, no server, no battle. Run with plain +## nim c -r common_libs/tests/test_adaptive_radar.nim +## +## The bugs live in the wraparound geometry and the phase hysteresis, so those +## are exercised exhaustively here: minimal covering arc (clustered, straddling +## 0/360, single, ~180, ~350, exact boundaries, duplicates), the turn-rate cap, +## and the acquire/track/fallback state machine. + +import std/[math, sets] +import gun_harness/gun_interface +import radars/adaptive_melee_radar + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: + echo "PASS: ", name + else: + echo "FAIL: ", name + inc failures + +# The harness contract: the module must satisfy the duck-typed `isRadarModule`. +static: + doAssert isRadarModule(AdaptiveMeleeRadarModule), + "AdaptiveMeleeRadarModule must implement computeScan(var, WorldState): float" + +const + SelfX = 1000.0 + SelfY = 1000.0 + +proc approx(a, b, eps = 1e-9): bool {.inline.} = abs(a - b) <= eps + +proc enemyAt(id: int, bearing, dist, lastSeen: float): EnemyInfo = + let br = degToRad(bearing) + EnemyInfo(id: id, + x: SelfX + dist * cos(br), + y: SelfY + dist * sin(br), + heading: 0.0, speed: 0.0, energy: 100.0, + lastSeenTick: lastSeen.int) + +proc ws(tick: int, radarHeading: float, enemies: seq[EnemyInfo]): WorldState = + WorldState(selfX: SelfX, selfY: SelfY, + selfRadarHeading: radarHeading, + arenaWidth: 2000.0, arenaHeight: 2000.0, + tick: tick, enemies: enemies) + +proc arcOf(bearings: openArray[float]): tuple[lo, hi, width: float] = + minimalCoveringArc(bearings) + +# ── minimal covering arc ───────────────────────────────────────────────────── + +proc testClustered() = + let a = arcOf([100.0, 110.0, 120.0]) + check "arc: clustered 100/110/120 -> lo=100 hi=120 width=20", + approx(a.lo, 100.0) and approx(a.hi, 120.0) and approx(a.width, 20.0) + check "arc: clustered arc contains every input", + isInsideArc(100.0, a.lo, a.hi, a.width) and + isInsideArc(110.0, a.lo, a.hi, a.width) and + isInsideArc(120.0, a.lo, a.hi, a.width) + +proc testStraddleZero() = + # THE classic case: 350 and 10 must be a 20 deg arc through 0, not 340. + let a = arcOf([350.0, 10.0]) + check "arc: 350/10 -> width 20 (not 340)", + approx(a.width, 20.0) + check "arc: 350/10 arc runs lo=350 CCW to hi=10", + approx(a.lo, 350.0) and approx(a.hi, 10.0) + check "arc: 350/10 arc contains both ends and 0", + isInsideArc(350.0, a.lo, a.hi, a.width) and + isInsideArc(10.0, a.lo, a.hi, a.width) and + isInsideArc(0.0, a.lo, a.hi, a.width) + +proc testStraddleZeroNegative() = + # Negative inputs must normalize to the same arc. + let a = arcOf([-10.0, 10.0]) + check "arc: -10/10 normalizes to a 20 deg arc", + approx(a.width, 20.0) and isInsideArc(0.0, a.lo, a.hi, a.width) + +proc testSingleEnemy() = + let a = arcOf([42.0]) + check "arc: single bearing -> width 0, lo=hi=42", + approx(a.lo, 42.0) and approx(a.hi, 42.0) and approx(a.width, 0.0) + let a0 = arcOf([0.0]) + check "arc: single bearing at 0 -> width 0", approx(a0.width, 0.0) + +proc testEmpty() = + let a = arcOf([]) + check "arc: empty -> zero arc", approx(a.width, 0.0) + +proc testHalfCircle() = + # Two exactly opposite bearings: either 180 deg arc covers them; both are + # valid, the key is the width is exactly 180 and both are inside. + let a = arcOf([0.0, 180.0]) + check "arc: opposite bearings -> width 180", + approx(a.width, 180.0) + check "arc: opposite bearings arc contains both", + isInsideArc(0.0, a.lo, a.hi, a.width) and + isInsideArc(180.0, a.lo, a.hi, a.width) + let b = arcOf([90.0, 270.0]) + check "arc: 90/270 -> width 180", + approx(b.width, 180.0) and + isInsideArc(90.0, b.lo, b.hi, b.width) and + isInsideArc(270.0, b.lo, b.hi, b.width) + +proc testNearFull() = + # Bearings every 10 deg: the largest gap is 10 deg, so width = 350. + var bearings: seq[float] + for i in 0 ..< 36: bearings.add(float(i) * 10.0) + let a = arcOf(bearings) + check "arc: ~full circle (10 deg gaps) -> width 350", + approx(a.width, 350.0, 1e-6) + var allIn = true + for x in bearings: + if not isInsideArc(x, a.lo, a.hi, a.width): allIn = false + check "arc: ~full circle arc contains all 36 bearings", allIn + +proc testDuplicates() = + let a = arcOf([77.0, 77.0, 77.0]) + check "arc: duplicate bearings -> width 0", approx(a.width, 0.0) + let b = arcOf([200.0, 200.0, 210.0, 210.0]) + check "arc: duplicated clustered ends -> width 10", + approx(b.width, 10.0) and isInsideArc(200.0, b.lo, b.hi, b.width) + +proc testWrapExtremes() = + # An arc that includes 359 and 1 -> 2 deg through 0. + let a = arcOf([359.0, 1.0]) + check "arc: 359/1 -> width 2 through 0", + approx(a.width, 2.0) and isInsideArc(0.0, a.lo, a.hi, a.width) + # Exact 0/360 alias. + let b = arcOf([0.0, 360.0]) + check "arc: 0/360 alias -> width 0", approx(b.width, 0.0) + +# ── turn-rate cap ──────────────────────────────────────────────────────────── + +proc testRateCapStatic() = + var ok = true + for width in [0.0, 10.0, 45.0, 90.0, 180.0, 269.0]: + for h in 0 ..< 360: + for dir in [-1, 1]: + let (rate, _) = sweepRate(h.float, 10.0, 10.0 + width, width, + MarginDeg, dir) + if abs(rate) > MaxRadarTurnRate + 1e-9: ok = false + check "rate: sweepRate never exceeds the 45 deg/tick cap (static sweep)", + ok + +proc testRateCapDynamicSweep() = + # Drive a real sweep over a 90 deg arc and check the cap + containment. + var m = initAdaptiveMeleeRadar() + let lo = 0.0 + let hi = 90.0 + let width = 90.0 + var heading = 45.0 + var dir = 1 + var capOk = true + var contained = true + for _ in 0 ..< 400: + let (rate, nd) = sweepRate(heading, lo, hi, width, MarginDeg, dir) + if abs(rate) > MaxRadarTurnRate + 1e-9: capOk = false + heading = normalizeDeg(heading + rate) + dir = nd + if not isInsideArc(heading, normalizeDeg(lo - MarginDeg), + normalizeDeg(hi + MarginDeg), width + 2.0 * MarginDeg): + contained = false + check "rate: dynamic sweep never exceeds the 45 deg/tick cap", capOk + check "rate: dynamic sweep stays inside the widened arc", contained + discard m + +proc testAcquireRateIsMax() = + var m = initAdaptiveMeleeRadar() + let s = ws(1, 0.0, @[]) + check "phase: empty enemies -> full spin 45", + m.computeScan(s) == MaxRadarTurnRate and m.phase == rpAcquire + +# ── phase machine ──────────────────────────────────────────────────────────── + +proc testAcquireToTrack() = + var m = initAdaptiveMeleeRadar() + var t = 0 + var rate = 0.0 + # Fresh every tick; no new id after the first tick. + for _ in 0 ..< FreshStreakTicks + 2: + inc t + rate = m.computeScan(ws(t, 0.0, @[enemyAt(1, 100.0, 300.0, float(t))])) + check "phase: full spin while acquiring, then tracking after the fresh streak", + m.phase == rpTrack + check "phase: acquisition emits the max rate", + rate <= MaxRadarTurnRate + +proc testTrackSingleEnemySmallArc() = + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(1) + var t = 0 + var rate = 0.0 + for _ in 0 ..< FreshStreakTicks + 6: + inc t + rate = m.computeScan(ws(t, 0.0, @[enemyAt(7, 123.0, 300.0, float(t))])) + check "single enemy: enters tracking", m.phase == rpTrack + check "single enemy: swept width is the 2*margin corridor", + approx(m.lastSweptWidth, 2.0 * MarginDeg) + check "single enemy: rate is capped", abs(rate) <= MaxRadarTurnRate + +proc testStaleFallback() = + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(1) + var t = 0 + for _ in 0 ..< FreshStreakTicks + 2: + inc t + discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 50.0, 300.0, float(t))])) + check "fallback: reached tracking before the staleness test", m.phase == rpTrack + inc t + # last seen 20 ticks ago > FreshnessTicks (16) + let rate = m.computeScan(ws(t, 0.0, @[enemyAt(1, 50.0, 300.0, float(t - 20))])) + check "fallback: a stale enemy drops back to full spin", + m.phase == rpAcquire and rate == MaxRadarTurnRate + +proc testNewIdFallback() = + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(1) + var t = 0 + for _ in 0 ..< FreshStreakTicks + 2: + inc t + discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 50.0, 300.0, float(t))])) + check "fallback: tracking a single enemy", m.phase == rpTrack + inc t + let rate = m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 50.0, 300.0, float(t)), + enemyAt(2, 60.0, 300.0, float(t)), + ])) + check "fallback: a brand-new enemy id drops back to full spin", + m.phase == rpAcquire and rate == MaxRadarTurnRate + +proc testExpectedCountGate() = + # expectedEnemies = 3 but only 2 are ever known -> must stay in acquisition + # (full spin), never enter tracking against a partial crowd. + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(3) + var t = 0 + var phase = rpAcquire + for _ in 0 ..< 40: + inc t + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 0.0, 300.0, float(t)), + enemyAt(2, 10.0, 300.0, float(t)), + ])) + phase = m.phase + check "gate: a missing expected enemy keeps the radar in full-spin acquisition", + phase == rpAcquire + # Once the third appears, it acquires and tracks. + for _ in 0 ..< FreshStreakTicks + 2: + inc t + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 0.0, 300.0, float(t)), + enemyAt(2, 10.0, 300.0, float(t)), + enemyAt(3, 20.0, 300.0, float(t)), + ])) + check "gate: tracking resumes once all expected enemies are known", + m.phase == rpTrack + +proc testWideArcFallback() = + # Three enemies evenly spread: covering arc is 240 deg, +40 margin = 280, + # below the 300 exit threshold. Four spread at 0/90/180/270 -> 270+40 = 310, + # above it, so the radar must stay in acquisition (full spin). + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(4) + var t = 0 + for _ in 0 ..< FreshStreakTicks + 10: + inc t + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 0.0, 300.0, float(t)), + enemyAt(2, 90.0, 300.0, float(t)), + enemyAt(3, 180.0, 300.0, float(t)), + enemyAt(4, 270.0, 300.0, float(t)), + ])) + check "fallback: a ~310 deg swept arc stays in full-spin acquisition", + m.phase == rpAcquire + +proc testWideArcExitAndEnterHysteresis() = + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(3) + var t = 0 + # A narrow cluster enters tracking. + for _ in 0 ..< FreshStreakTicks + 2: + inc t + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 0.0, 300.0, float(t)), + enemyAt(2, 10.0, 300.0, float(t)), + enemyAt(3, 20.0, 300.0, float(t)), + ])) + check "hysteresis: narrow cluster tracks", m.phase == rpTrack + # Widen to ~310 swept -> exit. + t += 1 + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 0.0, 300.0, float(t)), + enemyAt(2, 90.0, 300.0, float(t)), + enemyAt(3, 180.0, 300.0, float(t)), + enemyAt(4, 270.0, 300.0, float(t)), + ])) + check "hysteresis: wide arc exits tracking", m.phase == rpAcquire + +proc testNoKnownEnemyStaysAcquire() = + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(3) + var t = 0 + var rate = 0.0 + for _ in 0 ..< 20: + inc t + rate = m.computeScan(ws(t, 0.0, @[])) + check "gate: no known enemies -> always full spin", + m.phase == rpAcquire and rate == MaxRadarTurnRate + +proc testExpectedDisabled() = + # -1 disables the gate: a single known enemy acquires even if more exist. + var m = initAdaptiveMeleeRadar() + var t = 0 + for _ in 0 ..< FreshStreakTicks + 2: + inc t + discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 0.0, 300.0, float(t))])) + check "gate: expectedEnemies = -1 does not block acquisition", m.phase == rpTrack + +proc testCorpseIgnored() = + # A dead-but-unmarked enemy (unseen > CorpseTicks) must not keep the radar in + # acquisition, nor widen the arc. + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(1) + var t = 0 + for _ in 0 ..< FreshStreakTicks + 2: + inc t + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 100.0, 300.0, float(t)), + enemyAt(2, 300.0, 300.0, float(t - 100)), # corpse, far bearing + ])) + check "corpse: a long-unseen enemy is ignored and tracking is reached", + m.phase == rpTrack + check "corpse: the corpse does not widen the swept arc", + approx(m.lastSweptWidth, 2.0 * MarginDeg) + +proc testCorpseExpectedGate() = + # expectedEnemies = 2 but only one LIVE enemy (the other is a corpse): the + # live count is 1, so the gate keeps the radar in acquisition. + var m = initAdaptiveMeleeRadar() + m.setExpectedEnemies(2) + var t = 0 + for _ in 0 ..< FreshStreakTicks + 5: + inc t + discard m.computeScan(ws(t, 0.0, @[ + enemyAt(1, 100.0, 300.0, float(t)), + enemyAt(2, 300.0, 300.0, float(t - 100)), + ])) + check "corpse: the expected gate counts LIVE enemies only", + m.phase == rpAcquire + +# ── driver ─────────────────────────────────────────────────────────────────── + +testClustered() +testStraddleZero() +testStraddleZeroNegative() +testSingleEnemy() +testEmpty() +testHalfCircle() +testNearFull() +testDuplicates() +testWrapExtremes() +testRateCapStatic() +testRateCapDynamicSweep() +testAcquireRateIsMax() +testAcquireToTrack() +testTrackSingleEnemySmallArc() +testStaleFallback() +testNewIdFallback() +testExpectedCountGate() +testWideArcFallback() +testWideArcExitAndEnterHysteresis() +testNoKnownEnemyStaysAcquire() +testExpectedDisabled() +testCorpseIgnored() +testCorpseExpectedGate() + +if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) +echo "\nAll adaptive-radar checks passed."