From 4db46f02dd805f4d17604de4e2b8c892a67f228e Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 11:51:33 +0200 Subject: [PATCH] j132 strafe pick quality: chosen tile's heat in the [strafe] log line (heat=h/10.0 alt=best-other ok=0|1) and ON the tile (green/yellow/red ramp + value), legible with TR_STRAFE_HEAT_GRID=0; deterministic display check vs pathMaxHeat --- common_libs/movements/strafe.nim | 111 +++++++++++-- .../tests/measure_strafe_heat_display.nim | 157 ++++++++++++++++++ 2 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 common_libs/tests/measure_strafe_heat_display.nim diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim index 5f68199..985af14 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -159,6 +159,21 @@ ## TR_STRAFE_WALL_SAFE 24.0 px a wing point never lands nearer ## TR_STRAFE_ESCAPE 1 the all-hot guaranteed wall escape ## +## ── Pick quality on screen and in the log (j132) ──────────────────────────── +## The chosen tile's QUALITY used to be invisible: the overlay graded candidates +## with a binary colour and the `[strafe]` line never printed the chosen tile's +## heat, so a comfortably-safe pick could not be told from a marginal one. +## Both now carry the VALUE of the UNCHANGED safety rule `pathHeat <= 10`: +## * the `[strafe]` line gains `heat=/10.0 alt= +## ok=<1|0>` (see `strafeHeatText`); +## * the chosen tile gets a thick green->yellow->red border keyed to how close +## `h` is to the threshold, with `h` printed ON the tile, independent of +## `TR_STRAFE_HEAT_GRID` (so it is legible with the grid hidden); +## * candidates are stroked with the same ramp and, when the grid is hidden, +## labelled with their path heat, so the overlay reads on its own. +## This is display/logging only: `PathDangerThreshold` and every pick are +## byte-identical. +## ## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever ## called when the bot explicitly selects `strafe`. @@ -226,6 +241,36 @@ const PathDangerThreshold = 10.0 ## max lava on path; above this = unsafe const DangerReplanThreshold = 25.0 ## serious-threat replan (bullet core) const MaxTrackedBullets = 20 ## hard cap on tracked bullets +# ── pick-quality display (j132) ────────────────────────────────────────────── + +proc strafeHeatSafe*(h: float): bool {.inline.} = + ## The picker's safety rule as a predicate, so the log and the overlay grade a + ## tile EXACTLY the way the picker judges it. Unchanged rule: `<= 10`. + h <= PathDangerThreshold + +proc heatRamp(h: float): Color = + ## GREEN at or below half the threshold (comfortably safe), YELLOW exactly AT + ## the threshold (marginal), RED above it (saturating at twice it). A cue + ## only: the picker's rule stays `strafeHeatSafe`, so this can never admit or + ## reject a tile. + let t = if PathDangerThreshold > 0.0: h / PathDangerThreshold else: 0.0 + if t <= 0.5: + fromHex("#00DD00") + elif t <= 1.0: + fromRgb(uint8(255.0 * (t - 0.5) * 2.0), 255'u8, 0'u8) + else: + fromRgb(255'u8, uint8(255.0 * clamp(2.0 - t, 0.0, 1.0)), 0'u8) + +proc strafeHeatText*(heat, alt: float): string = + ## The display fields appended to the `[strafe]` line: the chosen tile's path + ## heat against the threshold, the coolest OTHER candidate (`-` = none left on + ## the table), and the safe/over label. `heat=7.3/10.0 alt=9.8 ok=1` reads as + ## "7.3 of the 10.0 allowed, the best tile passed up was 9.8 (marginal), and + ## 7.3 is safe". One line, greppable: `grep -o 'heat=[0-9.]*/[0-9.]*'`. + let altTxt = if alt < 0.0: "-" else: fmt"{alt:.1f}" + let okTxt = if strafeHeatSafe(heat): "ok=1" else: "ok=0" + fmt"heat={heat:.1f}/{PathDangerThreshold:.1f} alt={altTxt} " & okTxt + # ── Env knobs ──────────────────────────────────────────────────────────────── proc getEnvFloat(name: string, default: float): float = @@ -363,6 +408,10 @@ type targetX*, targetY*: float ## chosen tile centre (world coords) targetValid*: bool targetLava: float ## heat at the chosen tile when picked + # ── pick quality (j132): display/log only, never read by a decision ── + targetPathHeat*: float ## path max heat to the chosen tile, THIS tick + pickPathHeat*: float ## what the picker measured for the chosen tile + targetBestAlt*: float ## coolest OTHER candidate's path heat (-1 none) dwell*: int ## ticks remaining on the current target dir*: float ## commanded sign: +1 forward, -1 backward bandOffset: float ## random in [-band, band], re-rolled per pick @@ -434,6 +483,9 @@ proc resetRound*(m: var StrafeModule) = m.prevEnergy = @[] m.targetValid = false m.targetLava = 0.0 + m.targetPathHeat = 0.0 + m.pickPathHeat = 0.0 + m.targetBestAlt = -1.0 m.dwell = 0 m.dir = 1.0 m.bandOffset = 0.0 @@ -915,6 +967,15 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, m.targetValid = true m.lastMode = mode + # ── pick quality (j132): the number the picker measured, plus the coolest + # candidate it did NOT take (what it left on the table). Display/log only. + m.pickPathHeat = pool[chosen].pathHeat + m.targetBestAlt = -1.0 + for c in cands: + if c.col == pool[chosen].col and c.row == pool[chosen].row: continue + if m.targetBestAlt < 0.0 or c.pathHeat < m.targetBestAlt: + m.targetBestAlt = c.pathHeat + # sign is frozen for the whole dwell: reversal timing == dwell timing. let hx = cos(ws.selfHeading * DegToRad) let hy = sin(ws.selfHeading * DegToRad) @@ -945,7 +1006,9 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, fmt"along={pool[chosen].along.int} dir={m.dir.int} " & fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f} " & fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f} " & - fmt"wall={m.wallDist:.0f} kappa={kappa:.4f} mode={mode}" + fmt"wall={m.wallDist:.0f} kappa={kappa:.4f} " & + strafeHeatText(pool[chosen].pathHeat, m.targetBestAlt) & " " & + fmt"mode={mode}" if escaped or safe.len == 0 or mode == "escape": echo fmt"[strafe] WARNING: no SAFE tile on the line " & fmt"(cands={cands.len}, clearance={pool[chosen].clearance:.0f}, " & @@ -1076,6 +1139,16 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = else: dec m.dwell + # ── pick quality (j132), display only: the chosen tile's LIVE path heat. + # Recomputed every tick because the field moves with the bullets; the GUI + # prints it ON the tile and the `[strafe]` line prints it at pick time. On a + # pick tick it EQUALS `pickPathHeat`, because it is the same `pathMaxHeat` + # call over the same field and the same position - asserted by + # `common_libs/tests/measure_strafe_heat_display.nim`. + m.targetPathHeat = if m.targetValid: + m.pathMaxHeat(ws.selfX, ws.selfY, m.targetX, m.targetY) + else: 0.0 + # ── heading band: turn ONLY to stay on the line / arc tangent, never to the # target. In escape mode the band reference is the wall-away normal, so the # body slowly rotates inward until the sign can push us off the wall. ── @@ -1205,22 +1278,40 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = let (c, r) = m.tileAt(wx, wy) let x0 = m.marginX + c.float * GridSize let y0 = m.marginY + r.float * GridSize - if m.lavaAt(c, r) <= PathDangerThreshold: - setStrokeColor(fromHex("#00FF00")) - setStrokeWidth(1.0) - else: - setStrokeColor(fromHex("#804000")) - setStrokeWidth(1.0) + # GRADE the candidate by the SAME number the picker uses: the max heat + # on the straight path from the bot (the old binary looked at the + # tile's own lava, which is not the safety rule). + let ph = m.pathMaxHeat(ws.selfX, ws.selfY, + x0 + GridSize * 0.5, y0 + GridSize * 0.5) + setStrokeColor(heatRamp(ph)) + setStrokeWidth(if strafeHeatSafe(ph): 1.5 else: 1.0) drawRectangle(x0, y0, GridSize, GridSize) + # With the full heat grid HIDDEN (`TR_STRAFE_HEAT_GRID=0`) the + # candidates carry their own number, so the STRAFE overlay reads by + # itself; with the grid ON the grid already labels every tile. + if not StrafeHeatGrid: + setFillColor(heatRamp(ph)) + setFont("Arial", 10.0) + drawText(fmt"{ph:.1f}", x0 + 3.0, y0 + 14.0) - # Chosen target tile (magenta fill) + the sign-coloured movement ray. + # ── chosen target tile (j132): its QUALITY, not just its position ──────── + # A thick ramp-coloured border (green = comfortably safe, yellow = at the + # threshold, red = over) plus the tile's path heat printed ON the tile over + # a dark plate, so it stays legible whatever the fill colour is. The magenta + # dot still marks the tile as the chosen target. Drawn independently of + # `StrafeHeatGrid`, so the pick reads clearly with `TR_STRAFE_HEAT_GRID=0`. if m.targetValid: let (cc, cr) = m.tileAt(m.targetX, m.targetY) let gx0 = m.marginX + cc.float * GridSize let gy0 = m.marginY + cr.float * GridSize - setStrokeColor(fromHex("#FF00FF")) - setStrokeWidth(2.5) + setStrokeColor(heatRamp(m.targetPathHeat)) + setStrokeWidth(3.5) drawRectangle(gx0, gy0, GridSize, GridSize) + setFillColor(fromHex("#000000")) + fillRectangle(gx0 + 1.0, gy0 + 18.0, GridSize - 2.0, 17.0) + setFillColor(heatRamp(m.targetPathHeat)) + setFont("Arial", 12.0) + drawText(fmt"{m.targetPathHeat:.1f}", gx0 + 4.0, gy0 + 31.0) setFillColor(fromHex("#FF00FF")) fillCircle(m.targetX, m.targetY, 5.0) # movement ray: green forward, red backward (the sign flip is the point) diff --git a/common_libs/tests/measure_strafe_heat_display.nim b/common_libs/tests/measure_strafe_heat_display.nim new file mode 100644 index 0000000..be43025 --- /dev/null +++ b/common_libs/tests/measure_strafe_heat_display.nim @@ -0,0 +1,157 @@ +## Deterministic check for the j132 STRAFE pick-quality DISPLAY. +## +## NO battle, NO Java, NO server. Run with: +## nim c -r --nimcache:/tmp/nc_j132 --path:common_libs \ +## common_libs/tests/measure_strafe_heat_display.nim +## +## It drives `computeMove` over the recorded DrussGT fixture (the SAME stream +## `measure_strafe_gates.nim` replays) and asserts, for every tick with a valid +## target, that the number the display shows (`targetPathHeat`) is the field's +## REAL reading for that tile: +## +## pathMaxHeat(bot -> chosen tile centre) +## +## On every PICK it also asserts that the number the `[strafe]` line prints +## (`pickPathHeat`, formatted by `strafeHeatText`) is that same number, and that +## a pick at or under `PathDangerThreshold` is labelled safe (`ok=1`), i.e. that +## a "pick" (safe pool non-empty) never carries a heat above the threshold. +## +## This is a DISPLAY check. It changes nothing the mover decides: it only proves +## the displayed/logged heat is the tile's actual path heat. + +import std/[os, json, math, random, strformat] +import std/strutils except fromHex # `fromHex` clashes with color.fromHex +import gun_harness/gun_interface +# Private-field access: include (do NOT import) the mover, exactly as +# `test_tfil_commit_env.nim` includes `the_floor_is_lava.nim`. +include movements/strafe + +const repoRoot = currentSourcePath().parentDir.parentDir.parentDir +const fixtureRel = "tr_drussgt_vs_modularbot.jsonl" +const Seed = 20250923 +const ArenaW = 800.0 +const ArenaH = 600.0 + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +proc loadStates(): seq[WorldState] = + let path = repoRoot / "tools" / "fixtures" / fixtureRel + for rawLine in lines(path): + let line = rawLine.strip() + if line.len == 0: continue + let n = parseJson(line) + if n.hasKey("meta") or n.hasKey("end"): continue + let ex = n["ex"].getFloat() + let ey = n["ey"].getFloat() + result.add WorldState( + enemyX: ex, enemyY: ey, + enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(), + enemyEnergy: n["ee"].getFloat(), + selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(), + selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(), + selfEnergy: n["se"].getFloat(), + arenaWidth: ArenaW, arenaHeight: ArenaH, + tick: n["tick"].getInt(), + enemies: @[EnemyInfo(id: 1, x: ex, y: ey, + heading: n["eh"].getFloat(), speed: n["es"].getFloat(), + energy: n["ee"].getFloat())]) + +# ── the replay ─────────────────────────────────────────────────────────────── + +proc main() = + let states = loadStates() + check fmt"fixture loaded ({states.len} ticks)", states.len > 0 + randomize(Seed) + var m = initStrafe() + m.debugGraphics = false + + var + ticks = 0 + targetTicks = 0 + picks = 0 + under = 0 ## picks labelled ok=1 (pathHeat <= PathDangerThreshold) + over = 0 ## picks labelled ok=0 (forced fallback/escape) + comfy = 0 ## picks <= HALF the threshold (comfortably safe) + altKnown = 0 ## picks where a "best alternative" existed + dispBad = 0 ## display value != real pathMaxHeat of the chosen tile + pickBad = 0 ## picker value != real pathMaxHeat of the chosen tile + labelBad = 0 ## a "pick" (safe pool non-empty) with heat > threshold + textBad = 0 ## the printed text does not carry the same number + minH = Inf + maxH = -Inf + sumH = 0.0 + bandComfy = 0 ## <= 2.5 (half of half) + bandLow = 0 ## 2.5 .. 5.0 + bandMarginal = 0 ## 5.0 .. 10.0 + + for ws in states: + discard m.computeMove(ws) + inc ticks + if not m.targetValid: continue + inc targetTicks + + # The field's REAL reading for the chosen tile, this tick. + let realHeat = m.pathMaxHeat(ws.selfX, ws.selfY, m.targetX, m.targetY) + if abs(realHeat - m.targetPathHeat) > 1e-9: inc dispBad + + # A pick happened on this tick when the picker reset `lastPickCall` to the + # pre-increment `callCount` (computeMove increments callCount last). + if m.callCount != m.lastPickCall + 1: continue + inc picks + if abs(m.pickPathHeat - realHeat) > 1e-9: inc pickBad + + let safe = strafeHeatSafe(m.pickPathHeat) + if safe: inc under else: inc over + if m.pickPathHeat <= PathDangerThreshold * 0.5: inc comfy + if m.pickPathHeat <= PathDangerThreshold * 0.25: inc bandComfy + elif m.pickPathHeat <= PathDangerThreshold * 0.5: inc bandLow + elif safe: inc bandMarginal + if m.targetBestAlt >= 0.0: inc altKnown + if m.pickPathHeat < minH: minH = m.pickPathHeat + if m.pickPathHeat > maxH: maxH = m.pickPathHeat + sumH += m.pickPathHeat + + # A pick from the safe pool can never be over the threshold. + if m.lastMode == "pick" and not safe: inc labelBad + + # The EXACT string the log line prints for this pick, and the exact string + # the GUI prints on the chosen tile, must carry that same number. + let pickTxt = strafeHeatText(m.pickPathHeat, m.targetBestAlt) + let okTxt = if safe: "ok=1" else: "ok=0" + if not pickTxt.startsWith(fmt"heat={realHeat:.1f}/{PathDangerThreshold:.1f}"): + inc textBad + if not pickTxt.contains(okTxt): inc textBad + if not strafeHeatText(m.targetPathHeat, m.targetBestAlt).startsWith( + fmt"heat={m.targetPathHeat:.1f}/"): + inc textBad + + check fmt"every displayed heat == pathMaxHeat of the chosen tile " & + fmt"({targetTicks} targeted ticks, {dispBad} mismatches)", dispBad == 0 + check fmt"every printed pick heat == the picker's real path heat " & + fmt"({picks} picks, {pickBad} mismatches)", pickBad == 0 + check fmt"a safe pick is labelled safe and never exceeds the threshold " & + fmt"({under} under / {over} over, {labelBad} bad)", labelBad == 0 + check fmt"`strafeHeatText` carries the displayed number ({textBad} bad)", + textBad == 0 + check "the stream contains picks to check", picks > 0 + + echo "" + echo fmt"replayed {ticks} ticks, {targetTicks} with a target, {picks} picks" + echo fmt"labelled: under={under} over={over} comfortably-safe(<= " & + fmt"{PathDangerThreshold * 0.5:.1f})={comfy}" + echo fmt"chosen pathHeat: min={minH:.1f} mean={sumH / max(1.0, picks.float):.1f} " & + fmt"max={maxH:.1f} best-alt known={altKnown}/{picks}" + echo fmt"buckets: <=2.5 {bandComfy} | 2.5-5 {bandLow} | 5-10 {bandMarginal} | " & + fmt">10 (forced) {over}" + echo fmt"sample log fields: [{strafeHeatText(7.3, 9.8)}] " & + fmt"[{strafeHeatText(11.4, -1.0)}]" + echo "" + if failures > 0: + echo failures, " check(s) FAILED" + quit(1) + echo "All STRAFE pick-quality display checks passed." + +main()