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.
383 lines
14 KiB
Nim
383 lines
14 KiB
Nim
## Offline unit tests for the adaptive melee radar's geometry and phase logic.
|
|
##
|
|
## No Java, no server, no battle. Run with plain
|
|
## nim c -r common_libs/tests/test_adaptive_radar.nim
|
|
##
|
|
## The bugs live in the wraparound geometry and the phase hysteresis, so those
|
|
## are exercised exhaustively here: minimal covering arc (clustered, straddling
|
|
## 0/360, single, ~180, ~350, exact boundaries, duplicates), the turn-rate cap,
|
|
## and the acquire/track/fallback state machine.
|
|
|
|
import std/[math, sets]
|
|
import gun_harness/gun_interface
|
|
import radars/adaptive_melee_radar
|
|
|
|
var failures = 0
|
|
proc check(name: string, ok: bool) =
|
|
if ok:
|
|
echo "PASS: ", name
|
|
else:
|
|
echo "FAIL: ", name
|
|
inc failures
|
|
|
|
# The harness contract: the module must satisfy the duck-typed `isRadarModule`.
|
|
static:
|
|
doAssert isRadarModule(AdaptiveMeleeRadarModule),
|
|
"AdaptiveMeleeRadarModule must implement computeScan(var, WorldState): float"
|
|
|
|
const
|
|
SelfX = 1000.0
|
|
SelfY = 1000.0
|
|
|
|
proc approx(a, b, eps = 1e-9): bool {.inline.} = abs(a - b) <= eps
|
|
|
|
proc enemyAt(id: int, bearing, dist, lastSeen: float): EnemyInfo =
|
|
let br = degToRad(bearing)
|
|
EnemyInfo(id: id,
|
|
x: SelfX + dist * cos(br),
|
|
y: SelfY + dist * sin(br),
|
|
heading: 0.0, speed: 0.0, energy: 100.0,
|
|
lastSeenTick: lastSeen.int)
|
|
|
|
proc ws(tick: int, radarHeading: float, enemies: seq[EnemyInfo]): WorldState =
|
|
WorldState(selfX: SelfX, selfY: SelfY,
|
|
selfRadarHeading: radarHeading,
|
|
arenaWidth: 2000.0, arenaHeight: 2000.0,
|
|
tick: tick, enemies: enemies)
|
|
|
|
proc arcOf(bearings: openArray[float]): tuple[lo, hi, width: float] =
|
|
minimalCoveringArc(bearings)
|
|
|
|
# ── minimal covering arc ─────────────────────────────────────────────────────
|
|
|
|
proc testClustered() =
|
|
let a = arcOf([100.0, 110.0, 120.0])
|
|
check "arc: clustered 100/110/120 -> lo=100 hi=120 width=20",
|
|
approx(a.lo, 100.0) and approx(a.hi, 120.0) and approx(a.width, 20.0)
|
|
check "arc: clustered arc contains every input",
|
|
isInsideArc(100.0, a.lo, a.hi, a.width) and
|
|
isInsideArc(110.0, a.lo, a.hi, a.width) and
|
|
isInsideArc(120.0, a.lo, a.hi, a.width)
|
|
|
|
proc testStraddleZero() =
|
|
# THE classic case: 350 and 10 must be a 20 deg arc through 0, not 340.
|
|
let a = arcOf([350.0, 10.0])
|
|
check "arc: 350/10 -> width 20 (not 340)",
|
|
approx(a.width, 20.0)
|
|
check "arc: 350/10 arc runs lo=350 CCW to hi=10",
|
|
approx(a.lo, 350.0) and approx(a.hi, 10.0)
|
|
check "arc: 350/10 arc contains both ends and 0",
|
|
isInsideArc(350.0, a.lo, a.hi, a.width) and
|
|
isInsideArc(10.0, a.lo, a.hi, a.width) and
|
|
isInsideArc(0.0, a.lo, a.hi, a.width)
|
|
|
|
proc testStraddleZeroNegative() =
|
|
# Negative inputs must normalize to the same arc.
|
|
let a = arcOf([-10.0, 10.0])
|
|
check "arc: -10/10 normalizes to a 20 deg arc",
|
|
approx(a.width, 20.0) and isInsideArc(0.0, a.lo, a.hi, a.width)
|
|
|
|
proc testSingleEnemy() =
|
|
let a = arcOf([42.0])
|
|
check "arc: single bearing -> width 0, lo=hi=42",
|
|
approx(a.lo, 42.0) and approx(a.hi, 42.0) and approx(a.width, 0.0)
|
|
let a0 = arcOf([0.0])
|
|
check "arc: single bearing at 0 -> width 0", approx(a0.width, 0.0)
|
|
|
|
proc testEmpty() =
|
|
let a = arcOf([])
|
|
check "arc: empty -> zero arc", approx(a.width, 0.0)
|
|
|
|
proc testHalfCircle() =
|
|
# Two exactly opposite bearings: either 180 deg arc covers them; both are
|
|
# valid, the key is the width is exactly 180 and both are inside.
|
|
let a = arcOf([0.0, 180.0])
|
|
check "arc: opposite bearings -> width 180",
|
|
approx(a.width, 180.0)
|
|
check "arc: opposite bearings arc contains both",
|
|
isInsideArc(0.0, a.lo, a.hi, a.width) and
|
|
isInsideArc(180.0, a.lo, a.hi, a.width)
|
|
let b = arcOf([90.0, 270.0])
|
|
check "arc: 90/270 -> width 180",
|
|
approx(b.width, 180.0) and
|
|
isInsideArc(90.0, b.lo, b.hi, b.width) and
|
|
isInsideArc(270.0, b.lo, b.hi, b.width)
|
|
|
|
proc testNearFull() =
|
|
# Bearings every 10 deg: the largest gap is 10 deg, so width = 350.
|
|
var bearings: seq[float]
|
|
for i in 0 ..< 36: bearings.add(float(i) * 10.0)
|
|
let a = arcOf(bearings)
|
|
check "arc: ~full circle (10 deg gaps) -> width 350",
|
|
approx(a.width, 350.0, 1e-6)
|
|
var allIn = true
|
|
for x in bearings:
|
|
if not isInsideArc(x, a.lo, a.hi, a.width): allIn = false
|
|
check "arc: ~full circle arc contains all 36 bearings", allIn
|
|
|
|
proc testDuplicates() =
|
|
let a = arcOf([77.0, 77.0, 77.0])
|
|
check "arc: duplicate bearings -> width 0", approx(a.width, 0.0)
|
|
let b = arcOf([200.0, 200.0, 210.0, 210.0])
|
|
check "arc: duplicated clustered ends -> width 10",
|
|
approx(b.width, 10.0) and isInsideArc(200.0, b.lo, b.hi, b.width)
|
|
|
|
proc testWrapExtremes() =
|
|
# An arc that includes 359 and 1 -> 2 deg through 0.
|
|
let a = arcOf([359.0, 1.0])
|
|
check "arc: 359/1 -> width 2 through 0",
|
|
approx(a.width, 2.0) and isInsideArc(0.0, a.lo, a.hi, a.width)
|
|
# Exact 0/360 alias.
|
|
let b = arcOf([0.0, 360.0])
|
|
check "arc: 0/360 alias -> width 0", approx(b.width, 0.0)
|
|
|
|
# ── turn-rate cap ────────────────────────────────────────────────────────────
|
|
|
|
proc testRateCapStatic() =
|
|
var ok = true
|
|
for width in [0.0, 10.0, 45.0, 90.0, 180.0, 269.0]:
|
|
for h in 0 ..< 360:
|
|
for dir in [-1, 1]:
|
|
let (rate, _) = sweepRate(h.float, 10.0, 10.0 + width, width,
|
|
MarginDeg, dir)
|
|
if abs(rate) > MaxRadarTurnRate + 1e-9: ok = false
|
|
check "rate: sweepRate never exceeds the 45 deg/tick cap (static sweep)",
|
|
ok
|
|
|
|
proc testRateCapDynamicSweep() =
|
|
# Drive a real sweep over a 90 deg arc and check the cap + containment.
|
|
var m = initAdaptiveMeleeRadar()
|
|
let lo = 0.0
|
|
let hi = 90.0
|
|
let width = 90.0
|
|
var heading = 45.0
|
|
var dir = 1
|
|
var capOk = true
|
|
var contained = true
|
|
for _ in 0 ..< 400:
|
|
let (rate, nd) = sweepRate(heading, lo, hi, width, MarginDeg, dir)
|
|
if abs(rate) > MaxRadarTurnRate + 1e-9: capOk = false
|
|
heading = normalizeDeg(heading + rate)
|
|
dir = nd
|
|
if not isInsideArc(heading, normalizeDeg(lo - MarginDeg),
|
|
normalizeDeg(hi + MarginDeg), width + 2.0 * MarginDeg):
|
|
contained = false
|
|
check "rate: dynamic sweep never exceeds the 45 deg/tick cap", capOk
|
|
check "rate: dynamic sweep stays inside the widened arc", contained
|
|
discard m
|
|
|
|
proc testAcquireRateIsMax() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
let s = ws(1, 0.0, @[])
|
|
check "phase: empty enemies -> full spin 45",
|
|
m.computeScan(s) == MaxRadarTurnRate and m.phase == rpAcquire
|
|
|
|
# ── phase machine ────────────────────────────────────────────────────────────
|
|
|
|
proc testAcquireToTrack() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
var t = 0
|
|
var rate = 0.0
|
|
# Fresh every tick; no new id after the first tick.
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
rate = m.computeScan(ws(t, 0.0, @[enemyAt(1, 100.0, 300.0, float(t))]))
|
|
check "phase: full spin while acquiring, then tracking after the fresh streak",
|
|
m.phase == rpTrack
|
|
check "phase: acquisition emits the max rate",
|
|
rate <= MaxRadarTurnRate
|
|
|
|
proc testTrackSingleEnemySmallArc() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(1)
|
|
var t = 0
|
|
var rate = 0.0
|
|
for _ in 0 ..< FreshStreakTicks + 6:
|
|
inc t
|
|
rate = m.computeScan(ws(t, 0.0, @[enemyAt(7, 123.0, 300.0, float(t))]))
|
|
check "single enemy: enters tracking", m.phase == rpTrack
|
|
check "single enemy: swept width is the 2*margin corridor",
|
|
approx(m.lastSweptWidth, 2.0 * MarginDeg)
|
|
check "single enemy: rate is capped", abs(rate) <= MaxRadarTurnRate
|
|
|
|
proc testStaleFallback() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(1)
|
|
var t = 0
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 50.0, 300.0, float(t))]))
|
|
check "fallback: reached tracking before the staleness test", m.phase == rpTrack
|
|
inc t
|
|
# last seen 20 ticks ago > FreshnessTicks (16)
|
|
let rate = m.computeScan(ws(t, 0.0, @[enemyAt(1, 50.0, 300.0, float(t - 20))]))
|
|
check "fallback: a stale enemy drops back to full spin",
|
|
m.phase == rpAcquire and rate == MaxRadarTurnRate
|
|
|
|
proc testNewIdFallback() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(1)
|
|
var t = 0
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 50.0, 300.0, float(t))]))
|
|
check "fallback: tracking a single enemy", m.phase == rpTrack
|
|
inc t
|
|
let rate = m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 50.0, 300.0, float(t)),
|
|
enemyAt(2, 60.0, 300.0, float(t)),
|
|
]))
|
|
check "fallback: a brand-new enemy id drops back to full spin",
|
|
m.phase == rpAcquire and rate == MaxRadarTurnRate
|
|
|
|
proc testExpectedCountGate() =
|
|
# expectedEnemies = 3 but only 2 are ever known -> must stay in acquisition
|
|
# (full spin), never enter tracking against a partial crowd.
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(3)
|
|
var t = 0
|
|
var phase = rpAcquire
|
|
for _ in 0 ..< 40:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 0.0, 300.0, float(t)),
|
|
enemyAt(2, 10.0, 300.0, float(t)),
|
|
]))
|
|
phase = m.phase
|
|
check "gate: a missing expected enemy keeps the radar in full-spin acquisition",
|
|
phase == rpAcquire
|
|
# Once the third appears, it acquires and tracks.
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 0.0, 300.0, float(t)),
|
|
enemyAt(2, 10.0, 300.0, float(t)),
|
|
enemyAt(3, 20.0, 300.0, float(t)),
|
|
]))
|
|
check "gate: tracking resumes once all expected enemies are known",
|
|
m.phase == rpTrack
|
|
|
|
proc testWideArcFallback() =
|
|
# Three enemies evenly spread: covering arc is 240 deg, +40 margin = 280,
|
|
# below the 300 exit threshold. Four spread at 0/90/180/270 -> 270+40 = 310,
|
|
# above it, so the radar must stay in acquisition (full spin).
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(4)
|
|
var t = 0
|
|
for _ in 0 ..< FreshStreakTicks + 10:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 0.0, 300.0, float(t)),
|
|
enemyAt(2, 90.0, 300.0, float(t)),
|
|
enemyAt(3, 180.0, 300.0, float(t)),
|
|
enemyAt(4, 270.0, 300.0, float(t)),
|
|
]))
|
|
check "fallback: a ~310 deg swept arc stays in full-spin acquisition",
|
|
m.phase == rpAcquire
|
|
|
|
proc testWideArcExitAndEnterHysteresis() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(3)
|
|
var t = 0
|
|
# A narrow cluster enters tracking.
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 0.0, 300.0, float(t)),
|
|
enemyAt(2, 10.0, 300.0, float(t)),
|
|
enemyAt(3, 20.0, 300.0, float(t)),
|
|
]))
|
|
check "hysteresis: narrow cluster tracks", m.phase == rpTrack
|
|
# Widen to ~310 swept -> exit.
|
|
t += 1
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 0.0, 300.0, float(t)),
|
|
enemyAt(2, 90.0, 300.0, float(t)),
|
|
enemyAt(3, 180.0, 300.0, float(t)),
|
|
enemyAt(4, 270.0, 300.0, float(t)),
|
|
]))
|
|
check "hysteresis: wide arc exits tracking", m.phase == rpAcquire
|
|
|
|
proc testNoKnownEnemyStaysAcquire() =
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(3)
|
|
var t = 0
|
|
var rate = 0.0
|
|
for _ in 0 ..< 20:
|
|
inc t
|
|
rate = m.computeScan(ws(t, 0.0, @[]))
|
|
check "gate: no known enemies -> always full spin",
|
|
m.phase == rpAcquire and rate == MaxRadarTurnRate
|
|
|
|
proc testExpectedDisabled() =
|
|
# -1 disables the gate: a single known enemy acquires even if more exist.
|
|
var m = initAdaptiveMeleeRadar()
|
|
var t = 0
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 0.0, 300.0, float(t))]))
|
|
check "gate: expectedEnemies = -1 does not block acquisition", m.phase == rpTrack
|
|
|
|
proc testCorpseIgnored() =
|
|
# A dead-but-unmarked enemy (unseen > CorpseTicks) must not keep the radar in
|
|
# acquisition, nor widen the arc.
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(1)
|
|
var t = 0
|
|
for _ in 0 ..< FreshStreakTicks + 2:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 100.0, 300.0, float(t)),
|
|
enemyAt(2, 300.0, 300.0, float(t - 100)), # corpse, far bearing
|
|
]))
|
|
check "corpse: a long-unseen enemy is ignored and tracking is reached",
|
|
m.phase == rpTrack
|
|
check "corpse: the corpse does not widen the swept arc",
|
|
approx(m.lastSweptWidth, 2.0 * MarginDeg)
|
|
|
|
proc testCorpseExpectedGate() =
|
|
# expectedEnemies = 2 but only one LIVE enemy (the other is a corpse): the
|
|
# live count is 1, so the gate keeps the radar in acquisition.
|
|
var m = initAdaptiveMeleeRadar()
|
|
m.setExpectedEnemies(2)
|
|
var t = 0
|
|
for _ in 0 ..< FreshStreakTicks + 5:
|
|
inc t
|
|
discard m.computeScan(ws(t, 0.0, @[
|
|
enemyAt(1, 100.0, 300.0, float(t)),
|
|
enemyAt(2, 300.0, 300.0, float(t - 100)),
|
|
]))
|
|
check "corpse: the expected gate counts LIVE enemies only",
|
|
m.phase == rpAcquire
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
|
|
testClustered()
|
|
testStraddleZero()
|
|
testStraddleZeroNegative()
|
|
testSingleEnemy()
|
|
testEmpty()
|
|
testHalfCircle()
|
|
testNearFull()
|
|
testDuplicates()
|
|
testWrapExtremes()
|
|
testRateCapStatic()
|
|
testRateCapDynamicSweep()
|
|
testAcquireRateIsMax()
|
|
testAcquireToTrack()
|
|
testTrackSingleEnemySmallArc()
|
|
testStaleFallback()
|
|
testNewIdFallback()
|
|
testExpectedCountGate()
|
|
testWideArcFallback()
|
|
testWideArcExitAndEnterHysteresis()
|
|
testNoKnownEnemyStaysAcquire()
|
|
testExpectedDisabled()
|
|
testCorpseIgnored()
|
|
testCorpseExpectedGate()
|
|
|
|
if failures > 0:
|
|
echo "\n", failures, " check(s) FAILED"
|
|
quit(1)
|
|
echo "\nAll adaptive-radar checks passed."
|