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:
@@ -0,0 +1,509 @@
|
||||
## Offline gun range: replay recorded or synthetic `WorldState` streams through
|
||||
## the EXISTING `VirtualTracker`, exactly as ModularBot's live loop drives it —
|
||||
## no Java server, no radar, no movement module and no real firing.
|
||||
##
|
||||
## Why this is definitionally the same metric as the online one: virtual-bullet
|
||||
## fitness is already a pure function of (a stream of `WorldState`, a list of
|
||||
## guns). The only thing the Java battle supplies is where the states come from.
|
||||
## Guns keep their own internal history, so a `seq[WorldState]` replayed IN ORDER
|
||||
## is a complete movement history.
|
||||
##
|
||||
## Fixture format (JSONL):
|
||||
## optional first line:
|
||||
## {"meta":{"adversary":"<name>","source":"synthetic|classic-robocode|live",
|
||||
## "perfect_info":false,"arena":{"w":800,"h":600},"note":"..."}}
|
||||
## then one line per tick, Tank Royale convention (0° = East, CCW +, degrees):
|
||||
## {"tick":<int>,"ex":<f>,"ey":<f>,"eh":<f>,"es":<f>,"ee":<f>,
|
||||
## "sx":<f>,"sy":<f>,"sh":<f>,"ss":<f>,"se":<f>}
|
||||
## `e*` is the enemy to predict, `s*` is the shooter.
|
||||
## Two optional per-tick extensions are written by the ModularBot recorder and
|
||||
## tolerated (defaulted) by every other producer:
|
||||
## "lst":<int> enemyTracker.lastSeenTick at this tick (staleness fidelity)
|
||||
## "eid":<int> enemy bot id (fitness bucket; defaults to 1)
|
||||
## A trailing live end marker is also optional:
|
||||
## {"end":{"enemy_died":<bool>,"ticks":<int>}}
|
||||
## It lets the replay reproduce the live resolver's final-tick behaviour.
|
||||
##
|
||||
## The replay never calls the gun selector, so it is RNG-free for every
|
||||
## deterministic gun. Tsetlin is stochastic and is expected to differ.
|
||||
|
||||
import std/[json, os, strformat, math, tables, strutils, random]
|
||||
import gun_interface
|
||||
import virtual_bullets
|
||||
|
||||
export gun_interface, virtual_bullets
|
||||
|
||||
const
|
||||
DefaultArenaW* = 800.0
|
||||
DefaultArenaH* = 600.0
|
||||
DefaultEnemyId* = 1
|
||||
|
||||
type
|
||||
FixtureMeta* = object
|
||||
adversary*: string
|
||||
source*: string ## "synthetic" | "classic-robocode" | "live"
|
||||
perfectInfo*: bool
|
||||
arenaW*, arenaH*: float
|
||||
note*: string
|
||||
|
||||
Fixture* = object
|
||||
meta*: FixtureMeta
|
||||
states*: seq[WorldState]
|
||||
lastSeen*: seq[int] ## parallel to states; -1 = unknown (use state.tick)
|
||||
enemyId*: int
|
||||
enemyDied*: bool ## target was dead at round end (recorder end marker)
|
||||
|
||||
BinStat* = object
|
||||
shots*, hits*: int
|
||||
|
||||
GunReport* = object
|
||||
## Per-gun fitness over the whole fixture, computed with the SAME window
|
||||
## semantics as ModularBot.onRoundEnded (min(count, WindowSize), iterate the
|
||||
## ring slots 0..<n).
|
||||
name*: string
|
||||
shots*, hits*: int
|
||||
bins*: array[len(PowerBins), BinStat]
|
||||
|
||||
GunDriver* = object
|
||||
## Type-erased handle to one gun. `predictCb`/`resultCb` forward to the
|
||||
## concrete gun inside a heap box so heterogeneous guns can live in a seq.
|
||||
name*: string
|
||||
predictCb*: proc(state: WorldState, bulletSpeed: float): GunPrediction {.closure.}
|
||||
resultCb*: proc(e: FeedbackEvent) {.closure.}
|
||||
readyCb*: proc(): bool {.closure.} ## nil => always ready (Tsetlin gate)
|
||||
|
||||
GunBox[G] = ref object
|
||||
g: G
|
||||
|
||||
proc makeDriver*[G](name: string, gun: G): GunDriver =
|
||||
## Wrap a concrete gun value in a type-erased driver.
|
||||
let box = GunBox[G](g: gun)
|
||||
result.name = name
|
||||
result.predictCb = proc(state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
box.g.predict(state, bulletSpeed)
|
||||
result.resultCb = proc(e: FeedbackEvent) =
|
||||
box.g.onResult(e)
|
||||
when compiles(box.g.isWarmedUp()):
|
||||
result.readyCb = proc(): bool = box.g.isWarmedUp()
|
||||
else:
|
||||
result.readyCb = nil
|
||||
|
||||
# ── fixture I/O ───────────────────────────────────────────────────────────────
|
||||
|
||||
proc initMeta*(source = "synthetic", arenaW = DefaultArenaW,
|
||||
arenaH = DefaultArenaH): FixtureMeta =
|
||||
FixtureMeta(source: source, arenaW: arenaW, arenaH: arenaH)
|
||||
|
||||
proc initFixture*(states: seq[WorldState], meta: FixtureMeta,
|
||||
lastSeen: seq[int] = @[], enemyId = DefaultEnemyId,
|
||||
enemyDied = false): Fixture =
|
||||
Fixture(meta: meta, states: states, lastSeen: lastSeen, enemyId: enemyId,
|
||||
enemyDied: enemyDied)
|
||||
|
||||
proc stateToJson(ws: WorldState, lst: int, eid: int): JsonNode =
|
||||
result = %*{
|
||||
"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,
|
||||
}
|
||||
if lst >= 0: result["lst"] = %lst
|
||||
if eid >= 0: result["eid"] = %eid
|
||||
|
||||
proc metaToJson(m: FixtureMeta): JsonNode =
|
||||
%*{"meta": {
|
||||
"adversary": m.adversary,
|
||||
"source": m.source,
|
||||
"perfect_info": m.perfectInfo,
|
||||
"arena": {"w": m.arenaW, "h": m.arenaH},
|
||||
"note": m.note,
|
||||
}}
|
||||
|
||||
proc stateFromJson(node: JsonNode, arenaW, arenaH: float,
|
||||
enemyId: int): WorldState =
|
||||
let ex = node["ex"].getFloat()
|
||||
let ey = node["ey"].getFloat()
|
||||
result = WorldState(
|
||||
enemyX: ex, enemyY: ey,
|
||||
enemyHeading: node["eh"].getFloat(),
|
||||
enemySpeed: node["es"].getFloat(),
|
||||
enemyEnergy: node["ee"].getFloat(),
|
||||
selfX: node["sx"].getFloat(),
|
||||
selfY: node["sy"].getFloat(),
|
||||
selfHeading: node["sh"].getFloat(),
|
||||
selfRadarHeading: node["sh"].getFloat(),
|
||||
selfSpeed: node["ss"].getFloat(),
|
||||
selfEnergy: node["se"].getFloat(),
|
||||
arenaWidth: arenaW,
|
||||
arenaHeight: arenaH,
|
||||
tick: node["tick"].getInt(),
|
||||
enemies: @[EnemyInfo(id: enemyId, x: ex, y: ey,
|
||||
heading: node["eh"].getFloat(),
|
||||
speed: node["es"].getFloat(),
|
||||
energy: node["ee"].getFloat())],
|
||||
)
|
||||
|
||||
proc parseMeta(node: JsonNode): FixtureMeta =
|
||||
result = initMeta()
|
||||
if node.hasKey("adversary"): result.adversary = node["adversary"].getStr()
|
||||
if node.hasKey("source"): result.source = node["source"].getStr()
|
||||
if node.hasKey("perfect_info"): result.perfectInfo = node["perfect_info"].getBool()
|
||||
if node.hasKey("note"): result.note = node["note"].getStr()
|
||||
if node.hasKey("arena"):
|
||||
let a = node["arena"]
|
||||
if a.hasKey("w"): result.arenaW = a["w"].getFloat()
|
||||
if a.hasKey("h"): result.arenaH = a["h"].getFloat()
|
||||
|
||||
proc saveFixture*(path: string, fx: Fixture) =
|
||||
## Write a fixture as JSONL. Truncates the file. A trailing `end` marker
|
||||
## records whether the target died at round end (live-resolver semantics).
|
||||
let f = open(path, fmWrite)
|
||||
defer: f.close()
|
||||
f.writeLine($metaToJson(fx.meta))
|
||||
for i, ws in fx.states:
|
||||
let lst = if i < fx.lastSeen.len: fx.lastSeen[i] else: -1
|
||||
f.writeLine($stateToJson(ws, lst, fx.enemyId))
|
||||
f.writeLine($(%*{"end": {"enemy_died": fx.enemyDied, "ticks": fx.states.len}}))
|
||||
|
||||
proc saveFixture*(path: string, states: seq[WorldState], meta: FixtureMeta,
|
||||
lastSeen: seq[int] = @[], enemyId = DefaultEnemyId,
|
||||
enemyDied = false) =
|
||||
saveFixture(path, initFixture(states, meta, lastSeen, enemyId, enemyDied))
|
||||
|
||||
proc loadFixture*(path: string): Fixture =
|
||||
## Parse a JSONL fixture. The meta line and the optional lst/eid fields are
|
||||
## optional; arena defaults to 800x600, enemy id to 1.
|
||||
if not fileExists(path):
|
||||
raise newException(IOError, "fixture not found: " & path)
|
||||
result = initFixture(@[], initMeta())
|
||||
var arenaW = DefaultArenaW
|
||||
var arenaH = DefaultArenaH
|
||||
var enemyId = DefaultEnemyId
|
||||
for rawLine in lines(path):
|
||||
let line = rawLine.strip()
|
||||
if line.len == 0: continue
|
||||
let node = parseJson(line)
|
||||
if node.hasKey("meta"):
|
||||
result.meta = parseMeta(node["meta"])
|
||||
arenaW = result.meta.arenaW
|
||||
arenaH = result.meta.arenaH
|
||||
continue
|
||||
if node.hasKey("eid"): enemyId = node["eid"].getInt()
|
||||
if node.hasKey("end"):
|
||||
if node["end"].hasKey("enemy_died"):
|
||||
result.enemyDied = node["end"]["enemy_died"].getBool()
|
||||
continue
|
||||
result.states.add stateFromJson(node, arenaW, arenaH, enemyId)
|
||||
result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1)
|
||||
result.enemyId = enemyId
|
||||
|
||||
# ── replay engine ─────────────────────────────────────────────────────────────
|
||||
|
||||
proc reportFor(tracker: VirtualTracker, drivers: seq[GunDriver],
|
||||
targetId: int): seq[GunReport] =
|
||||
## Exactly the accounting ModularBot.onRoundEnded does.
|
||||
let fit = tracker.fitnessFor(targetId)
|
||||
for gi in 0..<drivers.len:
|
||||
var r = GunReport(name: drivers[gi].name)
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let fw = fit[gi].bins[binIdx]
|
||||
let n = min(fw.count, WindowSize)
|
||||
var hits = 0
|
||||
for k in 0..<n:
|
||||
if fw.hits[k]: inc hits
|
||||
r.bins[binIdx] = BinStat(shots: n, hits: hits)
|
||||
r.shots += n
|
||||
r.hits += hits
|
||||
result.add r
|
||||
|
||||
proc replayFixture*(fx: Fixture, drivers: seq[GunDriver],
|
||||
targetId = -1, liveActual = false): seq[GunReport] =
|
||||
## Drive a fresh `VirtualTracker` over the whole fixture, one tick at a time,
|
||||
## in the same order the live loop uses:
|
||||
## 1. predict(state, bulletSpeed(PowerBins[i])) for i = 0..3, per gun
|
||||
## 2. spawnBullets for every gun (skipped for a not-yet-warm gun)
|
||||
## 3. tickBullets -> onResult on the owning gun
|
||||
## The gun selector (and therefore the 5th predict on the selected gun) is not
|
||||
## replayed: every gun's per-tick caches are tick-guarded, so it is a no-op.
|
||||
##
|
||||
## `liveActual` reproduces two ModularBot ordering quirks discovered by the
|
||||
## Task 3 acceptance test:
|
||||
## 1. The live loop calls go() (which dispatches the NEXT tick's scan into
|
||||
## enemyTracker) BEFORE it builds the enemy table handed to tickBullets.
|
||||
## A fixture recorded at the WorldState construction site therefore
|
||||
## snapshots the position one tick before the one the live resolver used,
|
||||
## so the resolver reads the NEXT state's enemy pose/lastSeenTick.
|
||||
## 2. If the target died during that final go(), the live aim block (spawn AND
|
||||
## resolution) is skipped entirely; the end marker tells us so and we drop
|
||||
## the final tick's resolutions. Synthetic fixtures leave liveActual false
|
||||
## (the state at the resolution tick is the ground truth).
|
||||
let tid = if targetId >= 0: targetId else: fx.enemyId
|
||||
let skipFinal = liveActual and fx.enemyDied
|
||||
var tracker = initTracker(drivers.len)
|
||||
for si in 0..<fx.states.len:
|
||||
let state = fx.states[si]
|
||||
for gi in 0..<drivers.len:
|
||||
var preds: array[len(PowerBins), GunPrediction]
|
||||
for i in 0..<len(PowerBins):
|
||||
preds[i] = drivers[gi].predictCb(state, bulletSpeed(PowerBins[i]))
|
||||
let ready = if drivers[gi].readyCb == nil: true else: drivers[gi].readyCb()
|
||||
if ready:
|
||||
tracker.spawnBullets(gi, preds, state, tid)
|
||||
|
||||
let actIdx = if liveActual and si + 1 < fx.states.len: si + 1 else: si
|
||||
let act = fx.states[actIdx]
|
||||
var enemyPositions: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]]
|
||||
var lst = act.tick
|
||||
if actIdx < fx.lastSeen.len and fx.lastSeen[actIdx] >= 0: lst = fx.lastSeen[actIdx]
|
||||
if act.enemies.len > 0:
|
||||
for e in act.enemies:
|
||||
enemyPositions[e.id] = (x: e.x, y: e.y, lastSeenTick: lst, alive: true)
|
||||
else:
|
||||
enemyPositions[tid] = (x: act.enemyX, y: act.enemyY, lastSeenTick: lst, alive: true)
|
||||
|
||||
let dref = drivers
|
||||
if not (skipFinal and si == fx.states.len - 1):
|
||||
tracker.tickBullets(state, enemyPositions,
|
||||
proc(gunId: GunId, binIdx: int, e: FeedbackEvent) =
|
||||
dref[gunId].resultCb(e))
|
||||
|
||||
result = reportFor(tracker, drivers, tid)
|
||||
|
||||
# ── formatting ────────────────────────────────────────────────────────────────
|
||||
|
||||
proc hitRate*(r: GunReport): float =
|
||||
if r.shots == 0: 0.0 else: r.hits.float / r.shots.float
|
||||
|
||||
proc formatReportRow*(r: GunReport): string =
|
||||
## One deterministic line: name, hits/shots, %, per-bin hits/shots.
|
||||
var bins = ""
|
||||
for i in 0..<len(PowerBins):
|
||||
bins.add fmt" p{PowerBins[i]:.1f}={r.bins[i].hits}/{r.bins[i].shots}"
|
||||
fmt"{r.name:<11} {r.hits:>5}/{r.shots:<5} {hitRate(r)*100.0:>6.1f}%{bins}"
|
||||
|
||||
proc formatReports*(fx: Fixture, reports: seq[GunReport]): string =
|
||||
result = fmt"# {fx.meta.adversary} (source={fx.meta.source}, ticks={fx.states.len}, enemyId={fx.enemyId})"
|
||||
result.add "\n"
|
||||
for r in reports:
|
||||
result.add formatReportRow(r) & "\n"
|
||||
|
||||
# ── synthetic trajectory generators (ground truth by construction) ────────────
|
||||
#
|
||||
# Coordinates: 0° = East, CCW positive. Self is stationary at (SelfX, SelfY) so
|
||||
# that the trajectory is attributable solely to the enemy. Arena 800x600.
|
||||
|
||||
const
|
||||
SelfX = 200.0
|
||||
SelfY = 300.0
|
||||
SelfEnergy = 100.0
|
||||
|
||||
proc mkState(tick: int, ex, ey, eh, es, ee: float,
|
||||
arenaW = DefaultArenaW, arenaH = DefaultArenaH,
|
||||
selfX = SelfX, selfY = SelfY, selfEnergy = SelfEnergy,
|
||||
enemyId = DefaultEnemyId): WorldState =
|
||||
WorldState(
|
||||
enemyX: ex, enemyY: ey, enemyHeading: eh, enemySpeed: es, enemyEnergy: ee,
|
||||
selfX: selfX, selfY: selfY, selfSpeed: 0.0, selfHeading: 0.0,
|
||||
selfRadarHeading: 0.0, selfEnergy: selfEnergy,
|
||||
arenaWidth: arenaW, arenaHeight: arenaH, tick: tick,
|
||||
enemies: @[EnemyInfo(id: enemyId, x: ex, y: ey, heading: eh, speed: es, energy: ee)],
|
||||
)
|
||||
|
||||
proc finish(states: seq[WorldState], adversary, note: string): Fixture =
|
||||
var meta = initMeta(source = "synthetic")
|
||||
meta.adversary = adversary
|
||||
meta.note = note
|
||||
initFixture(states, meta)
|
||||
|
||||
proc synthesizeStationary*(ticks = 200, ex = 600.0, ey = 300.0): Fixture =
|
||||
var states: seq[WorldState]
|
||||
for t in 0..<ticks:
|
||||
states.add mkState(t, ex, ey, 0.0, 0.0, 100.0)
|
||||
finish(states, "stationary", "enemy fixed; any good gun scores ~100%")
|
||||
|
||||
proc synthesizeConstantVelocity*(ticks = 150, ex = 100.0, ey = 300.0,
|
||||
heading = 0.0, speed = 4.0): Fixture =
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
let hr = degToRad(heading)
|
||||
for t in 0..<ticks:
|
||||
states.add mkState(t, x, y, heading, speed, 100.0)
|
||||
x += cos(hr) * speed
|
||||
y += sin(hr) * speed
|
||||
finish(states, "constant-velocity",
|
||||
"straight line, no walls reached; Linear/HeadOn should lead the target")
|
||||
|
||||
proc synthesizeCircular*(ticks = 220, ex = 400.0, ey = 300.0,
|
||||
heading = 0.0, speed = 6.0, turnDeg = 3.0): Fixture =
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
var h = heading
|
||||
for t in 0..<ticks:
|
||||
states.add mkState(t, x, y, h, speed, 100.0)
|
||||
let hr = degToRad(h)
|
||||
x += cos(hr) * speed
|
||||
y += sin(hr) * speed
|
||||
h += turnDeg
|
||||
finish(states, "circular",
|
||||
fmt"constant turn {turnDeg} deg/tick; Circular/Accel should fit")
|
||||
|
||||
proc synthesizeWallBounce*(ticks = 240, ex = 100.0, ey = 100.0,
|
||||
heading = 45.0, speed = 6.0): Fixture =
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
var h = heading
|
||||
let m = BotRadius
|
||||
for t in 0..<ticks:
|
||||
states.add mkState(t, x, y, h, speed, 100.0)
|
||||
let hr = degToRad(h)
|
||||
var nx = x + cos(hr) * speed
|
||||
var ny = y + sin(hr) * speed
|
||||
if nx < m: nx = m; h = 180.0 - h
|
||||
elif nx > DefaultArenaW - m: nx = DefaultArenaW - m; h = 180.0 - h
|
||||
if ny < m: ny = m; h = -h
|
||||
elif ny > DefaultArenaH - m: ny = DefaultArenaH - m; h = -h
|
||||
x = nx
|
||||
y = ny
|
||||
finish(states, "wall-bounce", "specular reflection off all four walls")
|
||||
|
||||
proc synthesizeOscillator*(ticks = 240, ex = 200.0, ey = 300.0,
|
||||
speed = 4.0, period = 30): Fixture =
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
for t in 0..<ticks:
|
||||
let phase = (t div period) mod 2
|
||||
let h = if phase == 0: 0.0 else: 180.0
|
||||
let hr = degToRad(h)
|
||||
states.add mkState(t, x, y, h, speed, 100.0)
|
||||
x += cos(hr) * speed
|
||||
y += sin(hr) * speed
|
||||
finish(states, "oscillator", fmt"east for {period}, then west for {period}, repeat")
|
||||
|
||||
proc synthesizeRandomWalk*(ticks = 260, ex = 400.0, ey = 300.0,
|
||||
speed = 4.0, seed = 20250920,
|
||||
maxTurn = 15.0): Fixture =
|
||||
var rng = initRand(seed)
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
var h = 0.0
|
||||
let m = BotRadius
|
||||
for t in 0..<ticks:
|
||||
states.add mkState(t, x, y, h, speed, 100.0)
|
||||
h += rng.rand(-maxTurn .. maxTurn)
|
||||
let hr = degToRad(h)
|
||||
var nx = x + cos(hr) * speed
|
||||
var ny = y + sin(hr) * speed
|
||||
if nx < m or nx > DefaultArenaW - m: h = 180.0 - h
|
||||
if ny < m or ny > DefaultArenaH - m: h = -h
|
||||
nx = clamp(nx, m, DefaultArenaW - m)
|
||||
ny = clamp(ny, m, DefaultArenaH - m)
|
||||
x = nx
|
||||
y = ny
|
||||
finish(states, "random-walk",
|
||||
fmt"seeded (seed={seed}), +-{maxTurn} deg/tick heading jitter")
|
||||
|
||||
proc synthesizeDecelBeforeTurn*(ticks = 260, ex = 100.0, ey = 300.0,
|
||||
cruise = 6.0, cruiseTicks = 45,
|
||||
turnDeg = 45.0): Fixture =
|
||||
## Known rule: accelerate to cruise, hold, brake to a full stop, pivot
|
||||
## `turnDeg` degrees while stopped, then accelerate again. The stop is the
|
||||
## tell-tale StopShot is meant to catch.
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
var h = 0.0
|
||||
var v = 0.0
|
||||
var phase = "accel"
|
||||
var phaseT = 0
|
||||
for t in 0..<ticks:
|
||||
states.add mkState(t, x, y, h, v, 100.0)
|
||||
case phase
|
||||
of "accel":
|
||||
v = min(cruise, v + 1.0)
|
||||
if v >= cruise:
|
||||
phase = "cruise"; phaseT = 0
|
||||
of "cruise":
|
||||
inc phaseT
|
||||
if phaseT >= cruiseTicks: phase = "brake"
|
||||
of "brake":
|
||||
v = max(0.0, v - 3.0)
|
||||
if v <= 0.0:
|
||||
v = 0.0
|
||||
phase = "pivot"; phaseT = 0
|
||||
of "pivot":
|
||||
if phaseT < 3:
|
||||
h += turnDeg
|
||||
inc phaseT
|
||||
if phaseT >= 4:
|
||||
phase = "accel"
|
||||
else: discard
|
||||
let hr = degToRad(h)
|
||||
x += cos(hr) * v
|
||||
y += sin(hr) * v
|
||||
x = clamp(x, BotRadius, DefaultArenaW - BotRadius)
|
||||
y = clamp(y, BotRadius, DefaultArenaH - BotRadius)
|
||||
finish(states, "decel-before-turn",
|
||||
"cruise -> full stop -> pivot 3x45deg -> accelerate; tests StopShot")
|
||||
|
||||
proc synthesizeEnergyThresholdTurner*(ticks = 200, ex = 100.0, ey = 300.0,
|
||||
e0 = 50.0, decay = 0.5, threshold = 30.0,
|
||||
speed = 5.0, hardTurnDeg = 20.0): Fixture =
|
||||
## The falsifiable one. RULE (known by construction, stated in `note`):
|
||||
## energy(t) = max(5, e0 - decay*t)
|
||||
## while energy >= threshold: heading constant (straight, predictable)
|
||||
## while energy < threshold: heading += hardTurnDeg each tick (hard turn)
|
||||
## A learner that finds the rule should switch from straight-line prediction
|
||||
## to turn prediction exactly at the tick energy crosses the threshold.
|
||||
var states: seq[WorldState]
|
||||
var x = ex
|
||||
var y = ey
|
||||
var h = 0.0
|
||||
let m = BotRadius
|
||||
for t in 0..<ticks:
|
||||
let e = max(5.0, e0 - decay * t.float)
|
||||
states.add mkState(t, x, y, h, speed, e)
|
||||
if e < threshold: h += hardTurnDeg
|
||||
let hr = degToRad(h)
|
||||
var nx = x + cos(hr) * speed
|
||||
var ny = y + sin(hr) * speed
|
||||
if nx < m or nx > DefaultArenaW - m: h = 180.0 - h
|
||||
if ny < m or ny > DefaultArenaH - m: h = -h
|
||||
nx = clamp(nx, m, DefaultArenaW - m)
|
||||
ny = clamp(ny, m, DefaultArenaH - m)
|
||||
x = nx
|
||||
y = ny
|
||||
var meta = initMeta(source = "synthetic")
|
||||
meta.adversary = "energy-threshold-turner"
|
||||
meta.note = fmt"RULE: straight while energy>={threshold}; hard {hardTurnDeg} deg/tick turn below {threshold}; energy={e0}-{decay}*t (floor 5)"
|
||||
initFixture(states, meta)
|
||||
|
||||
# Named fixtures for the demo runner.
|
||||
const SyntheticFixtureNames* = [
|
||||
"stationary",
|
||||
"constant-velocity",
|
||||
"circular",
|
||||
"wall-bounce",
|
||||
"oscillator",
|
||||
"random-walk",
|
||||
"decel-before-turn",
|
||||
"energy-threshold-turner",
|
||||
]
|
||||
|
||||
proc synthesizeByName*(name: string): Fixture =
|
||||
case name
|
||||
of "stationary": synthesizeStationary()
|
||||
of "constant-velocity": synthesizeConstantVelocity()
|
||||
of "circular": synthesizeCircular()
|
||||
of "wall-bounce": synthesizeWallBounce()
|
||||
of "oscillator": synthesizeOscillator()
|
||||
of "random-walk": synthesizeRandomWalk()
|
||||
of "decel-before-turn": synthesizeDecelBeforeTurn()
|
||||
of "energy-threshold-turner": synthesizeEnergyThresholdTurner()
|
||||
else:
|
||||
raise newException(ValueError, "unknown synthetic fixture: " & name)
|
||||
@@ -0,0 +1,115 @@
|
||||
## 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 ON (compiled in via `const RecordWorldState = true`),
|
||||
## 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 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"
|
||||
serverJar = "/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar"
|
||||
runnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"
|
||||
|
||||
const TsetlinId = 2
|
||||
|
||||
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)
|
||||
|
||||
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 (is RecordWorldState true?)"
|
||||
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(), liveActual = true)
|
||||
|
||||
# Map online stats by gun id.
|
||||
var onShots: array[13, int]
|
||||
var onHits: array[13, int]
|
||||
var onNames: array[13, string]
|
||||
for g in online["guns"]:
|
||||
let id = g["id"].getInt()
|
||||
if id >= 0 and id < 13:
|
||||
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..<13:
|
||||
let r = reports[id]
|
||||
let match = r.hits == onHits[id] and r.shots == onShots[id]
|
||||
var verdict: string
|
||||
if id == TsetlinId:
|
||||
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 is stochastic and is allowed to differ.)"
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
## Task 2: emit the synthetic trajectory fixtures to tools/fixtures/ as JSONL.
|
||||
## Run with:
|
||||
## nim c -r common_libs/tests/gen_synthetic_fixtures.nim
|
||||
## Deterministic: the random-walk fixture is seeded.
|
||||
|
||||
import std/[os, strformat]
|
||||
import gun_harness/offline_range
|
||||
|
||||
const outDir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
|
||||
|
||||
proc main() =
|
||||
createDir(outDir)
|
||||
for name in SyntheticFixtureNames:
|
||||
let fx = synthesizeByName(name)
|
||||
let path = outDir / (name & ".jsonl")
|
||||
saveFixture(path, fx)
|
||||
echo fmt"wrote {path} ({fx.states.len} ticks, source={fx.meta.source})"
|
||||
echo "done."
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
## Shared helper: build the 13 ModularBot guns as type-erased offline range
|
||||
## drivers, in ModularBot's gun-id order, with the same readiness gate the live
|
||||
## loop uses (Tsetlin only spawns once its 10-frame window is full).
|
||||
|
||||
import std/random
|
||||
import gun_harness/offline_range
|
||||
import guns/head_on
|
||||
import guns/linear
|
||||
import guns/circular
|
||||
import guns/tsetlin
|
||||
import guns/guess_factor
|
||||
import guns/pattern_matcher
|
||||
import guns/wall_bounce
|
||||
import guns/accel_predictor
|
||||
import guns/stop_shot
|
||||
import guns/displacement
|
||||
import guns/averaged_lead
|
||||
import guns/decay_gf
|
||||
import guns/knn_gun
|
||||
|
||||
proc buildAllGunDrivers*(seed = -1): seq[GunDriver] =
|
||||
## seed >= 0 re-seeds the global RNG after constructing Tsetlin so the
|
||||
## stochastic gun's learning is reproducible for offline runs. (Its
|
||||
## constructor calls randomize(); we override that seed afterwards.)
|
||||
var tsetlin = initTsetlinGun()
|
||||
if seed >= 0:
|
||||
randomize(seed)
|
||||
result = @[
|
||||
makeDriver("HeadOn", HeadOnGun()),
|
||||
makeDriver("Linear", LinearGun()),
|
||||
makeDriver("Tsetlin", tsetlin),
|
||||
makeDriver("Circular", CircularGun()),
|
||||
makeDriver("GuessFactor", initGFGun()),
|
||||
makeDriver("Pattern", PatternMatcherGun()),
|
||||
makeDriver("WallBounce", initWallBounceGun()),
|
||||
makeDriver("Accel", initAccelGun()),
|
||||
makeDriver("StopShot", initStopShotGun()),
|
||||
makeDriver("Displace", initDisplacementGun()),
|
||||
makeDriver("AvgLead", initAveragedLeadGun()),
|
||||
makeDriver("DecayGF", initDecayGFGun()),
|
||||
makeDriver("KNN", initKNNGun()),
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
## Task 4: offline gun-range runner. Replays one or more fixtures through the
|
||||
## 13 ModularBot guns and prints a deterministic per-gun table (overall hit rate
|
||||
## plus per-power-bin hits/shots) for each fixture.
|
||||
##
|
||||
## Usage:
|
||||
## nim c -r common_libs/tests/run_range.nim # all tools/fixtures/*.jsonl
|
||||
## nim c -r common_libs/tests/run_range.nim path/a.jsonl path/b.jsonl
|
||||
## nim c -r common_libs/tests/run_range.nim --timing # add per-fixture wall time
|
||||
##
|
||||
## Tsetlin is stochastic; it is seeded (seed=1) here so this tool's output is
|
||||
## reproducible run-to-run. Its numbers are NOT expected to match a live battle.
|
||||
|
||||
import std/[os, algorithm, strformat, times]
|
||||
import gun_harness/offline_range
|
||||
import range_guns
|
||||
|
||||
const fixturesDir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
|
||||
|
||||
proc fixtureFiles(args: seq[string]): seq[string] =
|
||||
for a in args:
|
||||
if a == "--timing": continue
|
||||
if dirExists(a):
|
||||
for f in walkFiles(a / "*.jsonl"): result.add f
|
||||
elif fileExists(a):
|
||||
result.add a
|
||||
else:
|
||||
echo "warning: not found: ", a
|
||||
if result.len == 0:
|
||||
for f in walkFiles(fixturesDir / "*.jsonl"): result.add f
|
||||
result.sort()
|
||||
|
||||
proc main() =
|
||||
var args: seq[string]
|
||||
for i in 1..paramCount():
|
||||
args.add paramStr(i)
|
||||
let showTiming = "--timing" in args
|
||||
let files = fixtureFiles(args)
|
||||
if files.len == 0:
|
||||
echo "no fixtures found under ", fixturesDir
|
||||
echo "run: nim c -r common_libs/tests/gen_synthetic_fixtures.nim"
|
||||
quit(1)
|
||||
|
||||
var grandShots, grandHits = 0
|
||||
for path in files:
|
||||
let fx = loadFixture(path)
|
||||
var t0 = 0.0
|
||||
if showTiming: t0 = epochTime()
|
||||
let reports = replayFixture(fx, buildAllGunDrivers(seed = 1),
|
||||
liveActual = (fx.meta.source == "live"))
|
||||
let elapsed = if showTiming: epochTime() - t0 else: 0.0
|
||||
stdout.write formatReports(fx, reports)
|
||||
if showTiming:
|
||||
echo fmt" replay: {elapsed*1000.0:.1f} ms"
|
||||
var totShots, totHits = 0
|
||||
for r in reports:
|
||||
totShots += r.shots
|
||||
totHits += r.hits
|
||||
grandShots += totShots
|
||||
grandHits += totHits
|
||||
echo ""
|
||||
if files.len > 1:
|
||||
echo fmt"TOTAL across {files.len} fixtures: {grandHits}/{grandShots} ({grandHits.float/grandShots.float*100.0:.1f}%)"
|
||||
echo fmt"(replayed {files.len} fixture(s))"
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
@@ -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