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,