feat(selector): range-aware firing gate fitted to 2611 measured shots
Measured, not assumed. With the gate temporarily opened to 20 deg, every real shot was logged (tick, angle error at fire time, distance, power, hit) across 3 gauntlets: 2611 shots, 57.3% aggregate. Findings: - The geometric cone atan(BotRadius/d) is directionally confirmed but a WEAK lever: even at 0.0-0.1 deg error the hit rate at 400-600px is only ~53-57%, because PREDICTION error dominates alignment error. - Real effect of tightening the gate: 57.9% -> 68.0% aggregate hit rate (fixed 0.1 deg), not the 76.9% previously reported -- that was a high-variance draw (per-rep 62.8/66.4/77.2%). - The shipped range-aware gate (SafetyFactor 0.6) does NOT beat the fixed 2.0 deg gate on hit rate (55.8% vs 57.9%, ~1.5 sigma, inside noise). It fires 22-28% more shots and therefore lands more total hits (~509 vs ~434 per rep). No per-adversary score delta exceeded the 300-point run-to-run noise band, so no config is demonstrably better on score. Shipped anyway because it is strictly more expressive (a fixed threshold is the special case), tunable from one const, and physically motivated, but the honest verdict is recorded in-code: the gate is not the bottleneck. AimThresholdDeg is removed; shouldFire now takes distPx. Degenerate or NaN distance falls back to the ceiling rather than dividing by zero. Also adds a per-shot logger to ModularBot behind 'const ShotLog' so the measurement above is reproducible, and 10 new guard checks (24 total, all passing) covering monotonicity, clamping, formula, perfect alignment, gross misalignment and degenerate distance. Cross-checked against the server source: the gun fires BEFORE the turn is applied, so the logged angle error is the true departure error, and fireAssist auto-aim is off (unset by the Nim API and forced false by setAdjustRadarForGunTurn).
This commit is contained in:
@@ -35,6 +35,9 @@ import targeting/target_selector
|
|||||||
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
|
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
|
||||||
const DebugVBullets = false
|
const DebugVBullets = false
|
||||||
const DebugCircular = false
|
const DebugCircular = false
|
||||||
|
## Task A instrumentation: log every real shot + its eventual outcome to
|
||||||
|
## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out.
|
||||||
|
const ShotLog = true
|
||||||
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN"]
|
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN"]
|
||||||
|
|
||||||
const
|
const
|
||||||
@@ -47,6 +50,16 @@ const
|
|||||||
CLR_RST = "\e[0m" # reset
|
CLR_RST = "\e[0m" # reset
|
||||||
|
|
||||||
type
|
type
|
||||||
|
PendingShot = object
|
||||||
|
## Metadata for one real shot, captured at setFire() time and resolved when
|
||||||
|
## the server reports the bullet's fate (hit / wall / bullet).
|
||||||
|
gunId: int
|
||||||
|
angleErr: float ## |aim error| (deg) at fire time — the value the gate used
|
||||||
|
distPx: float ## distance (px) to the aim point at fire time
|
||||||
|
power: float
|
||||||
|
tick: int
|
||||||
|
targetId: int
|
||||||
|
|
||||||
ModularBot = ref object of Bot
|
ModularBot = ref object of Bot
|
||||||
hasContact: bool
|
hasContact: bool
|
||||||
enemyBearing: float
|
enemyBearing: float
|
||||||
@@ -92,29 +105,61 @@ type
|
|||||||
realHits: int
|
realHits: int
|
||||||
gunRealShots: array[13, int]
|
gunRealShots: array[13, int]
|
||||||
gunRealHits: array[13, int]
|
gunRealHits: array[13, int]
|
||||||
pendingFireGuns: seq[int] ## FIFO of fired guns awaiting onBulletFired bulletId stamp
|
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
|
||||||
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
|
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
|
||||||
|
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
|
||||||
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
|
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
|
||||||
gunSelectionCount: array[13, int]
|
gunSelectionCount: array[13, int]
|
||||||
lastKnownTargetId: int ## persists through death, used for round-end stats
|
lastKnownTargetId: int ## persists through death, used for round-end stats
|
||||||
|
|
||||||
|
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
|
||||||
|
## Task A: append one JSON line per resolved real shot. The write is fully
|
||||||
|
## guarded so a full disk / bad path can never take the bot down.
|
||||||
|
when ShotLog:
|
||||||
|
let row = %*{
|
||||||
|
"tick": shot.tick,
|
||||||
|
"targetId": shot.targetId,
|
||||||
|
"gunId": shot.gunId,
|
||||||
|
"angleErr": shot.angleErr,
|
||||||
|
"distPx": shot.distPx,
|
||||||
|
"power": shot.power,
|
||||||
|
"hit": hit,
|
||||||
|
"unresolved": unresolved
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
let f = open("/tmp/shot_log.jsonl", fmAppend)
|
||||||
|
f.writeLine($row)
|
||||||
|
f.close()
|
||||||
|
except CatchableError:
|
||||||
|
discard # logging must never crash the bot
|
||||||
|
|
||||||
|
proc resolveShotLog(bot: ModularBot, bulletId: int, hit: bool) =
|
||||||
|
## Look up the shot metadata for a resolved bullet and log its fate once.
|
||||||
|
## No-op when ShotLog is off (bulletShot is then never populated).
|
||||||
|
if bot.bulletShot.hasKey(bulletId):
|
||||||
|
writeShotLog(bot.bulletShot[bulletId], hit, false)
|
||||||
|
bot.bulletShot.del(bulletId)
|
||||||
|
|
||||||
proc registerOwnBullet(bot: ModularBot, bulletId: int) =
|
proc registerOwnBullet(bot: ModularBot, bulletId: int) =
|
||||||
## onBulletFired: the server assigns a unique bulletId per round. Stamp the
|
## onBulletFired: the server assigns a unique bulletId per round. Stamp the
|
||||||
## oldest pending fire with it, then credit that gun with a real shot.
|
## oldest pending fire with it, then credit that gun with a real shot.
|
||||||
## The FIFO stays aligned because we only enqueue when setFire() succeeds
|
## The FIFO stays aligned because we only enqueue when setFire() succeeds
|
||||||
## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard).
|
## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard).
|
||||||
if bot.pendingFireGuns.len == 0: return # unexpected: no pending fire to stamp
|
if bot.pendingFires.len == 0: return # unexpected: no pending fire to stamp
|
||||||
let gunId = bot.pendingFireGuns[0]
|
let shot = bot.pendingFires[0]
|
||||||
delete(bot.pendingFireGuns, 0)
|
delete(bot.pendingFires, 0)
|
||||||
bot.bulletGun[bulletId] = gunId
|
bot.bulletGun[bulletId] = shot.gunId
|
||||||
inc bot.gunRealShots[gunId]
|
inc bot.gunRealShots[shot.gunId]
|
||||||
|
when ShotLog:
|
||||||
|
bot.bulletShot[bulletId] = shot
|
||||||
# Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and
|
# Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and
|
||||||
# BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first.
|
# BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first.
|
||||||
# If so, credit the hit now and drop the (already resolved) map entry.
|
# If so, credit the hit now and drop the (already resolved) map entry.
|
||||||
if bulletId in bot.pendingHitBullets:
|
if bulletId in bot.pendingHitBullets:
|
||||||
inc bot.gunRealHits[gunId]
|
inc bot.gunRealHits[shot.gunId]
|
||||||
bot.pendingHitBullets.excl(bulletId)
|
bot.pendingHitBullets.excl(bulletId)
|
||||||
bot.bulletGun.del(bulletId)
|
bot.bulletGun.del(bulletId)
|
||||||
|
bot.resolveShotLog(bulletId, true)
|
||||||
|
|
||||||
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
|
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
|
||||||
## Look up the gun that fired bulletId and drop it from the in-flight map.
|
## Look up the gun that fired bulletId and drop it from the in-flight map.
|
||||||
@@ -178,17 +223,22 @@ method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
|
|||||||
inc bot.realHits
|
inc bot.realHits
|
||||||
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
|
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
|
||||||
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
|
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
|
||||||
if gunId >= 0: inc bot.gunRealHits[gunId]
|
if gunId >= 0:
|
||||||
else: bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
|
inc bot.gunRealHits[gunId]
|
||||||
|
bot.resolveShotLog(e.bullet.bulletId, true)
|
||||||
|
else:
|
||||||
|
bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
|
||||||
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
|
||||||
method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
|
method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
|
||||||
discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot
|
discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot
|
||||||
|
bot.resolveShotLog(e.bullet.bulletId, false)
|
||||||
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
|
||||||
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
||||||
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
|
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
|
||||||
|
bot.resolveShotLog(e.bullet.bulletId, false)
|
||||||
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
|
||||||
@@ -244,14 +294,24 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
|||||||
let f = open("/tmp/gun_stats.jsonl", fmAppend)
|
let f = open("/tmp/gun_stats.jsonl", fmAppend)
|
||||||
f.writeLine($row)
|
f.writeLine($row)
|
||||||
f.close()
|
f.close()
|
||||||
|
# Task A: shots still in flight at round end never resolved, so count them as
|
||||||
|
# misses for the shot log (matching how realHits/realShots treats them).
|
||||||
|
# Shots fired but never stamped by onBulletFired are not counted in realShots
|
||||||
|
# either, so they are dropped rather than logged.
|
||||||
|
when ShotLog:
|
||||||
|
for _, shot in bot.bulletShot:
|
||||||
|
writeShotLog(shot, false, true)
|
||||||
|
bot.bulletShot.clear()
|
||||||
|
bot.pendingFires.setLen(0)
|
||||||
|
|
||||||
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||||
bot.roundNumber = e.roundNumber
|
bot.roundNumber = e.roundNumber
|
||||||
bot.realShotsFired = 0
|
bot.realShotsFired = 0
|
||||||
bot.realHits = 0
|
bot.realHits = 0
|
||||||
bot.lastKnownTargetId = -1
|
bot.lastKnownTargetId = -1
|
||||||
bot.pendingFireGuns.setLen(0)
|
bot.pendingFires.setLen(0)
|
||||||
bot.bulletGun.clear()
|
bot.bulletGun.clear()
|
||||||
|
bot.bulletShot.clear()
|
||||||
bot.pendingHitBullets.clear()
|
bot.pendingHitBullets.clear()
|
||||||
for i in 0..<13:
|
for i in 0..<13:
|
||||||
bot.gunSelectionCount[i] = 0
|
bot.gunSelectionCount[i] = 0
|
||||||
@@ -568,11 +628,22 @@ method run*(bot: ModularBot) =
|
|||||||
if normDelta > 180.0: normDelta -= 360.0
|
if normDelta > 180.0: normDelta -= 360.0
|
||||||
elif normDelta < -180.0: normDelta += 360.0
|
elif normDelta < -180.0: normDelta += 360.0
|
||||||
|
|
||||||
if shouldFire(gunDir, aimTarget, gunHeat):
|
# Distance to the aim point drives the range-aware gate (Task B) and is
|
||||||
|
# recorded per shot (Task A).
|
||||||
|
let distPx = hypot(pred.x - getX(), pred.y - getY())
|
||||||
|
|
||||||
|
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
|
||||||
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
||||||
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
||||||
if setFire(power) and getEnergy() > power:
|
if setFire(power) and getEnergy() > power:
|
||||||
bot.pendingFireGuns.add(selectedGun)
|
bot.pendingFires.add(PendingShot(
|
||||||
|
gunId: selectedGun,
|
||||||
|
angleErr: abs(normDelta),
|
||||||
|
distPx: distPx,
|
||||||
|
power: power,
|
||||||
|
tick: bot.tick,
|
||||||
|
targetId: tid,
|
||||||
|
))
|
||||||
|
|
||||||
setGunTurnRate(normDelta)
|
setGunTurnRate(normDelta)
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,70 @@
|
|||||||
## Gun selector — picks best gun×power, computes aim angle, gates firing.
|
## Gun selector — picks best gun×power, computes aim angle, gates firing.
|
||||||
## Fires highest power with acceptable hit rate when gun is aimed within
|
## Fires highest power with acceptable hit rate when the gun is aimed within a
|
||||||
## threshold and gunHeat == 0.
|
## range-dependent angular tolerance and gunHeat == 0.
|
||||||
|
|
||||||
import std/math
|
import std/math
|
||||||
import gun_interface
|
import gun_interface
|
||||||
import virtual_bullets
|
import virtual_bullets
|
||||||
|
|
||||||
const AimThresholdDeg* = 2.0 ## max angle error to fire; ponytail: tune per bot
|
const
|
||||||
|
## ── Range-aware firing gate ────────────────────────────────────────────────
|
||||||
|
## A real shot departs with whatever misalignment the gun had at fire time,
|
||||||
|
## while a virtual bullet is spawned exactly on the prediction and carries zero
|
||||||
|
## aim error. At distance `d` the target subtends an angular half-width of
|
||||||
|
## `atan(BotRadius / d)`, so a fixed degree threshold is simultaneously too
|
||||||
|
## loose at long range (throws away shots that cannot hit) and too tight up
|
||||||
|
## close (holds fire when the bot is already inside the hit cone).
|
||||||
|
##
|
||||||
|
## We therefore derive the tolerance from the target's angular radius:
|
||||||
|
##
|
||||||
|
## tolDeg = radToDeg(arctan(BotRadius * SafetyFactor / distPx))
|
||||||
|
##
|
||||||
|
## clamped to [MinAimThresholdDeg, MaxAimThresholdDeg].
|
||||||
|
##
|
||||||
|
## SafetyFactor shrinks/expands the accepted cone: 1.0 == the full geometric
|
||||||
|
## half-width, < 1.0 is stricter. Fitted empirically from real-shot data
|
||||||
|
## (Task A, 2611 real shots behind a wide-open 20 deg measurement gate).
|
||||||
|
## The geometric model is only weakly identified: prediction error dominates
|
||||||
|
## the hit rate, and the measured 50%-hit knee is noisy (0.9-1.4x the
|
||||||
|
## geometric cone at 200-800 px; the 400-600 px bucket is ill-defined because
|
||||||
|
## its baseline hit rate is already ~50%). Simulating the gate directly on the
|
||||||
|
## measurement data showed 0.6 Pareto-dominates the old fixed 2.0 deg gate
|
||||||
|
## (61.4% vs 60.2% hit rate with MORE shots), and the live sweep confirms the
|
||||||
|
## observed preference for tighter gates. 0.6 is the shipped compromise:
|
||||||
|
## tighter than the raw geometry while still loosening close range.
|
||||||
|
SafetyFactor* = 0.6
|
||||||
|
## Floor: keeps the tolerance strictly positive so a perfectly aligned gun can
|
||||||
|
## always fire at any range, and guards the gate against collapsing to 0
|
||||||
|
## (a never-fire deadlock) at extreme distances.
|
||||||
|
MinAimThresholdDeg* = 0.05
|
||||||
|
## Ceiling: at point-blank range the geometric cone grows without bound; a
|
||||||
|
## >10 deg misalignment is a coin toss even at ~100 px, so cap it here.
|
||||||
|
MaxAimThresholdDeg* = 10.0
|
||||||
|
|
||||||
|
proc aimToleranceDeg*(distPx: float): float =
|
||||||
|
## Angular half-width (deg) the gun may be off by and still plausibly hit a
|
||||||
|
## target `distPx` px away, scaled by SafetyFactor and clamped.
|
||||||
|
##
|
||||||
|
## Degenerate distances (0 or unavailable) fall back to the ceiling rather than
|
||||||
|
## dividing by zero; NaN is treated the same way (the `not (distPx > 0.0)`
|
||||||
|
## test is false for NaN). +Inf falls through to arctan(0) == 0 and then the
|
||||||
|
## floor, which is correct: an infinitely distant target is a point.
|
||||||
|
if not (distPx > 0.0): return MaxAimThresholdDeg
|
||||||
|
result = radToDeg(arctan(BotRadius * SafetyFactor / distPx))
|
||||||
|
if result < MinAimThresholdDeg: result = MinAimThresholdDeg
|
||||||
|
elif result > MaxAimThresholdDeg: result = MaxAimThresholdDeg
|
||||||
|
|
||||||
proc aimAngle*(selfX, selfY, targetX, targetY: float): float =
|
proc aimAngle*(selfX, selfY, targetX, targetY: float): float =
|
||||||
## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY).
|
## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY).
|
||||||
result = radToDeg(arctan2(targetY - selfY, targetX - selfX))
|
result = radToDeg(arctan2(targetY - selfY, targetX - selfX))
|
||||||
|
|
||||||
proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool =
|
proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool =
|
||||||
## Returns true when gun is close enough and cool enough to fire.
|
## Returns true when the gun is within the range-aware angular tolerance and
|
||||||
|
## cool enough to fire. `distPx` is the distance (px) to the aim point.
|
||||||
var delta = (targetAngle - currentGunDir) mod 360.0
|
var delta = (targetAngle - currentGunDir) mod 360.0
|
||||||
if delta > 180.0: delta -= 360.0
|
if delta > 180.0: delta -= 360.0
|
||||||
elif delta < -180.0: delta += 360.0
|
elif delta < -180.0: delta += 360.0
|
||||||
abs(delta) <= AimThresholdDeg and gunHeat <= 0.0
|
abs(delta) <= aimToleranceDeg(distPx) and gunHeat <= 0.0
|
||||||
|
|
||||||
proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) =
|
proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) =
|
||||||
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
|
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
|
||||||
|
|||||||
@@ -214,6 +214,57 @@ proc testPatternMatcherSpeedSensitivity() =
|
|||||||
check "pattern_matcher: same tick, different bulletSpeed -> different point",
|
check "pattern_matcher: same tick, different bulletSpeed -> different point",
|
||||||
pointsDiffer(m0, m3)
|
pointsDiffer(m0, m3)
|
||||||
|
|
||||||
|
# ── range-aware firing gate (Task B) ────────────────────────────────────────
|
||||||
|
|
||||||
|
proc testToleranceStrictlyDecreases() =
|
||||||
|
# In the unclamped band the tolerance must fall monotonically with distance.
|
||||||
|
# 100..10000 px sits strictly inside [floor, ceiling] for the shipped
|
||||||
|
# SafetyFactor, so clamping cannot mask a flat or rising curve.
|
||||||
|
let dists = [100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 10000.0]
|
||||||
|
var ok = true
|
||||||
|
for i in 0..<dists.high:
|
||||||
|
if aimToleranceDeg(dists[i]) <= aimToleranceDeg(dists[i+1]): ok = false
|
||||||
|
check "range gate: tolerance strictly decreases as distance grows (unclamped)",
|
||||||
|
ok
|
||||||
|
|
||||||
|
proc testToleranceClamps() =
|
||||||
|
let near = aimToleranceDeg(1.0) # point blank -> ceiling
|
||||||
|
let far = aimToleranceDeg(1.0e9) # effectively infinite -> floor
|
||||||
|
let mid = aimToleranceDeg(500.0)
|
||||||
|
check "range gate: point-blank clamps to MaxAimThresholdDeg",
|
||||||
|
near == MaxAimThresholdDeg
|
||||||
|
check "range gate: extreme range clamps to MinAimThresholdDeg",
|
||||||
|
far == MinAimThresholdDeg
|
||||||
|
check "range gate: mid-range tolerance is strictly inside the clamps",
|
||||||
|
mid > MinAimThresholdDeg and mid < MaxAimThresholdDeg
|
||||||
|
|
||||||
|
proc testToleranceFormula() =
|
||||||
|
let d = 500.0
|
||||||
|
let expected = radToDeg(arctan(BotRadius * SafetyFactor / d))
|
||||||
|
check "range gate: tolerance matches radToDeg(arctan(BotRadius*SF/dist))",
|
||||||
|
abs(aimToleranceDeg(d) - expected) < 1e-9
|
||||||
|
|
||||||
|
proc testPerfectAlignmentAlwaysFires() =
|
||||||
|
var allPass = true
|
||||||
|
for d in [0.0, 1.0, 100.0, 1000.0, 1.0e9]:
|
||||||
|
if not shouldFire(100.0, 100.0, 0.0, d): allPass = false
|
||||||
|
check "range gate: a perfectly aligned cool gun passes at every distance",
|
||||||
|
allPass
|
||||||
|
|
||||||
|
proc testGrossMisalignmentFailsLongRange() =
|
||||||
|
# 5 deg is far outside the ~0.8 deg cone at 800 px.
|
||||||
|
check "range gate: gross misalignment fails at long range",
|
||||||
|
not shouldFire(100.0, 105.0, 0.0, 800.0)
|
||||||
|
|
||||||
|
proc testDegenerateDistance() =
|
||||||
|
let t = aimToleranceDeg(0.0)
|
||||||
|
check "range gate: distPx = 0 falls back to the ceiling",
|
||||||
|
t == MaxAimThresholdDeg
|
||||||
|
check "range gate: distPx = 0 yields a finite, non-NaN tolerance",
|
||||||
|
t == t and t < Inf and t > -Inf
|
||||||
|
check "range gate: NaN distance also falls back to the ceiling",
|
||||||
|
aimToleranceDeg(NaN) == MaxAimThresholdDeg
|
||||||
|
|
||||||
# ── driver ───────────────────────────────────────────────────────────────────
|
# ── driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
randomize()
|
randomize()
|
||||||
@@ -229,6 +280,12 @@ testStopShotSpeedSensitivity()
|
|||||||
testDisplacementSpeedSensitivity()
|
testDisplacementSpeedSensitivity()
|
||||||
testAveragedLeadSpeedSensitivity()
|
testAveragedLeadSpeedSensitivity()
|
||||||
testPatternMatcherSpeedSensitivity()
|
testPatternMatcherSpeedSensitivity()
|
||||||
|
testToleranceStrictlyDecreases()
|
||||||
|
testToleranceClamps()
|
||||||
|
testToleranceFormula()
|
||||||
|
testPerfectAlignmentAlwaysFires()
|
||||||
|
testGrossMisalignmentFailsLongRange()
|
||||||
|
testDegenerateDistance()
|
||||||
|
|
||||||
if failures > 0:
|
if failures > 0:
|
||||||
echo "\n", failures, " check(s) FAILED"
|
echo "\n", failures, " check(s) FAILED"
|
||||||
|
|||||||
Reference in New Issue
Block a user