STRAFE: curved wings + guaranteed wall/corner escape; shipped TFIL default untouched

Task j112. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped
tfil path; the binary default is still tfil.

CURVED WINGS: the candidate set was the straight 1-D line through the bot, which
a bounded segment always terminates at a wall. It is now an adaptive parabola
with the vertex on the bot:
  point(y) = bot + yhat*y + xhat*kappa(y)*y^2
xhat is the unit vector away from the nearest wall(s) (summed inward normals, so
a corner yields the diagonal). 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 chord
at the reach tilts the heading band toward the interior by atan(kappa*reach)
(capped by TR_STRAFE_WING_MAX); the body still only turns slowly to follow that
tangent, never to face the target, and reversals are still setForward sign flips.

GUARANTEED ESCAPE: with every candidate over threshold the old fallback minimised
pathMaxHeat, whose gradient points AT the wall (the shortest path has the least
wall exposure), so the least-hot tile was the adjacent one and led further along
the wall. Near a wall the picker now 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. That sign is re-asserted EVERY tick, so
speed*heading . away >= 0 while escape is active: the clearance cannot fall.
mode=escape reaches the [strafe] log. A mild wall-margin bias
(TR_STRAFE_WALL_BIAS) prefers higher-clearance tiles when near a wall.

GUI/log: the curved wings are drawn as an orange polyline (candidates follow the
curve), a white ray + ESCAPE label marks the escape, and the [strafe] line now
carries wall=<dist> kappa=<..> mode=<pick|fallback|escape|radial>.

Gates (offline, kinematic replay of the DrussGT fixtures; see
common_libs/tests/measure_strafe_wings.nim, plus the reused j108/j111 gates):
wall occupancy (within 54 px) falls 25.6->4.3 / 18.3->4.1 / 23.8->4.3 / 27.8->4.7
percent and the longest continuous wall run 191->31 / 49->25 / 208->47 / 246->27
ticks; corner-region occupancy 4.7->0.0 percent with the longest corner run
71->7. The escape sweep (3520 start x heading x enemy runs, 110k escape ticks)
shows ZERO per-tick guarantee violations and a worst corner run of 21 ticks.
Open-space parity is bit-identical (kappa == 0), reversals are still sign flips
(0 non-sign commands), and mean turn/speed are unchanged (OFF 4.42 deg/tick,
31.3 percent no-turn vs ON 4.45 / 30.7; reversal-interval entropy 5.309 -> 5.311
bits). The fixtures are OPEN-LOOP, so these are veto-capable checks, not a live
win claim.
This commit is contained in:
2026-09-25 23:51:12 +02:00
parent ed25ce29ad
commit ccff7e3e4a
3 changed files with 690 additions and 36 deletions
+9
View File
@@ -276,6 +276,13 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_STRAFE_RANGE_TOL", $StrafeRangeTol, sourceOf("TR_STRAFE_RANGE_TOL")) 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_MAX", $StrafeTiltMax, sourceOf("TR_STRAFE_TILT_MAX"))
emit("TR_STRAFE_TILT_GAIN", $StrafeTiltGain, sourceOf("TR_STRAFE_TILT_GAIN")) 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")) 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 # 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 # 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_DWELL_MIN", "TR_STRAFE_DWELL_MAX", "TR_STRAFE_LOG",
"TR_STRAFE_RANGE", "TR_STRAFE_RANGE_TOL", "TR_STRAFE_TILT_MAX", "TR_STRAFE_RANGE", "TR_STRAFE_RANGE_TOL", "TR_STRAFE_TILT_MAX",
"TR_STRAFE_TILT_GAIN", "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_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA",
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS", "TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
"TR_STRAFE_WALL_RADIANCE", "TR_STRAFE_WALL_RADIANCE",
+269 -36
View File
@@ -97,6 +97,31 @@
## ticks flips the sign; ## ticks flips the sign;
## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows it. ## 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 ─────────────────────────────────── ## ── Reuse of the j105/j106 heat machinery ───────────────────────────────────
## This module does NOT re-implement the time-indexed bullet model. It imports ## 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 ## `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_CORRIDOR_HEAT 10.0 lava per corridor tile (== threshold)
## TR_STRAFE_WALL_HOTNESS 15.0 peak wall radiance (retune) ## TR_STRAFE_WALL_HOTNESS 15.0 peak wall radiance (retune)
## TR_STRAFE_WALL_RADIANCE 5.0 wall radiance falloff (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 ## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever
## called when the bot explicitly selects `strafe`. ## called when the bot explicitly selects `strafe`.
@@ -227,6 +258,13 @@ const
DefaultStrafeRangeTol = 25.0 DefaultStrafeRangeTol = 25.0
DefaultStrafeTiltMax = 15.0 DefaultStrafeTiltMax = 15.0
DefaultStrafeTiltGain = 0.10 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 ## 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 ## in a row flips the sign. Small enough to look instant, large enough to ride
@@ -244,6 +282,12 @@ var
StrafeRangeTol* = DefaultStrafeRangeTol StrafeRangeTol* = DefaultStrafeRangeTol
StrafeTiltMax* = DefaultStrafeTiltMax StrafeTiltMax* = DefaultStrafeTiltMax
StrafeTiltGain* = DefaultStrafeTiltGain 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 ## 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 ## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the
## strafe overlays can be read on their own. ## strafe overlays can be read on their own.
@@ -271,6 +315,12 @@ proc loadStrafeEnv*() =
StrafeRangeTol = max(0.0, getEnvFloat("TR_STRAFE_RANGE_TOL", DefaultStrafeRangeTol)) StrafeRangeTol = max(0.0, getEnvFloat("TR_STRAFE_RANGE_TOL", DefaultStrafeRangeTol))
StrafeTiltMax = max(0.0, min(80.0, getEnvFloat("TR_STRAFE_TILT_MAX", DefaultStrafeTiltMax))) StrafeTiltMax = max(0.0, min(80.0, getEnvFloat("TR_STRAFE_TILT_MAX", DefaultStrafeTiltMax)))
StrafeTiltGain = max(0.0, getEnvFloat("TR_STRAFE_TILT_GAIN", DefaultStrafeTiltGain)) 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) StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv() loadStrafeHeatEnv()
@@ -315,6 +365,15 @@ type
# ── range control (j111) ── # ── range control (j111) ──
rangeDist*: float ## enemy distance this tick (-1 = unknown) rangeDist*: float ## enemy distance this tick (-1 = unknown)
rangeTilt*: float ## applied line tilt this tick (deg) 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) ── # ── diagnostics (gate B + GUI) ──
callCount*: int callCount*: int
picks*: int picks*: int
@@ -376,6 +435,14 @@ proc resetRound*(m: var StrafeModule) =
m.lineDir = 0.0 m.lineDir = 0.0
m.rangeDist = -1.0 m.rangeDist = -1.0
m.rangeTilt = 0.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.callCount = 0
m.picks = 0 m.picks = 0
m.lastPickCall = 0 m.lastPickCall = 0
@@ -638,8 +705,30 @@ type
col, row: int col, row: int
x, y: float x, y: float
pathHeat: 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) 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] = 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, ## 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 ## 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) result = (found: true, col: c, row: r, x: cx, y: cy, pathHeat: ph)
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
rng: RangeInfo, tiltMag: float) = rng: RangeInfo, tiltMag, kappa: float) =
let ux = cos(lineForward * DegToRad) let ux = cos(lineForward * DegToRad)
let uy = sin(lineForward * DegToRad) let uy = sin(lineForward * DegToRad)
let px = -uy let px = -uy
@@ -677,15 +766,29 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
let kmax = max(1, int(StrafeReach / GridSize)) let kmax = max(1, int(StrafeReach / GridSize))
let spread = max(0, StrafeSpread) let spread = max(0, StrafeSpread)
let (bc, br) = m.tileAt(ws.selfX, ws.selfY) 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] var cands: seq[Cand]
for k in 1..kmax: for k in 1..kmax:
for s in [-1.0, 1.0]: for s in [-1.0, 1.0]:
let along = s * k.float * GridSize let along = s * k.float * GridSize
let off = kappa * along * along # parabola, vertex on the bot
for j in -spread..spread: for j in -spread..spread:
let wx = ws.selfX + ux * along + px * (j.float * GridSize) var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize)
let wy = ws.selfY + uy * along + py * (j.float * GridSize) var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize)
if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue 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) let (c, r) = m.tileAt(wx, wy)
if c == bc and r == br: continue if c == bc and r == br: continue
var dup = false 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 let cy = m.marginY + (r.float + 0.5) * GridSize
cands.add Cand(col: c, row: r, x: cx, y: cy, cands.add Cand(col: c, row: r, x: cx, y: cy,
pathHeat: m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy), pathHeat: m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy),
destHeat: m.lavaAt(c, r),
clearance: m.wallClearance(cx, cy),
along: along) along: along)
var safe: seq[Cand] var safe: seq[Cand]
@@ -705,22 +810,48 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
m.lastSafeCount = safe.len m.lastSafeCount = safe.len
var pool: seq[Cand] var pool: seq[Cand]
var mode = "pick"
if safe.len > 0: if safe.len > 0:
pool = safe pool = safe
elif cands.len > 0: elif cands.len > 0:
# Fallback (never freeze): the two coolest tiles on the line, over if StrafeEscape and nearWall:
# threshold but still the least dangerous direction to move. # ── GUARANTEED ESCAPE (B) ─────────────────────────────────────────────
var sorted = cands # Every tile is over threshold. The old rule minimised `pathMaxHeat`,
for i in 1..<sorted.len: # but near a wall EVERY path is dominated by the same wall tile and the
let key = sorted[i] # SHORTEST path has the least wall exposure, so the "least hot" tile was
var j = i - 1 # always the adjacent one and led further along the wall. Rank by the
while j >= 0 and sorted[j].pathHeat > key.pathHeat: # DESTINATION instead: farthest from the wall first, then coolest tile.
sorted[j + 1] = sorted[j] var sorted = cands
dec j for i in 1..<sorted.len:
sorted[j + 1] = key let key = sorted[i]
let take = min(2, sorted.len) var j = i - 1
for i in 0..<take: pool.add sorted[i] while j >= 0 and (sorted[j].clearance < key.clearance or
inc m.fallbackPicks (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..<sorted.len:
let key = sorted[i]
var j = i - 1
while j >= 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..<take: pool.add sorted[i]
mode = "fallback"
inc m.fallbackPicks
# ── corner defense: a degenerate line (< 2 in-arena candidates) escapes ── # ── corner defense: a degenerate line (< 2 in-arena candidates) escapes ──
let degenerate = cands.len <= 1 let degenerate = cands.len <= 1
@@ -729,17 +860,35 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
let esc = m.radialEscape(ws) let esc = m.radialEscape(ws)
if esc.found: if esc.found:
pool = @[Cand(col: esc.col, row: esc.row, x: esc.x, y: esc.y, pool = @[Cand(col: esc.col, row: esc.row, x: esc.x, y: esc.y,
pathHeat: esc.pathHeat, along: 0.0)] pathHeat: esc.pathHeat,
destHeat: m.lavaAt(esc.col, esc.row),
clearance: m.wallClearance(esc.x, esc.y),
along: 0.0)]
escaped = true escaped = true
mode = "radial"
inc m.escapePicks inc m.escapePicks
if StrafeEscape and nearWall:
m.escapeActive = true
if awX != 0.0 or awY != 0.0:
m.escapeBearing = arctan2(awY, awX) * 180.0 / PI
if pool.len == 0: if pool.len == 0:
m.targetValid = false m.targetValid = false
m.lastMode = "none"
if StrafeLog: if StrafeLog:
echo fmt"[strafe] WARNING: no SAFE tile on the line and no in-arena " & echo fmt"[strafe] WARNING: no SAFE tile on the line and no in-arena " &
fmt"escape (cands={cands.len}); keeping the last sign" fmt"escape (cands={cands.len}); keeping the last sign"
return return
# ── wall-margin bias (C): a MILD preference for a tile farther from the
# wall, only within the margin and only when one is on offer. ──
if StrafeWallBias > 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 ── # ── range control: prefer the line end that closes the range error ──
# A tilt alone cannot move the range (the picker chooses BOTH ends at # 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 # 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.targetY = pool[chosen].y
m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row) m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row)
m.targetValid = true m.targetValid = true
m.lastMode = mode
# sign is frozen for the whole dwell: reversal timing == dwell timing. # sign is frozen for the whole dwell: reversal timing == dwell timing.
let hx = cos(ws.selfHeading * DegToRad) let hx = cos(ws.selfHeading * DegToRad)
let hy = sin(ws.selfHeading * DegToRad) let hy = sin(ws.selfHeading * DegToRad)
let alongDot = (m.targetX - ws.selfX) * hx + (m.targetY - ws.selfY) * hy 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 # COMMIT: the escape tile is near-perpendicular to the heading, so letting
# it set the sign re-creates the corner oscillation. Keep sliding. # it set the sign re-creates the corner oscillation. Keep sliding.
m.dir = m.lastCmdSign m.dir = m.lastCmdSign
@@ -779,15 +936,15 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
inc m.picks inc m.picks
if StrafeLog: if StrafeLog:
let reason = if escaped: "escape" elif safe.len == 0: "fallback" else: "pick" echo fmt"[strafe] {mode} n={cands.len} safe={safe.len} " &
echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " &
fmt"along={pool[chosen].along.int} dir={m.dir.int} " & fmt"along={pool[chosen].along.int} dir={m.dir.int} " &
fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f} " & fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f} " &
fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f}" fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f} " &
if escaped or safe.len == 0: 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 " & echo fmt"[strafe] WARNING: no SAFE tile on the line " &
fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f}" & fmt"(cands={cands.len}, clearance={pool[chosen].clearance:.0f}, " &
(if escaped: ", radial escape" else: "") & ")" fmt"destHeat={pool[chosen].destHeat:.1f}, mode={mode})"
# ── main entry point ───────────────────────────────────────────────────────── # ── main entry point ─────────────────────────────────────────────────────────
@@ -829,6 +986,20 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
appliedTilt = -tilt appliedTilt = -tilt
var lineAngle = threat + 90.0 + appliedTilt 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 ── # ── 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 # 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 # 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 appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it
inc m.cornerGuards 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.rangeTilt = appliedTilt
m.lineDir = lineAngle 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: if hypot(m.targetX - ws.selfX, m.targetY - ws.selfY) < GridSize * 0.75:
m.dwell = 0 m.dwell = 0
if (not m.targetValid) or m.dwell <= 0 or serious: 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: else:
dec m.dwell 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 var turnRate = 0.0
let mtr = 10.0 - 0.75 * abs(ws.selfSpeed) 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: if abs(dev) > StrafeBand:
let targetHeading = lineForward + m.bandOffset let targetHeading = bandRef + m.bandOffset
turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr) turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr)
# ── stuck detector: a commanded move that does not move us flips the sign ── # ── 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)" fmt"-> flipping the sign (pressed into a corner/wall)"
# ── move by sign only ── # ── 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 # 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 -- # radial escape), so "keep the last sign" is only the truly boxed-in case --
# and the stuck detector above still flips out of that. # 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, drawLine(ws.selfX - tx * 600.0, ws.selfY - ty * 600.0,
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. # Candidate line reach up to kmax tiles: safe tiles bright, unsafe dim.
let kmax = max(1, int(StrafeReach / GridSize)) let kmax = max(1, int(StrafeReach / GridSize))
let spread = max(0, StrafeSpread) let spread = max(0, StrafeSpread)
@@ -973,10 +1188,15 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
for k in 1..kmax: for k in 1..kmax:
for s in [-1.0, 1.0]: for s in [-1.0, 1.0]:
let along = s * k.float * GridSize let along = s * k.float * GridSize
let off = m.kappa * along * along
for j in -spread..spread: for j in -spread..spread:
let wx = ws.selfX + ux * along + px * (j.float * GridSize) var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize)
let wy = ws.selfY + uy * along + py * (j.float * GridSize) var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize)
if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue 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 (c, r) = m.tileAt(wx, wy)
let x0 = m.marginX + c.float * GridSize let x0 = m.marginX + c.float * GridSize
let y0 = m.marginY + r.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, drawLine(ws.selfX, ws.selfY,
ws.selfX + ux * m.dir * StrafeReach, ws.selfY + uy * m.dir * StrafeReach) 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) ── # ── range-control marker (j111) ──
# A ray toward the enemy whose LENGTH is |distance - target| and whose # A ray toward the enemy whose LENGTH is |distance - target| and whose
# COLOUR encodes the wanted motion: green = too far (closing in), red = too # 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) ws.selfX + cos(rb) * rl, ws.selfY + sin(rb) * rl)
setFillColor(fromHex("#FFFFFF")) setFillColor(fromHex("#FFFFFF"))
setFont("Arial", 12.0) 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) ws.selfX + 22.0, ws.selfY - 22.0)
# ── stuck marker (j111) ── # ── stuck marker (j111) ──
@@ -1029,11 +1261,12 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
setFillColor(fromHex("#FF0000")) setFillColor(fromHex("#FF0000"))
fillCircle(ws.selfX - 27.0, ws.selfY - 27.0, 5.0) 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")) setStrokeColor(fromHex("#FFFF00"))
setStrokeWidth(1.0) setStrokeWidth(1.0)
let bl = (lineForward - StrafeBand) * DegToRad let bl = (bandRef - StrafeBand) * DegToRad
let br2 = (lineForward + StrafeBand) * DegToRad let br2 = (bandRef + StrafeBand) * DegToRad
drawLine(ws.selfX, ws.selfY, drawLine(ws.selfX, ws.selfY,
ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0) ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0)
drawLine(ws.selfX, ws.selfY, drawLine(ws.selfX, ws.selfY,
+412
View File
@@ -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..<samples.len:
let s = samples[i]
if i == 0 or i in starts:
m.resetRound()
x = s.selfX; y = s.selfY; h = s.selfHeading; sp = 0.0
runWall = 0; runCorner = 0
let ws = makeWs(s, x, y, h, sp)
let cmd = m.computeMove(ws)
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)
let c = clearance(x, y)
clrSum += c
clrMin = min(clrMin, c)
inc result.ticks
if c < WallProxPx:
inc result.nearWall
inc runWall
else:
result.longestWallRun = max(result.longestWallRun, runWall); runWall = 0
if inCorner(x, y):
inc result.inCorner
inc runCorner
else:
result.longestCornerRun = max(result.longestCornerRun, runCorner); runCorner = 0
if m.kappa > 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..<nTicks:
let s = Sample(enemyX: ex, enemyY: ey, 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)
# The guarantee check: in escape mode the commanded velocity must have a
# non-negative component along the wall-away normal.
if m.escapeActive:
inc result.escTicks
let vDotAway = cmd.speed * cos(wrap180(h - m.escapeBearing) * PI / 180.0)
if vDotAway < -1e-6: inc result.viol
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)
if inCorner(x, y):
inc runCorner
else:
result.longestCorner = max(result.longestCorner, runCorner); runCorner = 0
result.longestCorner = max(result.longestCorner, runCorner)
result.endClear = clearance(x, y)
proc sweep(): Sweep =
const Ticks = 300
const StallTicks = 60 ## 2 s at 30 ticks/s: "sitting" in a corner
# Enemy placements: centre + four cardinal offsets (clamped into the arena)
# give a spread of threat bearings, hence of strafe-line orientations.
var xs: seq[float]
var x = 36.0
while x < ArenaW - 36.0: xs.add x; x += 72.0
var ys: seq[float]
var y = 36.0
while y < ArenaH - 36.0: ys.add y; y += 72.0
var headings: seq[float]
var hh = 0.0
while hh < 360.0: headings.add hh; hh += 45.0
for sx in xs:
for sy in ys:
for en in [0, 1, 2, 3, 4]:
let ex = case en
of 1: sx
of 2: sx
of 3: clamp(sx - 250.0, 18.0, ArenaW - 18.0)
of 4: clamp(sx + 250.0, 18.0, ArenaW - 18.0)
else: ArenaW / 2.0
let ey = case en
of 1: clamp(sy - 250.0, 18.0, ArenaH - 18.0)
of 2: clamp(sy + 250.0, 18.0, ArenaH - 18.0)
of 3: sy
of 4: sy
else: ArenaH / 2.0
for bh in headings:
let r = simSweep(sx, sy, bh, ex, ey, Ticks)
inc result.runs
result.guaranteeViol += r.viol
result.escapeTicks += r.escTicks
if r.longestCorner > 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..<min(a.cmds.len, b.cmds.len):
let d = max(abs(a.cmds[i][0] - b.cmds[i][0]), abs(a.cmds[i][1] - b.cmds[i][1]))
result.maxDiff = max(result.maxDiff, d)
if d > 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..<on.len:
let s = on[i]
if i == 0 or i in onStarts:
m.resetRound()
x = s.selfX; y = s.selfY; h = s.selfHeading; sp = 0.0
prevSign = 0.0
let ws = makeWs(s, x, y, h, sp)
let cmd = m.computeMove(ws)
if abs(abs(cmd.speed) - 8.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)