Files
SirRoboGarage/common_libs/movements/strafe.nim
T
SirStone 1ea84f5b6e STRAFE: draw the heat field, real bullet danger, corrected ring comment
Three defects the owner hit as "no heat tiles anymore" under TR_MOVEMENT=strafe.

1. The strafe overlay drew ONLY the tiles on its strafe line, so the computed
   heat field was essentially invisible. It now draws the WHOLE field exactly as
   TFIL does (every non-zero tile, yellow->orange->red ramp by field max, integer
   value label) behind the same debugGraphics flag, with TR_STRAFE_HEAT_GRID=0 to
   hide it. The strafe overlays draw on top, unchanged.

2. STRAFE carried the SHIPPED bullet constants (core 10 / aura 5), so a bullet's
   own heat sat exactly ON PathDangerThreshold (10.0) and a bullet was never
   dangerous on its own in this mover; it only ever bit through its corridor.
   Defaults are now the retune's 20/10, exposed as TR_STRAFE_BULLET_CORE /
   TR_STRAFE_BULLET_AURA.

3. The ring mover's header documented CorridorHeat 5.0 / WallHotness 10.0 while
   the code has always been 10.0 / 15.0. A job read the comment and handed out
   sub-threshold heat values, which emptied the field. The comment now states the
   real values and their actual behaviour; no code values changed.

Also sets strafe's heat defaults to the retune shape (bullet 20/10, corridor 10,
wall 15/5, pillar 0), documented with the reason.

Gate A re-run (j110, offline DrussGT fixture, measure_strafe_gates.nim):
  corrected DEFAULT : 24.6% of picks with ZERO safe tile, mean 11.17 safe
  j108 shipped field: 63.4% / 3.70   (reproduced exactly)
  j108 ring retune  :  8.1% / 18.41  (reproduced exactly)
  bullet isolated   : 11.4% / 17.07
The corrected default beats the shipped field but is WORSE than j108's retune
row: the bullet retune alone costs 8.1 -> 11.4, the corridor/wall retune accounts
for the rest. That is the deliberate price of making a bullet dangerous.

Guards green: test_env_report 24 PASS, test_tfil_commit_env 30 PASS (shipped TFIL
default untouched, byte-for-byte), test_tfil_ring_weights 24 PASS. The three new
knobs are registered in the boot env report so the tree-scan guard stays clean.
2026-09-25 23:25:34 +02:00

800 lines
35 KiB
Nim

## STRAFE — body pinned perpendicular to the threat; reversals by SIGN FLIP.
##
## ── The idea (the owner's design) ───────────────────────────────────────────
## TFIL changes its left<->right direction by TURNING the body. Job j85 measured
## the cost of that turn: the speed at the reversal tick drops to ~0.6 px/tick
## (from ~6.4), takes 7-8 ticks to recover, and the hit rate on reversal ticks
## is 1.40x baseline (peaking 1.84x at 6-10 ticks) — the shipped mover also has
## the LOWEST mean speed of the arms tested (4.69 vs 5.37 px/tick).
##
## In Tank Royale the backward speed EQUALS the forward speed (measured
## +8.000 / -8.000 over 75,518 ticks), so a reversal is free if it is done by
## flipping the sign of `setForward` instead of turning the hull around. STRAFE
## exploits exactly that:
##
## * keep the BODY pinned ~perpendicular to the threat (`threat axis` below),
## so "move left" and "move right" are both along the body axis;
## * pick a random safe tile on the perpendicular line (forward or backward)
## and move to it with `setForward(±8)`;
## * NEVER turn to face a movement target — the only turns are small
## corrections that keep the heading inside a band around the perpendicular.
##
## Bonus physics: the bot is a 36 px square. Projected width is 36 px side-on
## or head-on but 50.9 px at 45 degrees, so staying near 90 degrees avoids the
## worst orientation (up to 29% smaller target).
##
## ── Threat axis (point 1 of the spec) ───────────────────────────────────────
## When a bullet is in flight the axis is the INCOMING BULLET's direction: a
## bullet comes from where the enemy WAS when it fired, which at long range
## differs from its current position by 100+ px. When the sky is clear the axis
## falls back to the PERPENDICULAR of the enemy bearing.
##
## ── Heading band, not an exact pin (point 2) ────────────────────────────────
## The body heading is kept inside `TR_STRAFE_BAND` degrees of the perpendicular
## line (`lineDir`); the band offset is re-randomised on every pick so the
## heading is not a constant. Only turns when OUTSIDE the band, and turns the
## short way (the folded deviation is in [-90, 90]).
##
## ── Candidate tiles (point 3) ───────────────────────────────────────────────
## Tiles on the perpendicular line through the current position, both forward
## and backward, within `TR_STRAFE_REACH` px, inside the arena, with a small
## PERPENDICULAR JITTER of `±TR_STRAFE_SPREAD` tiles (the owner's "spread a
## little"). A tile is acceptable when the max heat ON THE STRAIGHT-LINE PATH
## from the bot is <= `PathDangerThreshold` (10.0) — the SAME safety rule TFIL
## uses. The heat field is a strafe-specific RETUNE of that field (bullet 20/10,
## corridor 10, wall 15/5, pillar off — see the defaults block below), not the
## shipped TFIL field: with the shipped shape a safe tile existed on only 36.6%
## of picks (j108 Gate A). The time-indexed bullet model + pillar-free default
## are still reused, see the reuse note below.
##
## ── Move by sign only (point 5) ─────────────────────────────────────────────
## `speed = MaxSpeed * sign`, where `sign` is +1 when the chosen tile lies along
## the current heading and -1 when it lies opposite. There is NO turn-to-target
## anywhere in this mover.
##
## ── Randomised dwell (point 6) ──────────────────────────────────────────────
## The target is re-picked after `rand(TR_STRAFE_DWELL_MIN .. TR_STRAFE_DWELL_MAX)`
## ticks, on arrival, or immediately when the chosen tile's heat spikes by
## `DangerReplanThreshold` (a serious threat). This is the PRIMARY anti-pattern
## defence: a periodic reversal is trivially learnable by DrussGT's pattern gun,
## so the reversal TIMING is randomised and the offline entropy gate (B) checks
## it.
##
## ── Reuse of the j105/j106 heat machinery ───────────────────────────────────
## This module does NOT re-implement the time-indexed bullet model. It imports
## `movements/the_floor_is_lava` and calls its EXPORTED `heatDecay(dt)` and
## `bulletMagScale(power)`, and reads its exported `PillarHotness` /
## `PillarRadiance` (0/0 = the shipped pillar-free default) and `TfilHeatTime`
## (for the debug corridor reach). So `TR_TFIL_HEAT_TIME` / `TR_TFIL_HEAT_TAU` /
## `TR_TFIL_HEAT_POWER_GAIN` / `TR_TFIL_PILLAR_ON` drive the STRAFE field exactly
## as they drive TFIL's. The bullet tracking, heat painting and path sampling are
## copied from the shipped mover (the same pattern `the_floor_is_lava_ring.nim`
## uses) because the shipped file must stay byte-identical and its private
## constants are not exported.
##
## ── Env knobs (all read at module init) ─────────────────────────────────────
## TR_MOVEMENT = strafe (selects this engine; default stays tfil)
## TR_STRAFE_BAND 20.0 heading band half-width (deg)
## TR_STRAFE_SPREAD 1 perpendicular jitter (tiles, ±)
## TR_STRAFE_REACH 144.0 along-line reach (px)
## TR_STRAFE_DWELL_MIN 6 min ticks before a re-pick
## TR_STRAFE_DWELL_MAX 20 max ticks before a re-pick
## TR_STRAFE_LOG off presence-based: echo one line per pick
## TR_STRAFE_HEAT_GRID 1 draw the full heat grid (0 = hide it)
## TR_STRAFE_BULLET_CORE 20.0 lava per bullet-overlapping tile (retune)
## TR_STRAFE_BULLET_AURA 10.0 lava for aura ring tiles (retune)
## 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)
##
## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever
## called when the bot explicitly selects `strafe`.
import std/[math, random, os]
from std/strutils import parseFloat, parseInt, strip, toLowerAscii
import std/strformat
import gun_harness/gun_interface
import movement_harness/movement_interface
import robocode_tankroyale_botapi/graphics
import robocode_tankroyale_botapi/color
# j105/j106 reuse: the exported time-indexed heat helpers + pillar globals.
import movements/the_floor_is_lava
const GridSize = 36.0
const MaxSpeed = 8.0
const BulletCoreRadiusMin = 9.0 ## core radius at power 0.1
const BulletCoreRadiusMax = 54.0 ## core radius at power 3.0
const BulletAuraExtMin = 36.0 ## aura extension at power 3.0 (slow)
const BulletAuraExtMax = 54.0 ## aura extension at power 0.1 (fast)
## ── Heat-shape defaults: the RETUNE, not the shipped TFIL field ─────────────
## STRAFE deliberately runs a DIFFERENT default heat shape from the shipped
## mover. The shipped field (corridor 20, wall 30/10) left a safe tile on the
## perpendicular line on only 36.6% of picks (j108 Gate A: 63.4% fallback, mean
## 3.70 safe candidates); this retune raises that to 75.4% (j110 Gate A: 24.6%
## fallback, mean 11.17 safe candidates). It is a deliberate middle ground: the
## bullet's own core MUST be dangerous (defect 2), and that by construction
## costs some safe tiles versus a field whose bullet core sat below threshold.
## BulletCore 20 / BulletAura 10 — a bullet's OWN heat (20) is now ABOVE
## PathDangerThreshold (10), so the bullet itself is the danger. With the
## shipped 10/5 a bullet core sat exactly ON the threshold and was
## therefore NEVER dangerous on its own; it only ever bit through its
## corridor. This is the ring mover's retune, adopted here.
## CorridorHeat 10 — exactly the threshold, so one corridor tile alone still
## cannot make a path unsafe (the rule is <= threshold); corridors nudge.
## WallHotness 15 / WallRadiance 5 — only the outer wall ring is a hard threat.
## Pillar 0/0 — off, exactly like the shipped default (imported globals).
## All five are env-overridable per run via the TR_STRAFE_* names below.
const BulletCoreDefault = 20.0 ## lava per bullet-overlapping tile (retune)
const BulletAuraDefault = 10.0 ## lava for aura ring tiles (retune)
const EnemyCoreRadius = 18.0 ## half of 36px body
const EnemyAuraRadius = 54.0 ## 18 + 36
const EnemyCore = 40.0 ## lava per tile overlapping enemy body
const EnemyAura = 10.0 ## lava per tile in enemy aura ring
const CorridorHeatDefault = 10.0 ## corridor heat (== PathDangerThreshold)
const WallHotnessDefault = 15.0 ## wall radiance peak (retune)
const WallRadianceDefault = 5.0 ## wall radiance falloff (retune)
## Heat shape is override-able so the shipped field and the retune can be
## compared on one binary. The DEFAULTS are the retune (see the block above);
## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do
## not touch this field and vice versa.
var
StrafeBulletCore* = BulletCoreDefault
StrafeBulletAura* = BulletAuraDefault
StrafeCorridorHeat* = CorridorHeatDefault
StrafeWallHotness* = WallHotnessDefault
StrafeWallRadiance* = WallRadianceDefault
const PathSampleStep = 18.0 ## ~half a tile
const PathDangerThreshold = 10.0 ## max lava on path; above this = unsafe
const DangerReplanThreshold = 25.0 ## serious-threat replan (bullet core)
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
# ── Env knobs ────────────────────────────────────────────────────────────────
proc getEnvFloat(name: string, default: float): float =
let s = getEnv(name, "")
if s.len == 0: return default
try: result = parseFloat(s.strip())
except ValueError: result = default
proc getEnvInt(name: string, default: int): int =
let s = getEnv(name, "")
if s.len == 0: return default
try: result = parseInt(s.strip())
except ValueError: result = default
proc getEnvBool(name: string, default: bool): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s in ["1", "true", "on", "yes"]
const
DefaultStrafeBand = 20.0
DefaultStrafeSpread = 1
DefaultStrafeReach = 144.0
DefaultStrafeDwellMin = 6
DefaultStrafeDwellMax = 20
var
StrafeBand* = DefaultStrafeBand
StrafeSpread* = DefaultStrafeSpread
StrafeReach* = DefaultStrafeReach
StrafeDwellMin* = DefaultStrafeDwellMin
StrafeDwellMax* = DefaultStrafeDwellMax
StrafeLog* = false
## 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.
StrafeHeatGrid* = true
proc loadStrafeHeatEnv*() =
## Re-read the heat-shape overrides. Exposed so a gate can restore the default
## field after temporarily retuning it on the SAME process.
StrafeBulletCore = getEnvFloat("TR_STRAFE_BULLET_CORE", BulletCoreDefault)
StrafeBulletAura = getEnvFloat("TR_STRAFE_BULLET_AURA", BulletAuraDefault)
StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault)
StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault)
StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault)
proc loadStrafeEnv*() =
## Read the strafe knobs. Called once at module init; callable again after
## `putEnv` so a gate script can exercise the arms in one process.
StrafeBand = max(0.0, min(90.0, getEnvFloat("TR_STRAFE_BAND", DefaultStrafeBand)))
StrafeSpread = max(0, getEnvInt("TR_STRAFE_SPREAD", DefaultStrafeSpread))
StrafeReach = max(GridSize, getEnvFloat("TR_STRAFE_REACH", DefaultStrafeReach))
StrafeDwellMin = max(1, getEnvInt("TR_STRAFE_DWELL_MIN", DefaultStrafeDwellMin))
StrafeDwellMax = max(StrafeDwellMin, getEnvInt("TR_STRAFE_DWELL_MAX", DefaultStrafeDwellMax))
StrafeLog = existsEnv("TR_STRAFE_LOG")
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv()
loadStrafeEnv()
# ── small angle helpers ──────────────────────────────────────────────────────
proc wrap180(d: float): float {.inline.} =
result = d
while result > 180.0: result -= 360.0
while result < -180.0: result += 360.0
const DegToRad = PI / 180.0
# ── module types ─────────────────────────────────────────────────────────────
type
TrackedBullet = object
originX, originY: float
x, y: float
velX, velY: float ## speed * cos(heading), speed * sin(heading)
power: float
alive: bool
age: int
StrafeModule* = object
debugGraphics*: bool
cols*, rows*: int
marginX*, marginY*: float
arenaWidth*, arenaHeight*: float
lava: seq[float]
bullets: seq[TrackedBullet]
prevEnergy: seq[tuple[id: int, energy: float]]
# ── decision state ──
targetX*, targetY*: float ## chosen tile centre (world coords)
targetValid*: bool
targetLava: float ## heat at the chosen tile when picked
dwell*: int ## ticks remaining on the current target
dir*: float ## commanded sign: +1 forward, -1 backward
bandOffset: float ## random in [-band, band], re-rolled per pick
lineDir*: float ## undirected strafe line bearing (deg)
# ── diagnostics (gate B + GUI) ──
callCount*: int
picks*: int
lastPickCall*: int ## callCount at the last pick (interval source)
lastCandCount*: int ## candidates generated at the last pick
lastSafeCount*: int ## of those, path-safe at the last pick
fallbackPicks*: int ## picks where the safe pool was empty
reversals*: int ## sign flips of the commanded direction
lineDirFlips*: int ## times the axis orientation flipped
lastCmdSign*: float
lastLineForward: float
lastBotX, lastBotY: float
lastTileCol, lastTileRow: int
proc initStrafe*(): StrafeModule =
StrafeModule(debugGraphics: false)
proc removeBulletNear*(m: var StrafeModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
var bestIdx = -1
var bestD2 = GridSize * GridSize
for i, b in m.bullets:
let d2 = (b.x - x)*(b.x - x) + (b.y - y)*(b.y - y)
if d2 < bestD2:
bestD2 = d2
bestIdx = i
if bestIdx >= 0:
m.bullets.del(bestIdx)
proc prevEnergyGet(m: StrafeModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergySet(m: var StrafeModule, id: int, energy: float) =
for i in 0..<m.prevEnergy.len:
if m.prevEnergy[i].id == id:
m.prevEnergy[i].energy = energy
return
m.prevEnergy.add((id: id, energy: energy))
proc clearGraphics*(m: var StrafeModule) =
## No-op: the SVG buffer is a module-level global cleared by the framework
## after every go(). Exists so callers can signal "STRAFE is inactive".
discard
proc resetRound*(m: var StrafeModule) =
m.bullets = @[]
m.prevEnergy = @[]
m.targetValid = false
m.targetLava = 0.0
m.dwell = 0
m.dir = 1.0
m.bandOffset = 0.0
m.lineDir = 0.0
m.callCount = 0
m.picks = 0
m.lastPickCall = 0
m.lastCandCount = 0
m.lastSafeCount = 0
m.fallbackPicks = 0
m.reversals = 0
m.lineDirFlips = 0
m.lastCmdSign = 0.0
m.lastLineForward = 0.0
m.lastBotX = 0.0
m.lastBotY = 0.0
m.lastTileCol = 0
m.lastTileRow = 0
proc initGrid(m: var StrafeModule, arenaWidth, arenaHeight: float) =
m.cols = int(arenaWidth / GridSize)
m.rows = int(arenaHeight / GridSize)
m.marginX = (arenaWidth - m.cols.float * GridSize) / 2.0
m.marginY = (arenaHeight - m.rows.float * GridSize) / 2.0
m.arenaWidth = arenaWidth
m.arenaHeight = arenaHeight
m.lava = newSeq[float](m.cols * m.rows)
proc lavaAt(m: StrafeModule, col, row: int): float {.inline.} =
m.lava[row * m.cols + col]
proc tileAt(m: StrafeModule, wx, wy: float): tuple[col, row: int] =
(col: clamp(int((wx - m.marginX) / GridSize), 0, m.cols - 1),
row: clamp(int((wy - m.marginY) / GridSize), 0, m.rows - 1))
proc bulletRadii(power: float): tuple[core, aura: float] =
let t = (power - 0.1) / 2.9
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
let auraExt = BulletAuraExtMax - t * (BulletAuraExtMax - BulletAuraExtMin)
(core, core + auraExt)
# ── bullet tracking (copied from the shipped TFIL mover) ─────────────────────
proc detectFires(m: var StrafeModule, ws: WorldState) =
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let drop = prev - ei.energy
m.prevEnergySet(ei.id, ei.energy)
if drop >= 0.09 and drop <= 3.01:
let speed = 20.0 - 3.0 * drop
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * DegToRad) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0)
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: drop, alive: true, age: 0)
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
var i = 0
while i < m.bullets.len:
var b = m.bullets[i]
b.x += b.velX
b.y += b.velY
b.age += 1
let dx = selfX - b.x
let dy = selfY - b.y
let dot = b.velX * dx + b.velY * dy
let outOfBounds = b.x < 0.0 or b.x > m.arenaWidth or b.y < 0.0 or b.y > m.arenaHeight
if dot < 0.0 or outOfBounds or b.age > 200:
b.alive = false
m.bullets[i] = b
if b.alive: inc i
else: m.bullets.del(i)
# ── heat field (SHIPPED TFIL field; time model + pillar via the import) ──────
proc buildHeat(m: var StrafeModule, ws: WorldState) =
for i in 0..<m.lava.len: m.lava[i] = 0.0
# Bullet cores / auras (j105 time model: heat is a function of time-to-arrive).
for b in m.bullets:
let bx = b.x
let by = b.y
let (coreR, auraR) = bulletRadii(b.power)
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bUx = if bSpeed > 0.0: b.velX / bSpeed else: 0.0
let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0
let bMag = bulletMagScale(b.power)
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
for row in rowMin..rowMax:
for col in colMin..colMax:
let x0 = m.marginX + col.float * GridSize
let y0 = m.marginY + row.float * GridSize
let nearX = clamp(bx, x0, x0 + GridSize)
let nearY = clamp(by, y0, y0 + GridSize)
let dx = nearX - bx
let dy = nearY - by
let d2 = dx*dx + dy*dy
if d2 <= coreR * coreR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += StrafeBulletCore * bMag * heatDecay(along / bSpeed)
elif d2 <= auraR * auraR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += StrafeBulletAura * bMag * heatDecay(along / bSpeed)
# Corridors (rotated rectangle from the bullet to the wall, auraR wide).
for b in m.bullets:
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
if speed < 0.001: continue
let dx = b.velX / speed
let dy = b.velY / speed
let px = -dy
let py = dx
var tMin = Inf
if dx > 0.0: tMin = min(tMin, (m.arenaWidth - b.x) / dx)
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
if dy > 0.0: tMin = min(tMin, (m.arenaHeight - b.y) / dy)
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
if tMin == 0.0: continue
let (_, auraR) = bulletRadii(b.power)
let bMag = bulletMagScale(b.power)
let wx = b.x + dx * tMin
let wy = b.y + dy * tMin
let c0x = b.x + px * auraR; let c0y = b.y + py * auraR
let c1x = b.x - px * auraR; let c1y = b.y - py * auraR
let c2x = wx - px * auraR; let c2y = wy - py * auraR
let c3x = wx + px * auraR; let c3y = wy + py * auraR
let xMin = min(min(c0x, c1x), min(c2x, c3x))
let xMax = max(max(c0x, c1x), max(c2x, c3x))
let yMin = min(min(c0y, c1y), min(c2y, c3y))
let yMax = max(max(c0y, c1y), max(c2y, c3y))
let colMin = max(0, int(floor((xMin - m.marginX) / GridSize)))
let colMax = min(m.cols-1, int(floor((xMax - m.marginX) / GridSize)))
let rowMin = max(0, int(floor((yMin - m.marginY) / GridSize)))
let rowMax = min(m.rows-1, int(floor((yMax - m.marginY) / GridSize)))
for row in rowMin..rowMax:
for col in colMin..colMax:
let cx = m.marginX + (col.float + 0.5) * GridSize
let cy = m.marginY + (row.float + 0.5) * GridSize
let relX = cx - b.x
let relY = cy - b.y
let along = relX * dx + relY * dy
let perp = relX * px + relY * py
if along >= 0.0 and along <= tMin and perp >= -auraR and perp <= auraR:
m.lava[row * m.cols + col] += StrafeCorridorHeat * bMag * heatDecay(along / speed)
# Enemy auras
for ei in ws.enemies:
let ex = ei.x
let ey = ei.y
let colMin = max(0, int(floor((ex - EnemyAuraRadius - m.marginX) / GridSize)))
let colMax = min(m.cols-1, int(floor((ex + EnemyAuraRadius - m.marginX) / GridSize)))
let rowMin = max(0, int(floor((ey - EnemyAuraRadius - m.marginY) / GridSize)))
let rowMax = min(m.rows-1, int(floor((ey + EnemyAuraRadius - m.marginY) / GridSize)))
for row in rowMin..rowMax:
for col in colMin..colMax:
let x0 = m.marginX + col.float * GridSize
let y0 = m.marginY + row.float * GridSize
let nearX = clamp(ex, x0, x0 + GridSize)
let nearY = clamp(ey, y0, y0 + GridSize)
let dx = nearX - ex
let dy = nearY - ey
let d2 = dx*dx + dy*dy
if d2 <= EnemyCoreRadius * EnemyCoreRadius:
m.lava[row * m.cols + col] += EnemyCore
elif d2 <= EnemyAuraRadius * EnemyAuraRadius:
m.lava[row * m.cols + col] += EnemyAura
# Wall radiance
for row in 0..<m.rows:
for col in 0..<m.cols:
let heat = max(0.0, StrafeWallHotness - col.float * StrafeWallRadiance) +
max(0.0, StrafeWallHotness - (m.cols-1-col).float * StrafeWallRadiance) +
max(0.0, StrafeWallHotness - row.float * StrafeWallRadiance) +
max(0.0, StrafeWallHotness - (m.rows-1-row).float * StrafeWallRadiance)
m.lava[row * m.cols + col] += heat
# Pillar radiance (imported globals; 0/0 = the shipped pillar-free default).
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
let pc1 = m.cols div 2
let pr0 = if m.rows mod 2 == 1: m.rows div 2 else: m.rows div 2 - 1
let pr1 = m.rows div 2
for row in 0..<m.rows:
for col in 0..<m.cols:
var minDist = int.high
for pcol in pc0..pc1:
for prow in pr0..pr1:
let d = max(abs(col - pcol), abs(row - prow))
if d < minDist: minDist = d
m.lava[row * m.cols + col] += max(0.0, PillarHotness - minDist.float * PillarRadiance)
proc pathMaxHeat(m: StrafeModule, x0, y0, x1, y1: float): float =
let dx = x1 - x0
let dy = y1 - y0
let dist = sqrt(dx*dx + dy*dy)
if dist < 0.1: return 0.0
let steps = max(1, int(dist / PathSampleStep))
for si in 0..steps:
let f = si.float / steps.float
let (c, r) = m.tileAt(x0 + dx * f, y0 + dy * f)
result = max(result, m.lavaAt(c, r))
# ── the threat axis ──────────────────────────────────────────────────────────
proc threatBearing(m: StrafeModule, ws: WorldState): float =
## Incoming bullet direction when a bullet is in flight (nearest one), else
## the enemy bearing.
var bestD = Inf
var found = false
for b in m.bullets:
let d = hypot(b.x - ws.selfX, b.y - ws.selfY)
if d < bestD:
bestD = d
result = arctan2(b.velY, b.velX) * 180.0 / PI
found = true
if found: return result
if ws.enemyX != 0.0 or ws.enemyY != 0.0:
return arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * 180.0 / PI
for ei in ws.enemies:
return arctan2(ei.y - ws.selfY, ei.x - ws.selfX) * 180.0 / PI
return 0.0
# ── target picking ───────────────────────────────────────────────────────────
type
Cand = object
col, row: int
x, y: float
pathHeat: float
along: float ## signed offset along the line (+ = forward of lineForward)
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
let ux = cos(lineForward * DegToRad)
let uy = sin(lineForward * DegToRad)
let px = -uy
let py = ux
let kmax = max(1, int(StrafeReach / GridSize))
let spread = max(0, StrafeSpread)
let (bc, br) = m.tileAt(ws.selfX, ws.selfY)
var cands: seq[Cand]
for k in 1..kmax:
for s in [-1.0, 1.0]:
let along = s * k.float * GridSize
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
let (c, r) = m.tileAt(wx, wy)
if c == bc and r == br: continue
var dup = false
for e in cands:
if e.col == c and e.row == r: dup = true; break
if dup: continue
let cx = m.marginX + (c.float + 0.5) * GridSize
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),
along: along)
var safe: seq[Cand]
for c in cands:
if c.pathHeat <= PathDangerThreshold: safe.add c
m.lastCandCount = cands.len
m.lastSafeCount = safe.len
var pool: seq[Cand]
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 pool.len == 0:
m.targetValid = false
return
let chosen = rand(pool.high)
m.targetX = pool[chosen].x
m.targetY = pool[chosen].y
m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row)
m.targetValid = true
# 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
m.dir = if alongDot >= 0.0: 1.0 else: -1.0
if m.lastCmdSign == 0.0: m.lastCmdSign = m.dir
# Randomised dwell + randomisation inside the band (point 2 / point 6).
m.dwell = rand(StrafeDwellMin..StrafeDwellMax)
m.bandOffset = rand(2.0 * StrafeBand) - StrafeBand
m.lastPickCall = m.callCount
inc m.picks
if StrafeLog:
let reason = if safe.len == 0: "fallback" else: "pick"
echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " &
fmt"along={pool[chosen].along.int} dir={m.dir.int} " &
fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f}"
if safe.len == 0:
echo fmt"[strafe] WARNING: no SAFE tile on the line " &
fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f})"
# ── main entry point ─────────────────────────────────────────────────────────
proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight)
# Soft reset on a position jump (a ram teleport moved us).
let jumpDist = sqrt((ws.selfX - m.lastBotX)^2 + (ws.selfY - m.lastBotY)^2)
let jumped = (m.callCount > 0) and (jumpDist > 12.0)
if jumped:
m.dwell = 0
m.targetValid = false
m.bullets = @[]
m.prevEnergy = @[]
for ei in ws.enemies:
m.prevEnergySet(ei.id, ei.energy)
m.advanceBullets(ws.selfX, ws.selfY)
m.detectFires(ws)
m.buildHeat(ws)
# ── threat axis -> perpendicular line ──
let threat = m.threatBearing(ws)
let lineAngle = threat + 90.0
m.lineDir = lineAngle
# Forward orientation = the half of the (undirected) line nearest our heading.
var lineForward = lineAngle
let hRel = wrap180(ws.selfHeading - lineAngle)
if hRel > 90.0 or hRel < -90.0:
lineForward = lineAngle + 180.0
if m.picks > 0:
let fl = abs(wrap180(lineForward - m.lastLineForward))
if fl > 90.0: inc m.lineDirFlips
m.lastLineForward = lineForward
# ── target (re)pick ──
var serious = false
if m.targetValid:
let (tc, tr) = m.tileAt(m.targetX, m.targetY)
if m.lavaAt(tc, tr) > m.targetLava + DangerReplanThreshold:
serious = true
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)
else:
dec m.dwell
# ── heading band: turn ONLY to stay perpendicular, never to the target ──
var turnRate = 0.0
let mtr = 10.0 - 0.75 * abs(ws.selfSpeed)
let dev = wrap180(ws.selfHeading - lineForward)
if abs(dev) > StrafeBand:
let targetHeading = lineForward + m.bandOffset
turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr)
# ── move by sign only ──
# Never freeze: if there is no valid target (the rare all-candidates-outside
# case) keep driving on the last sign. The normal path picks a target every
# dwell, and the fallback pool is non-empty whenever any candidate tile exists.
if m.targetValid and m.dir != m.lastCmdSign:
inc m.reversals
m.lastCmdSign = m.dir
result = (speed: MaxSpeed * (if m.dir != 0.0: m.dir else: 1.0), turnRate: turnRate)
# ── GUI overlay (the user watches this) ──
if m.debugGraphics:
# The WHOLE lava field, drawn exactly as TFIL draws it: every non-zero tile
# stroked in a yellow->orange->red ramp scaled by the field maximum and
# labelled with its integer value. This is the picture the owner is used to
# from TFIL; the strafe overlays below draw ON TOP of it. `TR_STRAFE_HEAT_GRID=0`
# hides it.
if StrafeHeatGrid:
var maxLava = 0.0
for v in m.lava:
if v > maxLava: maxLava = v
setFont("Arial", 10.0)
for row in 0..<m.rows:
for col in 0..<m.cols:
let val = m.lava[row * m.cols + col]
if val == 0.0: continue
let t = if maxLava > 0.0: val / maxLava else: 0.0
let heatColor = fromRgb(255'u8, uint8(255.0 * (1.0 - t)), 0'u8)
let x0 = m.marginX + col.float * GridSize
let y0 = m.marginY + row.float * GridSize
setStrokeColor(heatColor)
setStrokeWidth(1.0)
drawRectangle(x0, y0, GridSize, GridSize)
setFillColor(heatColor)
drawText($int(val), x0 + 12.0, y0 + 22.0)
let ux = cos(lineForward * DegToRad)
let uy = sin(lineForward * DegToRad)
# Strafe line across the arena (cyan).
setStrokeColor(fromHex("#00FFFF"))
setStrokeWidth(1.0)
drawLine(ws.selfX - ux * 600.0, ws.selfY - uy * 600.0,
ws.selfX + ux * 600.0, ws.selfY + uy * 600.0)
# Threat axis (enemy/bullet bearing) as a faint grey line through us.
let tx = cos(threat * DegToRad)
let ty = sin(threat * DegToRad)
setStrokeColor(fromHex("#888888"))
drawLine(ws.selfX - tx * 600.0, ws.selfY - ty * 600.0,
ws.selfX + tx * 600.0, ws.selfY + ty * 600.0)
# Candidate line reach up to kmax tiles: safe tiles bright, unsafe dim.
let kmax = max(1, int(StrafeReach / GridSize))
let spread = max(0, StrafeSpread)
let px = -uy
let py = ux
for k in 1..kmax:
for s in [-1.0, 1.0]:
let along = s * k.float * GridSize
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
let (c, r) = m.tileAt(wx, wy)
let x0 = m.marginX + c.float * GridSize
let y0 = m.marginY + r.float * GridSize
if m.lavaAt(c, r) <= PathDangerThreshold:
setStrokeColor(fromHex("#00FF00"))
setStrokeWidth(1.0)
else:
setStrokeColor(fromHex("#804000"))
setStrokeWidth(1.0)
drawRectangle(x0, y0, GridSize, GridSize)
# Chosen target tile (magenta fill) + the sign-coloured movement ray.
if m.targetValid:
let (cc, cr) = m.tileAt(m.targetX, m.targetY)
let gx0 = m.marginX + cc.float * GridSize
let gy0 = m.marginY + cr.float * GridSize
setStrokeColor(fromHex("#FF00FF"))
setStrokeWidth(2.5)
drawRectangle(gx0, gy0, GridSize, GridSize)
setFillColor(fromHex("#FF00FF"))
fillCircle(m.targetX, m.targetY, 5.0)
# movement ray: green forward, red backward (the sign flip is the point)
setStrokeColor(if m.dir >= 0.0: fromHex("#00FF00") else: fromHex("#FF2222"))
setStrokeWidth(2.0)
drawLine(ws.selfX, ws.selfY,
ws.selfX + ux * m.dir * StrafeReach, ws.selfY + uy * m.dir * StrafeReach)
# Heading band: the two ±band boundary rays (yellow) around the line.
setStrokeColor(fromHex("#FFFF00"))
setStrokeWidth(1.0)
let bl = (lineForward - StrafeBand) * DegToRad
let br2 = (lineForward + StrafeBand) * DegToRad
drawLine(ws.selfX, ws.selfY,
ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0)
drawLine(ws.selfX, ws.selfY,
ws.selfX + cos(br2) * 70.0, ws.selfY + sin(br2) * 70.0)
# Current heading ray (blue).
setStrokeColor(fromHex("#3399FF"))
setStrokeWidth(2.0)
drawLine(ws.selfX, ws.selfY,
ws.selfX + cos(ws.selfHeading * DegToRad) * 60.0,
ws.selfY + sin(ws.selfHeading * DegToRad) * 60.0)
# snapshot for the next call
m.lastBotX = ws.selfX
m.lastBotY = ws.selfY
m.lastTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
m.lastTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
m.callCount += 1