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:
@@ -38,6 +38,11 @@ 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
|
||||
## Offline range recorder: when true, append the exact WorldState the bot builds
|
||||
## each tick to /tmp/worldstate_record.jsonl so it can be replayed offline.
|
||||
## Mirrors the ShotLog idiom: false compiles the machinery out entirely.
|
||||
const RecordWorldState = false
|
||||
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
|
||||
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN"]
|
||||
|
||||
const
|
||||
@@ -185,12 +190,68 @@ proc printConfig(bot: ModularBot, changed: string = "") =
|
||||
bot.prevRadar = bot.radarMode
|
||||
bot.prevTarget = bot.currentTargetId
|
||||
|
||||
proc startWorldStateRecord(bot: ModularBot) =
|
||||
## Truncate the fixture and write the meta line at round start.
|
||||
when RecordWorldState:
|
||||
try:
|
||||
let f = open(WorldStateRecordPath, fmWrite)
|
||||
f.writeLine($(%*{"meta": {
|
||||
"adversary": "live-recorded",
|
||||
"source": "live",
|
||||
"perfect_info": false,
|
||||
"arena": {"w": getArenaWidth().float, "h": getArenaHeight().float},
|
||||
"note": "exact WorldState the bot built, with tracker lastSeenTick",
|
||||
}}))
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
proc finishWorldStateRecord(bot: ModularBot) =
|
||||
## Append the end marker so the offline replay can reproduce the live
|
||||
## resolver's final-tick behaviour: if the target died during the last go()
|
||||
## the live aim block was skipped, so no bullet resolved on that tick.
|
||||
when RecordWorldState:
|
||||
var died = false
|
||||
if bot.lastKnownTargetId >= 0 and bot.enemyTracker.enemies.contains(bot.lastKnownTargetId):
|
||||
died = not bot.enemyTracker.enemies[bot.lastKnownTargetId].alive
|
||||
try:
|
||||
let f = open(WorldStateRecordPath, fmAppend)
|
||||
f.writeLine($(%*{"end": {"enemy_died": died}}))
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
proc recordWorldState(bot: ModularBot, ws: WorldState) =
|
||||
## Append one tick of the state the bot ACTUALLY built (including the stale
|
||||
## tracker positions between radar scans) so the offline replay sees the same
|
||||
## information the guns saw online.
|
||||
when RecordWorldState:
|
||||
let tid = bot.currentTargetId
|
||||
var lst = -1
|
||||
if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
|
||||
lst = bot.enemyTracker.enemies[tid].lastSeenTick
|
||||
var row = %*{
|
||||
"tick": ws.tick,
|
||||
"ex": ws.enemyX, "ey": ws.enemyY,
|
||||
"eh": ws.enemyHeading, "es": ws.enemySpeed, "ee": ws.enemyEnergy,
|
||||
"sx": ws.selfX, "sy": ws.selfY,
|
||||
"sh": ws.selfHeading, "ss": ws.selfSpeed, "se": ws.selfEnergy,
|
||||
"eid": tid,
|
||||
}
|
||||
if lst >= 0: row["lst"] = %lst
|
||||
try:
|
||||
let f = open(WorldStateRecordPath, fmAppend)
|
||||
f.writeLine($row)
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): WorldState =
|
||||
var ei: seq[EnemyInfo]
|
||||
for es in bot.enemyTracker.allAlive():
|
||||
ei.add EnemyInfo(id: es.id, x: es.x, y: es.y,
|
||||
heading: es.heading, speed: es.speed, energy: es.energy)
|
||||
WorldState(
|
||||
result = WorldState(
|
||||
enemyX: ex,
|
||||
enemyY: ey,
|
||||
enemySpeed: espeed,
|
||||
@@ -207,6 +268,8 @@ proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): Worl
|
||||
tick: bot.tick,
|
||||
enemies: ei,
|
||||
)
|
||||
when RecordWorldState:
|
||||
bot.recordWorldState(result)
|
||||
|
||||
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
||||
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
|
||||
@@ -247,6 +310,8 @@ method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
||||
|
||||
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round).
|
||||
when RecordWorldState:
|
||||
bot.finishWorldStateRecord()
|
||||
# Use lastKnownTargetId: currentTargetId is -1 if enemy died before round end
|
||||
let targetId = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId
|
||||
# Per-target fitness when known, else a deterministic recency-weighted aggregate
|
||||
@@ -306,6 +371,8 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
|
||||
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.roundNumber = e.roundNumber
|
||||
when RecordWorldState:
|
||||
bot.startWorldStateRecord()
|
||||
bot.realShotsFired = 0
|
||||
bot.realHits = 0
|
||||
bot.lastKnownTargetId = -1
|
||||
|
||||
Reference in New Issue
Block a user