Files
SirRoboGarage/common_libs/movements/the_floor_is_lava.nim
T
SirStone d0750ab020 TFIL: remove the virtual centre pillar from the shipped default; register j102 env reads
The default mover painted a 30/10 radiance blob on the arena centre even
though the arena has NO physical pillar there, creating a 4x4 tile
(144x144 px) exclusion zone over open centre floor. Set
PillarHotness/PillarRadiance to 0/0 in the shipped default (matching the
ring variant) and add TR_TFIL_PILLAR_ON=1 to restore the old 30/10 field
for A/B without a rebuild; registered in env_report.

Because the shipped default legitimately changed, the default-path parity
golden (fixtures/tfil_commit_default.golden) was regenerated from the NEW
default, with an explicit 'deliberate default change' note in the test so
a future failure is treated as a real regression.

Also register the three env reads job j102 added in common_libs/bitbrain
(TR_BITBRAIN_MODE / _DECAY_EVERY / _DECAY_SHIFT), which the env-report
guard was failing on.

Verification: test_env_report all green; test_tfil_commit_env 30/30.
2026-09-25 22:02:00 +02:00

994 lines
41 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 std/os
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
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
## The "virtual centre pillar": heat OUR code paints on the arena centre even
## though the arena has NO physical pillar there. The shipped default is now
## OFF (0/0) — the bot must not avoid open centre floor for no reason. Set
## `TR_TFIL_PILLAR_ON=1` to restore the old 30/10 field for A/B (see
## `loadTfilPillarEnv`). The ring variant already ships 0/0.
const
PillarHotnessOn = 30.0
PillarRadianceOn = 10.0
var
PillarHotness* = 0.0
PillarRadiance* = 0.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
# ── Commit-behaviour knobs (A/B arms) ────────────────────────────────────────
#
# The shipped mover cancels its movement commitment whenever OUR tile changes
# (`ttrSelf`). With GridSize = 36 and speed up to 8 px/tick the bot crosses a
# tile boundary every ~5 ticks, so the 15-tick commitment is cancelled by the
# very motion it commands. `ttrOff` honours the commitment (the danger replan
# stays the safety valve); `ttrEnemy` keys the cancel to the TARGET's tile
# displacement, which is what the original comment claimed to do.
#
# Every default below reproduces the shipped mover byte-for-byte; see the
# default-path parity guard in `common_libs/tests/test_tfil_commit_env.nim`.
type
TfilTileReplan* = enum
ttrSelf, ttrOff, ttrEnemy
TfilReplanReason* = enum
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry
proc tileReplanName*(m: TfilTileReplan): string =
case m
of ttrSelf: "self"
of ttrOff: "off"
of ttrEnemy: "enemy"
proc reasonName*(r: TfilReplanReason): string =
case r
of rrNone: "none"
of rrInit: "init"
of rrTileSelf: "tile_self"
of rrTileEnemy: "tile_enemy"
of rrDanger: "danger"
of rrExpiry: "expiry"
const
DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length
NoRevForwardWeight = 3 ## forward:backward weight ratio (arm C)
var
TfilTileReplanMode*: TfilTileReplan = ttrSelf
TfilCommitTicks*: int = DefaultTfilCommitTicks
TfilNoRev*: bool = false
TfilCommitLogPath*: string = ""
proc getEnvInt(name: string, default: int): int =
let s = getEnv(name, "")
if s.len == 0: return default
try: result = parseInt(s.strip())
except ValueError: result = default
proc getEnvBool(name: string, default: bool): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s in ["1", "true", "on", "yes"]
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 loadTfilCommitEnv*() =
## Read the commit knobs. Called once at module init; the guard test calls it
## again after `putEnv` so the non-default arms can be exercised in one process.
case getEnv("TR_TFIL_TILE_REPLAN", "self").strip().toLowerAscii()
of "off", "none", "never", "0", "false": TfilTileReplanMode = ttrOff
of "enemy", "target": TfilTileReplanMode = ttrEnemy
else: TfilTileReplanMode = ttrSelf
TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks))
TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false)
TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "")
loadTfilCommitEnv()
proc loadTfilPillarEnv*() =
## Read the virtual-centre-pillar knob. OFF by default: the shipped field is
## the arena with no invented centre hazard. `TR_TFIL_PILLAR_ON=1` restores
## the pre-change 30/10 field so the two defaults can be A/B-ed offline.
if getEnvBool("TR_TFIL_PILLAR_ON", false):
PillarHotness = PillarHotnessOn
PillarRadiance = PillarRadianceOn
else:
PillarHotness = 0.0
PillarRadiance = 0.0
loadTfilPillarEnv()
# ── Time-indexed bullet heat (TR_TFIL_HEAT_TIME=1, default OFF = shipped) ─────
#
# WHY: the flat model gives every bullet-overlapping tile the same heat and
# paints the bullet's corridor all the way to the arena wall, regardless of how
# far away or how weak the bullet still is. `CorridorHeat` (20) is twice
# `PathDangerThreshold` (10), so ONE weak far bullet saturates a 108px-wide
# swath from its nose to the wall, and a path the bullet will not reach until
# long after the bot has left it is already marked unsafe.
#
# WHAT: heat becomes a function of `dt`, the time (ticks) until the bullet
# REACHES that cell:
#
# dt = along / speed # along = distance from the bullet
# heat = magnitude(power) * decay(dt)
#
# * `decay(dt) = exp(-dt / tau)` is a function of TIME, not pixels. A fixed time
# constant `tau` therefore projects a PIXEL reach of `speed * tau`: a fast
# bullet's slope is longer, a slow one's shorter — DERIVED from the physics
# (`speed = 20 - 3*power`), not hand-tuned per power. `tau` = TR_TFIL_HEAT_TAU.
# * `magnitude(power)` scales the near-end heat with power from DAMAGE, not from
# hit chance: server damage is `calcBulletDamage = 4p`, linear in p, and
# `SCORE_PER_BULLET_DAMAGE = 1.0`, so a stronger bullet costs more when it
# hits. Hit probability is FLAT across power (docs/env_reference.md), so risk
# does NOT justify power scaling — the COST of the hit does. Floored at 1.0 so
# a weak bullet's near end is never LESS dangerous than the flat model.
# Gain = TR_TFIL_HEAT_POWER_GAIN.
#
# TIME-INDEXED PLANNER (NOT implemented, by design): because every source is
# already expressed as `f(dt)`, evaluating a cell at the tick the bot would
# ARRIVE there is the one-line change `heatDecay(dt - arrivalDelay)` — heat a
# later bullet's path by that bullet's lead on the bot's own arrival time, so
# the bot can use the path and leave before the bullet arrives. This is the real
# fix for the user's second point; the shipped move is unchanged until then.
var
TfilHeatTime*: bool = false
TfilHeatTau*: float = 9.0 ## decay time constant, ticks
TfilHeatPowerGain*: float = 1.0 ## extra near-end heat at max power (damage proxy)
proc loadTfilHeatEnv*() =
## Read the heat-model knobs. Called once at module init; also callable after
## `putEnv` so one process can A/B both models (the offline ruler does this).
TfilHeatTime = getEnvBool("TR_TFIL_HEAT_TIME", false)
TfilHeatTau = max(0.05, getEnvFloat("TR_TFIL_HEAT_TAU", 9.0))
TfilHeatPowerGain = max(0.0, getEnvFloat("TR_TFIL_HEAT_POWER_GAIN", 1.0))
loadTfilHeatEnv()
proc heatDecay*(dt: float): float =
## Fraction of a bullet's heat still present `dt` ticks before it arrives.
## Exactly 1.0 when the time model is off, so the default field is
## bit-identical to the flat model (multiplying any heat by 1.0 is exact).
if not TfilHeatTime or dt <= 0.0: return 1.0
exp(-dt / TfilHeatTau)
proc bulletMagScale*(power: float): float =
## Near-end heat multiplier from the bullet's DAMAGE (4p, linear in power),
## floored at 1.0. Exactly 1.0 when the time model is off.
if not TfilHeatTime: return 1.0
1.0 + TfilHeatPowerGain * (power / 3.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
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
lastEnemyTileCol, lastEnemyTileRow: int ## grid tile of the target at last call (arm D)
replanReason: TfilReplanReason ## why the last commitment ended (log only)
lastPickCall: int ## callCount at the last pick (log only)
picks: int ## number of picks this round (log only)
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
m.lastEnemyTileCol = -1
m.lastEnemyTileRow = -1
m.replanReason = rrNone
m.lastPickCall = 0
m.picks = 0
# ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ──────────────────
# One JSONL line per computeMove call, used by the A/B to prove the treatment
# actually bit (decision interval, replan reason, reversal rate, speed).
var
tfilLogFile: File
tfilLogOpen = false
tfilLogPathOpen = ""
proc closeTfilCommitLog*() =
## Close the diagnostics stream. Needed because the log path is a knob: a
## caller (the A/B, or the guard test) may point it somewhere else mid-process.
if tfilLogOpen:
try: tfilLogFile.close()
except CatchableError: discard
tfilLogOpen = false
tfilLogPathOpen = ""
proc tfilLogWrite(line: string) =
if TfilCommitLogPath.len == 0: return
if tfilLogOpen and tfilLogPathOpen != TfilCommitLogPath: closeTfilCommitLog()
if not tfilLogOpen:
try:
tfilLogFile = open(TfilCommitLogPath, fmAppend)
tfilLogOpen = true
tfilLogPathOpen = TfilCommitLogPath
except CatchableError:
return
try:
tfilLogFile.writeLine(line)
tfilLogFile.flushFile()
except CatchableError:
discard
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 noRevWeights*(dirs: openArray[float], travelDeg: float): seq[int] =
## Soft no-reversal preference (arm C). `dirs` are candidate directions in
## degrees (world frame). Forward candidates (<=90 deg off the travel
## direction) get `NoRevForwardWeight`, every other candidate gets 1.
## A weight is NEVER 0, so the preference can only down-weight, never filter:
## the candidate pool can never be emptied by it. When no forward candidate
## exists the caller falls back to a uniform draw (all weights 1 anyway).
for d in dirs:
var a = d - travelDeg
while a > 180.0: a -= 360.0
while a < -180.0: a += 360.0
result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1)
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 — see the knob rationale at the top of the file.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
case TfilTileReplanMode
of ttrSelf:
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 = @[]
m.replanReason = rrTileSelf
of ttrEnemy:
# The comment's original intent: replan when the TARGET went stale, not
# when WE moved. Uses the primary enemy's tile displacement.
if ws.enemies.len > 0 and m.lastEnemyTileCol >= 0:
let ec = clamp(int((ws.enemies[0].x - m.marginX) / GridSize), 0, m.cols - 1)
let er = clamp(int((ws.enemies[0].y - m.marginY) / GridSize), 0, m.rows - 1)
if ec != m.lastEnemyTileCol or er != m.lastEnemyTileRow:
m.commitTicks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.replanReason = rrTileEnemy
of ttrOff:
discard # honour the commitment; the danger replan is the safety valve
# 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)
# Time model: project the tile's nearest point onto the bullet heading. When
# the model is off these factors are exactly 1.0, so heat is unchanged.
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bUx = if bSpeed > 0.0: b.velX / bSpeed else: 0.0
let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0
let bMag = bulletMagScale(b.power)
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
for row in rowMin..rowMax:
for col in colMin..colMax:
let x0 = m.marginX + col.float * GridSize
let y0 = m.marginY + row.float * GridSize
let nearX = clamp(bx, x0, x0 + GridSize)
let nearY = clamp(by, y0, y0 + GridSize)
let dx = nearX - bx
let dy = nearY - by
let d2 = dx*dx + dy*dy
if d2 <= coreR * coreR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletCore * bMag *
heatDecay(along / bSpeed)
elif d2 <= auraR * auraR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletAura * bMag *
heatDecay(along / bSpeed)
# 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)
# Time model: the corridor gradient is `dt = along / speed` (see the heat
# block above). Off -> exactly 1.0, so the corridor is the flat shipped one.
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bMag = bulletMagScale(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 * bMag *
heatDecay(along / bSpeed)
# 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
# When the time model is on, draw only as far as the corridor still blocks
# (heat > PathDangerThreshold = 10); otherwise the outline would claim a
# wall-to-wall threat the field no longer has. Off -> reach = cg.tMin.
var reach = cg.tMin
if TfilHeatTime:
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
let near = CorridorHeat * bulletMagScale(b.power)
reach = if speed > 0.0 and near > 10.0:
min(reach, -TfilHeatTau * ln(10.0 / near) * speed)
else: 0.0
let wx = cg.bx + cg.dx * reach
let wy = cg.by + cg.dy * reach
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)
# log-only bookkeeping for this tick
var pickedThisTick = false
var pickedRev = false
var pickedInterval = 0
# 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 = TfilCommitTicks - 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
m.replanReason = rrDanger
else:
dec m.commitTicks
if m.commitTicks == 0: m.replanReason = rrExpiry
else:
dec m.commitTicks
if m.commitTicks == 0: m.replanReason = rrExpiry
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 travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
var chosen = 0
if TfilNoRev and candidates.len >= 2:
# Soft no-reversal preference (arm C): down-weight — never filter — tiles
# that lie >90 deg from the current travel direction.
var dirs: seq[float]
for t in candidates:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
dirs.add arctan2(ty - ws.selfY, tx - ws.selfX) * 180.0 / PI
let weights = noRevWeights(dirs, travelDeg)
var total = 0
var forward = 0
for w in weights:
total += w
if w > 1: inc forward
if forward == 0:
# Fallback: no forward tile exists → uniform draw, so the pool can
# never empty and the pick is identical to the shipped one.
chosen = rand(candidates.high)
else:
let r = rand(total - 1)
var acc = 0
chosen = weights.high
for i in 0..<weights.len:
acc += weights[i]
if r < acc:
chosen = i
break
else:
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 = TfilCommitTicks
m.commitLava = m.lavaAt(ct.col, ct.row)
m.blockedTile.active = false # clear after successful pick
# log-only: reversal test against the travel direction
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
180.0 / PI - travelDeg
while rd > 180.0: rd -= 360.0
while rd < -180.0: rd += 360.0
pickedThisTick = true
pickedRev = abs(rd) > 90.0
pickedInterval = m.callCount - m.lastPickCall
m.lastPickCall = m.callCount
inc m.picks
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)
if ws.enemies.len > 0:
m.lastEnemyTileCol = clamp(int((ws.enemies[0].x - m.marginX) / GridSize), 0, m.cols - 1)
m.lastEnemyTileRow = clamp(int((ws.enemies[0].y - m.marginY) / GridSize), 0, m.rows - 1)
else:
m.lastEnemyTileCol = -1
m.lastEnemyTileRow = -1
m.callCount += 1
if TfilCommitLogPath.len > 0:
let reason = if pickedThisTick:
(if m.replanReason == rrNone: rrInit else: m.replanReason)
else: rrNone
tfilLogWrite("{\"tick\":" & $ws.tick & ",\"call\":" & $m.callCount &
",\"sp\":" & $ws.selfSpeed & ",\"ct\":" & $m.commitTicks &
",\"pick\":" & (if pickedThisTick: "1" else: "0") &
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
(if pickedRev: "1" else: "0") & ",\"interval\":" & $pickedInterval &
",\"picks\":" & $m.picks & "}")
if pickedThisTick: m.replanReason = rrNone
# ── 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)