3c90a5941d
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).
294 lines
12 KiB
Nim
294 lines
12 KiB
Nim
## First guard tests for the gun selector + the speed-sensitivity checks for the
|
||
## per-tick caching bug class.
|
||
##
|
||
## Headless: no Java, no server, no battle. Run with plain
|
||
## nim c -r common_libs/tests/test_gun_harness.nim
|
||
##
|
||
## Selection tests seed the tracker's exported fitness windows directly instead of
|
||
## dragging virtual bullets through spawnBullets/tickBullets. That is deliberate:
|
||
## it makes exact hit-rates (and therefore tie/rng/floor behaviour) deterministic
|
||
## and fast. The spawn/tick pipeline itself is exercised by the droppedBullets
|
||
## test below and by the full gauntlet.
|
||
|
||
import std/[math, random, tables]
|
||
import gun_harness/gun_interface
|
||
import gun_harness/virtual_bullets
|
||
import gun_harness/selector
|
||
import guns/stop_shot
|
||
import guns/displacement
|
||
import guns/averaged_lead
|
||
import guns/pattern_matcher
|
||
|
||
var failures = 0
|
||
|
||
proc check(name: string, ok: bool) =
|
||
if ok:
|
||
echo "PASS: ", name
|
||
else:
|
||
echo "FAIL: ", name
|
||
inc failures
|
||
|
||
proc recordHit(fw: var FitnessWindow, hit: bool) =
|
||
fw.hits[fw.head] = hit
|
||
fw.head = (fw.head + 1) mod WindowSize
|
||
inc fw.count
|
||
|
||
proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
|
||
## Narrowly-scoped test helper: write `hits`/`misses` samples straight into a
|
||
## gun×bin fitness window (fields are exported by virtual_bullets).
|
||
if targetId notin t.fitness:
|
||
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
|
||
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
|
||
for _ in 0..<hits: recordHit(fw[], true)
|
||
for _ in 0..<misses: recordHit(fw[], false)
|
||
|
||
proc ws(tick: int, ex, ey, espeed, eheading: float): WorldState =
|
||
WorldState(selfX: 100.0, selfY: 100.0, enemyX: ex, enemyY: ey,
|
||
enemySpeed: espeed, enemyHeading: eheading,
|
||
arenaWidth: 1000.0, arenaHeight: 1000.0, tick: tick)
|
||
|
||
proc pointsDiffer(a, b: GunPrediction): bool =
|
||
abs(a.x - b.x) > 0.5 or abs(a.y - b.y) > 0.5
|
||
|
||
# ── selector guards ──────────────────────────────────────────────────────────
|
||
|
||
proc testColdBestGun() =
|
||
var t = initTracker(3)
|
||
check "bestGun on a cold tracker returns 0 (HeadOn)", t.bestGun(-1) == 0
|
||
|
||
proc testRandomTiebreak() =
|
||
# Two guns with an identical, well-observed hit rate: the tiebreak must expose
|
||
# both ids. Before the random tiebreak landed this always returned index 0.
|
||
var t = initTracker(2)
|
||
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 50, misses = 0)
|
||
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 50, misses = 0)
|
||
var seen: array[2, bool]
|
||
for _ in 0..<500:
|
||
let g = t.bestGun(-1)
|
||
if g >= 0 and g < 2: seen[g] = true
|
||
check "random tiebreak returns BOTH tied gun ids (no index-0 determinism)",
|
||
seen[0] and seen[1]
|
||
|
||
proc testBestGunDeterministicWinner() =
|
||
# gun 2 clearly best and past MinObsBeforeCompete; must win every call.
|
||
var t = initTracker(3)
|
||
seedWindow(t, 7, gunId = 0, binIdx = 0, hits = 25, misses = 25) # 50 obs, 50%
|
||
seedWindow(t, 7, gunId = 1, binIdx = 0, hits = 0, misses = 0) # cold, skipped
|
||
seedWindow(t, 7, gunId = 2, binIdx = 0, hits = 50, misses = 0) # 50 obs, 100%
|
||
var allTwo = true
|
||
for _ in 0..<100:
|
||
if t.bestGun(-1) != 2: allTwo = false
|
||
check "gun with clearly best rate and >= MinObsBeforeCompete wins deterministically",
|
||
allTwo
|
||
|
||
proc testBestPowerCold() =
|
||
var t = initTracker(3)
|
||
let (bin, power) = t.bestPower(0, -1)
|
||
check "bestPower on a zero-observation gun returns bin 0 / power 1.0",
|
||
bin == 0 and power == 1.0
|
||
|
||
proc testBestPowerWarmBin3() =
|
||
var t = initTracker(3)
|
||
seedWindow(t, 7, gunId = 0, binIdx = 3, hits = 50, misses = 0) # 100% >= MinHitRate
|
||
let (bin, power) = t.bestPower(0, -1)
|
||
check "bestPower on a warm gun whose bin 3 rate >= MinHitRate returns bin 3",
|
||
bin == 3 and power == 3.0
|
||
|
||
proc testFitnessForDeterministic() =
|
||
# Same per-enemy data inserted in opposite orders must aggregate identically.
|
||
# Before fitnessFor sorted enemy ids, std/tables hash order leaked in.
|
||
var t1 = initTracker(2)
|
||
seedWindow(t1, 5, gunId = 0, binIdx = 0, hits = 10, misses = 5)
|
||
seedWindow(t1, 3, gunId = 0, binIdx = 0, hits = 5, misses = 10)
|
||
|
||
var t2 = initTracker(2)
|
||
seedWindow(t2, 3, gunId = 0, binIdx = 0, hits = 5, misses = 10)
|
||
seedWindow(t2, 5, gunId = 0, binIdx = 0, hits = 10, misses = 5)
|
||
|
||
var same = true
|
||
for _ in 0..<20:
|
||
let r1 = t1.fitnessFor(-1)[0].bins[0].hitRate()
|
||
let r2 = t2.fitnessFor(-1)[0].bins[0].hitRate()
|
||
if r1 != r2: same = false
|
||
let expected = 15.0 / 30.0
|
||
check "fitnessFor is deterministic across insertion orders",
|
||
same and abs(t1.fitnessFor(-1)[0].bins[0].hitRate() - expected) < 1e-12
|
||
|
||
proc testDroppedBullets() =
|
||
var t = initTracker(1)
|
||
let state = ws(0, 500.0, 500.0, 0.0, 0.0)
|
||
let preds = [GunPrediction(x: 500.0, y: 500.0),
|
||
GunPrediction(x: 500.0, y: 500.0),
|
||
GunPrediction(x: 500.0, y: 500.0),
|
||
GunPrediction(x: 500.0, y: 500.0)]
|
||
# Fill the ring exactly (4 bullets per spawn, no tickBullets -> never resolve).
|
||
for _ in 0..<(MaxBullets div len(PowerBins)):
|
||
t.spawnBullets(0, preds, state, 5)
|
||
check "droppedBullets stays 0 until the ring wraps", t.droppedBullets == 0
|
||
t.spawnBullets(0, preds, state, 5)
|
||
check "droppedBullets counts unresolved bullets clobbered by the ring",
|
||
t.droppedBullets == 4
|
||
|
||
# ── caching-bug speed sensitivity (Task 5) ───────────────────────────────────
|
||
|
||
proc testStopShotSpeedSensitivity() =
|
||
let spd0 = bulletSpeed(PowerBins[0])
|
||
let spd3 = bulletSpeed(PowerBins[3])
|
||
|
||
var ss = initStopShotGun()
|
||
# Constant speed 4: warm two frames, then compare on the same tick. Before the
|
||
# fix the tick-only cache returned bin 0's lead for every bin.
|
||
discard ss.predict(ws(1, 400.0, 100.0, 4.0, 0.0), spd0)
|
||
discard ss.predict(ws(2, 400.0, 100.0, 4.0, 0.0), spd0)
|
||
let s3 = ws(3, 400.0, 100.0, 4.0, 0.0)
|
||
let p0 = ss.predict(s3, spd0)
|
||
let p3 = ss.predict(s3, spd3)
|
||
check "stop_shot: same tick, different bulletSpeed -> different point",
|
||
pointsDiffer(p0, p3)
|
||
|
||
# Task 1a: deceleration is actually detected (8 -> 4 px/tick). The old ordering
|
||
# made prev == speed, so this branch was unreachable and the gun was Linear.
|
||
var ss2 = initStopShotGun()
|
||
discard ss2.predict(ws(1, 400.0, 100.0, 8.0, 0.0), spd0)
|
||
let pd = ss2.predict(ws(2, 400.0, 100.0, 4.0, 0.0), spd0)
|
||
# Stop point is 400 + 4 + 2 = 406 px (BrakeDecel=2); linear lead would be ~470.
|
||
check "stop_shot: deceleration branch reaches the simulated stop point",
|
||
abs(pd.x - 406.0) < 1.0
|
||
|
||
proc testDisplacementSpeedSensitivity() =
|
||
let spd0 = bulletSpeed(PowerBins[0])
|
||
let spd3 = bulletSpeed(PowerBins[3])
|
||
|
||
var dg = initDisplacementGun()
|
||
# Warm 16 ticks emulating the real harness: 4 predict() calls (one per power
|
||
# bin) on every tick. Feed 16 ticks of constant +5 px/tick motion so the
|
||
# 15-tick window is ready.
|
||
for tick in 1..16:
|
||
for bin in 0..<len(PowerBins):
|
||
discard dg.predict(ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0),
|
||
bulletSpeed(PowerBins[bin]))
|
||
let s17 = ws(17, 300.0 + 5.0 * 17.0, 200.0, 5.0, 0.0)
|
||
let d0 = dg.predict(s17, spd0)
|
||
let d3 = dg.predict(s17, spd3)
|
||
check "displacement: same tick, different bulletSpeed -> different point",
|
||
pointsDiffer(d0, d3)
|
||
|
||
# The real displacement bug: the speed-in-key cache advanced the ring ~4x per
|
||
# tick, so the nominal 15-tick window spanned ~4 ticks. Sampling once per tick
|
||
# means 4 calls/tick must be identical to 1 call/tick.
|
||
var dgMulti = initDisplacementGun()
|
||
var dgOnce = initDisplacementGun()
|
||
for tick in 1..16:
|
||
let s = ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0)
|
||
for bin in 0..<len(PowerBins):
|
||
discard dgMulti.predict(s, bulletSpeed(PowerBins[bin]))
|
||
discard dgOnce.predict(s, spd0)
|
||
let s18 = ws(18, 300.0 + 5.0 * 18.0, 200.0, 5.0, 0.0)
|
||
let a = dgMulti.predict(s18, spd0)
|
||
let b = dgOnce.predict(s18, spd0)
|
||
check "displacement: ring advances exactly once per tick (4 calls == 1 call)",
|
||
not pointsDiffer(a, b)
|
||
|
||
proc testAveragedLeadSpeedSensitivity() =
|
||
let spd0 = bulletSpeed(PowerBins[0])
|
||
let spd3 = bulletSpeed(PowerBins[3])
|
||
|
||
var al = initAveragedLeadGun()
|
||
discard al.predict(ws(1, 400.0, 100.0, 3.0, 0.0), spd0) # warm circular's omega
|
||
let s2 = ws(2, 400.0, 100.0, 3.0, 0.0)
|
||
let a0 = al.predict(s2, spd0)
|
||
let a3 = al.predict(s2, spd3)
|
||
check "averaged_lead: same tick, different bulletSpeed -> different point",
|
||
pointsDiffer(a0, a3)
|
||
|
||
proc testPatternMatcherSpeedSensitivity() =
|
||
let spd0 = bulletSpeed(PowerBins[0])
|
||
let spd3 = bulletSpeed(PowerBins[3])
|
||
|
||
var pm = PatternMatcherGun()
|
||
for tick in 1..25:
|
||
discard pm.predict(ws(tick, 300.0 + 5.0 * tick.float, 200.0, 5.0, 0.0), spd0)
|
||
let s26 = ws(26, 300.0 + 5.0 * 26.0, 200.0, 5.0, 0.0)
|
||
let m0 = pm.predict(s26, spd0)
|
||
let m3 = pm.predict(s26, spd3)
|
||
check "pattern_matcher: same tick, different bulletSpeed -> different point",
|
||
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 ───────────────────────────────────────────────────────────────────
|
||
|
||
randomize()
|
||
|
||
testColdBestGun()
|
||
testRandomTiebreak()
|
||
testBestGunDeterministicWinner()
|
||
testBestPowerCold()
|
||
testBestPowerWarmBin3()
|
||
testFitnessForDeterministic()
|
||
testDroppedBullets()
|
||
testStopShotSpeedSensitivity()
|
||
testDisplacementSpeedSensitivity()
|
||
testAveragedLeadSpeedSensitivity()
|
||
testPatternMatcherSpeedSensitivity()
|
||
testToleranceStrictlyDecreases()
|
||
testToleranceClamps()
|
||
testToleranceFormula()
|
||
testPerfectAlignmentAlwaysFires()
|
||
testGrossMisalignmentFailsLongRange()
|
||
testDegenerateDistance()
|
||
|
||
if failures > 0:
|
||
echo "\n", failures, " check(s) FAILED"
|
||
quit(1)
|
||
echo "\nAll gun-harness checks passed."
|