## 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..= 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.. 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.. 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.. 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.. 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)