fb36a0a685
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.
245 lines
9.5 KiB
Nim
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
|