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."