Files
SirRoboGarage/common_libs/radars/adaptive_melee_radar.nim
T
SirStone 68e0375be2 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.
2026-09-21 21:35:22 +02:00

250 lines
9.8 KiB
Nim

## 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