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:
@@ -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..<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]
|
||||
inc m.fallbackPicks
|
||||
if StrafeEscape and nearWall:
|
||||
# ── GUARANTEED ESCAPE (B) ─────────────────────────────────────────────
|
||||
# Every tile is over threshold. The old rule minimised `pathMaxHeat`,
|
||||
# but near a wall EVERY path is dominated by the same wall tile and the
|
||||
# SHORTEST path has the least wall exposure, so the "least hot" tile was
|
||||
# always the adjacent one and led further along the wall. Rank by the
|
||||
# DESTINATION instead: farthest from the wall first, then coolest tile.
|
||||
var sorted = cands
|
||||
for i in 1..<sorted.len:
|
||||
let key = sorted[i]
|
||||
var j = i - 1
|
||||
while j >= 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..<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 ──
|
||||
let degenerate = cands.len <= 1
|
||||
@@ -729,17 +860,35 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
|
||||
let esc = m.radialEscape(ws)
|
||||
if esc.found:
|
||||
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
|
||||
mode = "radial"
|
||||
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:
|
||||
m.targetValid = false
|
||||
m.lastMode = "none"
|
||||
if StrafeLog:
|
||||
echo fmt"[strafe] WARNING: no SAFE tile on the line and no in-arena " &
|
||||
fmt"escape (cands={cands.len}); keeping the last sign"
|
||||
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 ──
|
||||
# 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,
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user