## 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). 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 # No corpse filter: `state.enemies` is built from `EnemyTracker.allAlive()` # and `onBotDeath` reliably marks deaths (measured 0 corpse ticks; see # docs/tracker_death_events.md). A long-unseen entry is therefore a stale # LIVE enemy, and the freshness fallback below must re-acquire it rather # than the radar silently dropping it from the arc. let age = state.tick - e.lastSeenTick 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