fca899376e
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).
243 lines
9.5 KiB
Nim
243 lines
9.5 KiB
Nim
## 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()
|