Files
SirRoboGarage/common_libs/movements/the_floor_is_lava.nim
T
SirStone 2cc2a3bd87 fix(ModularBot): ram loop prevention, dead-target guards, cleaner logging
- 30-tick cooldown after ghost-stuck/timeout ram exit prevents re-entry loop
- enemy_tracker.update() skips dead bots to prevent same-tick scan resurrection
- TFIL graphics cleared when ramming is active movement
- [config] logs: white base with green-highlighted changes only
- [ram:enter] logs trigger reason and key values on false→true transition
- [death] and [target-invalid] logs retained for diagnostics
2026-09-20 20:44:45 +02:00

673 lines
26 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 — The Floor Is Lava. Built incrementally.
import std/math
import std/random
import gun_harness/gun_interface
import movement_harness/movement_interface
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 = 10.0 ## lava accumulation per bullet-overlapping tile
const BulletAura = 5.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
const CorridorHeat = 20.0
const WallHotness = 30.0
const WallRadiance = 10.0
const PillarHotness = 30.0
const PillarRadiance = 10.0
const CommitTicks = 15 ## ticks to commit to a dodge point
const MinCommitTicks = 5 ## 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
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
TFILModule* = object
debugGraphics*: bool
cols, rows: int
marginX, marginY: float
arenaWidth, arenaHeight: float
lava: seq[float] # flat row-major, index = row*cols + col
bullets: seq[TrackedBullet]
prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy
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)
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
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false)
proc removeBulletNear*(m: var TFILModule, 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: TFILModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergySet(m: var TFILModule, 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 TFILModule) =
## 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 TFILModule) =
m.bullets = @[]
m.prevEnergy = @[]
m.commitTicks = 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
proc initGrid(m: var TFILModule, 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 detectFires(m: var TFILModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed fire.
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let drop = prev - ei.energy
m.prevEnergySet(ei.id, ei.energy)
if drop >= 0.09 and drop <= 3.01:
let speed = 20.0 - 3.0 * drop
# Linear prediction: aim at where we will be when the bullet arrives
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: drop,
alive: true,
age: 0)
proc advanceBullets(m: var TFILModule, 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: TFILModule, col, row: int): float =
m.lava[row * m.cols + col]
proc tileAt(m: TFILModule, 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 computeMove*(m: var TFILModule, 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.prevEnergy = @[]
for ei in ws.enemies:
m.prevEnergySet(ei.id, ei.energy)
# Tile-change replan: catches gradual displacement that position threshold misses
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
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
if m.commitTicks > 0:
# Only allow danger 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)
if curLava > m.commitLava + DangerReplanThreshold:
# Mark committed tile blocked so we don't re-pick it
m.blockedTile = (col: cc, row: cr, active: true)
m.commitTicks = 0 # replan
else:
dec m.commitTicks
else:
dec m.commitTicks
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
let chosen = rand(candidates.high)
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.blockedTile.active = false # clear after successful pick
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)
# 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)