## Task 3: acceptance test — prove the offline range reproduces the live ## virtual-bullet metric, or the range is worthless. ## ## Steps: ## 1. run ONE live ModularBot vs OscillatorBot round with the ModularBot ## recorder enabled for this battle only (the test exports ## TR_RECORD_WORLDSTATE=1, which the bot reads at RUNTIME; ordinary ## builds leave it unset and write no fixture), ## 2. read the online per-gun virtual fitness from /tmp/gun_stats.jsonl, ## 3. replay the recorded WorldState fixture offline through the same guns, ## 4. compare. ## ## Tsetlin is stochastic (tmLearnOne calls rand(); its constructor calls ## randomize()), so byte-identical replay is impossible for it. The 12 ## deterministic guns must match EXACTLY; Tsetlin is reported separately. ## ## Run with: ## nim c -r common_libs/tests/acceptance_offline_vs_online.nim ## ## Requires TR_SERVER_JAR / TR_BATTLE_RUNNER (or the default dev paths below). import std/[json, os, strformat, strutils, math] import test_framework/test_framework import test_framework/server_manager import gun_harness/offline_range import range_guns const repoRoot = currentSourcePath().parentDir.parentDir.parentDir modularBotDir = repoRoot / "ModularBot_garage" adversaryDir = repoRoot / "common_libs" / "test_framework" / "adversaries" / "OscillatorBot" statsPath = "/tmp/gun_stats.jsonl" recordPath = "/tmp/worldstate_record.jsonl" defaultRunnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar" # Honor the caller's TR_SERVER_JAR / TR_BATTLE_RUNNER; otherwise use the # current defaults (server 1.3.1). The runner uses the same default as # runner_process.nim so an unset env var is not silently treated as "missing". let serverJar = getEnv("TR_SERVER_JAR", DefaultServerJar) let runnerJar = getEnv("TR_BATTLE_RUNNER", defaultRunnerJar) const TsetlinId = 2 const TmSelectorId = 13 ## also stochastic (rand() in Gate choose + TM feedback) proc isStochastic(id: int): bool = id == TsetlinId or id == TmSelectorId proc lastOnlineRound(path: string): JsonNode = result = nil for line in lines(path): let s = line.strip() if s.len == 0: continue let node = parseJson(s) if node.hasKey("guns"): result = node proc main() = if not fileExists(serverJar) or not fileExists(runnerJar): echo "Skipping: TR JARs not found (server=", serverJar, ", runner=", runnerJar, ")" quit(0) if not fileExists(modularBotDir / "src" / "ModularBot.nim"): echo "Skipping: ModularBot source not found" quit(0) for p in [statsPath, recordPath]: if fileExists(p): removeFile(p) # Enable the ModularBot's runtime world-state recorder for THIS battle only. # The env var is inherited by the battle-runner process and then by the bot # processes it spawns, so a single invocation of this test is self-contained. putEnv("TR_RECORD_WORLDSTATE", "1") defer: delEnv("TR_RECORD_WORLDSTATE") # This test validates PER-GUN offline==online parity, so it needs the FULL # virtual-bullet rack online. `TR_VBULLET_ADMIT_ONLY=1` (shipped) skips # predict+spawn for every non-admitted gun, so under the shipped onlyPattern # rack their online `vShots` would be 0 while the offline range still spawns # all of them — a guaranteed MISMATCH. Pin the gate OFF for this battle so the # live loop spawns every gun and the per-gun comparison stays well-defined. putEnv("TR_VBULLET_ADMIT_ONLY", "0") defer: delEnv("TR_VBULLET_ADMIT_ONLY") echo "=== live battle: ModularBot vs OscillatorBot, 1 round, max speed ===" let battle = runBattle(@[modularBotDir, adversaryDir], rounds = 1, timeout = 240000, maxSpeed = true) for res in battle.results: echo fmt" {res.name:<14} rank={res.rank} score={res.totalScore}" if not fileExists(recordPath): echo "FAIL: recorder produced no fixture (TR_RECORD_WORLDSTATE not inherited?)" quit(1) if not fileExists(statsPath): echo "FAIL: no /tmp/gun_stats.jsonl" quit(1) let online = lastOnlineRound(statsPath) let fx = loadFixture(recordPath) let reports = replayFixture(fx, buildAllGunDrivers(enableTmSelector = false), liveActual = true) # Map online stats by gun id. var onShots: array[14, int] var onHits: array[14, int] var onNames: array[14, string] for g in online["guns"]: let id = g["id"].getInt() if id >= 0 and id < 14: onShots[id] = g["vShots"].getInt() onHits[id] = g["vHits"].getInt() onNames[id] = g["name"].getStr() echo "" echo fmt"fixture: {recordPath} ticks={fx.states.len} enemyId={fx.enemyId} enemyDied={fx.enemyDied}" echo "online stats: /tmp/gun_stats.jsonl round ", online["round"].getInt() echo "" echo "gun online(vHits/vShots) offline(hits/shots) verdict" echo "-----------------------------------------------------------------------" var matches = 0 var deterministic = 0 for id in 0..<14: let r = reports[id] let match = r.hits == onHits[id] and r.shots == onShots[id] var verdict: string if isStochastic(id): verdict = if match: "MATCH (stochastic)" else: "differs (stochastic, expected)" else: inc deterministic if match: inc matches verdict = "OK" else: verdict = "MISMATCH" echo fmt"{r.name:<12} {onHits[id]:>5}/{onShots[id]:<5} {r.hits:>5}/{r.shots:<5} {verdict}" echo "" echo fmt"deterministic guns matching exactly: {matches}/{deterministic}" if matches != deterministic: echo "VERDICT: FAIL — offline range does NOT reproduce the live metric." quit(1) echo "VERDICT: PASS — offline == online for all 12 deterministic guns." echo "(Tsetlin and TMSelect are stochastic and are allowed to differ.)" when isMainModule: main()