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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user