Files
SirRoboGarage/common_libs/movements/strafe.nim
T

1455 lines
70 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.
##
## ── Range control: a small TILT of the strafe line (j111) ────────────────────
## Motion exactly perpendicular to the threat does not change the distance to
## the enemy, so pure strafe holds range BY CONSTRUCTION. To honour the owner's
## "we still need to try to go near the optimal distance", the line is tilted:
##
## lineAngle = threat + 90 + appliedTilt
## tilt = clamp(gain * (distance - TR_STRAFE_RANGE) beyond the dead band)
##
## Inside `±TR_STRAFE_RANGE_TOL` around `TR_STRAFE_RANGE` the tilt is EXACTLY 0,
## i.e. today's pure perpendicular strafe; that is meant to be the common case.
## Outside it the line leans so that one of its two ends is closer to the enemy
## (too far) or farther from it (too close). Because `threat` is the enemy
## bearing when the sky is clear but roughly its OPPOSITE when a bullet is in
## flight, the tilt's sign is aligned to the enemy bearing first, so a positive
## `tilt` always leans the SAME physical way. The magnitude is clamped to
## `±TR_STRAFE_TILT_MAX`, so the body still barely turns.
##
## A tilt ALONE cannot change the range: the picker chooses a random tile on
## BOTH ends of the line, so the radial drift averages to zero. The mover
## therefore also PREFERS the line end whose tile reduces |distance - target|,
## with a probability that grows with |tilt| (0.5 = no preference inside the
## dead band, up to 0.9 when the error is at `TILT_MAX`). Both ends stay
## possible, so the reversal randomness the pattern gun feeds on survives.
##
## ── Corner stall: always leave a corner (j111) ──────────────────────────────
## Candidate tiles outside the arena used to be silently skipped; when NONE
## survived, `targetValid` went false and the mover "kept driving on the last
## sign". In a corner whose outward direction was that sign the bot oscillated
## inside a tile forever (the owner's "goes straight to a corner and never come
## back"). Three defenses now guarantee an escape:
## 1. a DEGENERATE line (<= 1 in-arena candidate) falls back to a small radial
## search for the coolest in-arena tile and COMMITS the sign, so a
## near-perpendicular target cannot flip it back and forth;
## 2. a commanded move that produces no displacement for `StuckFlipTicks`
## ticks flips the sign;
## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows it.
##
## ── 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
## `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_RANGE 325.0 target enemy distance (px); mid of the 300-350 band
## TR_STRAFE_RANGE_TOL 25.0 dead-band half-width (px): tilt = 0 inside
## TR_STRAFE_TILT_MAX 15.0 max line tilt off the perpendicular (deg)
## TR_STRAFE_TILT_GAIN 0.10 deg of tilt per px of error BEYOND the band
## TR_STRAFE_HEAT_GRID 1 draw the full heat grid (0 = hide it)
## TR_STRAFE_BULLET_CORE 20.0 lava per bullet-overlapping tile (retune)
## TR_STRAFE_BULLET_AURA 10.0 lava for aura ring tiles (retune)
## 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
##
## ── Pick quality on screen and in the log (j132) ────────────────────────────
## The chosen tile's QUALITY used to be invisible: the overlay graded candidates
## with a binary colour and the `[strafe]` line never printed the chosen tile's
## heat, so a comfortably-safe pick could not be told from a marginal one.
## Both now carry the VALUE of the UNCHANGED safety rule `pathHeat <= 10`:
## * the `[strafe]` line gains `heat=<h>/10.0 alt=<best other candidate>
## ok=<1|0>` (see `strafeHeatText`);
## * the chosen tile gets a thick green->yellow->red border keyed to how close
## `h` is to the threshold, with `h` printed ON the tile, independent of
## `TR_STRAFE_HEAT_GRID` (so it is legible with the grid hidden);
## * candidates are stroked with the same ramp and, when the grid is hidden,
## labelled with their path heat, so the overlay reads on its own.
## This is display/logging only: `PathDangerThreshold` and every pick are
## byte-identical.
##
## 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
# ── pick-quality display (j132) ──────────────────────────────────────────────
proc strafeHeatSafe*(h: float): bool {.inline.} =
## The picker's safety rule as a predicate, so the log and the overlay grade a
## tile EXACTLY the way the picker judges it. Unchanged rule: `<= 10`.
h <= PathDangerThreshold
proc heatRamp(h: float): Color =
## GREEN at or below half the threshold (comfortably safe), YELLOW exactly AT
## the threshold (marginal), RED above it (saturating at twice it). A cue
## only: the picker's rule stays `strafeHeatSafe`, so this can never admit or
## reject a tile.
let t = if PathDangerThreshold > 0.0: h / PathDangerThreshold else: 0.0
if t <= 0.5:
fromHex("#00DD00")
elif t <= 1.0:
fromRgb(uint8(255.0 * (t - 0.5) * 2.0), 255'u8, 0'u8)
else:
fromRgb(255'u8, uint8(255.0 * clamp(2.0 - t, 0.0, 1.0)), 0'u8)
proc strafeHeatText*(heat, alt: float): string =
## The display fields appended to the `[strafe]` line: the chosen tile's path
## heat against the threshold, the coolest OTHER candidate (`-` = none left on
## the table), and the safe/over label. `heat=7.3/10.0 alt=9.8 ok=1` reads as
## "7.3 of the 10.0 allowed, the best tile passed up was 9.8 (marginal), and
## 7.3 is safe". One line, greppable: `grep -o 'heat=[0-9.]*/[0-9.]*'`.
let altTxt = if alt < 0.0: "-" else: fmt"{alt:.1f}"
let okTxt = if strafeHeatSafe(heat): "ok=1" else: "ok=0"
fmt"heat={heat:.1f}/{PathDangerThreshold:.1f} alt={altTxt} " & okTxt
# ── 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
## Range control (j111). Default = 325 px, the midpoint of the owner's
## stated 300-350 px band, chosen after watching live battles.
## History: j111 shipped 200.0 (= TR_POWER_FAR_DIST, where the power policy
## stops treating the enemy as close). The owner moved it to 300-350, which is
## also the direction the one piece of hard evidence points - j107 measured
## that drifting 25-30 px CLOSER made damage/run AND round wins WORSE.
## This is NOT a proven optimum: it is a knob with a band. A/B it before
## claiming anything, because our hit rate actually PEAKS around 100-200 px.
DefaultStrafeRange = 325.0
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
## out the server applying the first command a tick late.
const StuckFlipTicks = 5
var
StrafeBand* = DefaultStrafeBand
StrafeSpread* = DefaultStrafeSpread
StrafeReach* = DefaultStrafeReach
StrafeDwellMin* = DefaultStrafeDwellMin
StrafeDwellMax* = DefaultStrafeDwellMax
StrafeLog* = false
StrafeRange* = DefaultStrafeRange
StrafeRangeTol* = DefaultStrafeRangeTol
StrafeTiltMax* = DefaultStrafeTiltMax
StrafeTiltGain* = DefaultStrafeTiltGain
StrafeKappa* = DefaultStrafeKappa
StrafeWallMargin* = DefaultStrafeWallMargin
StrafeWingMax* = DefaultStrafeWingMax
StrafeWallBias* = DefaultStrafeWallBias
StrafeWallSafe* = DefaultStrafeWallSafe
StrafeEscape* = true
## ── fire-detection fix (j133) ──────────────────────────────────────────
## Corrects the enemy energy delta for the two SERVER effects that
## contaminate it before deciding whether a fire happened, and SPLITS a
## too-large drop instead of silently rejecting it:
## * `BULLET_HIT_ENERGY_GAIN_FACTOR = 3` (server rules.kt): when an enemy
## bullet hits US the SHOOTER gains `3*power`, which hides the `power`
## the enemy spent firing the same tick (a net >= 0 delta reads as "no
## fire"). `noteEnemyBulletHit` adds the bonus back.
## * our own bullet damaging the enemy the same tick inflates the drop
## above 3.0 and gets the enemy's own shot rejected. `noteDamageDealt`
## subtracts it.
## MEASURED on 70 recorded battles (62376 true enemy fires): catches
## 98.89% of enemy fires with the knob OFF and 99.999% with it ON.
## Default ON; `TR_STRAFE_FIRE_FIX=0` restores the shipped detector exactly.
StrafeFireFix*: bool = 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.
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")
StrafeRange = max(0.0, getEnvFloat("TR_STRAFE_RANGE", DefaultStrafeRange))
StrafeRangeTol = max(0.0, getEnvFloat("TR_STRAFE_RANGE_TOL", DefaultStrafeRangeTol))
StrafeTiltMax = max(0.0, min(80.0, getEnvFloat("TR_STRAFE_TILT_MAX", DefaultStrafeTiltMax)))
StrafeTiltGain = max(0.0, getEnvFloat("TR_STRAFE_TILT_GAIN", DefaultStrafeTiltGain))
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)
StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true)
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
# ── pick quality (j132): display/log only, never read by a decision ──
targetPathHeat*: float ## path max heat to the chosen tile, THIS tick
pickPathHeat*: float ## what the picker measured for the chosen tile
targetBestAlt*: float ## coolest OTHER candidate's path heat (-1 none)
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)
# ── 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"
# ── fire-detection fix (j133): event-fed energy-delta corrections ──
hitBonusPending: float ## 3 * power of enemy bullets that hit us this tick
dealtPending: float ## damage our bullets dealt to the enemy this tick
fireFixSplitWaves*: int ## waves emitted by splitting a too-large drop
fireFixCorrectedTicks*: int ## ticks whose drop was non-trivially corrected
# ── 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
escapePicks*: int ## picks where the line was degenerate -> radial escape
cornerGuards*: int ## ticks the line was projected off the arena corner
stuckTicks*: int ## consecutive commanded ticks with no displacement
stuckFlips*: int ## sign flips forced by the stuck detector
reversals*: int ## sign flips of the commanded direction
lineDirFlips*: int ## times the axis orientation flipped
lastCmdSign*: float
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.targetPathHeat = 0.0
m.pickPathHeat = 0.0
m.targetBestAlt = -1.0
m.dwell = 0
m.dir = 1.0
m.bandOffset = 0.0
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.hitBonusPending = 0.0
m.dealtPending = 0.0
m.fireFixSplitWaves = 0
m.fireFixCorrectedTicks = 0
m.callCount = 0
m.picks = 0
m.lastPickCall = 0
m.lastCandCount = 0
m.lastSafeCount = 0
m.fallbackPicks = 0
m.escapePicks = 0
m.cornerGuards = 0
m.stuckTicks = 0
m.stuckFlips = 0
m.reversals = 0
m.lineDirFlips = 0
m.lastCmdSign = 0.0
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 spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave: the enemy's CURRENT (scanned) position as origin,
## direction guessed at our predicted position, power as passed in.
let speed = 20.0 - 3.0 * power
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: power, alive: true, age: 0)
proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
## SERVER FACT (`rules.kt` `BULLET_HIT_ENERGY_GAIN_FACTOR = 3`): when an
## enemy bullet hits US, the SHOOTER's energy RISES by `3 * power`. That rise
## is folded into the enemy energy delta we read this tick and can MASK the
## `power` the enemy spent firing in the same tick (net delta >= 0 reads as
## "no fire"). ModularBot forwards `onHitByBullet`'s `e.bullet.power` here so
## `detectFires` can add the bonus back before classifying the delta.
## No-op when `TR_STRAFE_FIRE_FIX` is off (shipped detector preserved).
if StrafeFireFix:
m.hitBonusPending += 3.0 * power
proc noteDamageDealt*(m: var StrafeModule, damage: float) =
## The mirror contamination: OUR bullet damaging the enemy this tick adds
## `damage` to the enemy's energy drop, which can push it above the 3.0 power
## cap and get the enemy's OWN shot rejected by the shipped `<= 3.01` test.
## ModularBot forwards `onBulletHit`'s `e.damage` here.
## No-op when `TR_STRAFE_FIRE_FIX` is off.
if StrafeFireFix:
m.dealtPending += damage
proc detectFires(m: var StrafeModule, ws: WorldState) =
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let raw = prev - ei.energy
var drop = raw
if StrafeFireFix:
# Undo the server's known energy contaminations. The bonus raises the
# enemy's energy (shrinking/negating the drop); our damage lowers it
# (inflating the drop). Both are known exactly from the events.
drop += m.hitBonusPending - m.dealtPending
if abs(drop - raw) > 1e-9: inc m.fireFixCorrectedTicks
m.prevEnergySet(ei.id, ei.energy)
if StrafeFireFix and drop > 3.01:
# NEVER silently drop a drop. A delta above the power cap is either
# several fires folded into one reading (unobserved radar latency) or
# un-modelled contamination; either way SOME heat beats none. Split into
# the fewest waves each <= 3.0, all from the same origin.
let n = int(ceil(drop / 3.0))
let p = drop / n.float
for _ in 0..<n:
m.spawnTrackedWave(ws, ei, p)
inc m.fireFixSplitWaves
elif drop >= 0.09 and drop <= 3.01:
m.spawnTrackedWave(ws, ei, drop)
if StrafeFireFix:
# Consumed: each event-tick correction applies to exactly one reading.
m.hitBonusPending = 0.0
m.dealtPending = 0.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
# ── range: current enemy bearing / distance ──────────────────────────────────
type
RangeInfo = object
found: bool
bearing: float ## bot -> enemy, degrees
dist: float
ex, ey: float
proc enemyRange(ws: WorldState): RangeInfo =
## Current target enemy if set, else the first tracked enemy.
if ws.enemyX != 0.0 or ws.enemyY != 0.0:
let dx = ws.enemyX - ws.selfX
let dy = ws.enemyY - ws.selfY
return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI,
dist: hypot(dx, dy), ex: ws.enemyX, ey: ws.enemyY)
for ei in ws.enemies:
let dx = ei.x - ws.selfX
let dy = ei.y - ws.selfY
return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI,
dist: hypot(dx, dy), ex: ei.x, ey: ei.y)
RangeInfo(found: false)
# ── target picking ───────────────────────────────────────────────────────────
type
Cand = object
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
## (least path heat, then least tile lava, then nearest). Near a corner the
## wall radiance makes the interior the coolest, so this always points back
## into the arena.
var bestPh = Inf
var bestLava = Inf
var bestD2 = Inf
let (bc, br) = m.tileAt(ws.selfX, ws.selfY)
const R = 4
for dr in -R..R:
for dc in -R..R:
if dc == 0 and dr == 0: continue
let c = bc + dc
let r = br + dr
if c < 0 or c >= m.cols or r < 0 or r >= m.rows: continue
let cx = m.marginX + (c.float + 0.5) * GridSize
let cy = m.marginY + (r.float + 0.5) * GridSize
let ph = m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy)
let lv = m.lavaAt(c, r)
let d2 = (dc * dc + dr * dr).float
if ph < bestPh - 1e-9 or
(abs(ph - bestPh) <= 1e-9 and (lv < bestLava - 1e-9 or
(abs(lv - bestLava) <= 1e-9 and d2 < bestD2))):
bestPh = ph; bestLava = lv; bestD2 = d2
result = (found: true, col: c, row: r, x: cx, y: cy, pathHeat: ph)
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
rng: RangeInfo, tiltMag, kappa: 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)
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:
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
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),
destHeat: m.lavaAt(c, r),
clearance: m.wallClearance(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]
var mode = "pick"
if safe.len > 0:
pool = safe
elif cands.len > 0:
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
var escaped = false
if degenerate:
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,
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
# soft (the far end stays possible) so the reversal randomness survives.
if rng.found and tiltMag > 0.0 and StrafeTiltMax > 0.0:
let curErr = abs(rng.dist - StrafeRange)
var approach: seq[Cand]
for c in pool:
let nd = hypot(rng.ex - c.x, rng.ey - c.y)
if abs(nd - StrafeRange) < curErr: approach.add c
if approach.len > 0 and approach.len < pool.len:
let p = clamp(0.5 + 0.4 * (tiltMag / StrafeTiltMax), 0.0, 0.9)
if rand(1.0) < p: pool = approach
let chosen = rand(pool.high)
m.targetX = pool[chosen].x
m.targetY = pool[chosen].y
m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row)
m.targetValid = true
m.lastMode = mode
# ── pick quality (j132): the number the picker measured, plus the coolest
# candidate it did NOT take (what it left on the table). Display/log only.
m.pickPathHeat = pool[chosen].pathHeat
m.targetBestAlt = -1.0
for c in cands:
if c.col == pool[chosen].col and c.row == pool[chosen].row: continue
if m.targetBestAlt < 0.0 or c.pathHeat < m.targetBestAlt:
m.targetBestAlt = c.pathHeat
# 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 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
else:
m.dir = if alongDot >= 0.0: 1.0 else: -1.0
if m.lastCmdSign == 0.0: m.lastCmdSign = m.dir
# Randomised dwell + randomisation inside the band (point 2 / point 6).
m.dwell = rand(StrafeDwellMin..StrafeDwellMax)
m.bandOffset = rand(2.0 * StrafeBand) - StrafeBand
m.lastPickCall = m.callCount
inc m.picks
if StrafeLog:
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} " &
fmt"wall={m.wallDist:.0f} kappa={kappa:.4f} " &
strafeHeatText(pool[chosen].pathHeat, m.targetBestAlt) & " " &
fmt"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}, clearance={pool[chosen].clearance:.0f}, " &
fmt"destHeat={pool[chosen].destHeat:.1f}, mode={mode})"
# ── 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, TILTED by the range error (j111) ──
let threat = m.threatBearing(ws)
let rng = enemyRange(ws)
m.rangeDist = if rng.found: rng.dist else: -1.0
var tilt = 0.0
if rng.found and StrafeTiltMax > 0.0 and StrafeTiltGain > 0.0:
let err = rng.dist - StrafeRange
if abs(err) > StrafeRangeTol:
let excess = (abs(err) - StrafeRangeTol) * (if err > 0.0: 1.0 else: -1.0)
tilt = clamp(StrafeTiltGain * excess, -StrafeTiltMax, StrafeTiltMax)
# `threat` is the enemy bearing when the sky is clear, but roughly its
# OPPOSITE when it comes from an incoming bullet; align the tilt so a positive
# `tilt` always leans the SAME physical way (toward the enemy when too far).
var appliedTilt = tilt
if tilt != 0.0 and rng.found and
cos(wrap180(threat - rng.bearing) * DegToRad) > 0.0:
appliedTilt = -tilt
var lineAngle = threat + 90.0 + appliedTilt
# ── 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
# candidate tile is always in the arena and the bot slides along the wall
# instead of pressing into the corner -- the deterministic half of the stall
# fix; the radial escape in `pickTarget` is the backup for the rare case where
# even a projected line has no usable tile.
block:
let ux0 = cos(lineAngle * DegToRad)
let uy0 = sin(lineAngle * DegToRad)
let r = StrafeReach
let x1 = ws.selfX + ux0 * r
let y1 = ws.selfY + uy0 * r
let x2 = ws.selfX - ux0 * r
let y2 = ws.selfY - uy0 * r
let out1 = x1 < 0.0 or x1 >= m.arenaWidth or y1 < 0.0 or y1 >= m.arenaHeight
let out2 = x2 < 0.0 or x2 >= m.arenaWidth or y2 < 0.0 or y2 >= m.arenaHeight
if out1 and out2:
var gx = ux0
var gy = uy0
const WallMargin = 2.0 * GridSize
if ws.selfX < WallMargin and gx < 0.0: gx = 0.0
if ws.selfX > m.arenaWidth - WallMargin and gx > 0.0: gx = 0.0
if ws.selfY < WallMargin and gy < 0.0: gy = 0.0
if ws.selfY > m.arenaHeight - WallMargin and gy > 0.0: gy = 0.0
if abs(gx) < 1e-6 and abs(gy) < 1e-6:
# Dead corner: run parallel to whichever wall is nearest.
let mx = min(ws.selfX, m.arenaWidth - ws.selfX)
let my = min(ws.selfY, m.arenaHeight - ws.selfY)
if mx <= my: gx = 0.0; gy = 1.0
else: gx = 1.0; gy = 0.0
lineAngle = arctan2(gy, gx) * 180.0 / PI
appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it
inc m.cornerGuards
# 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
# 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, rng, abs(appliedTilt), m.kappa)
else:
dec m.dwell
# ── pick quality (j132), display only: the chosen tile's LIVE path heat.
# Recomputed every tick because the field moves with the bullets; the GUI
# prints it ON the tile and the `[strafe]` line prints it at pick time. On a
# pick tick it EQUALS `pickPathHeat`, because it is the same `pathMaxHeat`
# call over the same field and the same position - asserted by
# `common_libs/tests/measure_strafe_heat_display.nim`.
m.targetPathHeat = if m.targetValid:
m.pathMaxHeat(ws.selfX, ws.selfY, m.targetX, m.targetY)
else: 0.0
# ── 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)
var bandRef = lineForward
if m.escapeActive:
bandRef = m.escapeBearing
inc m.escapeModeTicks
let dev = wrap180(ws.selfHeading - bandRef)
if abs(dev) > StrafeBand:
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 ──
if m.callCount > 0 and not jumped:
let disp = hypot(ws.selfX - m.lastBotX, ws.selfY - m.lastBotY)
if disp < 0.5:
inc m.stuckTicks
else:
m.stuckTicks = 0
if m.stuckTicks >= StuckFlipTicks:
m.dir = if m.dir >= 0.0: -1.0 else: 1.0
m.lastCmdSign = m.dir
m.stuckTicks = 0
inc m.stuckFlips
if StrafeLog:
echo fmt"[strafe] WARNING: no displacement for {StuckFlipTicks} ticks " &
fmt"-> flipping the sign (pressed into a corner/wall)"
# ── move by sign only ──
# 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.
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)
# ── 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)
let px = -uy
let py = ux
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:
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
# GRADE the candidate by the SAME number the picker uses: the max heat
# on the straight path from the bot (the old binary looked at the
# tile's own lava, which is not the safety rule).
let ph = m.pathMaxHeat(ws.selfX, ws.selfY,
x0 + GridSize * 0.5, y0 + GridSize * 0.5)
setStrokeColor(heatRamp(ph))
setStrokeWidth(if strafeHeatSafe(ph): 1.5 else: 1.0)
drawRectangle(x0, y0, GridSize, GridSize)
# With the full heat grid HIDDEN (`TR_STRAFE_HEAT_GRID=0`) the
# candidates carry their own number, so the STRAFE overlay reads by
# itself; with the grid ON the grid already labels every tile.
if not StrafeHeatGrid:
setFillColor(heatRamp(ph))
setFont("Arial", 10.0)
drawText(fmt"{ph:.1f}", x0 + 3.0, y0 + 14.0)
# ── chosen target tile (j132): its QUALITY, not just its position ────────
# A thick ramp-coloured border (green = comfortably safe, yellow = at the
# threshold, red = over) plus the tile's path heat printed ON the tile over
# a dark plate, so it stays legible whatever the fill colour is. The magenta
# dot still marks the tile as the chosen target. Drawn independently of
# `StrafeHeatGrid`, so the pick reads clearly with `TR_STRAFE_HEAT_GRID=0`.
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(heatRamp(m.targetPathHeat))
setStrokeWidth(3.5)
drawRectangle(gx0, gy0, GridSize, GridSize)
setFillColor(fromHex("#000000"))
fillRectangle(gx0 + 1.0, gy0 + 18.0, GridSize - 2.0, 17.0)
setFillColor(heatRamp(m.targetPathHeat))
setFont("Arial", 12.0)
drawText(fmt"{m.targetPathHeat:.1f}", gx0 + 4.0, gy0 + 31.0)
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)
# ── 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
# close (backing off), white text = the numbers on the log line.
if rng.found:
let err = rng.dist - StrafeRange
let rb = rng.bearing * DegToRad
setStrokeColor(if err >= 0.0: fromHex("#00FF88") else: fromHex("#FF4444"))
setStrokeWidth(3.0)
let rl = min(abs(err), 120.0)
drawLine(ws.selfX, ws.selfY,
ws.selfX + cos(rb) * rl, ws.selfY + sin(rb) * rl)
setFillColor(fromHex("#FFFFFF"))
setFont("Arial", 12.0)
drawText(fmt"d={rng.dist:.0f} tgt={StrafeRange:.0f} tilt={appliedTilt:.1f} " &
fmt"wall={m.wallDist:.0f} k={m.kappa:.4f}",
ws.selfX + 22.0, ws.selfY - 22.0)
# ── stuck marker (j111) ──
if m.stuckTicks > 0:
setStrokeColor(fromHex("#FF0000"))
setStrokeWidth(3.0)
drawCircle(ws.selfX, ws.selfY, 24.0)
setFillColor(fromHex("#FF0000"))
fillCircle(ws.selfX - 27.0, ws.selfY - 27.0, 5.0)
# Heading band: the two ±band boundary rays (yellow) around the effective
# reference (the arc line, or the wall-away bearing in escape mode).
setStrokeColor(fromHex("#FFFF00"))
setStrokeWidth(1.0)
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,
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