68e0375be2
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.
250 lines
9.8 KiB
Nim
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
|