2daa519e15
Offline diagnostic driving the REAL TFILModule.computeMove over the committed
DrussGT fixtures (re-derived field matched the module's own m.lava bit-for-bit,
max diff 0.000e+00). Answers "is the heat map too hot, and are the corridors to
blame?" - the user's suspicion after watching a GUI run stay far away.
PRIMARY FIXTURE (tr_drussgt_vs_modularbot, 20,026 ticks / 15 rounds):
Field saturation
tiles == 0 22%
tiles > 0 78%
tiles > PathDangerThreshold(10) 61% (worst tick 91%)
median / p90 / max lava 20.06 / 44.53 / 77.51
> 10 with NO bullets at all 44% <- wall radiance + pillar alone
early/mid/late frac > 10 0.61 / 0.63 / 0.60 (saturated from tick 0, not degrading)
Safe pool - THIS IS THE KEY NUMBER
inside-hull tiles/tick 173.7
safeTiles/tick 15.19
ticks with ZERO tile passing the filter 58.5% (2-tile promote fallback used 59.0%)
ticks where the ring weighting is enabled (pool >= MinRingPool=4) 39.1%
ticks with >=1 safe tile in the 100-200px band 11.84%
MEAN DISTANCE TO THE CLOSEST SAFE TILE 397.8 px
ticks both pool>=4 AND band present ("band-weightable") 10.79%
So the safety filter leaves nothing safe near the target: the closest safe tile
averages 398px away. Our measured hit rate is 27.1% at 100-200px and ~5% at
450px, so TFIL's danger model structurally parks us at our worst range. This -
not only the env-var issue - is why the bot stays far away.
Per-source attribution (share of total lava / of the over-10 set)
wall 60.56% / 56.39% <- saturates the RAW field
corridor 25.43% / 21.35% <- blocks the BAND
pillar 7.44% / 5.06%
bullet_aura 2.22% / 1.47%
enemy_core 1.97% / 0.62%
bullet_core 1.17% / 0.33%
enemy_aura 1.21% / 0.95%
Note CorridorHeat=20 is TWICE PathDangerThreshold=10, so a single corridor can
poison a path on its own; WallHotness=30 with WallRadiance=10 puts the outer two
tile rings at/over threshold by themselves (38.6% of all tiles).
Counterfactuals (shipped constants NOT changed) - band-weightable ticks
corridor 20 (shipped) 10.79% band-safe 11.84% pool 15.19
corridor 10 16.47% pool 30.54
corridor 5 23.80% band-safe 24.21% pool 43.93
corridor 0 38.74% pool 62.14
wall 30->10 only pool 15.19 -> 24.80, band unchanged (12.31%)
corridor 5 + wall 10 26.45% band-safe 26.64% pool 85.04
Reachability - NOT the blocker
band inside the 50-tick reachable hull 64.94% of ticks
0-300px inside hull 90.34%
So the band is reachable 65% of the time but SAFE only 12%: the 53-point gap is
heat, not hull geometry.
VERDICT: heat saturation is the real blocker; the WALL is the largest raw-heat
source but the CORRIDORS are the band blocker (removing them multiplies
band-weightable ticks 3.6x, while taming walls leaves the band unchanged).
Even at corridor=0/wall=0 the band is weightable only 40% of ticks, so no
constant tweak fully unlocks the range weighting - the safe set against DrussGT
rarely reaches 100-200px at all. Recommended (NOT applied): CorridorHeat 20->5
and WallHotness 30->10, to be validated by a live A/B.
Adds common_libs/tests/measure_tfil_heat_field.nim (offline, no shipped file
touched; both movers byte-identical).
620 lines
27 KiB
Nim
620 lines
27 KiB
Nim
## OFFLINE DIAGNOSTIC — is the TFIL heat field saturated, and are the corridors
|
|
## the cause? READ-ONLY. No live battles, no bot rebuild, no shipped-constant
|
|
## changes.
|
|
##
|
|
## Method: drive the REAL `TFILModule.computeMove` over the committed DrussGT
|
|
## fixtures (so the selector state — reachable hull, cached inside-tiles,
|
|
## bullet tracking, commit gating — is byte-identical to the live mover), then
|
|
## READ its private state via `include`. `include` (not `import`) is the only
|
|
## way to see the private `lava`/`bullets`/`cachedInsideTiles` fields without
|
|
## editing the shipped file.
|
|
##
|
|
## The field is independently re-derived by `buildField` (a faithful copy of
|
|
## `computeMove`'s lava section, parameterised by corridor heat) and checked
|
|
## element-wise against the module's own `m.lava`, so per-source attribution and
|
|
## the CorridorHeat counterfactual are trustworthy.
|
|
##
|
|
## Run:
|
|
## nim c -r common_libs/tests/measure_tfil_heat_field.nim [fixture.jsonl ...]
|
|
## With no args it uses the two ModularBot-vs-DrussGT TR-bridge fixtures (the
|
|
## only fixtures in which ModularBot is the shooter `s*`).
|
|
|
|
import std/[os, strformat, math, algorithm, json, sets, tables]
|
|
import gun_harness/offline_range
|
|
# Private-field access: include (do NOT import) the shipped mover.
|
|
include movements/the_floor_is_lava
|
|
|
|
## The safety threshold is a const LOCAL to computeMove in the shipped file
|
|
## (`PathDangerThreshold = 10.0`). Re-declared here for the pool reproduction.
|
|
const SafeThreshold = 10.0
|
|
|
|
# ── source taxonomy ──────────────────────────────────────────────────────────
|
|
type
|
|
Source = enum
|
|
srcBulletCore, srcBulletAura, srcCorridor, srcEnemyCore, srcEnemyAura,
|
|
srcWall, srcPillar
|
|
|
|
const SourceNames: array[Source, string] = [
|
|
"bullet_core", "bullet_aura", "corridor", "enemy_core", "enemy_aura",
|
|
"wall", "pillar"
|
|
]
|
|
|
|
type
|
|
FieldResult = object
|
|
lava: seq[float]
|
|
src: array[Source, seq[float]]
|
|
|
|
# ── exact copy of computeMove's lava section, corridor heat parameterised ────
|
|
proc buildField(m: TFILModule, ws: WorldState, corridorHeat: float,
|
|
wallHotness = WallHotness): FieldResult =
|
|
let n = m.cols * m.rows
|
|
result.lava = newSeq[float](n)
|
|
for s in Source: result.src[s] = newSeq[float](n)
|
|
|
|
# Bullet core / aura
|
|
for b in m.bullets:
|
|
let bx = b.x
|
|
let by = b.y
|
|
let (coreR, auraR) = bulletRadii(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)))
|
|
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
|
|
for row in rowMin..rowMax:
|
|
for col in colMin..colMax:
|
|
let idx = row * m.cols + col
|
|
let x0 = m.marginX + col.float * GridSize
|
|
let y0 = m.marginY + row.float * GridSize
|
|
let nearX = clamp(bx, x0, x0 + GridSize)
|
|
let nearY = clamp(by, y0, y0 + GridSize)
|
|
let dx = nearX - bx
|
|
let dy = nearY - by
|
|
let d2 = dx*dx + dy*dy
|
|
if d2 <= coreR * coreR:
|
|
result.src[srcBulletCore][idx] += BulletCore
|
|
result.lava[idx] += BulletCore
|
|
elif d2 <= auraR * auraR:
|
|
result.src[srcBulletAura][idx] += BulletAura
|
|
result.lava[idx] += BulletAura
|
|
|
|
# Corridors — rotated rectangle from bullet position to the wall
|
|
for b in m.bullets:
|
|
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
|
|
if cg.tMin == 0.0: continue
|
|
let (_, auraR) = bulletRadii(b.power)
|
|
let wx = cg.bx + cg.dx * cg.tMin
|
|
let wy = cg.by + cg.dy * cg.tMin
|
|
let c0x = cg.bx + cg.px * auraR; let c0y = cg.by + cg.py * auraR
|
|
let c1x = cg.bx - cg.px * auraR; let c1y = cg.by - cg.py * auraR
|
|
let c2x = wx - cg.px * auraR; let c2y = wy - cg.py * auraR
|
|
let c3x = wx + cg.px * auraR; let c3y = wy + cg.py * auraR
|
|
let xMin = min(min(c0x, c1x), min(c2x, c3x))
|
|
let xMax = max(max(c0x, c1x), max(c2x, c3x))
|
|
let yMin = min(min(c0y, c1y), min(c2y, c3y))
|
|
let yMax = max(max(c0y, c1y), max(c2y, c3y))
|
|
let colMin = max(0, int(floor((xMin - m.marginX) / GridSize)))
|
|
let colMax = min(m.cols-1, int(floor((xMax - m.marginX) / GridSize)))
|
|
let rowMin = max(0, int(floor((yMin - m.marginY) / GridSize)))
|
|
let rowMax = min(m.rows-1, int(floor((yMax - m.marginY) / GridSize)))
|
|
for row in rowMin..rowMax:
|
|
for col in colMin..colMax:
|
|
let idx = row * m.cols + col
|
|
let cx = m.marginX + (col.float + 0.5) * GridSize
|
|
let cy = m.marginY + (row.float + 0.5) * GridSize
|
|
let relX = cx - cg.bx
|
|
let relY = cy - cg.by
|
|
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:
|
|
result.src[srcCorridor][idx] += corridorHeat
|
|
result.lava[idx] += corridorHeat
|
|
|
|
# Enemy core / aura
|
|
for ei in ws.enemies:
|
|
let ex = ei.x
|
|
let ey = ei.y
|
|
let colMin = max(0, int(floor((ex - EnemyAuraRadius - m.marginX) / GridSize)))
|
|
let colMax = min(m.cols-1, int(floor((ex + EnemyAuraRadius - m.marginX) / GridSize)))
|
|
let rowMin = max(0, int(floor((ey - EnemyAuraRadius - m.marginY) / GridSize)))
|
|
let rowMax = min(m.rows-1, int(floor((ey + EnemyAuraRadius - m.marginY) / GridSize)))
|
|
for row in rowMin..rowMax:
|
|
for col in colMin..colMax:
|
|
let idx = row * m.cols + col
|
|
let x0 = m.marginX + col.float * GridSize
|
|
let y0 = m.marginY + row.float * GridSize
|
|
let nearX = clamp(ex, x0, x0 + GridSize)
|
|
let nearY = clamp(ey, y0, y0 + GridSize)
|
|
let dx = nearX - ex
|
|
let dy = nearY - ey
|
|
let d2 = dx*dx + dy*dy
|
|
if d2 <= EnemyCoreRadius * EnemyCoreRadius:
|
|
result.src[srcEnemyCore][idx] += EnemyCore
|
|
result.lava[idx] += EnemyCore
|
|
elif d2 <= EnemyAuraRadius * EnemyAuraRadius:
|
|
result.src[srcEnemyAura][idx] += EnemyAura
|
|
result.lava[idx] += EnemyAura
|
|
|
|
# Wall radiance
|
|
for row in 0..<m.rows:
|
|
for col in 0..<m.cols:
|
|
let idx = row * m.cols + col
|
|
let heat = max(0.0, wallHotness - col.float * WallRadiance) +
|
|
max(0.0, wallHotness - (m.cols-1-col).float * WallRadiance) +
|
|
max(0.0, wallHotness - row.float * WallRadiance) +
|
|
max(0.0, wallHotness - (m.rows-1-row).float * WallRadiance)
|
|
result.src[srcWall][idx] += heat
|
|
result.lava[idx] += heat
|
|
|
|
# Pillar radiance
|
|
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
|
|
let pc1 = m.cols div 2
|
|
let pr0 = if m.rows mod 2 == 1: m.rows div 2 else: m.rows div 2 - 1
|
|
let pr1 = m.rows div 2
|
|
for row in 0..<m.rows:
|
|
for col in 0..<m.cols:
|
|
let idx = row * m.cols + col
|
|
var minDist = int.high
|
|
for pcol in pc0..pc1:
|
|
for prow in pr0..pr1:
|
|
let d = max(abs(col - pcol), abs(row - prow))
|
|
if d < minDist: minDist = d
|
|
let heat = max(0.0, PillarHotness - minDist.float * PillarRadiance)
|
|
result.src[srcPillar][idx] += heat
|
|
result.lava[idx] += heat
|
|
|
|
# ── reproduce the selector's safe pool from an arbitrary lava field ──────────
|
|
type
|
|
TileXY = tuple[col, row: int]
|
|
Scored = tuple[col, row: int; pathMaxHeat: float; dist: float]
|
|
Pool = object
|
|
insideTiles: int
|
|
coolTiles: int
|
|
safe: seq[Scored]
|
|
safeBeforeFallback: int
|
|
|
|
proc poolFromLava(lava: seq[float], m: TFILModule, ws: WorldState): Pool =
|
|
result.insideTiles = m.cachedInsideTiles.len
|
|
var distinctVals: seq[float]
|
|
for t in m.cachedInsideTiles:
|
|
let v = lava[t.row * m.cols + t.col]
|
|
var found = false
|
|
for dv in distinctVals:
|
|
if dv == v: found = true; break
|
|
if not found: distinctVals.add v
|
|
for i in 1..<distinctVals.len:
|
|
let key = distinctVals[i]
|
|
var j = i - 1
|
|
while j >= 0 and distinctVals[j] > key:
|
|
distinctVals[j + 1] = distinctVals[j]
|
|
dec j
|
|
distinctVals[j + 1] = key
|
|
|
|
var coolTiles: seq[TileXY]
|
|
let numLevels = min(CoolestLevels, distinctVals.len)
|
|
for t in m.cachedInsideTiles:
|
|
let v = lava[t.row * m.cols + t.col]
|
|
for li in 0..<numLevels:
|
|
if v == distinctVals[li]:
|
|
coolTiles.add (col: t.col, row: t.row)
|
|
break
|
|
result.coolTiles = coolTiles.len
|
|
|
|
const PathSampleStep = 18.0
|
|
var scoredTiles: seq[Scored]
|
|
for t in coolTiles:
|
|
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
|
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
|
let ddx = tx - ws.selfX
|
|
let ddy = ty - ws.selfY
|
|
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
|
var pathMaxHeat = 0.0
|
|
if lineDist > 0.1:
|
|
let steps = max(1, int(lineDist / PathSampleStep))
|
|
for si in 0..steps:
|
|
let frac = si.float / steps.float
|
|
let sx = ws.selfX + ddx * frac
|
|
let sy = ws.selfY + ddy * frac
|
|
let (sc, sr) = m.tileAt(sx, sy)
|
|
pathMaxHeat = max(pathMaxHeat, lava[sr * m.cols + sc])
|
|
scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat,
|
|
dist: hypot(tx - ws.enemyX, ty - ws.enemyY))
|
|
|
|
for i in 1..<scoredTiles.len:
|
|
let key = scoredTiles[i]
|
|
var j = i - 1
|
|
while j >= 0 and scoredTiles[j].pathMaxHeat > key.pathMaxHeat:
|
|
scoredTiles[j + 1] = scoredTiles[j]
|
|
dec j
|
|
scoredTiles[j + 1] = key
|
|
|
|
var safe: seq[Scored]
|
|
var blocked: seq[Scored]
|
|
for t in scoredTiles:
|
|
if t.pathMaxHeat <= SafeThreshold: safe.add t
|
|
else: blocked.add t
|
|
result.safeBeforeFallback = safe.len
|
|
if safe.len < 2:
|
|
let needed = 2 - safe.len
|
|
let promote = min(needed, blocked.len)
|
|
for i in 0..<promote:
|
|
safe.add blocked[i]
|
|
result.safe = safe
|
|
|
|
# ── round-boundary sidecar (read-only) ───────────────────────────────────────
|
|
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()
|
|
|
|
# ── accumulators ─────────────────────────────────────────────────────────────
|
|
type
|
|
PhaseSat = object
|
|
ticks: int
|
|
sumFrac0, sumFracPos, sumFracOver: float
|
|
sumSafePool, sumSafeBefore, sumBandHull, sumBandSafe, sumNear300Safe: float
|
|
bandHullTicks, bandSafeTicks, near300SafeTicks: int
|
|
sumMinSafeDist: float
|
|
minSafeDistTicks: int
|
|
|
|
FileStats = object
|
|
name: string
|
|
ticks: int
|
|
rounds: int
|
|
# field saturation
|
|
sumFrac0, sumFracPos, sumFracOver: float
|
|
sumMedian, sumP90, sumMax, sumMean: float
|
|
maxOver: float
|
|
# pool
|
|
sumSafePool, sumSafeBefore: float
|
|
zeroSafeTicks, fallbackTicks, zeroBeforeFallbackTicks: int
|
|
poolGE4Ticks, poolGE4BandTicks: int
|
|
sumInsideTiles: float
|
|
sumStructOver: float
|
|
# band reachability
|
|
bandHullTicks, bandSafeTicks, near300HullTicks, near300SafeTicks: int
|
|
sumMinSafeDist: float
|
|
minSafeDistTicks: int
|
|
# bullets
|
|
sumBullets: float
|
|
ticksWithBullets: int
|
|
# per-phase (early/mid/late)
|
|
phase: array[3, PhaseSat]
|
|
# per-source: total lava and over-threshold responsibility
|
|
srcTotal: array[Source, float]
|
|
srcOverResp: array[Source, int]
|
|
overTilesTotal: int
|
|
# counterfactual
|
|
cfSafePool: array[4, float] # scales 1.0, 0.5, 0.25, 0.0
|
|
cfBandHull: array[4, int]
|
|
cfBandSafe: array[4, int]
|
|
cfNear300Safe: array[4, int]
|
|
cfGE4Band: array[4, int]
|
|
cfTicks: int
|
|
# combined corridor x wall counterfactual
|
|
cfComboSafe: array[6, float]
|
|
cfComboBand: array[6, int]
|
|
cfComboNear: array[6, int]
|
|
cfComboGE4Band: array[6, int]
|
|
# identity check
|
|
maxFieldDiff: float
|
|
|
|
const CfScales = [1.0, 0.5, 0.25, 0.0]
|
|
|
|
## (corridorHeat, wallHotness) combos for the combined counterfactual.
|
|
const CfCombos = [
|
|
(20.0, 30.0), # shipped
|
|
(10.0, 30.0), # corridor only halved
|
|
(20.0, 10.0), # wall tamed (max wall heat == threshold)
|
|
(10.0, 10.0), # both halved / tamed
|
|
( 5.0, 10.0), # corridor quartered + wall tamed
|
|
( 0.0, 0.0), # no soft heat at all
|
|
]
|
|
|
|
proc percentile(sortedVals: openArray[float], p: float): float =
|
|
if sortedVals.len == 0: return 0.0
|
|
let idx = clamp(int(p * (sortedVals.len - 1).float + 0.5), 0, sortedVals.high)
|
|
sortedVals[idx]
|
|
|
|
proc analyseFile(path: string): FileStats =
|
|
result.name = extractFilename(path)
|
|
let fx = loadFixture(path)
|
|
let starts = loadRoundStarts(path)
|
|
# round membership per tick, for the early/mid/late split
|
|
var roundOf = newSeq[int](fx.states.len)
|
|
var roundStart = newSeq[int](fx.states.len)
|
|
var roundCount = newSeq[int](fx.states.len)
|
|
block:
|
|
var curStart = 0
|
|
var curCount = fx.states.len
|
|
for i in 0..<fx.states.len:
|
|
if starts.len > 0 and i in starts:
|
|
curStart = i
|
|
# find count from the sidecar is not tracked here; recompute boundaries
|
|
roundOf[i] = curStart
|
|
# build start->count map from sidecar
|
|
var startToCount = initTable[int, int]()
|
|
let side = currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "drussgt_meta" /
|
|
(extractFilename(path) & ".rounds.json")
|
|
if fileExists(side):
|
|
let root = parseFile(side)
|
|
for r in root["rounds"]:
|
|
startToCount[r["startTick"].getInt()] = r["count"].getInt()
|
|
result.rounds = root["rounds"].len
|
|
for i in 0..<fx.states.len:
|
|
if i in starts:
|
|
curStart = i
|
|
curCount = startToCount.getOrDefault(i, fx.states.len - i)
|
|
roundStart[i] = curStart
|
|
roundCount[i] = curCount
|
|
|
|
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
|
|
|
|
# ── identity: my re-derived field must equal the module's own ──────────
|
|
let fr = buildField(m, ws, CorridorHeat)
|
|
var diff = 0.0
|
|
for i in 0..<m.lava.len:
|
|
diff = max(diff, abs(fr.lava[i] - m.lava[i]))
|
|
result.maxFieldDiff = max(result.maxFieldDiff, diff)
|
|
|
|
# ── 1. field saturation ────────────────────────────────────────────────
|
|
var sortedVals = newSeq[float](m.lava.len)
|
|
var frac0 = 0.0
|
|
var fracPos = 0.0
|
|
var fracOver = 0.0
|
|
var total = 0.0
|
|
var mx = 0.0
|
|
for i in 0..<m.lava.len:
|
|
let v = m.lava[i]
|
|
sortedVals[i] = v
|
|
total += v
|
|
if v == 0.0: frac0 += 1.0
|
|
elif v > 0.0: fracPos += 1.0
|
|
if v > SafeThreshold: fracOver += 1.0
|
|
if v > mx: mx = v
|
|
let nf = m.lava.len.float
|
|
frac0 /= nf; fracPos /= nf; fracOver /= nf
|
|
sort(sortedVals)
|
|
let med = percentile(sortedVals, 0.5)
|
|
let p90 = percentile(sortedVals, 0.9)
|
|
result.sumFrac0 += frac0
|
|
result.sumFracPos += fracPos
|
|
result.sumFracOver += fracOver
|
|
result.sumMedian += med
|
|
result.sumP90 += p90
|
|
result.sumMax += mx
|
|
result.sumMean += total / nf
|
|
result.maxOver = max(result.maxOver, fracOver)
|
|
|
|
# ── 2. safe pool + the band distance distribution over it ─────────────
|
|
let p1 = poolFromLava(m.lava, m, ws)
|
|
result.sumSafePool += p1.safe.len.float
|
|
result.sumSafeBefore += p1.safeBeforeFallback.float
|
|
result.sumInsideTiles += p1.insideTiles.float
|
|
if p1.safe.len == 0: inc result.zeroSafeTicks
|
|
if p1.safeBeforeFallback < 2 and p1.safe.len >= 2: inc result.fallbackTicks
|
|
if p1.safeBeforeFallback == 0: inc result.zeroBeforeFallbackTicks
|
|
# The ring mover disables range shaping for pools < MinRingPool (4).
|
|
if p1.safe.len >= 4:
|
|
inc result.poolGE4Ticks
|
|
block:
|
|
var bs = 0
|
|
for t in p1.safe:
|
|
if t.dist >= 100.0 and t.dist <= 200.0: inc bs
|
|
if bs > 0: inc result.poolGE4BandTicks
|
|
|
|
# structural floor: wall + pillar heat alone (no bullets / corridors)
|
|
var structOver = 0.0
|
|
for i in 0..<m.lava.len:
|
|
if fr.src[srcWall][i] + fr.src[srcPillar][i] > SafeThreshold: structOver += 1.0
|
|
result.sumStructOver += structOver / nf
|
|
var minSafeDist = Inf
|
|
var bandSafe = 0
|
|
var near300Safe = 0
|
|
for t in p1.safe:
|
|
let d = t.dist
|
|
if d < minSafeDist: minSafeDist = d
|
|
if d >= 100.0 and d <= 200.0: inc bandSafe
|
|
if d <= 300.0: inc near300Safe
|
|
if bandSafe > 0: inc result.bandSafeTicks
|
|
if near300Safe > 0: inc result.near300SafeTicks
|
|
if p1.safe.len > 0:
|
|
result.sumMinSafeDist += minSafeDist
|
|
inc result.minSafeDistTicks
|
|
|
|
# ── 5. band inside the reachable hull ─────────────────────────────────
|
|
var bandHull = 0
|
|
var near300Hull = 0
|
|
for t in m.cachedInsideTiles:
|
|
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
|
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
|
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
|
if d >= 100.0 and d <= 200.0: inc bandHull
|
|
if d <= 300.0: inc near300Hull
|
|
if bandHull > 0: inc result.bandHullTicks
|
|
if near300Hull > 0: inc result.near300HullTicks
|
|
|
|
# ── 3. per-source attribution ──────────────────────────────────────────
|
|
for s in Source:
|
|
var st = 0.0
|
|
for v in fr.src[s]: st += v
|
|
result.srcTotal[s] += st
|
|
for i in 0..<m.lava.len:
|
|
if m.lava[i] > SafeThreshold:
|
|
inc result.overTilesTotal
|
|
for s in Source:
|
|
if m.lava[i] - fr.src[s][i] <= SafeThreshold:
|
|
inc result.srcOverResp[s]
|
|
|
|
# ── bullets ────────────────────────────────────────────────────────────
|
|
result.sumBullets += m.bullets.len.float
|
|
if m.bullets.len > 0: inc result.ticksWithBullets
|
|
|
|
# ── per phase ──────────────────────────────────────────────────────────
|
|
let rc = max(1, roundCount[si])
|
|
let pos = si - roundStart[si]
|
|
let ph = min(2, int(pos.float * 3.0 / rc.float))
|
|
inc result.phase[ph].ticks
|
|
result.phase[ph].sumFrac0 += frac0
|
|
result.phase[ph].sumFracPos += fracPos
|
|
result.phase[ph].sumFracOver += fracOver
|
|
result.phase[ph].sumSafePool += p1.safe.len.float
|
|
result.phase[ph].sumSafeBefore += p1.safeBeforeFallback.float
|
|
if bandSafe > 0: inc result.phase[ph].bandSafeTicks
|
|
if near300Safe > 0: inc result.phase[ph].near300SafeTicks
|
|
if p1.safe.len > 0:
|
|
result.phase[ph].sumMinSafeDist += minSafeDist
|
|
inc result.phase[ph].minSafeDistTicks
|
|
|
|
# ── 4. CorridorHeat counterfactual ─────────────────────────────────────
|
|
for ci, scale in CfScales:
|
|
let cf = buildField(m, ws, CorridorHeat * scale)
|
|
let pc = poolFromLava(cf.lava, m, ws)
|
|
result.cfSafePool[ci] += pc.safe.len.float
|
|
var bHull = 0
|
|
var bSafe = 0
|
|
var n3Safe = 0
|
|
for t in m.cachedInsideTiles:
|
|
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
|
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
|
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
|
if d >= 100.0 and d <= 200.0: inc bHull
|
|
for t in pc.safe:
|
|
if t.dist >= 100.0 and t.dist <= 200.0: inc bSafe
|
|
if t.dist <= 300.0: inc n3Safe
|
|
if bHull > 0: inc result.cfBandHull[ci]
|
|
if bSafe > 0: inc result.cfBandSafe[ci]
|
|
if n3Safe > 0: inc result.cfNear300Safe[ci]
|
|
if pc.safe.len >= 4 and bSafe > 0: inc result.cfGE4Band[ci]
|
|
inc result.cfTicks
|
|
|
|
# ── 4b. combined corridor x wall counterfactual ─────────────────────────
|
|
for ci, combo in CfCombos:
|
|
let cf2 = buildField(m, ws, combo[0], combo[1])
|
|
let pf = poolFromLava(cf2.lava, m, ws)
|
|
result.cfComboSafe[ci] += pf.safe.len.float
|
|
var bSafe = 0
|
|
var n3Safe = 0
|
|
for t in pf.safe:
|
|
if t.dist >= 100.0 and t.dist <= 200.0: inc bSafe
|
|
if t.dist <= 300.0: inc n3Safe
|
|
if bSafe > 0: inc result.cfComboBand[ci]
|
|
if n3Safe > 0: inc result.cfComboNear[ci]
|
|
if pf.safe.len >= 4 and bSafe > 0: inc result.cfComboGE4Band[ci]
|
|
|
|
# ── reporting ────────────────────────────────────────────────────────────────
|
|
proc f2(x: float): string = &"{x:.2f}"
|
|
|
|
proc report(res: FileStats) =
|
|
let n = res.ticks.float
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════════════════"
|
|
echo &"FILE {res.name} ticks={res.ticks} rounds={res.rounds}"
|
|
echo &"field identity check: max |buildField(scale=1) - m.lava| = {res.maxFieldDiff:.3e}" &
|
|
(if res.maxFieldDiff == 0.0: " (EXACT)" else: " <-- MISMATCH")
|
|
echo "───────────────────────────────────────────────────────────────────────────"
|
|
echo "1. FIELD SATURATION (per-tick mean over all tiles)"
|
|
echo &" frac == 0 : {f2(res.sumFrac0/n)}"
|
|
echo &" frac > 0 : {f2(res.sumFracPos/n)}"
|
|
echo &" frac > 10 (thresh) : {f2(res.sumFracOver/n)} (max per-tick={f2(res.maxOver)})"
|
|
echo &" median lava : {f2(res.sumMedian/n)}"
|
|
echo &" p90 lava : {f2(res.sumP90/n)}"
|
|
echo &" max lava : {f2(res.sumMax/n)}"
|
|
echo &" mean lava : {f2(res.sumMean/n)}"
|
|
echo &" STRUCTURAL (wall+pillar only, no bullets) frac>10: {f2(res.sumStructOver/n)}"
|
|
echo &" bullets tracked/tick : {f2(res.sumBullets/n)} (ticks with >=1: {res.ticksWithBullets})"
|
|
echo ""
|
|
echo " over-threshold fraction by round phase (early/mid/late):"
|
|
for ph in 0..2:
|
|
let p = res.phase[ph]
|
|
if p.ticks > 0:
|
|
let pn = p.ticks.float
|
|
echo &" phase {ph}: frac>10={f2(p.sumFracOver/pn)} frac==0={f2(p.sumFrac0/pn)}" &
|
|
&" safePool={f2(p.sumSafePool/pn)} bandSafeFreq={f2(p.bandSafeTicks.float/pn)}" &
|
|
&" near300SafeFreq={f2(p.near300SafeTicks.float/pn)}"
|
|
echo ""
|
|
echo "2. SAFE POOL (the `candidates`/`safeTiles` the selector sees)"
|
|
echo &" mean inside-hull tiles/tick : {f2(res.sumInsideTiles/n)}"
|
|
echo &" mean safeTiles/tick : {f2(res.sumSafePool/n)}"
|
|
echo &" mean safe-before-fallback : {f2(res.sumSafeBefore/n)}"
|
|
echo &" ticks with 1+ safe tile : {res.ticks - res.zeroSafeTicks}/{res.ticks}"
|
|
echo &" ticks with 0 safe before : {res.zeroBeforeFallbackTicks}/{res.ticks} (fallback saves them)"
|
|
echo &" ticks using promote-fallback: {res.fallbackTicks}/{res.ticks}"
|
|
echo &" ticks with safePool >= 4 : {res.poolGE4Ticks}/{res.ticks} ({f2(res.poolGE4Ticks.float/n*100.0)}%) <- ring weighting can act"
|
|
echo &" ticks with pool>=4 AND band : {res.poolGE4BandTicks}/{res.ticks} ({f2(res.poolGE4BandTicks.float/n*100.0)}%) <- band available to weight"
|
|
echo " safe-tile distance-to-target reachability (per tick):"
|
|
echo &" ticks with >=1 safe tile in 100-200px : {res.bandSafeTicks}/{res.ticks} ({f2(res.bandSafeTicks.float/n*100.0)}%)"
|
|
echo &" ticks with >=1 safe tile in 0-300px : {res.near300SafeTicks}/{res.ticks} ({f2(res.near300SafeTicks.float/n*100.0)}%)"
|
|
if res.minSafeDistTicks > 0:
|
|
echo &" mean closest-safe-tile distance : {f2(res.sumMinSafeDist/res.minSafeDistTicks.float)} px"
|
|
echo ""
|
|
echo "5. BAND INSIDE THE REACHABLE HULL (50-tick hull; outside => never a candidate)"
|
|
echo &" ticks with >=1 hull tile in 100-200px : {res.bandHullTicks}/{res.ticks} ({f2(res.bandHullTicks.float/n*100.0)}%)"
|
|
echo &" ticks with >=1 hull tile in 0-300px : {res.near300HullTicks}/{res.ticks} ({f2(res.near300HullTicks.float/n*100.0)}%)"
|
|
echo ""
|
|
echo "3. PER-SOURCE ATTRIBUTION"
|
|
var grandTotal = 0.0
|
|
for s in Source: grandTotal += res.srcTotal[s]
|
|
echo " source lava share over-thr resp share (resp tiles may overlap)"
|
|
for s in Source:
|
|
let share = if grandTotal > 0.0: res.srcTotal[s]/grandTotal*100.0 else: 0.0
|
|
let resp = if res.overTilesTotal > 0: res.srcOverResp[s].float/res.overTilesTotal.float*100.0 else: 0.0
|
|
echo &" {SourceNames[s]:<12} {share:>9.2f}% {resp:>19.2f}%"
|
|
echo &" total over-threshold tile-samples: {res.overTilesTotal}"
|
|
echo ""
|
|
echo "4. CORRIDORHEAT COUNTERFACTUAL (shipped constant NOT changed)"
|
|
echo " corridor scale heat safePool bandHullFreq bandSafeFreq near300SafeFreq"
|
|
for ci, scale in CfScales:
|
|
let tn = res.cfTicks.float
|
|
echo &" {f2(scale):<16} {f2(CorridorHeat*scale):>6} {f2(res.cfSafePool[ci]/tn):>9}" &
|
|
&" {f2(res.cfBandHull[ci].float/tn*100.0):>12}% {f2(res.cfBandSafe[ci].float/tn*100.0):>12}%" &
|
|
&" {f2(res.cfNear300Safe[ci].float/tn*100.0):>15}%"
|
|
echo " (band-weightable = pool>=4 AND band present)"
|
|
for ci, scale in CfScales:
|
|
let tn = res.cfTicks.float
|
|
echo &" corridor scale {f2(scale):<5}: band-weightable {f2(res.cfGE4Band[ci].float/tn*100.0)}%"
|
|
echo ""
|
|
echo "4b. COMBINED corridor x wall counterfactual (safe pool / band reach)"
|
|
echo " corridorHeat wallHotness safePool bandSafeFreq near300SafeFreq"
|
|
for ci, combo in CfCombos:
|
|
let tn = res.cfTicks.float
|
|
echo &" {f2(combo[0]):>12} {f2(combo[1]):>11} {f2(res.cfComboSafe[ci]/tn):>9}" &
|
|
&" {f2(res.cfComboBand[ci].float/tn*100.0):>12}% {f2(res.cfComboNear[ci].float/tn*100.0):>15}%"
|
|
echo " band-weightable (pool>=4 AND band present):"
|
|
for ci, combo in CfCombos:
|
|
let tn = res.cfTicks.float
|
|
echo &" corridorHeat {f2(combo[0]):>5} wallHotness {f2(combo[1]):>5}: {f2(res.cfComboGE4Band[ci].float/tn*100.0)}%"
|
|
|
|
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",
|
|
]
|
|
for f in files:
|
|
if not fileExists(f):
|
|
stderr.writeLine("missing fixture: " & f)
|
|
continue
|
|
report(analyseFile(f))
|
|
|
|
when isMainModule:
|
|
main()
|