Files
SirRoboGarage/common_libs/radars/adaptive_melee_radar.nim
SirStone fb36a0a685 tracker: corpses do not exist - revert the fix and retire the workaround
The belief "BotDeathEvent never reaches ModularBot, so enemyTracker keeps dead
enemies alive forever" was written into a code comment and then believed twice.
It is FALSE. Measured in a 7-bot melee with a per-tick probe comparing
enemyTracker's alive count against the server's getEnemyCount():

  metric                          1.3.1 (20 rd)   0.35.5 (15 rd)
  observed enemy deaths                83              68
  ...non-round-ending              83 (100%)       66 (97%)
  ekBotDeath events DROPPED             0               0
  max dispatch lag (turns behind)       1               1
  phantom ticks                  1 / 16,820      1 / 12,596
  MAX CORPSE LIFETIME               0 ticks         0 ticks
  victims still alive at round end      0               0

onBotDeath fires for every death, including non-round-ending ones. The
API-level event-drop mechanism IS real (test_event_drop_mechanism.nim proves
it: ekBotDeath is not in isCritical and MAX_EVENTS_AGE=2) - the bot simply
never falls far enough behind for it to trigger (max lag 1 turn).

Removed:
- reconcileWithServer + ReconcilePersistTicks/mismatchTicks/sawServerAlive
  (uncommitted, and ON BY DEFAULT despite the premise being false). Its own
  comment admitted a shorter window once KILLED A LIVE ENEMY ("it fired three
  more times after the tracker marked it dead") - a latent mis-prune path
  defending against a bug that does not exist.
- The radar's CorpseTicks=40 filter and the same-class age>60 filter in
  recordRadarStats, both carrying the false comment. Removal changes no real
  behaviour: buildState feeds the radar enemyTracker.allAlive(), so a dead
  enemy never reaches computeScan.

Kept:
- The TR_TRACKER_PROBE instrument (default OFF), which produced the table above.
- test_event_drop_mechanism.nim - the drop mechanism is a genuine library
  behaviour worth guarding.
- isAlive/aliveCount on the tracker.

Added: docs/tracker_death_events.md (the durable negative, so this is not
re-invented a third time) and test_enemy_tracker_death.nim (13 checks) in place
of the test for the deleted feature.

Guards: test_gun_harness 39/39, test_vbullet_metric 11, test_power_selection 3,
test_adaptive_radar 41/41, test_event_drop_mechanism 6, test_enemy_tracker_death
13, acceptance 12/12, ModularBot compiles.
2026-09-21 22:41:11 +02:00

245 lines
9.5 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).
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