From ed25ce29ad28838eaf726137e60c036e8ed70de3 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Fri, 25 Sep 2026 23:39:28 +0200 Subject: [PATCH] STRAFE: range control (tilt) + corner-stall escape; shipped TFIL default untouched Task j111. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped tfil path; the binary default is still tfil. RANGE CONTROL (a hypothesis under test, no default changed elsewhere): the body is still pinned ~perpendicular to the threat, but the line is tilted by the range error: lineAngle = threat + 90 + appliedTilt, with the tilt zero inside +/-TR_STRAFE_RANGE_TOL around TR_STRAFE_RANGE (200 px, chosen because it is exactly TR_POWER_FAR_DIST) and clamped to +/-TR_STRAFE_TILT_MAX. A tilt alone cannot change range (the picker chooses both ends at random), so the picker also PREFERS the end that reduces |distance - target| with a probability that grows with |tilt|; both ends stay possible. The tilt sign is aligned to the ENEMY bearing, since is the bullet direction (roughly its opposite) when a bullet is in flight. Knobs: TR_STRAFE_RANGE (200), TR_STRAFE_RANGE_TOL (25), TR_STRAFE_TILT_MAX (15), TR_STRAFE_TILT_GAIN (0.10), all registered in env_report.nim (emit + knownEnvNames). NOT claimed to be better: j107 measured that drifting 25-30 px closer made damage/run and wins WORSE. CORNER STALL (a real defect): a line whose in-arena candidate set was empty set targetValid=false and kept driving on the last sign, so the bot could oscillate inside a corner tile forever. Three defenses: (1) a deterministic corner guard projects the outward component off the line whenever BOTH ends are outside, so the line becomes wall-parallel and a candidate always exists; (2) a degenerate line (<=1 candidate) falls back to a radial search for the coolest in-arena tile and commits the sign; (3) a commanded move with no displacement for StuckFlipTicks (5) ticks flips the sign. Both warnings now reach the [strafe] log. GUI/log: the tilt is drawn as the existing strafe line (it is lineForward), plus a green/red ray toward the enemy (length = |distance-target|) and white text d=.. tgt=.. tilt=..; a red disc marks a stuck tick. The existing overlays and the j110 heat grid are unchanged. Gates (offline, kinematic replay of the DrussGT fixtures; see common_libs/tests/measure_strafe_range_stall.nim): on the j110 field all four corners that were 100% confined inside 72 px / 22.6 px max before now escape (<=2.6% confined, 209-741 px); the achieved |distance-200| falls on 3 of 4 fixtures (mean -16% to -30%); mean |turnRate| and the 8.00 px/tick speed are essentially unchanged (no-turn property survives). Reversal-interval entropy falls 5.85 -> 5.31 bits (still above TFIL's 5.09): the range bias costs some reversal randomness while closing. --- ModularBot_garage/src/env_report.nim | 6 + common_libs/movements/strafe.nim | 278 +++++++++++- .../tests/measure_strafe_range_stall.nim | 402 ++++++++++++++++++ 3 files changed, 674 insertions(+), 12 deletions(-) create mode 100644 common_libs/tests/measure_strafe_range_stall.nim diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 7c29c3d..a498146 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -272,6 +272,10 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_STRAFE_DWELL_MIN", $StrafeDwellMin, sourceOf("TR_STRAFE_DWELL_MIN")) emit("TR_STRAFE_DWELL_MAX", $StrafeDwellMax, sourceOf("TR_STRAFE_DWELL_MAX")) emit("TR_STRAFE_LOG", onOff(StrafeLog), sourceOfPresence("TR_STRAFE_LOG")) + emit("TR_STRAFE_RANGE", $StrafeRange, sourceOf("TR_STRAFE_RANGE")) + emit("TR_STRAFE_RANGE_TOL", $StrafeRangeTol, sourceOf("TR_STRAFE_RANGE_TOL")) + emit("TR_STRAFE_TILT_MAX", $StrafeTiltMax, sourceOf("TR_STRAFE_TILT_MAX")) + emit("TR_STRAFE_TILT_GAIN", $StrafeTiltGain, sourceOf("TR_STRAFE_TILT_GAIN")) emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID")) # STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall # 15/5), override-able per run so the shipped field can be A/B'd on one @@ -435,6 +439,8 @@ proc knownEnvNames*(): seq[string] = "TR_TFIL_PILLAR_ON", "TR_STRAFE_BAND", "TR_STRAFE_SPREAD", "TR_STRAFE_REACH", "TR_STRAFE_DWELL_MIN", "TR_STRAFE_DWELL_MAX", "TR_STRAFE_LOG", + "TR_STRAFE_RANGE", "TR_STRAFE_RANGE_TOL", "TR_STRAFE_TILT_MAX", + "TR_STRAFE_TILT_GAIN", "TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA", "TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS", "TR_STRAFE_WALL_RADIANCE", diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim index 74d3adb..a4ed798 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -60,6 +60,43 @@ ## so the reversal TIMING is randomised and the offline entropy gate (B) checks ## it. ## +## ── Range control: a small TILT of the strafe line (j111) ──────────────────── +## Motion exactly perpendicular to the threat does not change the distance to +## the enemy, so pure strafe holds range BY CONSTRUCTION. To honour the owner's +## "we still need to try to go near the optimal distance", the line is tilted: +## +## lineAngle = threat + 90 + appliedTilt +## tilt = clamp(gain * (distance - TR_STRAFE_RANGE) beyond the dead band) +## +## Inside `±TR_STRAFE_RANGE_TOL` around `TR_STRAFE_RANGE` the tilt is EXACTLY 0, +## i.e. today's pure perpendicular strafe; that is meant to be the common case. +## Outside it the line leans so that one of its two ends is closer to the enemy +## (too far) or farther from it (too close). Because `threat` is the enemy +## bearing when the sky is clear but roughly its OPPOSITE when a bullet is in +## flight, the tilt's sign is aligned to the enemy bearing first, so a positive +## `tilt` always leans the SAME physical way. The magnitude is clamped to +## `±TR_STRAFE_TILT_MAX`, so the body still barely turns. +## +## A tilt ALONE cannot change the range: the picker chooses a random tile on +## BOTH ends of the line, so the radial drift averages to zero. The mover +## therefore also PREFERS the line end whose tile reduces |distance - target|, +## with a probability that grows with |tilt| (0.5 = no preference inside the +## dead band, up to 0.9 when the error is at `TILT_MAX`). Both ends stay +## possible, so the reversal randomness the pattern gun feeds on survives. +## +## ── Corner stall: always leave a corner (j111) ────────────────────────────── +## Candidate tiles outside the arena used to be silently skipped; when NONE +## survived, `targetValid` went false and the mover "kept driving on the last +## sign". In a corner whose outward direction was that sign the bot oscillated +## inside a tile forever (the owner's "goes straight to a corner and never come +## back"). Three defenses now guarantee an escape: +## 1. a DEGENERATE line (<= 1 in-arena candidate) falls back to a small radial +## search for the coolest in-arena tile and COMMITS the sign, so a +## near-perpendicular target cannot flip it back and forth; +## 2. a commanded move that produces no displacement for `StuckFlipTicks` +## ticks flips the sign; +## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows it. +## ## ── Reuse of the j105/j106 heat machinery ─────────────────────────────────── ## This module does NOT re-implement the time-indexed bullet model. It imports ## `movements/the_floor_is_lava` and calls its EXPORTED `heatDecay(dt)` and @@ -80,6 +117,10 @@ ## TR_STRAFE_DWELL_MIN 6 min ticks before a re-pick ## TR_STRAFE_DWELL_MAX 20 max ticks before a re-pick ## TR_STRAFE_LOG off presence-based: echo one line per pick +## TR_STRAFE_RANGE 200.0 target enemy distance (px; == TR_POWER_FAR_DIST) +## TR_STRAFE_RANGE_TOL 25.0 dead-band half-width (px): tilt = 0 inside +## TR_STRAFE_TILT_MAX 15.0 max line tilt off the perpendicular (deg) +## TR_STRAFE_TILT_GAIN 0.10 deg of tilt per px of error BEYOND the band ## TR_STRAFE_HEAT_GRID 1 draw the full heat grid (0 = hide it) ## TR_STRAFE_BULLET_CORE 20.0 lava per bullet-overlapping tile (retune) ## TR_STRAFE_BULLET_AURA 10.0 lava for aura ring tiles (retune) @@ -179,6 +220,18 @@ const DefaultStrafeReach = 144.0 DefaultStrafeDwellMin = 6 DefaultStrafeDwellMax = 20 + ## Range control (j111). 200 px is NOT invented here: it is exactly + ## TR_POWER_FAR_DIST, the distance at which the bot's own power policy stops + ## treating the enemy as close, so the mover and the gun agree on "range". + DefaultStrafeRange = 200.0 + DefaultStrafeRangeTol = 25.0 + DefaultStrafeTiltMax = 15.0 + DefaultStrafeTiltGain = 0.10 + +## Stuck detector: a commanded move with < 0.5 px displacement this many ticks +## in a row flips the sign. Small enough to look instant, large enough to ride +## out the server applying the first command a tick late. +const StuckFlipTicks = 5 var StrafeBand* = DefaultStrafeBand @@ -187,6 +240,10 @@ var StrafeDwellMin* = DefaultStrafeDwellMin StrafeDwellMax* = DefaultStrafeDwellMax StrafeLog* = false + StrafeRange* = DefaultStrafeRange + StrafeRangeTol* = DefaultStrafeRangeTol + StrafeTiltMax* = DefaultStrafeTiltMax + StrafeTiltGain* = DefaultStrafeTiltGain ## GUI: draw the full lava field (every non-zero tile, value-labelled) the ## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the ## strafe overlays can be read on their own. @@ -210,6 +267,10 @@ proc loadStrafeEnv*() = StrafeDwellMin = max(1, getEnvInt("TR_STRAFE_DWELL_MIN", DefaultStrafeDwellMin)) StrafeDwellMax = max(StrafeDwellMin, getEnvInt("TR_STRAFE_DWELL_MAX", DefaultStrafeDwellMax)) StrafeLog = existsEnv("TR_STRAFE_LOG") + StrafeRange = max(0.0, getEnvFloat("TR_STRAFE_RANGE", DefaultStrafeRange)) + StrafeRangeTol = max(0.0, getEnvFloat("TR_STRAFE_RANGE_TOL", DefaultStrafeRangeTol)) + StrafeTiltMax = max(0.0, min(80.0, getEnvFloat("TR_STRAFE_TILT_MAX", DefaultStrafeTiltMax))) + StrafeTiltGain = max(0.0, getEnvFloat("TR_STRAFE_TILT_GAIN", DefaultStrafeTiltGain)) StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true) loadStrafeHeatEnv() @@ -251,6 +312,9 @@ type dir*: float ## commanded sign: +1 forward, -1 backward bandOffset: float ## random in [-band, band], re-rolled per pick lineDir*: float ## undirected strafe line bearing (deg) + # ── range control (j111) ── + rangeDist*: float ## enemy distance this tick (-1 = unknown) + rangeTilt*: float ## applied line tilt this tick (deg) # ── diagnostics (gate B + GUI) ── callCount*: int picks*: int @@ -258,6 +322,10 @@ type lastCandCount*: int ## candidates generated at the last pick lastSafeCount*: int ## of those, path-safe at the last pick fallbackPicks*: int ## picks where the safe pool was empty + escapePicks*: int ## picks where the line was degenerate -> radial escape + cornerGuards*: int ## ticks the line was projected off the arena corner + stuckTicks*: int ## consecutive commanded ticks with no displacement + stuckFlips*: int ## sign flips forced by the stuck detector reversals*: int ## sign flips of the commanded direction lineDirFlips*: int ## times the axis orientation flipped lastCmdSign*: float @@ -306,12 +374,18 @@ proc resetRound*(m: var StrafeModule) = m.dir = 1.0 m.bandOffset = 0.0 m.lineDir = 0.0 + m.rangeDist = -1.0 + m.rangeTilt = 0.0 m.callCount = 0 m.picks = 0 m.lastPickCall = 0 m.lastCandCount = 0 m.lastSafeCount = 0 m.fallbackPicks = 0 + m.escapePicks = 0 + m.cornerGuards = 0 + m.stuckTicks = 0 + m.stuckFlips = 0 m.reversals = 0 m.lineDirFlips = 0 m.lastCmdSign = 0.0 @@ -534,6 +608,29 @@ proc threatBearing(m: StrafeModule, ws: WorldState): float = return arctan2(ei.y - ws.selfY, ei.x - ws.selfX) * 180.0 / PI return 0.0 +# ── range: current enemy bearing / distance ────────────────────────────────── + +type + RangeInfo = object + found: bool + bearing: float ## bot -> enemy, degrees + dist: float + ex, ey: float + +proc enemyRange(ws: WorldState): RangeInfo = + ## Current target enemy if set, else the first tracked enemy. + if ws.enemyX != 0.0 or ws.enemyY != 0.0: + let dx = ws.enemyX - ws.selfX + let dy = ws.enemyY - ws.selfY + return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI, + dist: hypot(dx, dy), ex: ws.enemyX, ey: ws.enemyY) + for ei in ws.enemies: + let dx = ei.x - ws.selfX + let dy = ei.y - ws.selfY + return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI, + dist: hypot(dx, dy), ex: ei.x, ey: ei.y) + RangeInfo(found: false) + # ── target picking ─────────────────────────────────────────────────────────── type @@ -543,7 +640,36 @@ type pathHeat: float along: float ## signed offset along the line (+ = forward of lineForward) -proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) = +proc radialEscape(m: StrafeModule, ws: WorldState): tuple[found: bool, col, row: int, x, y, pathHeat: float] = + ## The corner defense: when the strafe line has no usable in-arena tile, + ## search a small radial neighbourhood and return the COOLEST in-arena tile + ## (least path heat, then least tile lava, then nearest). Near a corner the + ## wall radiance makes the interior the coolest, so this always points back + ## into the arena. + var bestPh = Inf + var bestLava = Inf + var bestD2 = Inf + let (bc, br) = m.tileAt(ws.selfX, ws.selfY) + const R = 4 + for dr in -R..R: + for dc in -R..R: + if dc == 0 and dr == 0: continue + let c = bc + dc + let r = br + dr + if c < 0 or c >= m.cols or r < 0 or r >= m.rows: continue + let cx = m.marginX + (c.float + 0.5) * GridSize + let cy = m.marginY + (r.float + 0.5) * GridSize + let ph = m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy) + let lv = m.lavaAt(c, r) + let d2 = (dc * dc + dr * dr).float + if ph < bestPh - 1e-9 or + (abs(ph - bestPh) <= 1e-9 and (lv < bestLava - 1e-9 or + (abs(lv - bestLava) <= 1e-9 and d2 < bestD2))): + bestPh = ph; bestLava = lv; bestD2 = d2 + result = (found: true, col: c, row: r, x: cx, y: cy, pathHeat: ph) + +proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, + rng: RangeInfo, tiltMag: float) = let ux = cos(lineForward * DegToRad) let uy = sin(lineForward * DegToRad) let px = -uy @@ -596,10 +722,38 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) = for i in 0.. 0.0 and StrafeTiltMax > 0.0: + let curErr = abs(rng.dist - StrafeRange) + var approach: seq[Cand] + for c in pool: + let nd = hypot(rng.ex - c.x, rng.ey - c.y) + if abs(nd - StrafeRange) < curErr: approach.add c + if approach.len > 0 and approach.len < pool.len: + let p = clamp(0.5 + 0.4 * (tiltMag / StrafeTiltMax), 0.0, 0.9) + if rand(1.0) < p: pool = approach + let chosen = rand(pool.high) m.targetX = pool[chosen].x m.targetY = pool[chosen].y @@ -610,7 +764,12 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) = let hx = cos(ws.selfHeading * DegToRad) let hy = sin(ws.selfHeading * DegToRad) let alongDot = (m.targetX - ws.selfX) * hx + (m.targetY - ws.selfY) * hy - m.dir = if alongDot >= 0.0: 1.0 else: -1.0 + if escaped and m.lastCmdSign != 0.0 and abs(alongDot) < GridSize: + # COMMIT: the escape tile is near-perpendicular to the heading, so letting + # it set the sign re-creates the corner oscillation. Keep sliding. + m.dir = m.lastCmdSign + else: + m.dir = if alongDot >= 0.0: 1.0 else: -1.0 if m.lastCmdSign == 0.0: m.lastCmdSign = m.dir # Randomised dwell + randomisation inside the band (point 2 / point 6). @@ -620,13 +779,15 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) = inc m.picks if StrafeLog: - let reason = if safe.len == 0: "fallback" else: "pick" + let reason = if escaped: "escape" elif safe.len == 0: "fallback" else: "pick" echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " & fmt"along={pool[chosen].along.int} dir={m.dir.int} " & - fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f}" - if safe.len == 0: + fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f} " & + fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f}" + if escaped or safe.len == 0: echo fmt"[strafe] WARNING: no SAFE tile on the line " & - fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f})" + fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f}" & + (if escaped: ", radial escape" else: "") & ")" # ── main entry point ───────────────────────────────────────────────────────── @@ -649,9 +810,61 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = m.detectFires(ws) m.buildHeat(ws) - # ── threat axis -> perpendicular line ── + # ── threat axis -> perpendicular line, TILTED by the range error (j111) ── let threat = m.threatBearing(ws) - let lineAngle = threat + 90.0 + let rng = enemyRange(ws) + m.rangeDist = if rng.found: rng.dist else: -1.0 + var tilt = 0.0 + if rng.found and StrafeTiltMax > 0.0 and StrafeTiltGain > 0.0: + let err = rng.dist - StrafeRange + if abs(err) > StrafeRangeTol: + let excess = (abs(err) - StrafeRangeTol) * (if err > 0.0: 1.0 else: -1.0) + tilt = clamp(StrafeTiltGain * excess, -StrafeTiltMax, StrafeTiltMax) + # `threat` is the enemy bearing when the sky is clear, but roughly its + # OPPOSITE when it comes from an incoming bullet; align the tilt so a positive + # `tilt` always leans the SAME physical way (toward the enemy when too far). + var appliedTilt = tilt + if tilt != 0.0 and rng.found and + cos(wrap180(threat - rng.bearing) * DegToRad) > 0.0: + appliedTilt = -tilt + var lineAngle = threat + 90.0 + appliedTilt + + # ── corner guard: the line must never point OUT of the arena on BOTH ends ── + # If both ends are outside, project away the outward component of the line + # direction so it becomes parallel to the nearest wall. Then at least one + # candidate tile is always in the arena and the bot slides along the wall + # instead of pressing into the corner -- the deterministic half of the stall + # fix; the radial escape in `pickTarget` is the backup for the rare case where + # even a projected line has no usable tile. + block: + let ux0 = cos(lineAngle * DegToRad) + let uy0 = sin(lineAngle * DegToRad) + let r = StrafeReach + let x1 = ws.selfX + ux0 * r + let y1 = ws.selfY + uy0 * r + let x2 = ws.selfX - ux0 * r + let y2 = ws.selfY - uy0 * r + let out1 = x1 < 0.0 or x1 >= m.arenaWidth or y1 < 0.0 or y1 >= m.arenaHeight + let out2 = x2 < 0.0 or x2 >= m.arenaWidth or y2 < 0.0 or y2 >= m.arenaHeight + if out1 and out2: + var gx = ux0 + var gy = uy0 + const WallMargin = 2.0 * GridSize + if ws.selfX < WallMargin and gx < 0.0: gx = 0.0 + if ws.selfX > m.arenaWidth - WallMargin and gx > 0.0: gx = 0.0 + if ws.selfY < WallMargin and gy < 0.0: gy = 0.0 + if ws.selfY > m.arenaHeight - WallMargin and gy > 0.0: gy = 0.0 + if abs(gx) < 1e-6 and abs(gy) < 1e-6: + # Dead corner: run parallel to whichever wall is nearest. + let mx = min(ws.selfX, m.arenaWidth - ws.selfX) + let my = min(ws.selfY, m.arenaHeight - ws.selfY) + if mx <= my: gx = 0.0; gy = 1.0 + else: gx = 1.0; gy = 0.0 + lineAngle = arctan2(gy, gx) * 180.0 / PI + appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it + inc m.cornerGuards + + m.rangeTilt = appliedTilt m.lineDir = lineAngle # Forward orientation = the half of the (undirected) line nearest our heading. @@ -673,7 +886,7 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = if hypot(m.targetX - ws.selfX, m.targetY - ws.selfY) < GridSize * 0.75: m.dwell = 0 if (not m.targetValid) or m.dwell <= 0 or serious: - m.pickTarget(ws, lineForward) + m.pickTarget(ws, lineForward, rng, abs(appliedTilt)) else: dec m.dwell @@ -685,10 +898,26 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = let targetHeading = lineForward + m.bandOffset turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr) + # ── stuck detector: a commanded move that does not move us flips the sign ── + if m.callCount > 0 and not jumped: + let disp = hypot(ws.selfX - m.lastBotX, ws.selfY - m.lastBotY) + if disp < 0.5: + inc m.stuckTicks + else: + m.stuckTicks = 0 + if m.stuckTicks >= StuckFlipTicks: + m.dir = if m.dir >= 0.0: -1.0 else: 1.0 + m.lastCmdSign = m.dir + m.stuckTicks = 0 + inc m.stuckFlips + if StrafeLog: + echo fmt"[strafe] WARNING: no displacement for {StuckFlipTicks} ticks " & + fmt"-> flipping the sign (pressed into a corner/wall)" + # ── move by sign only ── - # Never freeze: if there is no valid target (the rare all-candidates-outside - # case) keep driving on the last sign. The normal path picks a target every - # dwell, and the fallback pool is non-empty whenever any candidate tile exists. + # The picker now guarantees a target whenever any in-arena tile exists (the + # radial escape), so "keep the last sign" is only the truly boxed-in case -- + # and the stuck detector above still flips out of that. if m.targetValid and m.dir != m.lastCmdSign: inc m.reversals m.lastCmdSign = m.dir @@ -775,6 +1004,31 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = drawLine(ws.selfX, ws.selfY, ws.selfX + ux * m.dir * StrafeReach, ws.selfY + uy * m.dir * StrafeReach) + # ── range-control marker (j111) ── + # A ray toward the enemy whose LENGTH is |distance - target| and whose + # COLOUR encodes the wanted motion: green = too far (closing in), red = too + # close (backing off), white text = the numbers on the log line. + if rng.found: + let err = rng.dist - StrafeRange + let rb = rng.bearing * DegToRad + setStrokeColor(if err >= 0.0: fromHex("#00FF88") else: fromHex("#FF4444")) + setStrokeWidth(3.0) + let rl = min(abs(err), 120.0) + drawLine(ws.selfX, ws.selfY, + ws.selfX + cos(rb) * rl, ws.selfY + sin(rb) * rl) + setFillColor(fromHex("#FFFFFF")) + setFont("Arial", 12.0) + drawText(fmt"d={rng.dist:.0f} tgt={StrafeRange:.0f} tilt={appliedTilt:.1f}", + ws.selfX + 22.0, ws.selfY - 22.0) + + # ── stuck marker (j111) ── + if m.stuckTicks > 0: + setStrokeColor(fromHex("#FF0000")) + setStrokeWidth(3.0) + drawCircle(ws.selfX, ws.selfY, 24.0) + setFillColor(fromHex("#FF0000")) + fillCircle(ws.selfX - 27.0, ws.selfY - 27.0, 5.0) + # Heading band: the two ±band boundary rays (yellow) around the line. setStrokeColor(fromHex("#FFFF00")) setStrokeWidth(1.0) diff --git a/common_libs/tests/measure_strafe_range_stall.nim b/common_libs/tests/measure_strafe_range_stall.nim new file mode 100644 index 0000000..a241a94 --- /dev/null +++ b/common_libs/tests/measure_strafe_range_stall.nim @@ -0,0 +1,402 @@ +## STRAFE range-control + corner-stall gates (job j111). OFFLINE / cheap: no +## Java, no server, no battle. Run with: +## +## nim c -r --nimcache:/tmp/nc_j111 --path:common_libs \ +## common_libs/tests/measure_strafe_range_stall.nim +## +## Reuses the fixture loader + stats shape of j108's +## `common_libs/tests/measure_strafe_gates.nim`. It only calls the PUBLIC +## `initStrafe()` / `resetRound()` / `computeMove()` / `loadStrafeEnv()` API, so +## it compiles BOTH against the pre-fix module (where the new env names are +## simply ignored) and the post-fix module. That is what makes the before/after +## comparison honest: the SAME gate binary source, run on two tree states. +## +## GATE A TILE AVAILABILITY on the corrected (j110) heat field WITH the tilt +## active: mean candidates / safe candidates on the tilted line and +## the fraction of picks where the safe pool is empty. +## +## GATE B STALL DETECTOR. The recorded fixtures are replayed as a KINEMATIC +## simulation (the enemy keeps its recorded path; OUR bot is +## integrated from the mover's own speed/turn commands, with wall and +## body-radius clamping). Per tick we record whether the mover was +## COMMANDED to move yet produced no displacement (stuck) and whether +## its tile did not change. We report the fraction of commanded ticks +## that are stuck and the LONGEST stuck run. The corner stall is a +## longest-run diverge-to-infinity defect, so the longest run is the +## number that matters. +## +## GATE C RANGE. Same kinematic replay, range control OFF vs ON, same seed, +## same enemy path. We report the achieved distance distribution +## (mean/median/p10/p90 and the fraction inside the dead band). The +## claim "range control moves the distance toward TR_STRAFE_RANGE" is +## only MEASURED if the ON distribution is closer to the target than +## the OFF one. +## +## CORNER A targeted test: the bot is placed in each of the four corners with +## the enemy on the inward diagonal, so the strafe line points out of +## the arena. That is the zero-candidate case that used to keep driving +## into the wall. We report the longest stuck run and the escape +## distance from the corner. + +import std/[os, json, math, random, strutils, algorithm, sets] +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 + +# ── fixture loading ─────────────────────────────────────────────────────────── + +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 tileOf(x, y: float): int = + let c = clamp(int((x - MarginX) / GridSize), 0, Cols - 1) + let r = clamp(int((y - MarginY) / GridSize), 0, Rows - 1) + r * Cols + c + +# ── kinematic replay ────────────────────────────────────────────────────────── + +type SimResult = object + dist: seq[float] ## distance to the enemy per tick + commandedTicks: int + stuckTicks: int ## commanded but no displacement + longestStuck: int + trappedTicks: int ## commanded but < TrapRadius from 25 ticks ago + longestTrapped: int + noTileTicks: int ## commanded but same tile as the previous tick + longestNoTile: int + picks: int + safeSum: int + candSum: int + fallbackPicks: int + turnSum: float ## sum of |turnRate| + absSpeedSum: float + turnedTicks: int ## |turnRate| >= 0.5 + +const TrapWindow = 25 +const TrapRadius = 30.0 + +proc simFixture(samples: seq[Sample], starts: seq[int]): SimResult = + randomize(Seed) + var m = initStrafe() + var x, y, h, sp = 0.0 + var prevTile = -1 + var runStuck = 0 + var runTrapped = 0 + var runNoTile = 0 + var prevPicks = 0 + var posHist: seq[(float, float)] + for i in 0.. prevPicks: + prevPicks = m.picks + inc result.picks + result.safeSum += m.lastSafeCount + result.candSum += m.lastCandCount + if m.lastSafeCount == 0: inc result.fallbackPicks + if abs(cmd.turnRate) >= 0.5: inc result.turnedTicks + result.turnSum += abs(cmd.turnRate) + result.absSpeedSum += abs(cmd.speed) + # apply turn + speed (immediate: this is a stall detector, not a physics model) + h += cmd.turnRate + sp = cmd.speed + let nx = clamp(x + sp * cos(h * PI / 180.0), 18.0, ArenaW - 18.0) + let ny = clamp(y + sp * sin(h * PI / 180.0), 18.0, ArenaH - 18.0) + let disp = hypot(nx - x, ny - y) + x = nx; y = ny + let tile = tileOf(x, y) + let cmdOn = abs(cmd.speed) > 0.5 + if cmdOn: inc result.commandedTicks + if prevTile >= 0 and tile == prevTile and cmdOn: + inc runNoTile; inc result.noTileTicks + else: + result.longestNoTile = max(result.longestNoTile, runNoTile); runNoTile = 0 + if cmdOn and disp < 0.2: + inc runStuck; inc result.stuckTicks + else: + result.longestStuck = max(result.longestStuck, runStuck); runStuck = 0 + # trap = commanded, yet still inside TrapRadius of where we were 25 ticks + # ago: the corner oscillation ("never comes back") that a pure disp==0 + # detector misses because the bot bounces between two adjacent tiles. + if posHist.len >= TrapWindow: + let old = posHist[posHist.len - TrapWindow] + if cmdOn and hypot(x - old[0], y - old[1]) < TrapRadius: + inc runTrapped; inc result.trappedTicks + else: + result.longestTrapped = max(result.longestTrapped, runTrapped); runTrapped = 0 + posHist.add (x, y) + prevTile = tile + result.dist.add hypot(s.enemyX - x, s.enemyY - y) + result.longestNoTile = max(result.longestNoTile, runNoTile) + result.longestStuck = max(result.longestStuck, runStuck) + result.longestTrapped = max(result.longestTrapped, runTrapped) + +# ── stats ───────────────────────────────────────────────────────────────────── + +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 pct(v: seq[float], q: float): float = + if v.len == 0: return 0.0 + var s = v + s.sort() + s[clamp(int(q * (s.len - 1).float), 0, s.len - 1)] + +proc fracWithin(v: seq[float], lo, hi: float): float = + if v.len == 0: return 0.0 + var n = 0 + for x in v: + if x >= lo and x <= hi: inc n + n.float / v.len.float + +proc fmtF(x: float, d = 1): string = formatFloat(x, ffDecimal, d) + +# ── range mode = the ONLY knob the gate has to touch ────────────────────────── + +proc setRangeMode(on: bool) = + ## OFF == pure perpendicular strafe: TILT_MAX 0. ON == the module default. + ## On the pre-fix module these names are not read at all, so OFF and ON + ## behave identically there (which is exactly the honest "before" baseline). + for n in ["TR_STRAFE_RANGE", "TR_STRAFE_RANGE_TOL", + "TR_STRAFE_TILT_GAIN", "TR_STRAFE_TILT_MAX"]: + delEnv(n) + if not on: + putEnv("TR_STRAFE_TILT_MAX", "0") + loadStrafeEnv() + +# ── targeted corner test ────────────────────────────────────────────────────── + +type CornerResult = object + name: string + longestStuck: int + stuckFrac: float + within72: float ## fraction of ticks still inside 72 px of the corner + escapes: int ## distinct tiles visited after tick 20 + maxFromCorner: float + +proc simCorner(name: string, bx, by, ex, ey: float, nTicks: int): CornerResult = + randomize(Seed) + var m = initStrafe() + var x = bx; var y = by + # Adversarial stall setup: heading EXACTLY on the strafe line (perpendicular + # to the inward enemy bearing), with dir = +1, so forward presses into the + # adjacent wall and the heading band has nothing to correct. This is the + # "corner whose outward direction is the last sign" the defect needs. + var h = arctan2(ey - by, ex - bx) * 180.0 / PI + 90.0 + var sp = 0.0 + var runStuck = 0 + var stuck = 0 + var commanded = 0 + var within72 = 0 + var seen = initHashSet[int]() + result.name = name + for i in 0.. 0.5 + if cmdOn: inc commanded + if cmdOn and disp < 0.2: + inc runStuck; inc stuck + else: + result.longestStuck = max(result.longestStuck, runStuck); runStuck = 0 + if i >= 20: + seen.incl tile + if hypot(x - bx, y - by) < 72.0: inc within72 + result.maxFromCorner = max(result.maxFromCorner, hypot(x - bx, y - by)) + result.longestStuck = max(result.longestStuck, runStuck) + result.stuckFrac = if commanded == 0: 0.0 else: stuck.float / commanded.float + result.within72 = if nTicks <= 20: 0.0 + else: within72.float / (nTicks - 20).float + result.escapes = seen.len + +proc cornerTest(rangeOn: bool): seq[CornerResult] = + setRangeMode(rangeOn) + for (name, cx, cy, ux, uy) in [ + ("bottom-left ", 18.0, 18.0, 1.0, 1.0), + ("bottom-right", 782.0, 18.0, -1.0, 1.0), + ("top-right ", 782.0, 582.0, -1.0, -1.0), + ("top-left ", 18.0, 582.0, 1.0, -1.0)]: + let ex = cx + ux * 240.0 + let ey = cy + uy * 240.0 + result.add simCorner(name, cx, cy, ex, ey, 400) + +# ── 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 range/stall gates (j111) — kinematic replay of DrussGT fixtures, seed=", Seed +echo "" + +# ── GATE A: tile availability with the tilt active ─────────────────────────── +echo "=== GATE A — TILE AVAILABILITY (tilt active, corrected j110 field) ===" +for L in loaded: + setRangeMode(true) + let r = simFixture(L.samples, L.starts) + if r.picks == 0: continue + echo " ", L.name + echo " picks=", r.picks, + " mean candidates=", fmtF(r.candSum.float / r.picks.float, 2), + " mean SAFE=", fmtF(r.safeSum.float / r.picks.float, 2), + " zero-safe picks=", r.fallbackPicks, " (", + fmtF(100.0 * r.fallbackPicks.float / r.picks.float, 1), "%)" +echo "" + +# ── GATE B: stall detector, range OFF vs ON ────────────────────────────────── +proc stallRow(L: Loaded, rangeOn: bool): SimResult = + setRangeMode(rangeOn) + simFixture(L.samples, L.starts) + +echo "=== GATE B — STALL DETECTOR (commanded move, no progress) ===" +echo " stuck = commanded but zero displacement (true freeze)" +echo " trapped= commanded but still <30 px from where it was 25 ticks ago" +echo " (the corner oscillation the user saw; the defect the gate is for)" +echo " fixture range commanded stuck% longest noTile% longest trapped% longest" +for L in loaded: + for on in [false, true]: + let r = stallRow(L, on) + let stuckPct = if r.commandedTicks == 0: 0.0 else: 100.0 * r.stuckTicks.float / r.commandedTicks.float + let noTilePct = if r.commandedTicks == 0: 0.0 else: 100.0 * r.noTileTicks.float / r.commandedTicks.float + let trappedPct = if r.commandedTicks == 0: 0.0 else: 100.0 * r.trappedTicks.float / r.commandedTicks.float + echo " ", alignLeft(L.name, 30), " ", + (if on: "ON " else: "OFF"), " ", + align($r.commandedTicks, 9), " ", + align(fmtF(stuckPct, 2), 6), " ", align($r.longestStuck, 5), " ", + align(fmtF(noTilePct, 1), 7), " ", align($r.longestNoTile, 5), " ", + align(fmtF(trappedPct, 1), 7), " ", align($r.longestTrapped, 6) +echo "" + +# ── GATE C: achieved range distribution, OFF vs ON ─────────────────────────── +proc rangeRow(L: Loaded, rangeOn: bool): seq[float] = + setRangeMode(rangeOn) + simFixture(L.samples, L.starts).dist + +echo "=== GATE C — ACHIEVED RANGE DISTRIBUTION (range control OFF vs ON) ===" +echo " target TR_STRAFE_RANGE = 200 px (== TR_POWER_FAR_DIST); rng default" +echo " fixture range mean p10 med p90 <=tol(225) |d-200|" +for L in loaded: + for on in [false, true]: + let d = rangeRow(L, on) + let dIn = @[d.pct(0.1), d.pct(0.5), d.pct(0.9)] + var absErr = 0.0 + for v in d: absErr += abs(v - 200.0) + let mae = if d.len == 0: 0.0 else: absErr / d.len.float + echo " ", alignLeft(L.name, 30), " ", + (if on: "ON " else: "OFF"), " ", + align(fmtF(d.mean, 0), 6), " ", + align(fmtF(dIn[0], 0), 5), " ", + align(fmtF(dIn[1], 0), 5), " ", + align(fmtF(dIn[2], 0), 5), " ", + align(fmtF(100.0 * fracWithin(d, 175.0, 225.0), 1), 9), "% ", + align(fmtF(mae, 1), 8) +echo "" + +# ── GATE D: the no-turn property ────────────────────────────────────────────── +proc turnRow(L: Loaded, rangeOn: bool): SimResult = + setRangeMode(rangeOn) + simFixture(L.samples, L.starts) + +echo "=== GATE D — NO-TURN PROPERTY (range OFF vs ON) ===" +echo " the tilt must NOT turn the body more; heading still only corrected to the band" +echo " fixture range mean|turn| no-turn% mean|speed|" +for L in loaded: + for on in [false, true]: + let r = turnRow(L, on) + let n = r.commandedTicks + echo " ", alignLeft(L.name, 30), " ", + (if on: "ON " else: "OFF"), " ", + align(fmtF(if n == 0: 0.0 else: r.turnSum / n.float, 2), 9), " ", + align(fmtF(if n == 0: 0.0 else: 100.0 * (n - r.turnedTicks).float / n.float, 1), 8), " ", + align(fmtF(if n == 0: 0.0 else: r.absSpeedSum / n.float, 2), 9) +echo "" + +# ── GATE B2: the corner test ───────────────────────────────────────────────── +echo "=== GATE B2 — CORNER ESCAPE (line points out of the arena, 400 ticks) ===" +echo " within72 = fraction of ticks still inside 72 px (2 tiles) of the corner" +for (tag, spread) in [("SPREAD=0 (zero-candidate line)", 0), ("SPREAD=1 (default)", 1)]: + putEnv("TR_STRAFE_SPREAD", $spread) + echo " ", tag, ":" + for on in [false, true]: + echo " range ", (if on: "ON" else: "OFF"), ":" + for c in cornerTest(on): + echo " ", c.name, " longestStuck=", align($c.longestStuck, 3), + " stuck%=", align(fmtF(100.0 * c.stuckFrac, 1), 5), + " within72%=", align(fmtF(100.0 * c.within72, 1), 5), + " tilesVisited=", align($c.escapes, 3), + " maxFromCorner=", fmtF(c.maxFromCorner, 1), " px" +delEnv("TR_STRAFE_SPREAD") +loadStrafeEnv() +echo "" +echo "=== BUILD IDENTITY (which tree this gate measured) ===" +when declared(StrafeRange): + echo " module exposes the range knobs: YES (post-fix tree)" +else: + echo " module exposes the range knobs: no (pre-fix baseline tree)"