From fca899376e36f422d2a4ffc1aa60f6a315aed45d Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Fri, 25 Sep 2026 21:47:54 +0200 Subject: [PATCH] TFIL: time-indexed bullet heat (TR_TFIL_HEAT_TIME, DEFAULT OFF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ModularBot_garage/src/env_report.nim | 6 + common_libs/movements/the_floor_is_lava.nim | 101 +++++++- common_libs/tests/measure_tfil_heat_time.nim | 242 +++++++++++++++++++ 3 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 common_libs/tests/measure_tfil_heat_time.nim diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 58c221f..1783a0d 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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", diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index e068c27..9316c9e 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -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), diff --git a/common_libs/tests/measure_tfil_heat_time.nim b/common_libs/tests/measure_tfil_heat_time.nim new file mode 100644 index 0000000..24d3722 --- /dev/null +++ b/common_libs/tests/measure_tfil_heat_time.nim @@ -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.. 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.. 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..= 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.. 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.. 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()