feat(gun_harness): offline gun range, proven equivalent to live play

Gun evaluation previously required a full end-to-end battle (Java server +
battle runner + websocket IPC to 2 bot processes, 50 rounds, ~3.4 min) and
yielded only ~300-900 REAL shots across 13 guns -- far too few to rank
guns, which is why tuning needed many repetitions.

VirtualTracker is already a pure function of (WorldState stream, gun list);
the only reason it needed Java was where WorldState came from. So the range
replays a seq[WorldState] through the SAME tracker: offline and online
scores are the same metric by construction, not an approximation.

ACCEPTANCE TEST (the point of the whole thing): record one live round, replay
it offline, compare per-gun virtual hit rates. 12/12 deterministic guns match
EXACTLY, reproduced twice. Tsetlin is compared separately because tmLearnOne
calls rand(). Getting to 12/12 exposed two real ordering quirks in the live
loop: run() calls go() before the aim/fire block, so tickBullets resolves
against the NEXT tick's scan while the prediction used the previous one; and
if the target dies during that go() the final tick's spawn+resolution is
skipped entirely. The recorder emits an end marker for the second case.
The 5th (selected-gun) predict call was verified to be a no-op.

Measured cost: 8 fixtures (1770 ticks, ~92k virtual bullets, 13 guns) replay
in 2.9 s, ~32k virtual bullets/s -- roughly 70x faster and 100x more samples
than a live gauntlet.

Also adds a per-tick WorldState recorder behind const RecordWorldState
(default off, mirrors the ShotLog idiom) which records the state the bot
ACTUALLY builds, staleness included, rather than true positions -- recording
the latter would hand the guns perfect information and produce flattering
scores.

9 new guard checks (33 total, all passing), including fixture round-trip,
replay determinism, stationary->HeadOn 100%, constant-velocity->Linear>HeadOn,
and the energy-threshold turner crossing at t=41.
This commit is contained in:
2026-09-20 23:44:42 +02:00
parent 3c90a5941d
commit 974528d5cf
16 changed files with 2698 additions and 2 deletions
+87 -1
View File
@@ -10,14 +10,17 @@
## 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 std/[math, random, tables, os]
import gun_harness/gun_interface
import gun_harness/virtual_bullets
import gun_harness/selector
import gun_harness/offline_range
import guns/stop_shot
import guns/displacement
import guns/averaged_lead
import guns/pattern_matcher
import guns/head_on
import guns/linear
var failures = 0
@@ -265,6 +268,83 @@ proc testDegenerateDistance() =
check "range gate: NaN distance also falls back to the ceiling",
aimToleranceDeg(NaN) == MaxAimThresholdDeg
# ── offline range (Task 5) ────────────────────────────────────────────────────
proc testFixtureRoundTrip() =
let fx = synthesizeConstantVelocity(ticks = 40)
let path = getTempDir() / "gun_range_roundtrip.jsonl"
saveFixture(path, fx)
let back = loadFixture(path)
check "range: fixture round-trip preserves tick count",
back.states.len == fx.states.len
var same = back.states.len == fx.states.len
for i in 0..<min(back.states.len, fx.states.len):
let a = fx.states[i]
let b = back.states[i]
if a.tick != b.tick or abs(a.enemyX - b.enemyX) > 1e-9 or
abs(a.enemyY - b.enemyY) > 1e-9 or
abs(a.enemyHeading - b.enemyHeading) > 1e-9 or
abs(a.enemySpeed - b.enemySpeed) > 1e-9 or
abs(a.selfX - b.selfX) > 1e-9:
same = false
check "range: fixture round-trip preserves per-tick fields", same
check "range: fixture round-trip defaults arena to 800x600",
back.meta.arenaW == 800.0 and back.meta.arenaH == 600.0
check "range: fixture round-trip preserves enemy id", back.enemyId == fx.enemyId
removeFile(path)
proc testFixtureEndMarkerRoundTrip() =
let fx = synthesizeStationary(ticks = 10)
var withEnd = fx
withEnd.enemyDied = true
let path = getTempDir() / "gun_range_end.jsonl"
saveFixture(path, withEnd)
let back = loadFixture(path)
check "range: fixture round-trip preserves the enemyDied end marker", back.enemyDied
removeFile(path)
proc testReplayDeterminism() =
let fx = synthesizeCircular(ticks = 80)
let r1 = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun()),
makeDriver("Linear", LinearGun())])
let r2 = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun()),
makeDriver("Linear", LinearGun())])
var same = r1.len == r2.len
for i in 0..<r1.len:
if r1[i].name != r2[i].name or r1[i].shots != r2[i].shots or
r1[i].hits != r2[i].hits:
same = false
for b in 0..<len(PowerBins):
if r1[i].bins[b].shots != r2[i].bins[b].shots or
r1[i].bins[b].hits != r2[i].bins[b].hits:
same = false
check "range: replaying the same fixture twice is byte-identical (deterministic guns)",
same
proc testRangeGroundTruthStationary() =
let fx = synthesizeStationary(ticks = 120)
let r = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun())])
# The last bullets are still in flight when the fixture ends, so shots < 480;
# every resolved shot must still be a hit.
check "range: stationary enemy -> HeadOn scores 100%",
r[0].shots > 300 and r[0].hits == r[0].shots
proc testRangeConstantVelocityLinearWins() =
let fx = synthesizeConstantVelocity(ticks = 120)
let r = replayFixture(fx, @[makeDriver("HeadOn", HeadOnGun()),
makeDriver("Linear", LinearGun())])
check "range: constant velocity -> Linear beats HeadOn",
r[1].hitRate() > r[0].hitRate()
proc testEnergyThresholdFixtureRule() =
# RULE e(t) = max(5, 50 - 0.5*t); e drops below 30 at t = 41.
let fx = synthesizeEnergyThresholdTurner(ticks = 100, e0 = 50.0,
decay = 0.5, threshold = 30.0)
let idx = 41
check "range: energy-threshold fixture crosses the rule threshold at t=41",
fx.states[idx].enemyEnergy < 30.0 and
fx.states[idx-1].enemyEnergy >= 30.0
# ── driver ───────────────────────────────────────────────────────────────────
randomize()
@@ -286,6 +366,12 @@ testToleranceFormula()
testPerfectAlignmentAlwaysFires()
testGrossMisalignmentFailsLongRange()
testDegenerateDistance()
testFixtureRoundTrip()
testFixtureEndMarkerRoundTrip()
testReplayDeterminism()
testRangeGroundTruthStationary()
testRangeConstantVelocityLinearWins()
testEnergyThresholdFixtureRule()
if failures > 0:
echo "\n", failures, " check(s) FAILED"