## STRAFE curved-wings gates (job j112). OFFLINE / cheap: no Java, no server, no ## battle. Run with: ## ## nim c -r --nimcache:/tmp/nc_j112 --path:common_libs \ ## common_libs/tests/measure_strafe_wings.nim ## ## Reuses the fixture loader + kinematic replay shape of j111's ## `measure_strafe_range_stall.nim`. The "before" arm is the module with the ## wings DISABLED (`TR_STRAFE_KAPPA=0`, `TR_STRAFE_ESCAPE=0`, ## `TR_STRAFE_WALL_BIAS=0`), which is exactly the pre-j112 straight-line mover; ## the "after" arm is the module defaults. ## ## GATE A WALL/CORNER OCCUPANCY (the load-bearing metric). On the DrussGT ## fixtures, the fraction of commanded ticks within WallProxPx of a ## wall and inside the corner region, and the LONGEST CONTINUOUS RUN ## inside each. Before vs after. ## ## GATE B ESCAPE PROOF. Two things: ## (1) a per-tick guarantee check over a dense grid of start positions ## x headings x enemy placements: whenever the mover runs in ## escape mode, is (velocity . wall-away-normal) >= 0? A single ## violation falsifies the guarantee. ## (2) the worst-case longest corner run over the same sweep. ## ## GATE C REGRESSION. Open-space parity (with no wall within the margin the ## ON and OFF command streams must be bit-identical and kappa == 0), ## the sign-flip property (every command is exactly +/-MaxSpeed), and ## speed / turn-rate ON vs OFF. ## ## HONESTY: the fixtures are OPEN-LOOP (recorded while the enemy was reacting to ## a PREVIOUS mover). A positive here is NOT a live win; it is a veto-capable ## check only. import std/[os, json, math, random, strutils, algorithm] import gun_harness/gun_interface import movements/strafe const repoRoot = currentSourcePath().parentDir.parentDir.parentDir const ArenaW = 800.0 const ArenaH = 600.0 const GridSize = 36.0 const Cols = int(ArenaW / GridSize) # 22 const Rows = int(ArenaH / GridSize) # 16 const MarginX = (ArenaW - Cols.float * GridSize) / 2.0 const MarginY = (ArenaH - Rows.float * GridSize) / 2.0 const Seed = 20250923 ## "Near a wall" and "in a corner region" in px. 54 == 1.5 tiles, 72 == 2 tiles. const WallProxPx = 54.0 const CornerPx = 72.0 # ── fixture loading (same shape as j111's gate) ────────────────────────────── type Sample = object enemyX, enemyY, enemyEnergy: float selfX, selfY, selfHeading, selfEnergy: float proc loadSamples(fixture: string): seq[Sample] = let path = repoRoot / "tools" / "fixtures" / fixture for rawLine in lines(path): let line = rawLine.strip() if line.len == 0: continue let n = parseJson(line) if n.hasKey("meta") or n.hasKey("end"): continue result.add Sample( enemyX: n["ex"].getFloat(), enemyY: n["ey"].getFloat(), enemyEnergy: n["ee"].getFloat(), selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(), selfHeading: n["sh"].getFloat(), selfEnergy: n["se"].getFloat()) proc loadRoundStarts(fixture: string): seq[int] = let side = repoRoot / "tools" / "fixtures" / "drussgt_meta" / (fixture & ".rounds.json") if not fileExists(side): return for r in parseFile(side)["rounds"]: result.add r["startTick"].getInt() proc makeWs(s: Sample, x, y, h, sp: float): WorldState = WorldState( enemyX: s.enemyX, enemyY: s.enemyY, enemyHeading: 0.0, enemySpeed: 0.0, enemyEnergy: s.enemyEnergy, selfX: x, selfY: y, selfHeading: h, selfSpeed: sp, selfEnergy: s.selfEnergy, arenaWidth: ArenaW, arenaHeight: ArenaH, enemies: @[EnemyInfo(id: 1, x: s.enemyX, y: s.enemyY, energy: s.enemyEnergy)]) proc clearance(x, y: float): float = min(min(x, ArenaW - x), min(y, ArenaH - y)) proc wrap180(d: float): float {.inline.} = result = d while result > 180.0: result -= 360.0 while result < -180.0: result += 360.0 proc inCorner(x, y: float): bool = (x < CornerPx or x > ArenaW - CornerPx) and (y < CornerPx or y > ArenaH - CornerPx) # ── mode switch ────────────────────────────────────────────────────────────── proc setWings(on: bool) = ## ON == module defaults. OFF == wings disabled, which is byte-for-byte the ## pre-j112 mover (the candidate geometry and the fallback ranking revert). for n in ["TR_STRAFE_KAPPA", "TR_STRAFE_ESCAPE", "TR_STRAFE_WALL_BIAS", "TR_STRAFE_WALL_MARGIN", "TR_STRAFE_WING_MAX", "TR_STRAFE_WALL_SAFE"]: delEnv(n) if not on: putEnv("TR_STRAFE_KAPPA", "0") putEnv("TR_STRAFE_ESCAPE", "0") putEnv("TR_STRAFE_WALL_BIAS", "0") loadStrafeEnv() proc maxF(v: seq[float]): float = result = 0.0 for x in v: result = max(result, x) proc mean(v: seq[float]): float = if v.len == 0: return 0.0 var t = 0.0 for x in v: t += x t / v.len.float proc fmtF(x: float, d = 2): string = formatFloat(x, ffDecimal, d) # ── GATE A: occupancy on the fixtures ──────────────────────────────────────── type OccResult = object ticks: int nearWall: int inCorner: int longestWallRun: int longestCornerRun: int minClearance: float meanClearance: float kappaSum: float escapeTicks: int proc simOccupancy(samples: seq[Sample], starts: seq[int]): OccResult = randomize(Seed) var m = initStrafe() var x, y, h, sp = 0.0 var runWall = 0 var runCorner = 0 var clrSum = 0.0 var clrMin = 1e9 for i in 0.. 0.0: result.kappaSum += m.kappa if m.escapeActive: inc result.escapeTicks result.longestWallRun = max(result.longestWallRun, runWall) result.longestCornerRun = max(result.longestCornerRun, runCorner) result.minClearance = clrMin result.meanClearance = if result.ticks == 0: 0.0 else: clrSum / result.ticks.float # ── GATE B: escape proof over a start x heading x enemy sweep ──────────────── type Sweep = object runs: int guaranteeViol: int ## ticks in escape mode with (v . away) < 0 escapeTicks: int worstCorner: int ## longest continuous corner run, worst run worstName: string worstEndClear: float stallRuns: int ## runs whose longest corner run exceeded 2 s stallThresh: int proc simSweep(bx, by, bh, ex, ey: float, nTicks: int): tuple[longestCorner, viol, escTicks: int, endClear: float] = randomize(Seed) var m = initStrafe() var x = bx; var y = by; var h = bh; var sp = 0.0 var runCorner = 0 for i in 0.. result.worstCorner: result.worstCorner = r.longestCorner result.worstName = "start=(" & $int(sx) & "," & $int(sy) & ") h=" & $int(bh) & " enemy=" & $en if r.longestCorner > StallTicks: inc result.stallRuns result.stallThresh = StallTicks # ── GATE C: regression ─────────────────────────────────────────────────────── type ParityResult = object identical: bool maxDiff: float maxKappa: float minClear: float nonSignComm: int reversals: int completion: int meanTurn: float noTurnPct: float meanSpeed: float proc openSpace(off: bool): tuple[cmds: seq[(float, float)], kappaMax, clrMin: float] = randomize(Seed) var m = initStrafe() var x = ArenaW / 2.0 var y = ArenaH / 2.0 var h = 0.0 var sp = 0.0 result.clrMin = 1e9 for i in 0..<30: # Enemy due north => the strafe line is horizontal; the bot never reaches a # wall inside 30 ticks (max travel 240 px, clearance stays > 160 px). let s = Sample(enemyX: x, enemyY: y - 200.0, enemyEnergy: 100.0, selfX: x, selfY: y, selfHeading: h, selfEnergy: 100.0) let ws = makeWs(s, x, y, h, sp) let cmd = m.computeMove(ws) result.cmds.add (cmd.speed, cmd.turnRate) result.kappaMax = max(result.kappaMax, m.kappa) result.clrMin = min(result.clrMin, m.wallDist) h += cmd.turnRate sp = cmd.speed x = x + sp * cos(h * PI / 180.0) y = y + sp * sin(h * PI / 180.0) proc regression(off, on: seq[Sample], offStarts, onStarts: seq[int]): ParityResult = # 1. open-space parity let a = openSpace(true) let b = openSpace(false) result.identical = a.cmds.len == b.cmds.len for i in 0.. 1e-9: result.identical = false result.maxKappa = max(a.kappaMax, b.kappaMax) result.minClear = min(a.clrMin, b.clrMin) # 2. sign-flip property + turn/speed stats on the main fixture (ON) randomize(Seed) var m = initStrafe() var x, y, h, sp = 0.0 var prevSign = 0.0 var turnSum = 0.0 var noTurn = 0 var n = 0 for i in 0.. 1e-9: inc result.nonSignComm let sign = if cmd.speed > 0.0: 1.0 else: -1.0 if sign != prevSign and prevSign != 0.0: inc result.reversals prevSign = sign turnSum += abs(cmd.turnRate) if abs(cmd.turnRate) < 0.5: inc noTurn inc n h += cmd.turnRate sp = cmd.speed x = clamp(x + sp * cos(h * PI / 180.0), 18.0, ArenaW - 18.0) y = clamp(y + sp * sin(h * PI / 180.0), 18.0, ArenaH - 18.0) result.completion = n result.meanTurn = if n == 0: 0.0 else: turnSum / n.float result.noTurnPct = if n == 0: 0.0 else: 100.0 * noTurn.float / n.float result.meanSpeed = 8.0 # ── driver ──────────────────────────────────────────────────────────────────── const Fixtures = [ "tr_drussgt_vs_modularbot.jsonl", "tr_drussgt_vs_corners.jsonl", "tr_drussgt_vs_spinbot.jsonl", "tr_drussgt_vs_crazy.jsonl", ] type Loaded = object name: string samples: seq[Sample] starts: seq[int] var loaded: seq[Loaded] for f in Fixtures: loaded.add Loaded(name: f, samples: loadSamples(f), starts: loadRoundStarts(f)) echo "STRAFE curved-wings gates (j112) — offline, seed=", Seed echo "WallProx=", fmtF(WallProxPx, 0), "px CornerPx=", fmtF(CornerPx, 0), "px (before = wings OFF, after = module defaults)" echo "" # ── GATE A ─────────────────────────────────────────────────────────────────── echo "=== GATE A — WALL/CORNER OCCUPANCY (kinematic replay) ===" echo " fixture wings nearWall% longestWall corner% longestCorner minClr meanClr escapeTicks" for L in loaded: for on in [false, true]: setWings(on) let r = simOccupancy(L.samples, L.starts) echo " ", alignLeft(L.name, 32), " ", (if on: "ON " else: "OFF"), " ", align(fmtF(100.0 * r.nearWall.float / max(1, r.ticks).float, 1), 8), "% ", align($r.longestWallRun, 10), " ", align(fmtF(100.0 * r.inCorner.float / max(1, r.ticks).float, 1), 6), "% ", align($r.longestCornerRun, 12), " ", align(fmtF(r.minClearance, 0), 5), " ", align(fmtF(r.meanClearance, 0), 6), " ", align($r.escapeTicks, 10) echo "" # ── GATE B ─────────────────────────────────────────────────────────────────── echo "=== GATE B — ESCAPE PROOF (grid x 8 headings x 5 enemy placements, 300 ticks) ===" setWings(true) let sw = sweep() echo " runs : ", sw.runs echo " escape-mode ticks : ", sw.escapeTicks echo " GUARANTEE VIOLATIONS : ", sw.guaranteeViol, " (velocity . wall-away must never be < 0 in escape mode)" echo " worst longest corner run : ", sw.worstCorner, " ticks at ", sw.worstName echo " runs over ", sw.stallThresh, " corner ticks : ", sw.stallRuns, " / ", sw.runs echo "" # ── GATE C ─────────────────────────────────────────────────────────────────── echo "=== GATE C — REGRESSION ===" setWings(false); let offRes = loaded[0] setWings(true); let onRes = loaded[0] let reg = regression(offRes.samples, onRes.samples, offRes.starts, onRes.starts) echo " open space parity (ON vs OFF command stream, 30 ticks):" echo " bit-identical : ", reg.identical, " (max |diff| = ", fmtF(reg.maxDiff, 6), ")" echo " max kappa in open space : ", fmtF(reg.maxKappa, 6), " (must be 0)" echo " min clearance seen : ", fmtF(reg.minClear, 1), " px (> wall margin 108)" echo " sign-flip property (every command exactly +/-8 px/tick):" echo " non-sign commands : ", reg.nonSignComm, " / ", reg.completion echo " reversals (sign flips) : ", reg.reversals echo " speed / turn on ", onRes.name, ":" echo " mean |turnRate| : ", fmtF(reg.meanTurn, 2), " deg/tick" echo " no-turn ticks (<0.5) : ", fmtF(reg.noTurnPct, 1), "%" echo " mean |speed| : ", fmtF(reg.meanSpeed, 2), " px/tick" echo "" echo "HONEST READ: the fixtures are OPEN-LOOP (the enemy reacted to a previous" echo "mover at capture time). A positive here is NOT a live win; it is a" echo "veto-capable check only. The escape GUARANTEE is the per-tick proof above" echo "(0 violations), not the fixture numbers." setWings(true)