68e0375be2
Replaces melee_scan in the rack. melee_scan spun the radar at the 45 deg/tick cap unconditionally, so a full 360 deg revolution took 8 ticks and every enemy was scanned roughly every 8 ticks. The new module starts with the same full spin, and once it is SURE it has covered every enemy it sweeps back and forth over only the minimal covering arc of all enemy bearings. MEASURED, real melees via the bridge, per-enemy onScannedBot counts: 3-bot melee (2 enemies): 29.6 -> 61.1 scans/100 melee-ticks (2.06x) 4-bot melee (3 enemies): 36.0 -> 75.7 scans/100 melee-ticks (2.10x) Covering-arc widths observed: mostly <90 deg in the 2-enemy case, up to 240 deg in the 3-enemy case, so the gain shrinks as the arc widens - and at the ExitTrackWidthDeg=300 fallback it degenerates to exactly the old full spin, so there is no loss when narrowing would not help. TRADEOFF, recorded rather than hidden: a wider arc legitimately takes longer to traverse, so the freshness window costs 5-7 points (fresh<=16: 93-95% vs 98-100%) and more at fresh<=8 (75-76% vs 97-100%). More scans per enemy, at slightly staler individual fixes. DESIGN: acquisition spins 360 until every live known enemy was seen within FreshnessTicks=16 (two revolutions of slack), no new id appeared, and the live count matches getEnemyCount(); that must hold FreshStreakTicks=3 consecutive ticks. Tracking then bang-bang sweeps the wraparound-aware covering arc (350+10 -> 20 through 0) widened by MarginDeg=20 each end, at up to 45 deg/tick. Fallbacks return to acquisition: any stale enemy, any new id, or an arc >= 300 deg. Enter 270 / Exit 300 gives 30 deg of hysteresis so it cannot flap. Adds EnemyInfo.lastSeenTick (additive) so coverage is judged on staleness, not mere knowledge - without it an enemy that slipped behind the sweep would keep contributing its own stale bearing, which is self-confirming. The offline range now round-trips that field from the fixture 'lst'. COMPANION FIX, and it matters: the radar-mode switch used the TRACKER's known enemy count, so in melee the bot saw one enemy before scanning the second, locked to 1v1, and the melee radar never ran at all. Now uses getEnemyCount() (server truth), so melee mode persists until one enemy is genuinely left. 41 new unit checks (wraparound arcs, straddle at 0/360, single/empty enemies, the 45 deg/tick cap, every phase transition and fallback). melee_scan is kept but marked DEPRECATED; nothing in the rack imports it. Non-regression: 33 gun-harness checks, vbullet metric, power selection, and 12/12 offline==online acceptance all pass.
520 lines
20 KiB
Nim
520 lines
20 KiB
Nim
## 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(),
|
|
lastSeenTick: (if node.hasKey("lst"): node["lst"].getInt()
|
|
else: node["tick"].getInt()))],
|
|
)
|
|
|
|
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,
|
|
metric = ActiveMetric,
|
|
tickCb: proc(t: ptr VirtualTracker) {.closure.} = nil): 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).
|
|
##
|
|
## `metric` defaults to the process-wide `GUN_VBULLET_METRIC` switch read by
|
|
## virtual_bullets; pass it explicitly only to force a model in one process.
|
|
let tid = if targetId >= 0: targetId else: fx.enemyId
|
|
let skipFinal = liveActual and fx.enemyDied
|
|
var tracker = initTracker(drivers.len, metric)
|
|
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))
|
|
if tickCb != nil:
|
|
tickCb(addr tracker)
|
|
|
|
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,
|
|
lastSeenTick: tick)],
|
|
)
|
|
|
|
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)
|