diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index a498146..2f9d180 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -276,6 +276,13 @@ proc printEffectiveValues(ctx: EnvReportContext) = 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")) + # curved wings + wall escape (j112) + emit("TR_STRAFE_KAPPA", $StrafeKappa, sourceOf("TR_STRAFE_KAPPA")) + emit("TR_STRAFE_WALL_MARGIN", $StrafeWallMargin, sourceOf("TR_STRAFE_WALL_MARGIN")) + emit("TR_STRAFE_WING_MAX", $StrafeWingMax, sourceOf("TR_STRAFE_WING_MAX")) + emit("TR_STRAFE_WALL_BIAS", $StrafeWallBias, sourceOf("TR_STRAFE_WALL_BIAS")) + emit("TR_STRAFE_WALL_SAFE", $StrafeWallSafe, sourceOf("TR_STRAFE_WALL_SAFE")) + emit("TR_STRAFE_ESCAPE", onOff(StrafeEscape), sourceOf("TR_STRAFE_ESCAPE")) 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 @@ -441,6 +448,8 @@ proc knownEnvNames*(): seq[string] = "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_KAPPA", "TR_STRAFE_WALL_MARGIN", "TR_STRAFE_WING_MAX", + "TR_STRAFE_WALL_BIAS", "TR_STRAFE_WALL_SAFE", "TR_STRAFE_ESCAPE", "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 a4ed798..c71a6ee 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -97,6 +97,31 @@ ## ticks flips the sign; ## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows it. ## +## ── Curved wings + guaranteed escape (j112) ──────────────────────────────── +## The candidate set used to be the straight 1-D line through the bot. A bounded +## segment always ends at a wall, so the bot was FORCED into an edge/corner; and +## the all-hot fallback took the lowest `pathMaxHeat`, whose gradient points AT +## the wall (the shortest path has the least wall exposure). The line is now an +## ADAPTIVE PARABOLA with the vertex on the bot: +## +## point(y) = bot + yhat * y + xhat * kappa(y) * y^2 +## +## `yhat` is the strafe axis, `xhat` is the unit vector AWAY from the nearest +## wall(s). `kappa` grows as the wall approaches and saturates at +## `TR_STRAFE_KAPPA`; every wing point is clamped inside `TR_STRAFE_WALL_SAFE`, +## so the wing FLATTENS and runs parallel to the wall instead of touching it. In +## OPEN SPACE `kappa == 0` and the wing is exactly the old straight line. The +## wing's chord at the reach tilts the heading band toward the interior +## (`atan(kappa*reach)`, capped by `TR_STRAFE_WING_MAX`); the body still only +## turns slowly to follow that tangent, never to face the target. +## +## ESCAPE: when every candidate is over threshold AND the bot is within +## `TR_STRAFE_WALL_MARGIN`, the picker ranks by the DESTINATION (farthest from +## the wall, then coolest tile) and commands the sign whose velocity has a +## positive component along the wall-away normal. Because that sign is re-asserted +## EVERY tick, `speed * heading . away >= 0` while escape is active: the +## clearance can never fall. The `[strafe]` log marks it `mode=escape`. +## ## ── 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 @@ -127,6 +152,12 @@ ## TR_STRAFE_CORRIDOR_HEAT 10.0 lava per corridor tile (== threshold) ## TR_STRAFE_WALL_HOTNESS 15.0 peak wall radiance (retune) ## TR_STRAFE_WALL_RADIANCE 5.0 wall radiance falloff (retune) +## TR_STRAFE_KAPPA 0.0025 wing curvature at the wall (1/px; 0 = straight) +## TR_STRAFE_WALL_MARGIN 108.0 px range over which the wing bends +## TR_STRAFE_WING_MAX 30.0 cap on the wing's line tilt (deg) +## TR_STRAFE_WALL_BIAS 0.35 P(prefer a tile farther from the wall) +## TR_STRAFE_WALL_SAFE 24.0 px a wing point never lands nearer +## TR_STRAFE_ESCAPE 1 the all-hot guaranteed wall escape ## ## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever ## called when the bot explicitly selects `strafe`. @@ -227,6 +258,13 @@ const DefaultStrafeRangeTol = 25.0 DefaultStrafeTiltMax = 15.0 DefaultStrafeTiltGain = 0.10 + ## Curved wings + wall escape (j112). In OPEN SPACE kappa is 0 and the wing + ## is exactly the straight line above; the correction exists only near a wall. + DefaultStrafeKappa = 0.0025 ## wing curvature AT the wall (1/px) + DefaultStrafeWallMargin = 108.0 ## px: range over which the wing bends + DefaultStrafeWingMax = 30.0 ## deg: cap on the wing's line tilt + DefaultStrafeWallBias = 0.35 ## P(prefer a tile farther from the wall) + DefaultStrafeWallSafe = 24.0 ## px: a wing point never lands closer ## 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 @@ -244,6 +282,12 @@ var StrafeRangeTol* = DefaultStrafeRangeTol StrafeTiltMax* = DefaultStrafeTiltMax StrafeTiltGain* = DefaultStrafeTiltGain + StrafeKappa* = DefaultStrafeKappa + StrafeWallMargin* = DefaultStrafeWallMargin + StrafeWingMax* = DefaultStrafeWingMax + StrafeWallBias* = DefaultStrafeWallBias + StrafeWallSafe* = DefaultStrafeWallSafe + StrafeEscape* = true ## 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. @@ -271,6 +315,12 @@ proc loadStrafeEnv*() = 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)) + StrafeKappa = max(0.0, getEnvFloat("TR_STRAFE_KAPPA", DefaultStrafeKappa)) + StrafeWallMargin = max(0.0, getEnvFloat("TR_STRAFE_WALL_MARGIN", DefaultStrafeWallMargin)) + StrafeWingMax = max(0.0, min(89.0, getEnvFloat("TR_STRAFE_WING_MAX", DefaultStrafeWingMax))) + StrafeWallBias = max(0.0, min(1.0, getEnvFloat("TR_STRAFE_WALL_BIAS", DefaultStrafeWallBias))) + StrafeWallSafe = max(0.0, getEnvFloat("TR_STRAFE_WALL_SAFE", DefaultStrafeWallSafe)) + StrafeEscape = getEnvBool("TR_STRAFE_ESCAPE", true) StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true) loadStrafeHeatEnv() @@ -315,6 +365,15 @@ type # ── range control (j111) ── rangeDist*: float ## enemy distance this tick (-1 = unknown) rangeTilt*: float ## applied line tilt this tick (deg) + # ── curved wings + wall escape (j112) ── + wallDist*: float ## distance from the bot to the nearest wall (px) + kappa*: float ## applied wing curvature this tick (1/px) + wingTilt*: float ## applied line tilt toward the wall-away normal (deg) + escapeActive*: bool ## this tick runs on the wall-away escape bearing + escapeBearing*: float ## bearing of the wall-away normal when escaping + wallEscapePicks*: int ## picks forced inward by the all-hot escape + escapeModeTicks*: int ## ticks the escape bearing was in effect + lastMode*: string ## "pick" | "fallback" | "escape" | "radial" # ── diagnostics (gate B + GUI) ── callCount*: int picks*: int @@ -376,6 +435,14 @@ proc resetRound*(m: var StrafeModule) = m.lineDir = 0.0 m.rangeDist = -1.0 m.rangeTilt = 0.0 + m.wallDist = 1e9 + m.kappa = 0.0 + m.wingTilt = 0.0 + m.escapeActive = false + m.escapeBearing = 0.0 + m.wallEscapePicks = 0 + m.escapeModeTicks = 0 + m.lastMode = "" m.callCount = 0 m.picks = 0 m.lastPickCall = 0 @@ -638,8 +705,30 @@ type col, row: int x, y: float pathHeat: float + destHeat: float ## heat of the DESTINATION tile (not the path) + clearance: float ## distance from the tile centre to the nearest wall (px) along: float ## signed offset along the line (+ = forward of lineForward) +proc wallClearance(m: StrafeModule, x, y: float): float {.inline.} = + ## Distance from (x,y) to the nearest wall. + min(min(x, m.arenaWidth - x), min(y, m.arenaHeight - y)) + +proc wallAwayDir(m: StrafeModule, x, y: float): tuple[ax, ay: float] = + ## Unit vector AWAY from the nearest wall(s). Sums the inward normal of every + ## wall within `TR_STRAFE_WALL_MARGIN`, so a CORNER yields the diagonal and + ## the away direction has a non-negative dot with every binding wall's outward + ## gradient. (0,0) when no wall is within the margin == open space. + var ax = 0.0 + var ay = 0.0 + let mm = max(1.0, StrafeWallMargin) + if x < mm: ax += (mm - x) / mm + if m.arenaWidth - x < mm: ax -= (mm - (m.arenaWidth - x)) / mm + if y < mm: ay += (mm - y) / mm + if m.arenaHeight - y < mm: ay -= (mm - (m.arenaHeight - y)) / mm + let n = hypot(ax, ay) + if n > 1e-9: (ax / n, ay / n) + else: (0.0, 0.0) + 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 @@ -669,7 +758,7 @@ proc radialEscape(m: StrafeModule, ws: WorldState): tuple[found: bool, col, row: 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) = + rng: RangeInfo, tiltMag, kappa: float) = let ux = cos(lineForward * DegToRad) let uy = sin(lineForward * DegToRad) let px = -uy @@ -677,15 +766,29 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, let kmax = max(1, int(StrafeReach / GridSize)) let spread = max(0, StrafeSpread) let (bc, br) = m.tileAt(ws.selfX, ws.selfY) + let (awX, awY) = m.wallAwayDir(ws.selfX, ws.selfY) + let nearWall = m.wallDist < StrafeWallMargin + # `wings` is false only when BOTH wings and escape are off; then the candidate + # geometry is EXACTLY the pre-j112 straight line, so the gate's "before" arm + # reproduces the old mover bit for bit (open-space parity). + let wings = StrafeKappa > 0.0 or StrafeEscape + m.escapeActive = false var cands: seq[Cand] for k in 1..kmax: for s in [-1.0, 1.0]: let along = s * k.float * GridSize + let off = kappa * along * along # parabola, vertex on the bot for j in -spread..spread: - let wx = ws.selfX + ux * along + px * (j.float * GridSize) - let wy = ws.selfY + uy * along + py * (j.float * GridSize) - if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue + var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize) + var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize) + if wings: + # SAFETY MARGIN: clamp every wing point inside it, so the wing + # flattens and runs parallel to the wall instead of touching it. + wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe) + wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe) + elif wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: + continue let (c, r) = m.tileAt(wx, wy) if c == bc and r == br: continue var dup = false @@ -696,6 +799,8 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, let cy = m.marginY + (r.float + 0.5) * GridSize cands.add Cand(col: c, row: r, x: cx, y: cy, pathHeat: m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy), + destHeat: m.lavaAt(c, r), + clearance: m.wallClearance(cx, cy), along: along) var safe: seq[Cand] @@ -705,22 +810,48 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, m.lastSafeCount = safe.len var pool: seq[Cand] + var mode = "pick" if safe.len > 0: pool = safe elif cands.len > 0: - # Fallback (never freeze): the two coolest tiles on the line, over - # threshold but still the least dangerous direction to move. - var sorted = cands - for i in 1..= 0 and sorted[j].pathHeat > key.pathHeat: - sorted[j + 1] = sorted[j] - dec j - sorted[j + 1] = key - let take = min(2, sorted.len) - for i in 0..= 0 and (sorted[j].clearance < key.clearance or + (sorted[j].clearance == key.clearance and + sorted[j].destHeat > key.destHeat)): + sorted[j + 1] = sorted[j] + dec j + sorted[j + 1] = key + pool = @[sorted[0]] + mode = "escape" + m.escapeActive = true + if awX != 0.0 or awY != 0.0: + m.escapeBearing = arctan2(awY, awX) * 180.0 / PI + inc m.wallEscapePicks + else: + # Fallback (never freeze): the two coolest tiles on the line, over + # threshold but still the least dangerous direction to move. + var sorted = cands + for i in 1..= 0 and sorted[j].pathHeat > key.pathHeat: + sorted[j + 1] = sorted[j] + dec j + sorted[j + 1] = key + let take = min(2, sorted.len) + for i in 0.. 0.0 and nearWall and pool.len > 1: + var improved: seq[Cand] + for c in pool: + if c.clearance > m.wallDist + 1.0: improved.add c + if improved.len > 0 and rand(1.0) < StrafeWallBias: + pool = improved + # ── range control: prefer the line end that closes the range error ── # A tilt alone cannot move the range (the picker chooses BOTH ends at # random); this is what turns the tilt into a net radial drift. The bias is @@ -759,12 +908,20 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, m.targetY = pool[chosen].y m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row) m.targetValid = true + m.lastMode = mode # sign is frozen for the whole dwell: reversal timing == dwell timing. let hx = cos(ws.selfHeading * DegToRad) let hy = sin(ws.selfHeading * DegToRad) let alongDot = (m.targetX - ws.selfX) * hx + (m.targetY - ws.selfY) * hy - if escaped and m.lastCmdSign != 0.0 and abs(alongDot) < GridSize: + if m.escapeActive and (awX != 0.0 or awY != 0.0): + # GUARANTEE (B): pick the sign whose velocity has a POSITIVE component + # AWAY from the wall. `hdot` = (heading . away); the speed is MaxSpeed*dir + # along the heading, so dir = sign(hdot) makes (speed*heading . away) >= 0 + # and the wall clearance cannot fall. + let hdot = cos(wrap180(ws.selfHeading - m.escapeBearing) * DegToRad) + m.dir = if hdot >= 0.0: 1.0 else: -1.0 + elif 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 @@ -779,15 +936,15 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, inc m.picks if StrafeLog: - let reason = if escaped: "escape" elif safe.len == 0: "fallback" else: "pick" - echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " & + echo fmt"[strafe] {mode} 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} " & - fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f}" - if escaped or safe.len == 0: + fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f} " & + fmt"wall={m.wallDist:.0f} kappa={kappa:.4f} mode={mode}" + if escaped or safe.len == 0 or mode == "escape": echo fmt"[strafe] WARNING: no SAFE tile on the line " & - fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f}" & - (if escaped: ", radial escape" else: "") & ")" + fmt"(cands={cands.len}, clearance={pool[chosen].clearance:.0f}, " & + fmt"destHeat={pool[chosen].destHeat:.1f}, mode={mode})" # ── main entry point ───────────────────────────────────────────────────────── @@ -829,6 +986,20 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = appliedTilt = -tilt var lineAngle = threat + 90.0 + appliedTilt + # ── curved wings (j112): kappa grows as the wall approaches, saturates at + # TR_STRAFE_KAPPA, and is EXACTLY 0 in open space (the wing is then today's + # straight line). The parabola's chord at the reach is tilted toward the + # wall-away normal by atan(kappa*reach), capped by TR_STRAFE_WING_MAX, so the + # body follows the arc's local tangent instead of turning to face the target. + m.wallDist = m.wallClearance(ws.selfX, ws.selfY) + let prox = if StrafeWallMargin > 0.0: + clamp((StrafeWallMargin - m.wallDist) / StrafeWallMargin, 0.0, 1.0) + else: 0.0 + m.kappa = StrafeKappa * prox + let (awX, awY) = m.wallAwayDir(ws.selfX, ws.selfY) + let wingTilt = min(arctan(m.kappa * StrafeReach) * 180.0 / PI, StrafeWingMax) + m.wingTilt = wingTilt + # ── 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 @@ -864,6 +1035,16 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it inc m.cornerGuards + # Apply the wing tilt AFTER the corner guard, so the curve survives in a + # corner: the wall-parallel line is leaned back toward the interior. + if wingTilt > 0.0 and (awX != 0.0 or awY != 0.0): + let awayBearing = arctan2(awY, awX) * 180.0 / PI + lineAngle += (if wrap180(awayBearing - lineAngle) >= 0.0: wingTilt + else: -wingTilt) + + if m.escapeActive and m.wallDist >= StrafeWallMargin: + m.escapeActive = false + m.rangeTilt = appliedTilt m.lineDir = lineAngle @@ -886,16 +1067,22 @@ 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, rng, abs(appliedTilt)) + m.pickTarget(ws, lineForward, rng, abs(appliedTilt), m.kappa) else: dec m.dwell - # ── heading band: turn ONLY to stay perpendicular, never to the target ── + # ── heading band: turn ONLY to stay on the line / arc tangent, never to the + # target. In escape mode the band reference is the wall-away normal, so the + # body slowly rotates inward until the sign can push us off the wall. ── var turnRate = 0.0 let mtr = 10.0 - 0.75 * abs(ws.selfSpeed) - let dev = wrap180(ws.selfHeading - lineForward) + var bandRef = lineForward + if m.escapeActive: + bandRef = m.escapeBearing + inc m.escapeModeTicks + let dev = wrap180(ws.selfHeading - bandRef) if abs(dev) > StrafeBand: - let targetHeading = lineForward + m.bandOffset + let targetHeading = bandRef + m.bandOffset turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr) # ── stuck detector: a commanded move that does not move us flips the sign ── @@ -915,6 +1102,14 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = fmt"-> flipping the sign (pressed into a corner/wall)" # ── move by sign only ── + # ESCAPE (B): keep the commanded sign aligned to the wall-away normal on + # EVERY tick, not only at pick time. If the heading crosses the normal during + # the dwell the sign flips with it, so `speed * heading . away` is ALWAYS + # >= 0: the clearance cannot fall while escaping. That is the guarantee, and + # it is a sign flip (setForward(±)), never a 180-degree turn. + if m.escapeActive and (awX != 0.0 or awY != 0.0): + let hdot = cos(wrap180(ws.selfHeading - m.escapeBearing) * DegToRad) + m.dir = if hdot >= 0.0: 1.0 else: -1.0 # 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. @@ -965,6 +1160,26 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = drawLine(ws.selfX - tx * 600.0, ws.selfY - ty * 600.0, ws.selfX + tx * 600.0, ws.selfY + ty * 600.0) + # ── CURVED WINGS (j112): the path actually offered to the picker. + # point(y) = bot + yhat*y + xhat*kappa*y^2, clamped to the safety margin so + # the wing flattens and runs parallel to the wall instead of touching it. + # In open space kappa == 0, so this collapses onto the cyan line above. + if m.kappa > 0.0 and (awX != 0.0 or awY != 0.0): + setStrokeColor(fromHex("#FFA500")) + setStrokeWidth(3.0) + for sgn in [-1.0, 1.0]: + var prevX = ws.selfX + var prevY = ws.selfY + for si in 1..24: + let y = sgn * StrafeReach * (si.float / 24.0) + let off = m.kappa * y * y + var wx = ws.selfX + ux * y + awX * off + var wy = ws.selfY + uy * y + awY * off + wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe) + wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe) + drawLine(prevX, prevY, wx, wy) + prevX = wx; prevY = wy + # Candidate line reach up to kmax tiles: safe tiles bright, unsafe dim. let kmax = max(1, int(StrafeReach / GridSize)) let spread = max(0, StrafeSpread) @@ -973,10 +1188,15 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = for k in 1..kmax: for s in [-1.0, 1.0]: let along = s * k.float * GridSize + let off = m.kappa * along * along for j in -spread..spread: - let wx = ws.selfX + ux * along + px * (j.float * GridSize) - let wy = ws.selfY + uy * along + py * (j.float * GridSize) - if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue + var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize) + var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize) + if m.kappa > 0.0: + wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe) + wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe) + elif wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: + continue let (c, r) = m.tileAt(wx, wy) let x0 = m.marginX + c.float * GridSize let y0 = m.marginY + r.float * GridSize @@ -1004,6 +1224,17 @@ 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) + # ── ESCAPE marker (j112): the wall-away ray + a label, so the user can see + # the guaranteed-escape mode engage. ── + if m.escapeActive: + setStrokeColor(fromHex("#FFFFFF")) + setStrokeWidth(3.0) + drawLine(ws.selfX, ws.selfY, + ws.selfX + awX * 110.0, ws.selfY + awY * 110.0) + setFillColor(fromHex("#FF0000")) + setFont("Arial", 15.0) + drawText("ESCAPE", ws.selfX + 24.0, ws.selfY + 36.0) + # ── 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 @@ -1018,7 +1249,8 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = 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}", + drawText(fmt"d={rng.dist:.0f} tgt={StrafeRange:.0f} tilt={appliedTilt:.1f} " & + fmt"wall={m.wallDist:.0f} k={m.kappa:.4f}", ws.selfX + 22.0, ws.selfY - 22.0) # ── stuck marker (j111) ── @@ -1029,11 +1261,12 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = 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. + # Heading band: the two ±band boundary rays (yellow) around the effective + # reference (the arc line, or the wall-away bearing in escape mode). setStrokeColor(fromHex("#FFFF00")) setStrokeWidth(1.0) - let bl = (lineForward - StrafeBand) * DegToRad - let br2 = (lineForward + StrafeBand) * DegToRad + let bl = (bandRef - StrafeBand) * DegToRad + let br2 = (bandRef + StrafeBand) * DegToRad drawLine(ws.selfX, ws.selfY, ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0) drawLine(ws.selfX, ws.selfY, diff --git a/common_libs/tests/measure_strafe_wings.nim b/common_libs/tests/measure_strafe_wings.nim new file mode 100644 index 0000000..3b894df --- /dev/null +++ b/common_libs/tests/measure_strafe_wings.nim @@ -0,0 +1,412 @@ +## 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)