movement: range-weighted TFIL variant + tamed heat field (opt-in, default unchanged)

New mover `the_floor_is_lava_ring.nim`, a COPY of `the_floor_is_lava.nim` (which
stays byte-identical - the user explicitly wants the current TFIL preserved).
Selected only via `TR_MOVEMENT=tfil_ring`; the default stays `tfil`.

WHY: our measured real hit rate vs DrussGT is strongly range-dependent - 21.6%
at 0-100px, 27.1% at 100-200px, 19.3% at 200-300, 10.9% at 300-400, 6.8% at
400-600, 5.4% at 600-800 - but we shoot from ~450px on average. Plain TFIL has
no range preference at all.

THE ONE CHANGE: the final tile draw is re-weighted toward a target band.
  rangeW(d) = 1.0 if lo<=d<=hi; exp(-((lo-d)/K)^2) if d<lo; exp(-((d-hi)/K)^2) if d>hi
  w_i = rangeW(d_i)^(1/T);  chosen ~ Categorical(w)
FLAT TOP on purpose: a Gaussian centred on the band midpoint would collapse the
band to a point and destroy the within-band hedge. `T` is the only knob;
`TR_TFIL_RANGE_TEMP=0` gives plain `rand(candidates.high)` - the exact control
arm. Safety stays a HARD constraint: the weighting only reorders the draw among
the pool the old code already accepted, so it can never pick a tile the old code
rejected (monotone refinement). Small pools (<4) stay uniform.
Randomness is deliberately KEPT: a measured A/B showed committing to the "best"
tile made real hit rate WORSE (7.02% -> 5.10%), so the distribution is tilted,
never removed.

HEAT TAMING (ring copy only; env-overridable):
  TR_TFIL_CORRIDOR_HEAT  20.0 -> 5.0
  TR_TFIL_WALL_HOTNESS   30.0 -> 10.0
Rationale, measured: `CorridorHeat=20` is TWICE `PathDangerThreshold=10`, so a
single corridor could poison a path by itself; `WallHotness=30` with
`WallRadiance=10` put the outer two tile rings over threshold on their own.
Per-source shares of total lava: wall 60.6%, corridor 25.4%, pillar 7.4%,
everything else <3%.

MEASURED EFFECT (primary fixture, 20,026 ticks / 15 rounds, field identity
verified max diff 0.000e+00):
  metric                        original(20/30)   ring(5/10)
  band-weightable ticks              10.79%         26.45%
  mean safeTiles/tick                 15.19          85.04
  ticks with 0 safe (pre-fallback)    58.5%           7.5%
  safePool >= 4                       39.05%         92.47%
  >=1 safe tile in 100-200px          11.84%         26.64%
  MEAN CLOSEST-SAFE-TILE DISTANCE    397.78px       284.84px
  tiles > 10 threshold                 0.61           0.15
The 397.78px figure is why the bot stayed far away: the safety filter left
nothing safe near the target, and 397px is our WORST range. Control: setting
corridor=20 wall=30 reproduces the original baseline exactly.
CEILING, honestly: even at corridor 0 / wall 0 only ~40% of ticks are
band-weightable, so no constant tweak fully unlocks the range weighting.

Ram unification: the ring mover takes a `band` field; ramming becomes just
`band=(0,50)`, so there is one movement engine. The `tfil` path is unchanged.

Observability: magenta annulus at the band edges, candidates tinted by weight,
chosen tile marked; one `[tfil_ring]` log line on change (now including
corridorHeat/wallHotness).

Guards: test_tfil_ring_weights 24/24 (new, pure, no battle), test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41.
UNVERIFIED: the mover's live effect. It has not been run in a battle yet.
This commit is contained in:
2026-09-21 23:46:47 +02:00
parent 2daa519e15
commit 9caf1d3728
3 changed files with 1082 additions and 4 deletions
+33 -4
View File
@@ -28,6 +28,7 @@ import guns/tm_selector
import movements/phantom_meteor
import movements/rammer
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import movement_harness/virtual_bodies as mvb
import movement_harness/bullet_shadows
import targeting/enemy_tracker
@@ -87,6 +88,15 @@ let DisabledGuns =
s
proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns
## ── Movement rack (runtime switch) ─────────────────────────────────────────
## `TR_MOVEMENT` selects the movement engine, mirroring the gun rack's
## env-driven construction. Both engines are always constructed, so the switch
## needs no rebuild:
## tfil (DEFAULT) — today's behaviour, byte-for-byte, including the
## separate rammer dispatch.
## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring).
## UNPROVEN: it must never become the default silently.
let MovementName* = getEnv("TR_MOVEMENT", "tfil").strip().toLowerAscii()
## Per-process output paths so concurrent A/B runs do not clobber each other.
let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl")
let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl")
@@ -137,6 +147,7 @@ type
knnGun: KNNGun
tmSelector: TmSelectorGun
mover: TFILModule
ringMover: TFILRingModule
rammer: RammerModule
isRamming: bool
ramStuckTicks: int
@@ -241,7 +252,11 @@ proc printConfig(bot: ModularBot, forceAll: bool = false) =
let rc = if bot.radarMode != bot.prevRadar or forceAll: CLR_CHANGE else: ""
let tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: ""
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
let moveName = if bot.isRamming: "rammer" else: "tfil"
let moveName =
if MovementName == "tfil_ring":
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
elif bot.isRamming: "rammer"
else: "tfil"
var line = "[config] "
if bot.currentGun >= 0 and bot.currentGun < GunNames.len:
line &= gc & "gun=" & GunNames[bot.currentGun] & (if gc != "": rst else: "") & " | "
@@ -471,12 +486,14 @@ method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
bot.resolveShotLog(e.bullet.bulletId, false)
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
bot.resolveShotLog(e.bullet.bulletId, false)
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
@@ -611,6 +628,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.radar.init()
bot.meleeRadar.init()
bot.mover.resetRound()
bot.ringMover.resetRound()
bot.enemyTracker.resetRound()
bot.isRamming = false
bot.ramStuckTicks = 0
@@ -767,11 +785,21 @@ method run*(bot: ModularBot) =
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
let (spd, tr) = if shouldRam:
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
# ramming through the single ring engine (ram is just band [0, 50]); the ram
# DECISION above is unchanged. The default `tfil` keeps the exact old
# two-engine behaviour (tfil when holding, rammer when ramming).
var spd, tr: float
if MovementName == "tfil_ring":
bot.ringMover.band =
if shouldRam: (lo: 0.0, hi: 50.0)
else: (lo: RangeLo, hi: RangeHi)
(spd, tr) = bot.ringMover.computeMove(ws)
elif shouldRam:
bot.mover.clearGraphics()
bot.rammer.computeMove(ws)
(spd, tr) = bot.rammer.computeMove(ws)
else:
bot.mover.computeMove(ws)
(spd, tr) = bot.mover.computeMove(ws)
setTargetSpeed(spd)
setTurnRate(tr)
@@ -999,6 +1027,7 @@ when isMainModule:
radar: RadarLockModule(),
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: true),
ringMover: TFILRingModule(debugGraphics: true),
rammer: initRammer(),
moveTracker: mvb.initVirtualBodyTracker(1),
currentGun: -1,
@@ -0,0 +1,891 @@
## 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 5.0 lava per corridor-overlapping tile
## TR_TFIL_WALL_HOTNESS default 10.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 two constants below
## are retuned so that NO SINGLE soft source can poison a path on its own:
## CorridorHeat = 5.0 — below `PathDangerThreshold` (10.0): one corridor can
## no longer make a path unsafe by itself.
## WallHotness = 10.0 — equals the threshold: the outer two tile rings are no
## longer over-threshold from wall radiance alone.
## Both are env-overridable (TR_TFIL_CORRIDOR_HEAT / TR_TFIL_WALL_HOTNESS) for
## an A/B; the defaults are the values measured to unlock the range weighting.
## WallRadiance, PillarHotness/Radiance, Enemy*/Bullet* and PathDangerThreshold
## are UNCHANGED from the original mover.
import std/[math, random, os, strformat]
from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes 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
## 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 = 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
# ── 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
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", 5.0)
let WallHotness* = getEnvFloat("TR_TFIL_WALL_HOTNESS", 10.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]
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
# 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))
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 =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergySet(m: var TFILRingModule, 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 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.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.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 detectFires(m: var TFILRingModule, 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 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.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
# 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)
@@ -0,0 +1,158 @@
## Pure unit tests for the range-weighted tile draw in
## `movements/the_floor_is_lava_ring.nim` (the new TFIL-RING mover).
##
## NO battle, NO Java, NO server. Run with:
## nim c -r common_libs/tests/test_tfil_ring_weights.nim
##
## These pin the two contracts that make the mover safe to ship:
## 1. the weight SHAPE (flat top exactly 1.0 in band, Gaussian falloff
## outside, uniform as T -> inf, uniform for pools smaller than 4);
## 2. the draw can only ever return an index INSIDE the candidate pool, so it
## can never pick a tile the unweighted mover would have rejected.
import std/[math, random]
import movements/the_floor_is_lava_ring
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
const
Lo = 100.0
Hi = 200.0
K = 60.0
# ── 1. flat top is exactly 1.0 across the band ───────────────────────────────
proc testFlatTop() =
var allOne = true
var d = Lo
while d <= Hi:
if rangeWeight(d, Lo, Hi, K) != 1.0: allOne = false
d += 2.5
check "flat top: weight is exactly 1.0 for every d in [lo, hi]", allOne
check "flat top: lower edge d == lo is 1.0", rangeWeight(Lo, Lo, Hi, K) == 1.0
check "flat top: upper edge d == hi is 1.0", rangeWeight(Hi, Lo, Hi, K) == 1.0
# The flat top is what preserves the within-band hedge: all in-band tiles are
# weighted identically, so the random draw still spreads across the band.
check "flat top: two different in-band tiles are equally weighted",
rangeWeight(120.0, Lo, Hi, K) == rangeWeight(180.0, Lo, Hi, K)
# ── 2. weights fall off outside the band ─────────────────────────────────────
proc testFalloff() =
let wBelow = rangeWeight(Lo - 10.0, Lo, Hi, K)
let wAbove = rangeWeight(Hi + 10.0, Lo, Hi, K)
check "falloff: below lo is strictly between 0 and 1", wBelow > 0.0 and wBelow < 1.0
check "falloff: above hi is strictly between 0 and 1", wAbove > 0.0 and wAbove < 1.0
check "falloff: symmetric (lo-d == d-hi)",
abs(wBelow - wAbove) < 1e-12
check "falloff: monotone decreasing away from the band",
rangeWeight(Lo - 40.0, Lo, Hi, K) < rangeWeight(Lo - 20.0, Lo, Hi, K) and
rangeWeight(Hi + 40.0, Lo, Hi, K) < rangeWeight(Hi + 20.0, Lo, Hi, K)
check "falloff: exactly 1.0 at the band edge, < 1.0 just outside",
rangeWeight(Lo, Lo, Hi, K) == 1.0 and
rangeWeight(Lo - 0.001, Lo, Hi, K) < 1.0
# ── 3. T -> infinity gives uniform weights (the old distribution) ────────────
proc testTemperature() =
let dists = @[50.0, 120.0, 180.0, 260.0, 400.0]
let wHuge = bandWeights(dists, Lo, Hi, K, 1e9)
var allOne = true
for w in wHuge:
if abs(w - 1.0) > 1e-6: allOne = false
check "T -> inf: every weight collapses to 1.0 (uniform)", allOne
let wTiny = bandWeights(dists, Lo, Hi, K, 0.05)
check "T -> 0: in-band tiles dominate the outside tiles",
wTiny[1] > wTiny[0] and wTiny[2] > wTiny[3] and wTiny[2] > wTiny[4]
# In-band weight is exactly 1.0 regardless of T (1 ^ anything == 1).
let wDefault = bandWeights(dists, Lo, Hi, K, 0.4)
check "in-band weight is exactly 1.0 at the default T", wDefault[1] == 1.0
# ── 4. small pools are forced uniform (melee safety) ─────────────────────────
proc testSmallPoolUniform() =
let w2 = bandWeights(@[50.0, 900.0], Lo, Hi, K, 0.4)
check "pool of 2 -> uniform (never shaped)",
w2.len == 2 and w2[0] == 1.0 and w2[1] == 1.0
let w3 = bandWeights(@[50.0, 150.0, 900.0], Lo, Hi, K, 0.4)
check "pool of 3 -> uniform (never shaped)",
w3[0] == 1.0 and w3[1] == 1.0 and w3[2] == 1.0
let w4 = bandWeights(@[50.0, 150.0, 900.0, 950.0], Lo, Hi, K, 0.4)
check "pool of 4 IS shaped (shaping starts at MinRingPool)",
w4[1] > w4[0] and w4[1] > w4[2]
# ── 5. the chosen index is ALWAYS inside the candidate pool ──────────────────
proc testIndexInsidePool() =
let dists = @[50.0, 120.0, 180.0, 260.0, 400.0, 900.0]
let w = bandWeights(dists, Lo, Hi, K, 0.4)
var rng = initRand(12345)
var allIn = true
for _ in 0..<20000:
let idx = weightedIndex(w, rand(rng, 1.0))
if idx < 0 or idx >= dists.len: allIn = false
check "random draws: chosen index is always within candidates", allIn
# Edge variates and degenerate weight vectors must stay in range too.
check "u == 0 -> index 0", weightedIndex(w, 0.0) == 0
# A near-1 variate returns the LAST tile only when that tile has weight; use a
# uniform vector so the expectation is unambiguous.
check "u just below 1 -> last index (uniform weights)",
weightedIndex(@[1.0, 1.0, 1.0, 1.0], 0.999999) == 3
check "empty pool -> 0", weightedIndex(newSeq[float](), 0.5) == 0
check "all-zero weights -> a valid in-pool index",
weightedIndex(@[0.0, 0.0, 0.0], 0.5) in 0..2
check "single weight -> index 0", weightedIndex(@[1.0], 0.99) == 0
# The `var Rand` wrapper must agree on the range contract.
var rng2 = initRand(1)
let idx2 = weightedIndex(w, rng2)
check "var Rand wrapper returns an in-pool index",
idx2 >= 0 and idx2 < w.len
# ── 6. the default T actually tilts the draw toward the band ─────────────────
proc testTilt() =
# Two in-band tiles, three far. Uniform would pick an in-band tile 2/5 = 40%
# of the time; the default T = 0.4 must push that well above 80%.
let dists = @[60.0, 120.0, 180.0, 500.0, 700.0]
let w = bandWeights(dists, Lo, Hi, K, 0.4)
var rng = initRand(999)
var inBand = 0
const N = 20000
for _ in 0..<N:
let idx = weightedIndex(w, rand(rng, 1.0))
if idx == 1 or idx == 2: inc inBand
check "default T=0.4 tilts the draw toward the in-band tiles (>80% vs 40%)",
inBand.float / N.float > 0.8
# ── 7. OFF path (temp <= 0) is uniform, matching the old control arm ─────────
proc testOffPathUniform() =
let dists = @[50.0, 120.0, 180.0, 260.0, 400.0]
let wOff = bandWeights(dists, Lo, Hi, K, 0.0)
var allOne = true
for w in wOff:
if w != 1.0: allOne = false
check "temp <= 0 (OFF path) -> uniform weights", allOne
# ── run ──────────────────────────────────────────────────────────────────────
testFlatTop()
testFalloff()
testTemperature()
testSmallPoolUniform()
testIndexInsidePool()
testTilt()
testOffPathUniform()
if failures == 0:
echo "\nAll tfil-ring weight checks passed."
else:
echo "\n", failures, " tfil-ring weight check(s) FAILED."
quit(1)