Files
SirRoboGarage/common_libs/movements/the_floor_is_lava_ring.nim
T

929 lines
40 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_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()
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)
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.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 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
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
# 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.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)