STRAFE: draw the heat field, real bullet danger, corrected ring comment

Three defects the owner hit as "no heat tiles anymore" under TR_MOVEMENT=strafe.

1. The strafe overlay drew ONLY the tiles on its strafe line, so the computed
   heat field was essentially invisible. It now draws the WHOLE field exactly as
   TFIL does (every non-zero tile, yellow->orange->red ramp by field max, integer
   value label) behind the same debugGraphics flag, with TR_STRAFE_HEAT_GRID=0 to
   hide it. The strafe overlays draw on top, unchanged.

2. STRAFE carried the SHIPPED bullet constants (core 10 / aura 5), so a bullet's
   own heat sat exactly ON PathDangerThreshold (10.0) and a bullet was never
   dangerous on its own in this mover; it only ever bit through its corridor.
   Defaults are now the retune's 20/10, exposed as TR_STRAFE_BULLET_CORE /
   TR_STRAFE_BULLET_AURA.

3. The ring mover's header documented CorridorHeat 5.0 / WallHotness 10.0 while
   the code has always been 10.0 / 15.0. A job read the comment and handed out
   sub-threshold heat values, which emptied the field. The comment now states the
   real values and their actual behaviour; no code values changed.

Also sets strafe's heat defaults to the retune shape (bullet 20/10, corridor 10,
wall 15/5, pillar 0), documented with the reason.

Gate A re-run (j110, offline DrussGT fixture, measure_strafe_gates.nim):
  corrected DEFAULT : 24.6% of picks with ZERO safe tile, mean 11.17 safe
  j108 shipped field: 63.4% / 3.70   (reproduced exactly)
  j108 ring retune  :  8.1% / 18.41  (reproduced exactly)
  bullet isolated   : 11.4% / 17.07
The corrected default beats the shipped field but is WORSE than j108's retune
row: the bullet retune alone costs 8.1 -> 11.4, the corridor/wall retune accounts
for the rest. That is the deliberate price of making a bullet dangerous.

Guards green: test_env_report 24 PASS, test_tfil_commit_env 30 PASS (shipped TFIL
default untouched, byte-for-byte), test_tfil_ring_weights 24 PASS. The three new
knobs are registered in the boot env report so the tree-scan guard stays clean.
This commit is contained in:
2026-09-25 23:25:34 +02:00
parent dc071b83f3
commit 1ea84f5b6e
4 changed files with 146 additions and 42 deletions
+7 -2
View File
@@ -272,8 +272,12 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_STRAFE_DWELL_MIN", $StrafeDwellMin, sourceOf("TR_STRAFE_DWELL_MIN"))
emit("TR_STRAFE_DWELL_MAX", $StrafeDwellMax, sourceOf("TR_STRAFE_DWELL_MAX"))
emit("TR_STRAFE_LOG", onOff(StrafeLog), sourceOfPresence("TR_STRAFE_LOG"))
# STRAFE's heat shape is override-able (default = the shipped TFIL field) so
# the shipped field and the ring retune can be A/B'd on one binary at boot.
emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID"))
# STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall
# 15/5), override-able per run so the shipped field can be A/B'd on one
# binary at boot.
emit("TR_STRAFE_BULLET_CORE", $StrafeBulletCore, sourceOf("TR_STRAFE_BULLET_CORE"))
emit("TR_STRAFE_BULLET_AURA", $StrafeBulletAura, sourceOf("TR_STRAFE_BULLET_AURA"))
emit("TR_STRAFE_CORRIDOR_HEAT", $StrafeCorridorHeat, sourceOf("TR_STRAFE_CORRIDOR_HEAT"))
emit("TR_STRAFE_WALL_HOTNESS", $StrafeWallHotness, sourceOf("TR_STRAFE_WALL_HOTNESS"))
emit("TR_STRAFE_WALL_RADIANCE", $StrafeWallRadiance, sourceOf("TR_STRAFE_WALL_RADIANCE"))
@@ -431,6 +435,7 @@ proc knownEnvNames*(): seq[string] =
"TR_TFIL_PILLAR_ON",
"TR_STRAFE_BAND", "TR_STRAFE_SPREAD", "TR_STRAFE_REACH",
"TR_STRAFE_DWELL_MIN", "TR_STRAFE_DWELL_MAX", "TR_STRAFE_LOG",
"TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA",
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
"TR_STRAFE_WALL_RADIANCE",
# harness vars (read by the test framework, inherited by the bot, so they
+80 -21
View File
@@ -41,9 +41,11 @@
## PERPENDICULAR JITTER of `±TR_STRAFE_SPREAD` tiles (the owner's "spread a
## little"). A tile is acceptable when the max heat ON THE STRAIGHT-LINE PATH
## from the bot is <= `PathDangerThreshold` (10.0) — the SAME safety rule TFIL
## uses. The heat field itself is the SHIPPED TFIL field (jobs j105/j106: the
## time-indexed bullet model + the pillar-free default) — see the reuse note
## below.
## uses. The heat field is a strafe-specific RETUNE of that field (bullet 20/10,
## corridor 10, wall 15/5, pillar off — see the defaults block below), not the
## shipped TFIL field: with the shipped shape a safe tile existed on only 36.6%
## of picks (j108 Gate A). The time-indexed bullet model + pillar-free default
## are still reused, see the reuse note below.
##
## ── Move by sign only (point 5) ─────────────────────────────────────────────
## `speed = MaxSpeed * sign`, where `sign` is +1 when the chosen tile lies along
@@ -78,15 +80,18 @@
## TR_STRAFE_DWELL_MIN 6 min ticks before a re-pick
## TR_STRAFE_DWELL_MAX 20 max ticks before a re-pick
## TR_STRAFE_LOG off presence-based: echo one line per pick
## TR_STRAFE_CORRIDOR_HEAT 20.0 lava per corridor tile (shipped TFIL value)
## TR_STRAFE_WALL_HOTNESS 30.0 peak wall radiance (shipped TFIL value)
## TR_STRAFE_WALL_RADIANCE 10.0 wall radiance falloff (shipped TFIL value)
## TR_STRAFE_HEAT_GRID 1 draw the full heat grid (0 = hide it)
## TR_STRAFE_BULLET_CORE 20.0 lava per bullet-overlapping tile (retune)
## TR_STRAFE_BULLET_AURA 10.0 lava for aura ring tiles (retune)
## TR_STRAFE_CORRIDOR_HEAT 10.0 lava per corridor tile (== threshold)
## TR_STRAFE_WALL_HOTNESS 15.0 peak wall radiance (retune)
## TR_STRAFE_WALL_RADIANCE 5.0 wall radiance falloff (retune)
##
## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever
## called when the bot explicitly selects `strafe`.
import std/[math, random, os]
from std/strutils import parseFloat, parseInt, strip
from std/strutils import parseFloat, parseInt, strip, toLowerAscii
import std/strformat
import gun_harness/gun_interface
import movement_harness/movement_interface
@@ -103,25 +108,43 @@ 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 per bullet-overlapping tile (shipped default)
const BulletAura = 5.0 ## lava for aura ring tiles (shipped default)
## ── Heat-shape defaults: the RETUNE, not the shipped TFIL field ─────────────
## STRAFE deliberately runs a DIFFERENT default heat shape from the shipped
## mover. The shipped field (corridor 20, wall 30/10) left a safe tile on the
## perpendicular line on only 36.6% of picks (j108 Gate A: 63.4% fallback, mean
## 3.70 safe candidates); this retune raises that to 75.4% (j110 Gate A: 24.6%
## fallback, mean 11.17 safe candidates). It is a deliberate middle ground: the
## bullet's own core MUST be dangerous (defect 2), and that by construction
## costs some safe tiles versus a field whose bullet core sat below threshold.
## BulletCore 20 / BulletAura 10 — a bullet's OWN heat (20) is now ABOVE
## PathDangerThreshold (10), so the bullet itself is the danger. With the
## shipped 10/5 a bullet core sat exactly ON the threshold and was
## therefore NEVER dangerous on its own; it only ever bit through its
## corridor. This is the ring mover's retune, adopted here.
## CorridorHeat 10 — exactly the threshold, so one corridor tile alone still
## cannot make a path unsafe (the rule is <= threshold); corridors nudge.
## WallHotness 15 / WallRadiance 5 — only the outer wall ring is a hard threat.
## Pillar 0/0 — off, exactly like the shipped default (imported globals).
## All five are env-overridable per run via the TR_STRAFE_* names below.
const BulletCoreDefault = 20.0 ## lava per bullet-overlapping tile (retune)
const BulletAuraDefault = 10.0 ## lava for aura ring tiles (retune)
const EnemyCoreRadius = 18.0 ## half of 36px body
const EnemyAuraRadius = 54.0 ## 18 + 36
const EnemyCore = 40.0 ## lava per tile overlapping enemy body
const EnemyAura = 10.0 ## lava per tile in enemy aura ring
const CorridorHeatDefault = 20.0 ## shipped TFIL corridor heat
const WallHotnessDefault = 30.0 ## shipped TFIL wall radiance peak
const WallRadianceDefault = 10.0 ## shipped TFIL wall radiance falloff
const CorridorHeatDefault = 10.0 ## corridor heat (== PathDangerThreshold)
const WallHotnessDefault = 15.0 ## wall radiance peak (retune)
const WallRadianceDefault = 5.0 ## wall radiance falloff (retune)
## Heat shape is override-able so the two candidate fields can be compared on
## one binary (the shipped field vs the ring variant's retune). Default = the
## SHIPPED TFIL field, so the strafe mover is judged on the same field as the
## baseline. `TR_STRAFE_CORRIDOR_HEAT` / `TR_STRAFE_WALL_HOTNESS` /
## `TR_STRAFE_WALL_RADIANCE` are strafe-specific (the ring mover's own field is
## controlled by its separate `TR_TFIL_*` names).
## Heat shape is override-able so the shipped field and the retune can be
## compared on one binary. The DEFAULTS are the retune (see the block above);
## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do
## not touch this field and vice versa.
var
StrafeBulletCore* = BulletCoreDefault
StrafeBulletAura* = BulletAuraDefault
StrafeCorridorHeat* = CorridorHeatDefault
StrafeWallHotness* = WallHotnessDefault
StrafeWallRadiance* = WallRadianceDefault
@@ -145,6 +168,11 @@ proc getEnvInt(name: string, default: int): int =
try: result = parseInt(s.strip())
except ValueError: result = default
proc getEnvBool(name: string, default: bool): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s in ["1", "true", "on", "yes"]
const
DefaultStrafeBand = 20.0
DefaultStrafeSpread = 1
@@ -159,10 +187,16 @@ var
StrafeDwellMin* = DefaultStrafeDwellMin
StrafeDwellMax* = DefaultStrafeDwellMax
StrafeLog* = false
## GUI: draw the full lava field (every non-zero tile, value-labelled) the
## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the
## strafe overlays can be read on their own.
StrafeHeatGrid* = true
proc loadStrafeHeatEnv*() =
## Re-read the heat-shape overrides. Exposed so a gate can restore the shipped
## Re-read the heat-shape overrides. Exposed so a gate can restore the default
## field after temporarily retuning it on the SAME process.
StrafeBulletCore = getEnvFloat("TR_STRAFE_BULLET_CORE", BulletCoreDefault)
StrafeBulletAura = getEnvFloat("TR_STRAFE_BULLET_AURA", BulletAuraDefault)
StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault)
StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault)
StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault)
@@ -176,6 +210,7 @@ proc loadStrafeEnv*() =
StrafeDwellMin = max(1, getEnvInt("TR_STRAFE_DWELL_MIN", DefaultStrafeDwellMin))
StrafeDwellMax = max(StrafeDwellMin, getEnvInt("TR_STRAFE_DWELL_MAX", DefaultStrafeDwellMax))
StrafeLog = existsEnv("TR_STRAFE_LOG")
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv()
loadStrafeEnv()
@@ -377,10 +412,10 @@ proc buildHeat(m: var StrafeModule, ws: WorldState) =
let d2 = dx*dx + dy*dy
if d2 <= coreR * coreR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletCore * bMag * heatDecay(along / bSpeed)
m.lava[row * m.cols + col] += StrafeBulletCore * bMag * heatDecay(along / bSpeed)
elif d2 <= auraR * auraR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletAura * bMag * heatDecay(along / bSpeed)
m.lava[row * m.cols + col] += StrafeBulletAura * bMag * heatDecay(along / bSpeed)
# Corridors (rotated rectangle from the bullet to the wall, auraR wide).
for b in m.bullets:
@@ -661,6 +696,30 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
# ── GUI overlay (the user watches this) ──
if m.debugGraphics:
# The WHOLE lava field, drawn exactly as TFIL draws it: every non-zero tile
# stroked in a yellow->orange->red ramp scaled by the field maximum and
# labelled with its integer value. This is the picture the owner is used to
# from TFIL; the strafe overlays below draw ON TOP of it. `TR_STRAFE_HEAT_GRID=0`
# hides it.
if StrafeHeatGrid:
var maxLava = 0.0
for v in m.lava:
if v > maxLava: maxLava = v
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)
let ux = cos(lineForward * DegToRad)
let uy = sin(lineForward * DegToRad)
@@ -46,24 +46,40 @@
## 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_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
## TR_TFIL_WALL_HOTNESS default 15.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.
## the over-threshold set, so they dominate the field. The ACTUAL values in this
## file (read at module init, `let` block further down) are:
## CorridorHeat = 10.0 — exactly `PathDangerThreshold` (10.0). The safety rule
## is `pathMaxHeat <= PathDangerThreshold`, so a corridor
## tile sitting on a path is still SAFE: a corridor alone
## can no longer make a path unsafe.
## WallHotness = 15.0 — ABOVE the threshold at the OUTERMOST ring only (the
## second ring sits at exactly 10.0, the threshold, and
## is safe), so wall radiance alone CAN poison the outer
## ring but no deeper.
## WallRadiance = 5.0 — the falloff that produces the two rings above.
## BulletCore/BulletAura = 20.0/10.0 — the bullet's own core is ABOVE the
## threshold, which is what makes an incoming bullet
## dangerous on its own.
## Both soft knobs are env-overridable (TR_TFIL_CORRIDOR_HEAT /
## TR_TFIL_WALL_HOTNESS) for an A/B.
## CHANGED vs the original mover: BulletCore/BulletAura (20/10 here, 10/5 in
## the original), WallRadiance (5.0 here, 10.0 in the original). UNCHANGED:
## EnemyCore/EnemyAura and their radii, PillarHotness/PillarRadiance (0/0
## default) and PathDangerThreshold (10.0).
##
## WARNING: an earlier revision of THIS comment claimed CorridorHeat = 5.0 and
## WallHotness = 10.0. That did NOT match the code — it was a stale copy of an
## earlier retune PROPOSAL, and a job that read the comment handed out
## sub-threshold heat values that left the field empty. The code has always been
## 10.0 / 15.0; the comment now says so.
import std/[math, random, os, strformat]
from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes with color.fromHex
+31 -7
View File
@@ -221,15 +221,37 @@ let nTicks = states.len
let tfil = replayTfil(states, starts)
let sf = replayStrafe(states, starts)
# Field variant: the ring mover's retune, where no SINGLE soft source can poison
# a path (corridor below the threshold, wall radiance == the threshold). This is
# the same field shape the user's ring experiment used; we only test whether it
# unblocks the strafe line.
# ── reference fields for the Gate A comparison (j108's two rows) ───────────
# NOTE: `sf` above already ran with the CURRENT strafe defaults (the corrected
# retune: bullet 20/10, corridor 10, wall 15/5). The two replays below
# reproduce j108's historical rows so the numbers are comparable on one binary.
# j108 row 1: the SHIPPED TFIL field (bullet 10/5, corridor 20, wall 30/10).
StrafeBulletCore = 10.0
StrafeBulletAura = 5.0
StrafeCorridorHeat = 20.0
StrafeWallHotness = 30.0
StrafeWallRadiance = 10.0
let sfShipped = replayStrafe(states, starts)
# j108 row 2: the ring mover's retune field (bullet 10/5, corridor 5, wall 10/5).
StrafeBulletCore = 10.0
StrafeBulletAura = 5.0
StrafeCorridorHeat = 5.0
StrafeWallHotness = 10.0
StrafeWallRadiance = 5.0
let sfRetune = replayStrafe(states, starts)
loadStrafeHeatEnv() # restore the shipped field for any later use
# Isolation of DEFECT 2 alone: the ring retune's soft sources (corridor 5, wall
# 10/5) with ONLY the bullet heat raised to 20/10, so the gap between the
# corrected default and j108's retune row can be attributed.
StrafeBulletCore = 20.0
StrafeBulletAura = 10.0
StrafeCorridorHeat = 5.0
StrafeWallHotness = 10.0
StrafeWallRadiance = 5.0
let sfBulletOnly = replayStrafe(states, starts)
loadStrafeHeatEnv() # restore the CURRENT strafe defaults for any later use
echo "STRAFE gates — offline fixture ", fixtureRel, " (", nTicks, " ticks, ",
starts.len, " rounds), seed=", Seed
@@ -260,8 +282,10 @@ proc gateA(tag: string, rep: Replay) =
line.add &"{k}:{hist.getOrDefault(k,0)} "
echo " safe-count dist (0..12+) : ", line
gateA("shipped TFIL heat (corridor 20, wall 30/10)", sf)
gateA("ring retune (corridor 5, wall 10/5)", sfRetune)
gateA("STRAFE DEFAULT (corrected: bullet 20/10, corridor 10, wall 15/5)", sf)
gateA("j108 shipped TFIL field (bullet 10/5, corridor 20, wall 30/10)", sfShipped)
gateA("j108 ring retune (bullet 10/5, corridor 5, wall 10/5)", sfRetune)
gateA("bullet retune ISOLATED (bullet 20/10, corridor 5, wall 10/5)", sfBulletOnly)
echo ""
# ── GATE B ───────────────────────────────────────────────────────────────────