94ffc63160
Measures, on the recorded fixtures and with NO counterfactual replay: how long until a safe tile appears at a forced pick, whether the enemy had just fired, whether our own tile is already hot, and all three split by distance. Registers nothing and changes no default: the knob TR_TFIL_HOLD_WHEN_TRAPPED and its implementation live in the concurrently edited working tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
245 lines
11 KiB
Nim
245 lines
11 KiB
Nim
## OFFLINE — j153. THE OWNER'S "HOLD WHEN TRAPPED" — OPEN-LOOP DESCRIPTORS ONLY.
|
|
##
|
|
## The claim: "if no tile is found to go, to not choose the less dangerous, but
|
|
## to stay still! the next tick probably the situation already changed and we
|
|
## did not commit to any dangerous place."
|
|
##
|
|
## This ruler reports FOUR DESCRIPTORS of the RECORDED field around every forced
|
|
## pick (the pick the mover makes when the safe set is too small to draw from).
|
|
## They are all measured on the recorded trajectory, forward-looking in TIME but
|
|
## NOT counterfactual: nothing here replays "what damage would holding have
|
|
## cost". Per `docs/offline_harness_trust.md` that question is closed-loop and
|
|
## the replay ruler scored 0/6 on such questions, so it is NOT asked here.
|
|
##
|
|
## 1. WAIT WINDOW — at a forced pick, holding OUR POSITION fixed, how many
|
|
## ticks pass before ANY tile inside the reachable hull is safe
|
|
## (pathMaxHeat <= PathDangerThreshold, after the CoolestLevels=2 filter)?
|
|
## CAVEAT: the safe set depends on where we are, so this holds the pick
|
|
## position and replays the field — the recorded field still belongs to a
|
|
## bot that moved, so the window is an APPROXIMATION of the wait a holder
|
|
## would really see. It is an UPPER bound on the wait (a moving recorded
|
|
## bot stirs the field) and it is reported with the censoring share.
|
|
## 2. FRESH FIRE — ticks since the enemy's last confirmed shot (energy-drop
|
|
## detection, the same signal the mover itself uses).
|
|
## 3. OUR OWN TILE — is our current tile already over the threshold at the
|
|
## forced pick? (if yes, "stay" is not on the table at all)
|
|
## 4. DISTANCE — all of the above split by distance to the nearest enemy.
|
|
##
|
|
## No battle, no Java, no server, no behaviour change (the knob is off).
|
|
##
|
|
## Run:
|
|
## nim c -r --path:common_libs --nimcache:/tmp/nc_j153 \
|
|
## common_libs/tests/measure_tfil_hold_window.nim [fixture.jsonl ...]
|
|
|
|
import std/[os, strformat, math, algorithm, json, random, sequtils, sets]
|
|
import std/strutils except fromHex
|
|
import gun_harness/offline_range
|
|
include movements/the_floor_is_lava
|
|
|
|
const PathSampleStep = 18.0 ## the picker's own path sampling step
|
|
const MaxWait = 40 ## censor horizon, ticks
|
|
const ArenaW = 800.0
|
|
const ArenaH = 600.0
|
|
|
|
type Probe = object
|
|
idx: int ## fixture index of the forced pick
|
|
x, y, heading, speed: float ## OUR position at the pick (held fixed)
|
|
dist: float ## to the nearest enemy at the pick
|
|
ownHot: bool ## our own tile already over the threshold
|
|
emptySet: bool ## the safe set was EMPTY (0), not merely < 2
|
|
sinceFire: int ## ticks since the enemy's last confirmed shot
|
|
wait: int ## filled below: -1 = censored
|
|
resolved: bool
|
|
|
|
proc loadRoundStarts(path: string): HashSet[int] =
|
|
result = initHashSet[int]()
|
|
for side in [path & ".rounds.json",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "drussgt_meta" /
|
|
(extractFilename(path) & ".rounds.json")]:
|
|
if not fileExists(side): continue
|
|
let root = parseFile(side)
|
|
if not root.hasKey("rounds"): continue
|
|
for r in root["rounds"]:
|
|
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
|
|
|
proc pathMax(m: TFILModule, fx, fy, tx, ty: float): float =
|
|
let ddx = tx - fx; let ddy = ty - fy
|
|
let d = sqrt(ddx*ddx + ddy*ddy)
|
|
if d <= 0.1: return 0.0
|
|
let steps = max(1, int(d / PathSampleStep))
|
|
var h = 0.0
|
|
for si in 0..steps:
|
|
let f = si.float / steps.float
|
|
let (sc, sr) = m.tileAt(fx + ddx * f, fy + ddy * f)
|
|
h = max(h, m.lavaAt(sc, sr))
|
|
h
|
|
|
|
proc coolestOf(m: TFILModule, tiles: seq[tuple[col, row: int]]):
|
|
seq[tuple[col, row: int]] =
|
|
## The picker's CoolestLevels=2 filter: only the coolest 2 DISTINCT lava
|
|
## values among the tiles count as candidates at all.
|
|
var vals: seq[float]
|
|
for t in tiles:
|
|
let v = m.lavaAt(t.col, t.row)
|
|
var found = false
|
|
for dv in vals:
|
|
if dv == v: found = true; break
|
|
if not found: vals.add v
|
|
for i in 1..<vals.len:
|
|
let key = vals[i]; var j = i - 1
|
|
while j >= 0 and vals[j] > key:
|
|
vals[j + 1] = vals[j]; dec j
|
|
vals[j + 1] = key
|
|
let lv = min(CoolestLevels, vals.len)
|
|
for t in tiles:
|
|
let v = m.lavaAt(t.col, t.row)
|
|
for li in 0..<lv:
|
|
if v == vals[li]:
|
|
result.add t
|
|
break
|
|
|
|
proc safeCount(m: TFILModule, tiles: seq[tuple[col, row: int]],
|
|
fx, fy, thr: float): int =
|
|
for t in coolestOf(m, tiles):
|
|
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
|
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
|
if pathMax(m, fx, fy, tx, ty) <= thr: inc result
|
|
|
|
proc safeTileExists(m: TFILModule, p: Probe, thr: float): bool =
|
|
## The picker's own definition, from OUR HELD POSITION: inside the reachable
|
|
## hull -> CoolestLevels=2 coolest distinct lava values -> path heat filter.
|
|
let hull = computeReachableHull(p.x, p.y, p.heading, p.speed,
|
|
ArenaW, ArenaH, HullTicks)
|
|
if hull.len < 3: return false
|
|
var inside: seq[tuple[col, row: int]]
|
|
for row in 0..<m.rows:
|
|
for col in 0..<m.cols:
|
|
let cx = m.marginX + (col.float + 0.5) * GridSize
|
|
let cy = m.marginY + (row.float + 0.5) * GridSize
|
|
if pointInHull(cx, cy, hull): inside.add (col, row)
|
|
safeCount(m, inside, p.x, p.y, thr) > 0
|
|
|
|
proc nearestEnemyDist(ws: WorldState): float =
|
|
result = Inf
|
|
for e in ws.enemies:
|
|
result = min(result, sqrt((e.x - ws.selfX)^2 + (e.y - ws.selfY)^2))
|
|
|
|
proc collect(fx: seq[WorldState], starts: HashSet[int], seed: int): seq[Probe] =
|
|
randomize(seed)
|
|
var m = initTFIL()
|
|
var lastPicks = 0
|
|
var lastBullets = 0
|
|
var sinceFire = 99
|
|
for i in 0..<fx.len:
|
|
if i == 0 or i in starts: m.resetRound()
|
|
discard m.computeMove(fx[i])
|
|
if m.bullets.len > lastBullets: sinceFire = 0 else: inc sinceFire
|
|
lastBullets = m.bullets.len
|
|
if m.picks == lastPicks: continue
|
|
lastPicks = m.picks
|
|
# a pick happened: was the safe set big enough to draw from?
|
|
let safePre = safeCount(m, m.cachedInsideTiles, fx[i].selfX, fx[i].selfY,
|
|
TfilDangerThreshold)
|
|
if safePre >= 2: continue # not a forced pick
|
|
let (cc, cr) = m.tileAt(fx[i].selfX, fx[i].selfY)
|
|
result.add Probe(idx: i, x: fx[i].selfX, y: fx[i].selfY,
|
|
heading: fx[i].selfHeading, speed: fx[i].selfSpeed,
|
|
dist: nearestEnemyDist(fx[i]),
|
|
emptySet: safePre == 0,
|
|
ownHot: m.lavaAt(cc, cr) > TfilDangerThreshold,
|
|
sinceFire: sinceFire, wait: -1)
|
|
|
|
proc fillWaits(fx: seq[WorldState], starts: HashSet[int], seed: int,
|
|
probes: var seq[Probe]) =
|
|
## Second linear pass, SAME trajectory: at tick j every still-open probe is
|
|
## advanced to k = j - idx and asked whether a safe tile exists at k.
|
|
randomize(seed)
|
|
var m = initTFIL()
|
|
for j in 0..<fx.len:
|
|
if j == 0 or j in starts: m.resetRound()
|
|
discard m.computeMove(fx[j])
|
|
for p in probes.mitems:
|
|
if p.resolved: continue
|
|
let k = j - p.idx
|
|
if k < 1: continue
|
|
if k > MaxWait:
|
|
p.resolved = true; p.wait = -1; continue
|
|
if safeTileExists(m, p, TfilDangerThreshold):
|
|
p.resolved = true; p.wait = k
|
|
|
|
proc mean(xs: seq[int]): float =
|
|
if xs.len == 0: return 0.0
|
|
var t = 0
|
|
for x in xs: t += x
|
|
t.float / xs.len.float
|
|
|
|
proc median(xs: seq[int]): int =
|
|
if xs.len == 0: return -1
|
|
let s = xs.sorted()
|
|
s[s.len div 2]
|
|
|
|
proc shareN(n, d: int): string =
|
|
if d == 0: return "n/a"
|
|
&"{100.0 * n.float / d.float:.1f}%"
|
|
|
|
proc share(n, d: int): string = shareN(n, d)
|
|
|
|
proc report(label: string, ps: seq[Probe]) =
|
|
echo &"\n\u2550\u2550\u2550 {label}"
|
|
if ps.len == 0: echo " no forced picks"; return
|
|
let waits = ps.filterIt(it.wait >= 0).mapIt(it.wait)
|
|
let cens = ps.filterIt(it.wait < 0)
|
|
echo &" forced picks: {ps.len} of which EMPTY safe set (0 tiles): " &
|
|
&"{shareN(ps.filterIt(it.emptySet).len, ps.len)}"
|
|
echo &" WAIT WINDOW (ticks until ANY safe tile exists, position held):"
|
|
echo &" median {median(waits)} mean {mean(waits):.1f}" &
|
|
&" resolved {waits.len}/{ps.len} censored(>{MaxWait}) {cens.len}"
|
|
for k in [1, 3, 5, 10]:
|
|
echo &" within {k:>2} tick(s): {share(waits.filterIt(it <= k).len, ps.len)}"
|
|
echo &" FRESH FIRE (ticks since the enemy's last confirmed shot):"
|
|
echo &" same tick {shareN(ps.filterIt(it.sinceFire == 0).len, ps.len)}" &
|
|
&" prev 1 tick {shareN(ps.filterIt(it.sinceFire <= 1).len, ps.len)}" &
|
|
&" prev 3 ticks {shareN(ps.filterIt(it.sinceFire <= 3).len, ps.len)}" &
|
|
&" >3 ticks {shareN(ps.filterIt(it.sinceFire > 3).len, ps.len)}"
|
|
echo &" OUR OWN TILE already over the threshold: " &
|
|
&"{shareN(ps.filterIt(it.ownHot).len, ps.len)}"
|
|
for (name, lo, hi) in [("close <150px", 0.0, 150.0), ("mid 150-300px", 150.0, 300.0),
|
|
("far >300px", 300.0, 1.0e9)]:
|
|
let g = ps.filterIt(it.dist >= lo and it.dist < hi)
|
|
if g.len == 0:
|
|
echo &" {name}: n/a"; continue
|
|
let w = g.filterIt(it.wait >= 0).mapIt(it.wait)
|
|
echo &" {name}: n={g.len} median wait {median(w)} " &
|
|
&"<=3t {share(w.filterIt(it <= 3).len, g.len)} <=10t {share(w.filterIt(it <= 10).len, g.len)}" &
|
|
&" fired<=1t ago {shareN(g.filterIt(it.sinceFire <= 1).len, g.len)}" &
|
|
&" own tile hot {shareN(g.filterIt(it.ownHot).len, g.len)}"
|
|
|
|
# ── driver ───────────────────────────────────────────────────────────────────
|
|
let args = commandLineParams()
|
|
let fixtures = if args.len > 0: args
|
|
else: @["/tmp/firelag_live2/tfil_on/run1.jsonl",
|
|
"/tmp/firelag_live2/tfil_off/run1.jsonl",
|
|
"/tmp/firelag_live2/strafe_on/run1.jsonl",
|
|
"/tmp/firelag_live2/strafe_off/run1.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
|
|
currentSourcePath().parentDir.parentDir.parentDir /
|
|
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
|
|
var total: seq[Probe]
|
|
for f in fixtures:
|
|
if not fileExists(f):
|
|
echo "skip (missing): ", f; continue
|
|
let fx = loadFixture(f).states
|
|
let starts = loadRoundStarts(f)
|
|
for seed in [7, 8, 9]:
|
|
var ps = collect(fx, starts, seed)
|
|
fillWaits(fx, starts, seed, ps)
|
|
total.add ps
|
|
if seed == 7: report(extractFilename(f), ps)
|
|
report("ALL FIXTURES x 3 SEEDS", total)
|