Files
SirRoboGarage/common_libs/movements/the_floor_is_lava_ring.nim
T
SirStone fe77056459 j165: port tfil's arrival commitment to tfil_ring, default off
The_floor_is_lava_ring carried raw commitTicks with no arrival guard and no
no-reversal guard. Port the behaviour of tfil's j144 fix on RING-SPECIFIC env
names (TR_TFIL_RING_COMMIT_ARRIVAL, TR_TFIL_RING_NOREV_SPEED) so the two forks
never share a namespace. Both default OFF: with them unset the ring mover is
byte-for-byte the pre-change mover over the whole 20026-tick fixture replay
(golden generated from git show HEAD:..., checked by tfil_ring_replay.nim).

Structural differences from tfil, all noted in the code:
  * ring has no TfilTileReplanMode - the tile-crossing cancel is unconditional
    self-tile, so the arrival guard is just 'not TfilRingCommitArrival'.
  * ring has no replanReason enum, so the arrival/danger/expiry outcomes are a
    local bool; the default-off path keeps ring's original dec/no-dec exactly.
  * ring's MinCommitTicks is 0 (tfil's is 5), so the arrival branch is evaluated
    from the first committed tick. Left as is: changing it would change the
    default path.
  * ring's ScoredTile carries no turnDeg, so the no-reversal offsets are
    computed by ringTileOffTravel at the pick site.
TR_TFIL_COMMIT_MARGIN (tfil's hysteresis) is deliberately NOT ported: it is a
third knob, outside the two named, and inert at its 0.0 default.

No other tfil mechanism touched: no turn-cost tiebreak, TR_TFIL_ARRIVE_TICKS,
TR_FIRE_LAG, heat-field override, corridor bound, hold, or geometry weighting.
No default changed anywhere.
2026-09-27 13:27:02 +02:00

1055 lines
47 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## TFIL-RING — The Floor Is Lava, range-weighted. A COPY of
## `the_floor_is_lava.nim` that keeps that mover's semantics and adds ONE
## behavioural lever: the per-tick random tile pick is re-weighted so tiles at
## the bot's preferred TARGET RANGE are drawn more often.
##
## ── Why ─────────────────────────────────────────────────────────────────────
## ModularBot's measured real hit rate vs DrussGT is strongly range-dependent
## (21.6% at 0-100px, 27.1% at 100-200px, then falling off: 19.3% at 200-300,
## 10.9% at 300-400, 6.8% at 400-600, 5.4% at 600-800). The bot shoots from
## ~450px on average, where it hits ~5%. The plain TFIL mover has NO range
## preference, so it drifts; this mover SHIFTS the random distribution toward a
## configured band. 100-200 is DrussGT-only data and MUST be re-measured per
## adversary (set TR_TFIL_RANGE_LO/HI).
##
## ── What is preserved (safety stays a HARD constraint) ──────────────────────
## The reachable hull, the cool-tile pool, the path-heat scoring/sort, the
## `PathDangerThreshold = 10.0` safety filter and the `safeTiles.len < 2`
## promote fallback are IDENTICAL to `the_floor_is_lava.nim`. The weighting is
## applied ONLY to the final draw over `candidates`, which is exactly the pool
## the unweighted `rand(candidates.high)` picked from. It can therefore never
## select a tile the old code would have rejected — a monotone refinement of
## the same safe set. The per-tick randomness is deliberately KEPT (a measured
## A/B showed committing to the "best" tile made hit rate WORSE, 7.02% ->
## 5.10%); only its distribution is tilted, never removed.
##
## ── The one change ──────────────────────────────────────────────────────────
## For each candidate tile, d = distance from the tile CENTRE to the current
## TARGET enemy (ws.enemyX/enemyY). Band weight with a FLAT TOP:
##
## rangeW(d) = 1.0 if lo <= d <= hi
## exp(-((lo - d)/K)^2) if d < lo
## exp(-((d - hi)/K)^2) if d > hi
##
## The flat top matters: a Gaussian centred on the band midpoint would collapse
## the band to a point and destroy the within-band hedge. Then a
## temperature-shaped categorical draw:
##
## w_i = rangeW(d_i) ^ (1/T); chosen ~ Categorical(w)
##
## T -> infinity gives uniform weights (the old distribution). Small safe pools
## (len < 4, i.e. melee) are always drawn uniformly, so shaping cannot drag the
## bot toward the enemy while the tiles are hot.
##
## ── Env knobs (read once at module init, like the gun rack) ─────────────────
## TR_TFIL_RANGE_LO default 100.0 band lower edge (px)
## TR_TFIL_RANGE_HI default 200.0 band upper edge (px)
## TR_TFIL_RANGE_TEMP default 0.4 softmax temperature; <= 0 = OFF path
## TR_TFIL_RANGE_K default 60.0 Gaussian falloff scale (px)
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
## TR_TFIL_RING_COMMIT_ARRIVAL default off hold the committed tile until we
## are ON it (port of tfil's j144 fix)
## TR_TFIL_RING_NOREV_SPEED default 0.0 px/tick; below this a mid-flight
## switch may not turn the bot around
## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the
## original mover did, so the same binary can serve as the control arm.
##
## ── Heat-field knobs (the deviation from the original TFIL) ─────────────────
## Measured (offline, DrussGT fixtures): corridors are 21.4% and walls 56.4% of
## the over-threshold set, so they dominate the field. The ACTUAL values in this
## file (read at module init, `let` block further down) are:
## CorridorHeat = 10.0 — exactly `PathDangerThreshold` (10.0). The safety rule
## is `pathMaxHeat <= PathDangerThreshold`, so a corridor
## tile sitting on a path is still SAFE: a corridor alone
## can no longer make a path unsafe.
## WallHotness = 15.0 — ABOVE the threshold at the OUTERMOST ring only (the
## second ring sits at exactly 10.0, the threshold, and
## is safe), so wall radiance alone CAN poison the outer
## ring but no deeper.
## WallRadiance = 5.0 — the falloff that produces the two rings above.
## BulletCore/BulletAura = 20.0/10.0 — the bullet's own core is ABOVE the
## threshold, which is what makes an incoming bullet
## dangerous on its own.
## Both soft knobs are env-overridable (TR_TFIL_CORRIDOR_HEAT /
## TR_TFIL_WALL_HOTNESS) for an A/B.
## CHANGED vs the original mover: BulletCore/BulletAura (20/10 here, 10/5 in
## the original), WallRadiance (5.0 here, 10.0 in the original). UNCHANGED:
## EnemyCore/EnemyAura and their radii, PillarHotness/PillarRadiance (0/0
## default) and PathDangerThreshold (10.0).
##
## WARNING: an earlier revision of THIS comment claimed CorridorHeat = 5.0 and
## WallHotness = 10.0. That did NOT match the code — it was a stale copy of an
## earlier retune PROPOSAL, and a job that read the comment handed out
## sub-threshold heat values that left the field empty. The code has always been
## 10.0 / 15.0; the comment now says so.
import std/[math, random, os, strformat]
from std/strutils import parseFloat, strip, toLowerAscii # selective: strutils.fromHex clashes with color.fromHex
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/fire_tracker
import robocode_tankroyale_botapi/graphics
import robocode_tankroyale_botapi/color
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)
const BulletCore = 20.0 ## lava accumulation per bullet-overlapping tile
const BulletAura = 10.0 ## lava accumulation for aura ring tiles
const EnemyCoreRadius = 18.0 ## half of 36px body
const EnemyAuraRadius = 54.0 ## 18 + 36
const EnemyCore = 40.0 ## lava per tile overlapping enemy body circle
const EnemyAura = 10.0 ## lava per tile in enemy aura ring
## NOTE: CorridorHeat and WallHotness are NOT const here (unlike the original
## `the_floor_is_lava.nim`): they are env-overridable and read at module init,
## below, in the same style as the range-weighting knobs.
const WallRadiance = 5.0
const PillarHotness = 0.0
const PillarRadiance = 0.0
const CommitTicks = 5 ## ticks to commit to a dodge point
const MinCommitTicks = 0 ## must commit for this many ticks before danger replan allowed
const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras
const CoolestLevels = 2 ## how many distinct lava values count as "cool"
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
# ── Range-weighting knobs (read once at module init, like the gun rack) ──────
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 getEnvBool(name: string, default: bool): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s in ["1", "true", "on", "yes"]
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
var TfilRingFireFix* = true
proc loadTfilRingFireEnv*() =
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilRingFireEnv()
## ── j165: the ARRIVAL commitment, ported from `the_floor_is_lava.nim` ────────
## Same BEHAVIOUR as tfil's `TR_TFIL_COMMIT_ARRIVAL` / `TR_TFIL_NOREV_SPEED`,
## RING-SPECIFIC env names so the two forks never share a namespace by accident.
## Both default OFF, so the default path stays byte-for-byte today's ring
## (proved over the 20026-tick fixture replay in `test_tfil_commit_env.nim`).
## TR_TFIL_RING_COMMIT_ARRIVAL 0/1 hold the committed tile until we are
## ON it, instead of dropping the
## commitment on a tile crossing
## TR_TFIL_RING_NOREV_SPEED float while |speed| is below this, a
## mid-flight switch to the OPPOSITE
## side is refused (0 = off)
const
RingArriveRadius* = 18.0 ## "we are on the committed tile" — the same 18px
## radius `the_floor_is_lava.nim` uses
RingHullTicks = 50 ## the reachable-hull planning horizon (the literal
## 50 already passed to `computeReachableHull`
## below). Past it the committed target is no longer
## guaranteed reachable: the stall escape.
var
TfilRingCommitArrival* = false
TfilRingNoRevSpeed* = 0.0
proc loadTfilRingCommitEnv*() =
## Read the j165 knobs. Called once at module init; the guard test calls it
## again after `putEnv` so the non-default arms run in one process.
TfilRingCommitArrival = getEnvBool("TR_TFIL_RING_COMMIT_ARRIVAL", false)
TfilRingNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_RING_NOREV_SPEED", 0.0))
loadTfilRingCommitEnv()
const
DefaultRangeLo = 100.0
DefaultRangeHi = 200.0
DefaultRangeTemp = 0.4
DefaultRangeK = 60.0
## Minimum safe-pool size before range shaping applies. Below this the draw is
## uniform: a 2-3 tile melee pool must not be pulled toward the enemy while
## those tiles are hot.
MinRingPool = 4
let RangeLo* = getEnvFloat("TR_TFIL_RANGE_LO", DefaultRangeLo)
let RangeHi* = getEnvFloat("TR_TFIL_RANGE_HI", DefaultRangeHi)
let RangeTemp* = getEnvFloat("TR_TFIL_RANGE_TEMP", DefaultRangeTemp)
let RangeK* = getEnvFloat("TR_TFIL_RANGE_K", DefaultRangeK)
let MovementLog* = existsEnv("TR_MOVEMENT_LOG")
# ── Heat-field knobs (see the rationale in the file header) ──────────────────
let CorridorHeat* = getEnvFloat("TR_TFIL_CORRIDOR_HEAT", 10.0)
let WallHotness* = getEnvFloat("TR_TFIL_WALL_HOTNESS", 15.0)
proc rangeWeight*(d, lo, hi, k: float): float =
## Flat-topped range weight. Exactly 1.0 for d inside [lo, hi]; Gaussian
## falloff with scale `k` outside. The flat top is deliberate: a Gaussian
## centred ON the band would collapse it to a point, whereas this keeps every
## in-band tile equally likely and preserves the within-band hedge. `k <= 0`
## degrades to a hard band edge (1 inside, 0 outside).
if d >= lo and d <= hi: return 1.0
if k <= 0.0: return 0.0
let z = (if d < lo: (lo - d) / k else: (d - hi) / k)
exp(-z * z)
proc bandWeights*(dists: openArray[float],
lo, hi, k, temp: float): seq[float] =
## Categorical weights over a candidate pool. `temp` is the softmax
## temperature: `temp -> inf` drives every weight to 1.0 (uniform = the old
## distribution). `temp <= 0` (the explicit OFF path) and pools smaller than
## `MinRingPool` also return uniform weights.
result = newSeq[float](dists.len)
let uniform = temp <= 0.0 or dists.len < MinRingPool
for i in 0..<dists.len:
if uniform:
result[i] = 1.0
else:
result[i] = pow(rangeWeight(dists[i], lo, hi, k), 1.0 / temp)
proc weightedIndex*(weights: openArray[float], u: float): int =
## PURE categorical draw: given weights proportional to the probabilities and
## a uniform variate `u` in [0, 1), return the selected index in
## 0..<weights.len. The RNG output is injected as `u`, so this is
## unit-testable with no battle. It always returns an index INSIDE the pool,
## never outside. Falls back to a uniform index when the weights sum to zero
## (keeps the function total; with the default K every weight is > 0).
if weights.len == 0: return 0
var total = 0.0
for w in weights: total += w
if not (total > 0.0):
return min(weights.high, int(u * weights.len.float))
let target = u * total
var acc = 0.0
for i in 0..<weights.len:
acc += weights[i]
if target < acc: return i
weights.high
proc weightedIndex*(weights: openArray[float], rng: var Rand): int =
## Convenience wrapper for callers holding a `Rand` to inject.
weightedIndex(weights, rand(rng, 1.0))
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)
type
TrackedBullet = object
originX, originY: float
x, y: float
velX, velY: float ## speed * cos(heading), speed * sin(heading)
power: float
alive: bool
age: int ## ticks alive; die if > 200
TFILRingModule* = object
debugGraphics*: bool
## Preferred target-range band (px from the target enemy). The BOT sets this
## each tick from the existing ram decision: (RangeLo, RangeHi) while
## holding, (0.0, 50.0) while ramming. Ram is therefore just a band, so one
## movement engine covers both modes. Defaults to (RangeLo, RangeHi).
band*: tuple[lo, hi: float]
cols, rows: int
marginX, marginY: float
arenaWidth, arenaHeight: float
lava: seq[float] # flat row-major, index = row*cols + col
bullets: seq[TrackedBullet]
fire: FireTracker ## shared energy-drop detector (j134)
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment
commitLava: float ## lava at commit time (for spike detection)
commitAge: int ## j165: ticks since the current target
## was picked (0 = just picked)
picks: int ## j165: picks made this round; > 0 means
## a switch would be MID-FLIGHT
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
cachedHull: seq[tuple[x, y: float]]
cachedInsideTiles: seq[tuple[col, row: int]]
callCount: int ## computeMove call count; 0 = never called
lastBotX, lastBotY: float ## bot position at last call; used to detect position jumps
lastTileCol, lastTileRow: int ## grid tile at last call; used to detect gradual displacement
# Ring-mode observability (TR_MOVEMENT_LOG): last logged range class and
# band, so the log fires on change rather than every tick.
lastLogClass: int ## -1 = never; 0 near, 1 in-band, 2 far
lastLogBandLo: float
lastLogBandHi: float
loggedOnce: bool
proc initTFILRing*(): TFILRingModule =
TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi),
fire: initFireTracker())
proc removeBulletNear*(m: var TFILRingModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
var bestIdx = -1
var bestD2 = GridSize * GridSize # tolerance²
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: TFILRingModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var TFILRingModule, id: int, energy: float) =
m.fire.prevEnergySet(id, energy)
proc clearGraphics*(m: var TFILRingModule) =
## No-op: the SVG buffer is a module-level global cleared by the framework
## after every go(). Exists so callers can signal "TFIL is inactive this tick".
discard
proc resetRound*(m: var TFILRingModule) =
m.bullets = @[]
m.fire.reset()
m.commitTicks = 0
m.commitAge = 0
m.picks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false)
m.callCount = 0
m.lastBotX = 0.0
m.lastBotY = 0.0
m.lastTileCol = 0
m.lastTileRow = 0
m.band = (lo: RangeLo, hi: RangeHi)
m.lastLogClass = -1
m.lastLogBandLo = RangeLo
m.lastLogBandHi = RangeHi
m.loggedOnce = false
proc initGrid(m: var TFILRingModule, 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) # all 0.0
proc spawnTrackedWave(m: var TFILRingModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave for a confirmed fire of `power` (extracted from
## `detectFires` so the shared split can spawn several).
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 * PI / 180.0) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
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 TFILRingModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
if TfilRingFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var TFILRingModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
if TfilRingFireFix:
m.fire.noteDamageDealt(damage)
proc detectFires(m: var TFILRingModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
## fire. The shared tracker corrects the delta and splits over-cap drops.
for ei in ws.enemies:
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilRingFireFix):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
proc advanceBullets(m: var TFILRingModule, selfX, selfY: float) =
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
var i = 0
while i < m.bullets.len:
var b = m.bullets[i]
b.x += b.velX
b.y += b.velY
b.age += 1
# Death conditions (any triggers removal):
# 1. Passed us (dot < 0, moving away)
# 2. Out of arena bounds
# 3. Too old (>200 ticks)
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)
type CorridorGeom = object
dx, dy: float ## unit heading
px, py: float ## unit perpendicular
tMin: float ## distance to wall
bx, by: float ## bullet origin
proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom =
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
if speed < 0.001: return
let dx = b.velX / speed
let dy = b.velY / speed
var tMin = Inf
if dx > 0.0: tMin = min(tMin, (arenaWidth - b.x) / dx)
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy)
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y)
proc lavaAt(m: TFILRingModule, col, row: int): float =
m.lava[row * m.cols + col]
proc tileAt(m: TFILRingModule, 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 pointInHull(px, py: float, hull: seq[(float, float)]): bool =
var inside = false
var j = hull.high
for i in 0..hull.high:
if ((hull[i][1] > py) != (hull[j][1] > py)) and
(px < (hull[j][0] - hull[i][0]) * (py - hull[i][1]) / (hull[j][1] - hull[i][1]) + hull[i][0]):
inside = not inside
j = i
inside
proc computeReachableHull(x0, y0, heading0, speed0,
arenaW, arenaH: float, ticks: int = 50): seq[(float, float)] =
## Simulate `ticks` ticks at various turn rates / target speeds.
## Returns convex hull (gift-wrap) of final positions.
const TargetSpeeds = [8.0, 4.0, -4.0, -8.0]
const NumRates = 11
var pts: seq[(float, float)]
pts.add (x0, y0) # always reachable: stay
for tSpeed in TargetSpeeds:
# Max turn rate at target speed (approximate; actual varies per tick but close enough)
let mtr = 10.0 - 0.75 * abs(tSpeed)
for ri in 0..<NumRates:
let turnRate = if NumRates == 1: 0.0
else: -mtr + (2.0 * mtr / (NumRates - 1).float) * ri.float
var x = x0; var y = y0
var h = heading0; var spd = speed0
for _ in 0..<ticks:
# Accelerate toward target
if spd < tSpeed: spd = min(spd + 1.0, tSpeed)
elif spd > tSpeed: spd = max(spd - 1.0, tSpeed)
spd = clamp(spd, -MaxSpeed, MaxSpeed)
# Turn (clamp to current max turn rate)
let curMtr = 10.0 - 0.75 * abs(spd)
let tr = clamp(turnRate, -curMtr, curMtr)
h += tr
let hr = h * PI / 180.0
x = clamp(x + spd * cos(hr), 0.0, arenaW)
y = clamp(y + spd * sin(hr), 0.0, arenaH)
pts.add (x, y)
# Gift-wrap convex hull (O(n²), ~45 points — fine)
# Find leftmost point as start
var startIdx = 0
for i in 1..<pts.len:
if pts[i][0] < pts[startIdx][0] or
(pts[i][0] == pts[startIdx][0] and pts[i][1] < pts[startIdx][1]):
startIdx = i
var hull: seq[(float, float)]
var cur = startIdx
while true:
hull.add pts[cur]
var next = 0
for i in 1..<pts.len:
if next == cur:
next = i
continue
let ax = pts[next][0] - pts[cur][0]
let ay = pts[next][1] - pts[cur][1]
let bx = pts[i][0] - pts[cur][0]
let by = pts[i][1] - pts[cur][1]
let cross = ax * by - ay * bx
if cross < 0.0: # i is more counterclockwise
next = i
cur = next
if cur == startIdx: break
hull
proc ringTileOffTravel*(m: TFILRingModule, col, row: int,
sx, sy, travelDeg: float): float =
## Signed angle in degrees from the travel direction to the tile centre,
## folded into (-180, 180]. Verbatim from `the_floor_is_lava.nim`'s
## `tileOffTravel`; the ring's `ScoredTile` carries no `turnDeg`, so the
## no-reversal pool computes the offsets itself.
let tx = m.marginX + (col.float + 0.5) * GridSize
let ty = m.marginY + (row.float + 0.5) * GridSize
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
while result > 180.0: result -= 360.0
while result < -180.0: result += 360.0
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
## j165: which candidate tiles may a slow, mid-flight switch take? Verbatim
## from `the_floor_is_lava.nim`. `offs` are the signed angles (deg) from the
## travel direction to each candidate, `threshold` is the speed gate
## (px/tick). Returns the indices NOT more than 90 deg off — the bot does not
## have to turn around to reach them. If EVERY candidate is behind us the
## reversal is unavoidable, so the single LEAST-bad one is returned (a shallow
## turn, not a 180 deg flip): the result is NEVER empty, so the pick can never
## be starved. `threshold <= 0` = the knob is off and every candidate stays.
if offs.len == 0: return
if threshold <= 0.0:
for i in 0..<offs.len: result.add i
return
var keep: seq[int]
for i, a in offs:
if abs(a) <= 90.0: keep.add i
if keep.len > 0: return keep
var best = 0
for i, a in offs:
if abs(a) < abs(offs[best]): best = i
@[best]
proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight)
# Soft reset: detect gap by position jump (rammer moved us across ticks)
# 12px threshold: above single-tick max movement (8px) but catches even short ram gaps
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.commitTicks = 0 # force replan — old target invalid
m.cachedHull = @[] # stale position/heading
m.cachedInsideTiles = @[]
m.bullets = @[] # bullet positions are hopelessly stale
m.blockedTile = (col: 0, row: 0, active: false)
# Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires
m.fire.prevEnergy = @[]
for ei in ws.enemies:
m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan: catches gradual displacement that position threshold misses
# j165: with TR_TFIL_RING_COMMIT_ARRIVAL the SELF-tile crossing is exactly the
# event that must NOT cancel a commitment: crossing a boundary is the very
# motion the commitment commands, and at GridSize 36 / speed 8 it fires every
# ~5 ticks — which is precisely this fork's CommitTicks. Under the shipped
# default (arrival off) this is the original block verbatim.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
not TfilRingCommitArrival:
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
m.commitTicks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
# Per-tick: advance existing bullets, detect new fires
m.advanceBullets(ws.selfX, ws.selfY)
m.detectFires(ws)
# Recompute lava from scratch each tick
for i in 0..<m.lava.len: m.lava[i] = 0.0
for b in m.bullets:
let bx = b.x
let by = b.y
let (coreR, auraR) = bulletRadii(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:
m.lava[row * m.cols + col] += BulletCore
elif d2 <= auraR * auraR:
m.lava[row * m.cols + col] += BulletAura
# Corridor heat — rotated rectangle from bullet position to arena wall, auraR wide
for b in m.bullets:
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue # zero-speed bullet, skip
let (_, auraR) = bulletRadii(b.power)
let wx = cg.bx + cg.dx * cg.tMin
let wy = cg.by + cg.dy * cg.tMin
# Bounding box of the 4 corners
let c0x = cg.bx + cg.px * auraR; let c0y = cg.by + cg.py * auraR
let c1x = cg.bx - cg.px * auraR; let c1y = cg.by - cg.py * auraR
let c2x = wx - cg.px * auraR; let c2y = wy - cg.py * auraR
let c3x = wx + cg.px * auraR; let c3y = wy + cg.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
# Project tile center onto heading and perpendicular axes
let relX = cx - cg.bx
let relY = cy - cg.by
let along = relX * cg.dx + relY * cg.dy
let perp = relX * cg.px + relY * cg.py
if along >= 0.0 and along <= cg.tMin and perp >= -auraR and perp <= auraR:
m.lava[row * m.cols + col] += CorridorHeat
# Enemy heat auras — core (18px) and aura ring (54px), same pattern as bullets
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 heat — additive with bullet heat
for row in 0..<m.rows:
for col in 0..<m.cols:
let heat = max(0.0, WallHotness - col.float * WallRadiance) +
max(0.0, WallHotness - (m.cols-1-col).float * WallRadiance) +
max(0.0, WallHotness - row.float * WallRadiance) +
max(0.0, WallHotness - (m.rows-1-row).float * WallRadiance)
m.lava[row * m.cols + col] += heat
# Pillar radiance heat — center 1×1, 1×2, 2×1, or 2×2 depending on grid parity
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
let pc1 = m.cols div 2 # same as pc0 when odd, pc0+1 when even
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)
if m.debugGraphics:
# Compute max lava for heat gradient
var maxLava = 0.0
for v in m.lava:
if v > maxLava: maxLava = v
# Non-zero tiles: colored border + colored value text (yellow→orange→red)
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)
# Draw tracked bullet circles
setStrokeColor(RED)
setStrokeWidth(1.0)
setFillColor(RED)
for b in m.bullets:
let (coreR, auraR) = bulletRadii(b.power)
drawCircle(b.x, b.y, coreR)
fillCircle(b.x, b.y, 3.0)
setStrokeColor(fromHex("#FF8800")) # orange aura
setStrokeWidth(1.0)
drawCircle(b.x, b.y, auraR)
setStrokeColor(RED)
setStrokeWidth(1.0)
# Danger corridor: rotated rectangle projecting each bullet forward to arena wall
setStrokeColor(fromHex("#AAAAAA"))
setStrokeWidth(1.0)
for b in m.bullets:
let (_, auraR) = bulletRadii(b.power)
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue
let wx = cg.bx + cg.dx * cg.tMin
let wy = cg.by + cg.dy * cg.tMin
let corners: seq[(float, float)] = @[
(cg.bx + cg.px * auraR, cg.by + cg.py * auraR),
(cg.bx - cg.px * auraR, cg.by - cg.py * auraR),
(wx - cg.px * auraR, wy - cg.py * auraR),
(wx + cg.px * auraR, wy + cg.py * auraR),
]
drawPolygon(corners)
# Enemy core (cyan) and aura (green)
setStrokeColor(fromHex("#00FFFF")) # cyan core
setStrokeWidth(1.5)
for ei in ws.enemies:
drawCircle(ei.x, ei.y, EnemyCoreRadius)
setStrokeColor(fromHex("#00CC00")) # green aura
setStrokeWidth(1.0)
for ei in ws.enemies:
drawCircle(ei.x, ei.y, EnemyAuraRadius)
# ── Tile-based Dodge System ───────────────────────────────────────────────────
let botCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
let botRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
type TileRef = tuple[col, row: int]
if m.commitTicks == 0:
let hull = computeReachableHull(ws.selfX, ws.selfY, ws.selfHeading, ws.selfSpeed,
m.arenaWidth, m.arenaHeight, 50)
# store as named-field seq to match cachedHull type
m.cachedHull = @[]
for p in hull: m.cachedHull.add (x: p[0], y: p[1])
m.cachedInsideTiles = @[]
if hull.len >= 3:
for row in 0..<m.rows:
for col in 0..<m.cols:
let cx = m.marginX + (col.float + 0.5) * GridSize
let cy = m.marginY + (row.float + 0.5) * GridSize
if pointInHull(cx, cy, hull):
m.cachedInsideTiles.add (col: col, row: row)
let insideTiles = m.cachedInsideTiles
# Find the CoolestLevels distinct lava values among inside-hull tiles
var distinctVals: seq[float]
for t in insideTiles:
let v = m.lavaAt(t.col, t.row)
var found = false
for dv in distinctVals:
if dv == v: found = true; break
if not found: distinctVals.add v
# Sort ascending (insertion sort — small N)
for i in 1..<distinctVals.len:
let key = distinctVals[i]
var j = i - 1
while j >= 0 and distinctVals[j] > key:
distinctVals[j + 1] = distinctVals[j]
dec j
distinctVals[j + 1] = key
# Collect tiles matching the CoolestLevels coolest distinct values
var coolTiles: seq[TileRef]
let numLevels = min(CoolestLevels, distinctVals.len)
for t in insideTiles:
let v = m.lavaAt(t.col, t.row)
for li in 0..<numLevels:
if v == distinctVals[li]:
coolTiles.add t
break
# Score each cool tile by MAX lava on the straight-line path from bot.
# A single hot tile on the path (corridor, bullet core, enemy aura) makes the whole path unsafe.
const PathSampleStep = 18.0 # ~half a tile
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe
type ScoredTile = tuple[col, row: int; pathMaxHeat: float]
var scoredTiles: seq[ScoredTile]
for t in coolTiles:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
let ddx = tx - ws.selfX
let ddy = ty - ws.selfY
let lineDist = sqrt(ddx*ddx + ddy*ddy)
var pathMaxHeat = 0.0
if lineDist > 0.1:
let steps = max(1, int(lineDist / PathSampleStep))
for si in 0..steps:
let frac = si.float / steps.float
let sx = ws.selfX + ddx * frac
let sy = ws.selfY + ddy * frac
let (sc, sr) = m.tileAt(sx, sy)
pathMaxHeat = max(pathMaxHeat, m.lavaAt(sc, sr))
scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat)
# Sort by pathMaxHeat ascending (insertion sort — small N)
for i in 1..<scoredTiles.len:
let key = scoredTiles[i]
var j = i - 1
while j >= 0 and scoredTiles[j].pathMaxHeat > key.pathMaxHeat:
scoredTiles[j + 1] = scoredTiles[j]
dec j
scoredTiles[j + 1] = key
# Absolute threshold filter: safe = path max lava <= PathDangerThreshold.
# Fallback: if everything is hot, keep the 2 coolest paths anyway.
var safeTiles: seq[ScoredTile]
var blockedTiles: seq[ScoredTile]
for t in scoredTiles:
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
else: blockedTiles.add t
if safeTiles.len < 2:
# Fallback: promote the least-hot blocked tiles until we have 2
# ponytail: O(n) scan on already-sorted seq — fine for small N
let needed = 2 - safeTiles.len
let promote = min(needed, blockedTiles.len)
for i in 0..<promote:
safeTiles.add blockedTiles[i]
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
# Commitment logic. With every j165 knob at its default (both off) this is the
# original three-way test, unchanged. j165 adds one way OUT of a commitment
# that is NOT a tile crossing (the block above is skipped when armed) and turns
# the tick counter into a MINIMUM dwell: the target is held until we are
# actually standing on it.
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
RingArriveRadius * RingArriveRadius
if m.commitTicks > 0:
inc m.commitAge
# Only allow a replan after MinCommitTicks have elapsed
let ticksElapsed = CommitTicks - m.commitTicks
if ticksElapsed >= MinCommitTicks:
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let curLava = m.lavaAt(cc, cr)
var commitEnd = false
if curLava > m.commitLava + DangerReplanThreshold:
# GENUINE DANGER: the committed tile got hot. Block it so we don't
# immediately re-pick it, and replan. This safety valve is deliberately
# independent of the arrival rule and is UNCHANGED by j165.
m.blockedTile = (col: cc, row: cr, active: true)
commitEnd = true
elif TfilRingCommitArrival:
if atTarget:
# Reached. Only now is a new target allowed.
commitEnd = true
elif m.commitAge >= RingHullTicks:
# Stall escape: past the planner's own reachability horizon the
# committed tile is no longer guaranteed reachable (rammed, boxed in).
commitEnd = true
if not commitEnd:
dec m.commitTicks
if m.commitTicks == 0 and TfilRingCommitArrival:
m.commitTicks = CommitTicks # minimum dwell reached: renew, don't abandon
else:
m.commitTicks = 0
else:
dec m.commitTicks
# j165: was the commitment we are about to replace still UNREACHED? A pick
# that replaces a target we had not yet got to is the owner's failure mode:
# the bot is still accelerating and the target flips under it. `picks == 0`
# means this is the first pick of the round, which is not a switch at all.
let midFlight = m.picks > 0 and not atTarget
if m.commitTicks == 0 and safeTiles.len > 0:
# Filter out the blocked tile from candidates
var candidates: seq[ScoredTile]
for t in safeTiles:
if m.blockedTile.active and t.col == m.blockedTile.col and t.row == m.blockedTile.row:
continue
candidates.add t
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
# j165, no opposite-direction flip while still accelerating. Below the speed
# threshold the bot physically cannot complete a reversal before the bullet
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
# we are already standing on the committed tile (an arrival pick) the bot is
# free to go anywhere, and that is exactly the pick that must not be blocked.
if TfilRingNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilRingNoRevSpeed and midFlight:
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
var offs: seq[float]
for t in candidates:
offs.add ringTileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
let keep = norevPool(offs, TfilRingNoRevSpeed)
var narrowed: seq[ScoredTile]
for i in keep: narrowed.add candidates[i]
candidates = narrowed
# Safety is a HARD constraint: the weighting below only re-orders the draw
# AMONG `candidates`, which is exactly the pool the old `rand` picked from.
# It can never select a tile the unweighted code would have rejected
# (monotone refinement of the same safe set).
var chosen: int
if RangeTemp <= 0.0:
# Explicit OFF path: byte-for-byte the old uniform draw (control arm).
chosen = rand(candidates.high)
else:
var dists = newSeq[float](candidates.len)
for i, t in candidates:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
dists[i] = hypot(tx - ws.enemyX, ty - ws.enemyY)
let weights = bandWeights(dists, m.band.lo, m.band.hi, RangeK, RangeTemp)
chosen = weightedIndex(weights, rand(1.0))
let ct = candidates[chosen]
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
y: m.marginY + (ct.row.float + 0.5) * GridSize)
m.commitTicks = CommitTicks
m.commitLava = m.lavaAt(ct.col, ct.row)
m.commitAge = 0
inc m.picks
m.blockedTile.active = false # clear after successful pick
# One concise log line on a range-class or band change (never per-tick).
if MovementLog:
let d = hypot(m.commitTarget.x - ws.enemyX, m.commitTarget.y - ws.enemyY)
let cls = if d < m.band.lo: 0 elif d <= m.band.hi: 1 else: 2
if (not m.loggedOnce) or cls != m.lastLogClass or
m.band.lo != m.lastLogBandLo or m.band.hi != m.lastLogBandHi:
let clsName = ["near", "band", "far"][cls]
echo fmt"[tfil_ring] mode=ring band=[{m.band.lo.int},{m.band.hi.int}] " &
fmt"chosenDist={d.int} class={clsName} cands={candidates.len} " &
fmt"corridorHeat={CorridorHeat} wallHotness={WallHotness}"
m.loggedOnce = true
m.lastLogClass = cls
m.lastLogBandLo = m.band.lo
m.lastLogBandHi = m.band.hi
if m.debugGraphics:
# Reachable hull perimeter (darker blue)
if m.cachedHull.len >= 3:
let hullPairs: seq[(float, float)] = block:
var s: seq[(float, float)]
for p in m.cachedHull: s.add (p.x, p.y)
s
setStrokeColor(fromHex("#336699"))
setStrokeWidth(1.0)
drawPolygon(hullPairs)
# Dim (dark cyan) for path-blocked cool tiles
setStrokeColor(fromHex("#006666"))
setStrokeWidth(1.0)
for t in blockedTiles:
let x0 = m.marginX + t.col.float * GridSize
let y0 = m.marginY + t.row.float * GridSize
drawRectangle(x0, y0, GridSize, GridSize)
# Bright cyan borders on safe-to-reach tiles
setStrokeColor(fromHex("#00FFFF"))
setStrokeWidth(2.0)
for t in safeTiles:
let x0 = m.marginX + t.col.float * GridSize
let y0 = m.marginY + t.row.float * GridSize
drawRectangle(x0, y0, GridSize, GridSize)
# Green on chosen tile
let (chosenCol, chosenRow) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let gx0 = m.marginX + chosenCol.float * GridSize
let gy0 = m.marginY + chosenRow.float * GridSize
setStrokeColor(fromHex("#00FF00"))
setStrokeWidth(2.5)
drawRectangle(gx0, gy0, GridSize, GridSize)
# Blue on bot tile
let bx0 = m.marginX + botCol.float * GridSize
let by0 = m.marginY + botRow.float * GridSize
setStrokeColor(fromHex("#0088FF"))
setStrokeWidth(2.5)
drawRectangle(bx0, by0, GridSize, GridSize)
# Committed target line
setStrokeColor(fromHex("#00FF00"))
setStrokeWidth(1.5)
drawLine(ws.selfX, ws.selfY, m.commitTarget.x, m.commitTarget.y)
# ── Ring-mode observability ─────────────────────────────────────────────
# Target band annulus around the current target enemy: two magenta rings at
# lo and hi radius read as the annulus between them. (lo == 0 in ram mode,
# so the inner ring is skipped there.)
setStrokeColor(fromHex("#FF00FF"))
setStrokeWidth(1.5)
if m.band.lo > 0.0: drawCircle(ws.enemyX, ws.enemyY, m.band.lo)
drawCircle(ws.enemyX, ws.enemyY, m.band.hi)
# Tint each safe tile by its range weight (red = 0, green = 1) and thicken
# the border with the weight, so the shaped distribution is visible.
setFont("Arial", 9.0)
for t in safeTiles:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
let w = rangeWeight(d, m.band.lo, m.band.hi, RangeK)
let x0 = m.marginX + t.col.float * GridSize
let y0 = m.marginY + t.row.float * GridSize
setStrokeColor(fromRgb(255'u8, uint8(255.0 * (1.0 - w)), 0'u8))
setStrokeWidth(1.0 + 3.0 * w)
drawRectangle(x0, y0, GridSize, GridSize)
# Chosen tile: a filled magenta marker, distinct from the tinted candidates.
setFillColor(fromHex("#FF00FF"))
fillCircle(m.commitTarget.x, m.commitTarget.y, 6.0)
setStrokeColor(fromHex("#FFFFFF"))
setStrokeWidth(1.0)
drawCircle(m.commitTarget.x, m.commitTarget.y, 6.0)
# Update position snapshot and call counter for next gap detection
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
# ── Steering ─────────────────────────────────────────────────────────────────
let stepDx = m.commitTarget.x - ws.selfX
let stepDy = m.commitTarget.y - ws.selfY
let dist2 = stepDx*stepDx + stepDy*stepDy
if dist2 < 324.0: # already at target (18px radius)
return (speed: 0.0, turnRate: 0.0)
let targetBearing = arctan2(stepDy, stepDx) * 180.0 / PI
var delta = targetBearing - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
let maxTurnRate = 10.0 - 0.75 * abs(ws.selfSpeed)
var speed: float
var turnRate: float
if abs(delta) <= 90.0:
speed = MaxSpeed
turnRate = clamp(delta, -maxTurnRate, maxTurnRate)
else:
let flipped = if delta > 0.0: delta - 180.0 else: delta + 180.0
speed = -MaxSpeed
turnRate = clamp(flipped, -maxTurnRate, maxTurnRate)
result = (speed: speed, turnRate: turnRate)