TFIL: time-indexed bullet heat (TR_TFIL_HEAT_TIME, DEFAULT OFF)

Make danger a function of time-to-arrival instead of flat distance. Bullet
core/aura/corridor heat becomes magnitude(power) * decay(dt), dt = along/speed:

  * decay(dt) = exp(-dt/tau) is a function of TIME; a fixed tau projects a
    pixel reach of speed*tau, so fast/weak bullets get a longer slope and slow
    ones a shorter one — derived from speed = 20 - 3*power, not hand-tuned.
    tau = TR_TFIL_HEAT_TAU.
  * magnitude(power) scales the near-end heat with power from DAMAGE
    (calcBulletDamage = 4p, linear in p; SCORE_PER_BULLET_DAMAGE = 1.0). 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.

Every source is already f(dt), so the time-indexed planner (evaluate a cell at
the tick the bot would ARRIVE, i.e. heatDecay(dt - arrivalDelay)) is a one-line
change. It is intentionally NOT implemented here.

Default path is byte-identical: with TR_TFIL_HEAT_TIME unset both factors are
exactly 1.0 (IEEE x*1.0 is exact), and the committed golden replay in
common_libs/tests/test_tfil_commit_env.nim (20,026 ticks) still passes
byte-for-byte against the pre-change mover. The debug corridor outline is also
drawn only to the model's reach when enabled, so the GUI shows the shortening.

Offline field measurement (common_libs/tests/measure_tfil_heat_time.nim,
46,054 fixture ticks, tau=9/gain=1): corridor reach drops from 443px
wall-to-wall to 143px mean (32% retained); fraction of tiles > 10 goes
0.61 -> 0.57; largest contiguous safe region 118 -> 140 tiles; mean
distance-to-nearest-safe-tile 49 -> 42px. Saturation stays high because wall
radiance + pillar alone are 44% of tiles over threshold and are untouched.

Registers the three knobs in env_report (report + known-name set).
This commit is contained in:
2026-09-25 21:47:54 +02:00
parent 4270136948
commit fca899376e
3 changed files with 344 additions and 5 deletions
+6
View File
@@ -251,6 +251,11 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_TFIL_COMMIT_TICKS", $TfilCommitTicks, sourceOf("TR_TFIL_COMMIT_TICKS"))
emit("TR_TFIL_NO_REV", onOff(TfilNoRev), sourceOfPresence("TR_TFIL_NO_REV"))
emit("TR_TFIL_COMMIT_LOG", TfilCommitLogPath, sourceOfPresence("TR_TFIL_COMMIT_LOG"))
# time-indexed bullet heat (default off = shipped flat model)
emit("TR_TFIL_HEAT_TIME", onOff(TfilHeatTime), sourceOf("TR_TFIL_HEAT_TIME"))
emit("TR_TFIL_HEAT_TAU", $TfilHeatTau, sourceOf("TR_TFIL_HEAT_TAU"))
emit("TR_TFIL_HEAT_POWER_GAIN", $TfilHeatPowerGain,
sourceOf("TR_TFIL_HEAT_POWER_GAIN"))
# ── ramming ───────────────────────────────────────────────────────────────
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
@@ -398,6 +403,7 @@ proc knownEnvNames*(): seq[string] =
"TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS",
"TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV",
"TR_TFIL_COMMIT_LOG",
"TR_TFIL_HEAT_TIME", "TR_TFIL_HEAT_TAU", "TR_TFIL_HEAT_POWER_GAIN",
# harness vars (read by the test framework, inherited by the bot, so they
# must NOT be reported as typos)
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
+96 -5
View File
@@ -94,6 +94,12 @@ proc getEnvBool(name: string, default: bool): bool =
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.
@@ -107,6 +113,66 @@ proc loadTfilCommitEnv*() =
loadTfilCommitEnv()
# ── 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)
@@ -444,6 +510,12 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
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)))
@@ -458,15 +530,23 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
let dy = nearY - by
let d2 = dx*dx + dy*dy
if d2 <= coreR * coreR:
m.lava[row * m.cols + col] += BulletCore
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletCore * bMag *
heatDecay(along / bSpeed)
elif d2 <= auraR * auraR:
m.lava[row * m.cols + col] += BulletAura
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
@@ -492,7 +572,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
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
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:
@@ -582,8 +663,18 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
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
# 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),
@@ -0,0 +1,242 @@
## OFFLINE — does the time-indexed bullet heat (TR_TFIL_HEAT_TIME=1) actually
## open free zones? READ-ONLY: no live battles, no change to shipped defaults.
##
## Reuses the machinery of `measure_tfil_heat_field.nim` (jobs 42/43): drive the
## REAL `TFILModule.computeMove` over the committed DrussGT fixtures (so bullet
## tracking, reachable hull and cached inside-tiles are identical to the live
## mover) and read its private `lava` via `include`.
##
## Because `lava` depends only on bullets/enemies/walls/pillars — NOT on the
## bot's picks or the RNG — the OFF and ON replays differ in nothing but the
## heat function. That makes the before/after a clean static-field comparison.
##
## Metrics, per the user's question:
## * fraction of grid tiles above PathDangerThreshold (10)
## * largest contiguous safe region (4-connected), in tiles
## * mean grid-step / px distance from a tile to the nearest safe tile
## plus the corridor reach: the `dt` (and px) at which a bullet's corridor heat
## falls back to <= threshold, vs the old wall-to-wall corridor.
##
## Run:
## nim c -r --path:common_libs common_libs/tests/measure_tfil_heat_time.nim \
## [fixture.jsonl ...]
import std/[os, strformat, math, deques, sets, json]
import gun_harness/offline_range
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
const SafeThreshold = 10.0
type
FieldMetrics = object
fracOver: float ## tiles with lava > 10 / all tiles
largestSafe: int ## largest 4-connected safe region, tiles
meanDistAll: float ## mean nearest-safe-tile distance over ALL tiles
meanDistUnsafe: float ## ... over the unsafe tiles only
hasSafe: bool
RunStats = object
ticks: int
sumFracOver: float
sumLargestSafe: float
maxLargestSafe: int
sumDistAll: float
sumDistUnsafe: float
noSafeTicks: int
# corridor reach (ON model only)
corridors: int
sumReachDt: float
sumReachPx: float
sumOldPx: float
histLo: array[6, int] ## reach px buckets: <50,50-100,... ,>=250
proc loadRoundStarts(fixturePath: string): HashSet[int] =
result = initHashSet[int]()
let side = currentSourcePath().parentDir.parentDir.parentDir /
"tools" / "fixtures" / "drussgt_meta" /
(extractFilename(fixturePath) & ".rounds.json")
if not fileExists(side): return
let root = parseFile(side)
if not root.hasKey("rounds"): return
for r in root["rounds"]:
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
# ── the three field metrics, on a real `m.lava` ──────────────────────────────
proc fieldMetrics(m: TFILModule): FieldMetrics =
let n = m.cols * m.rows
var over = 0
var safe = newSeq[bool](n)
for i in 0..<n:
if m.lava[i] > SafeThreshold: inc over
else: safe[i] = true
result.fracOver = over.float / n.float
result.hasSafe = over < n
const Neigh = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# multi-source BFS from every safe tile -> distance to nearest safe tile
var dist = newSeq[int](n)
var q = initDeque[int]()
for i in 0..<n:
if safe[i]:
dist[i] = 0
q.addLast(i)
else:
dist[i] = -1
while q.len > 0:
let cur = q.popFirst()
let cc = cur mod m.cols
let rr = cur div m.cols
for (dc, dr) in Neigh:
let nc = cc + dc
let nr = rr + dr
if nc < 0 or nc >= m.cols or nr < 0 or nr >= m.rows: continue
let ni = nr * m.cols + nc
if dist[ni] < 0:
dist[ni] = dist[cur] + 1
q.addLast(ni)
var sumAll = 0.0
var sumUnsafe = 0.0
var nUnsafe = 0
for i in 0..<n:
if dist[i] >= 0: sumAll += dist[i].float
if (not safe[i]) and dist[i] >= 0:
sumUnsafe += dist[i].float
inc nUnsafe
result.meanDistAll = sumAll / n.float
result.meanDistUnsafe = if nUnsafe > 0: sumUnsafe / nUnsafe.float else: 0.0
# largest 4-connected safe region
var seen = newSeq[bool](n)
var best = 0
for i in 0..<n:
if safe[i] and not seen[i]:
var size = 0
var q2 = initDeque[int]()
q2.addLast(i); seen[i] = true
while q2.len > 0:
let cur = q2.popFirst()
inc size
let cc = cur mod m.cols
let rr = cur div m.cols
for (dc, dr) in Neigh:
let nc = cc + dc
let nr = rr + dr
if nc < 0 or nc >= m.cols or nr < 0 or nr >= m.rows: continue
let ni = nr * m.cols + nc
if safe[ni] and not seen[ni]:
seen[ni] = true
q2.addLast(ni)
if size > best: best = size
result.largestSafe = best
# ── replay one fixture under one heat model ──────────────────────────────────
proc analyse(path: string, timeOn: bool, tau, gain: float): RunStats =
putEnv("TR_TFIL_HEAT_TIME", if timeOn: "1" else: "0")
putEnv("TR_TFIL_HEAT_TAU", $tau)
putEnv("TR_TFIL_HEAT_POWER_GAIN", $gain)
loadTfilHeatEnv()
let fx = loadFixture(path)
let starts = loadRoundStarts(path)
var m = initTFIL()
for si in 0..<fx.states.len:
let ws = fx.states[si]
if si == 0 or si in starts: m.resetRound()
discard m.computeMove(ws)
inc result.ticks
let fm = fieldMetrics(m)
result.sumFracOver += fm.fracOver
result.sumLargestSafe += fm.largestSafe.float
result.maxLargestSafe = max(result.maxLargestSafe, fm.largestSafe)
result.sumDistAll += fm.meanDistAll
result.sumDistUnsafe += fm.meanDistUnsafe
if not fm.hasSafe: inc result.noSafeTicks
if timeOn:
# Corridor reach: heat along the corridor is
# CorridorHeat * mag * exp(-dt/tau); it stops blocking at dt* where it
# falls to SafeThreshold, i.e. dt* = -tau*ln(thr/(CorridorHeat*mag)).
for b in m.bullets:
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
let mag = 1.0 + gain * (b.power / 3.0)
let near = CorridorHeat * mag
var reachPx = 0.0
var reachDt = 0.0
if near > SafeThreshold:
reachDt = -tau * ln(SafeThreshold / near)
reachPx = reachDt * speed
inc result.corridors
result.sumReachDt += reachDt
result.sumReachPx += reachPx
result.sumOldPx += cg.tMin
let bkt = if reachPx < 50.0: 0 elif reachPx < 100.0: 1
elif reachPx < 150.0: 2 elif reachPx < 200.0: 3
elif reachPx < 250.0: 4 else: 5
inc result.histLo[bkt]
proc f2(x: float): string = &"{x:.2f}"
proc reportOne(name: string, ticks: int, off, on: RunStats) =
let n = max(1, ticks).float
echo ""
echo "═══════════════════════════════════════════════════════════════════════════"
echo &"FILE {name} ticks={ticks}"
echo " OFF (shipped flat) ON (time-indexed)"
echo &" frac tiles > 10 {f2(off.sumFracOver/n):>19} {f2(on.sumFracOver/n):>19}"
echo &" largest safe region (tiles){f2(off.sumLargestSafe/n):>19} {f2(on.sumLargestSafe/n):>19}" &
&" (max {off.maxLargestSafe} -> {on.maxLargestSafe})"
echo &" mean dist->safe (all tiles){f2(off.sumDistAll/n):>19} {f2(on.sumDistAll/n):>19}" &
&" grid-steps (=x36 px: {f2(off.sumDistAll/n*36.0)} -> {f2(on.sumDistAll/n*36.0)})"
echo &" mean dist->safe (unsafe) {f2(off.sumDistUnsafe/n):>19} {f2(on.sumDistUnsafe/n):>19}"
echo &" ticks with NO safe tile {off.noSafeTicks:>19} {on.noSafeTicks:>19}"
if on.corridors > 0:
echo ""
echo " corridor reach under the ON model (where it stops blocking):"
echo &" mean reach dt = {f2(on.sumReachDt/on.corridors.float)} ticks"
echo &" mean reach px = {f2(on.sumReachPx/on.corridors.float)} px" &
&" vs mean old wall-to-wall corridor = {f2(on.sumOldPx/on.corridors.float)} px"
echo &" retained fraction = {f2(on.sumReachPx/on.sumOldPx*100.0)}% of the old corridor length"
const Names = ["<50", "50-100", "100-150", "150-200", "200-250", ">=250"]
for i in 0..5:
echo &" reach {Names[i]:>7} px : {on.histLo[i]} of {on.corridors} bullet-ticks"
proc main() =
var files: seq[string]
for i in 1..paramCount():
files.add paramStr(i)
if files.len == 0:
let dir = currentSourcePath().parentDir.parentDir.parentDir / "tools" / "fixtures"
files = @[
dir / "tr_drussgt_vs_modularbot.jsonl",
dir / "tr_drussgt_vs_modularbot_shield.jsonl",
dir / "tr_drussgt_vs_spinbot.jsonl",
dir / "tr_drussgt_vs_corners.jsonl",
]
const Tau = 9.0
const Gain = 1.0
# Sweep hooks: HT_TAU / HT_GAIN override the constants without a rebuild.
var tau = Tau
var gain = Gain
if existsEnv("HT_TAU"): tau = parseFloat(getEnv("HT_TAU"))
if existsEnv("HT_GAIN"): gain = parseFloat(getEnv("HT_GAIN"))
echo &"# Time-indexed bullet heat — offline field comparison (tau={tau} ticks, gain={gain})"
var ticks = 0
for f in files:
if not fileExists(f):
stderr.writeLine("missing fixture: " & f)
continue
let off = analyse(f, timeOn = false, tau = tau, gain = gain)
let on = analyse(f, timeOn = true, tau = tau, gain = gain)
reportOne(extractFilename(f), off.ticks, off, on)
ticks += off.ticks
echo ""
echo &"# done: {ticks} fixture ticks across {files.len} files (OFF vs ON)"
when isMainModule:
main()