Files
SirRoboGarage/common_libs/tests/test_tfil_commit_env.nim
T
SirStone d21f7ce5f5 j147: the 1-tick fire-detection lag is OURS — measure it, then back-date it (TR_FIRE_LAG)
MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, 1777
matched ghost spawns, both movers): the server dispatches a turn's fire AFTER
our go() for that same turn, so a turn-T shot's energy drop first reaches our
scan at turn T+1 — and a bullet takes its FIRST step during the turn it is
fired, so the true bullet is already one whole bullet step (11-20 px) downrange.
Both movers place the ghost at the SCANNED enemy position (where the bullet was
born), so the whole ghost trajectory is the true one shifted one turn later and
the arrival deadline is a full tick late.

MEASURED: detection lag +1 tick on 100% of 1777 matched spawns; ghost-vs-
observer displacement 19.06 px mean / 22.00 p90 (tfil) and 16.08 / 21.81
(strafe); arrival-deadline error 0.99 / 0.77 ticks. NOT a rendering artefact:
the draw/advance order is correct (advanceBullets -> detectFires -> build).

THE FIX: TR_FIRE_LAG (int, default 0 = today byte-for-byte) in the shared
fire_tracker, applied by both movers at spawn: x = origin + dir*speed*lag.
The deadline needs no separate change — both movers derive it from the ghost's
own position, so a correct position gives a correct deadline.
WITH IT: displacement 19.06 -> 5.37 px mean (the residue is the enemy's own
<=8 px scan staleness) and the deadline error 0.99 -> 0.06 ticks.

Guards: test_tfil_commit_env 77 -> 87 checks (default golden parity, exact
n-step back-date, deadline shortens by exactly lag, junk/negative degrade to 0,
reaped exactly one tick earlier); test_env_report + test_env_dotenv green.
TR_FIRE_LAG registered in env_report + knownEnvNames + .env.example +
docs/env_reference.md. Live A/B pre-registered in docs/movement_campaign.md
(Batch 8) with its MDE stated up front; arms tools/ab/arms_fire_lag.txt.
TR_FIRE_DIAG gains a per-round ROUND line (the tick->getTurn anchor) and a
per-spawn SPAWN line (the ghost's drawn position).
2026-09-26 23:08:57 +02:00

973 lines
47 KiB
Nim

## Guard test for the TFIL commitment knobs:
## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped
## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped
## TR_TFIL_NO_REV (0/1) default 0 = shipped
## TR_TFIL_COMMIT_LOG (path) default off
## TR_TFIL_COMMIT_ARRIVAL (0/1) default 0 = shipped (j144)
## TR_TFIL_COMMIT_MARGIN (float) default 0.0 = shipped (j144)
## TR_TFIL_NOREV_SPEED (float) default 0.0 = shipped (j144)
## TR_TFIL_TURN_BIAS (float) default 0.0 = shipped (j145)
## TR_TFIL_TURN_REF_DEG (float) default 45.0 (j145)
##
## NO battle, NO Java, NO server. Run with:
## nim c -r --path:common_libs common_libs/tests/test_tfil_commit_env.nim
##
## The important check is #1: with EVERY knob unset the mover must reproduce,
## byte-for-byte, the move commands recorded from the PRE-CHANGE build. The
## golden `tests/fixtures/tfil_commit_default.golden` was generated from the
## shipped mover at commit f842ac0 by compiling THIS file with
## `-d:tfilGenGolden` against `git archive f842ac0` (see the golden's header).
## Regenerating it from the new code would defeat the check — only do that after
## a DELIBERATE change to the shipped defaults.
##
## *** DELIBERATE DEFAULT CHANGE (this commit): the virtual centre pillar was
## removed from the shipped default (PillarHotness/PillarRadiance 30/10 -> 0/0;
## TR_TFIL_PILLAR_ON=1 restores it). That legitimately changes the DEFAULT move
## path, so the golden was regenerated from the NEW default. This is NOT a
## regression being blessed: the fixture replay below is byte-for-byte stable
## for a fixed build, it simply runs against a different (pillar-free) field.
## If a future change makes this check fail, it is a REAL default-path diff
##
## The knob-dependent half of this file is wrapped in
## `when declared(loadTfilCommitEnv)` so the SAME file still compiles against
## the pre-change module and can regenerate the golden there. That is the whole
## point: the golden must come from the old code, not from the new one.
##
## The remaining checks prove the non-default arms actually do something (an
## A/B whose treatment did not apply is worthless) and that the soft
## no-reversal preference can never empty the candidate pool.
import std/[os, json, random, math, sequtils]
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/gun_interface
# Private-field access: include (do NOT import) the shipped mover.
include movements/the_floor_is_lava
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
const goldenPath = currentSourcePath().parentDir / "fixtures" /
"tfil_commit_default.golden"
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
# ── fixture replay (works on BOTH the old and the new module) ────────────────
type
TickRec = object
call: int
spd, trn: float
tx, ty: float
ct: int
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())])
proc loadRoundStarts(): seq[int] =
let side = repoRoot / "tools" / "fixtures" / "drussgt_meta" /
(fixtureRel & ".rounds.json")
if not fileExists(side): return
for r in parseFile(side)["rounds"]:
result.add r["startTick"].getInt()
proc replay(states: seq[WorldState], starts: seq[int]): seq[TickRec] =
## Drive the REAL computeMove over the recorded WorldState stream with a fixed
## RNG seed, exactly as `measure_tfil_heat_field.nim` does. Touches NO env
## knob, so it is identical on the old and the new module.
randomize(Seed)
var m = initTFIL()
for i in 0..<states.len:
if i == 0 or i in starts: m.resetRound()
let cmd = m.computeMove(states[i])
result.add TickRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate,
tx: m.commitTarget.x, ty: m.commitTarget.y,
ct: m.commitTicks)
proc recLine(r: TickRec): string =
$r.call & " " & $r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
# ── golden generation (OLD module only) ──────────────────────────────────────
when defined(tfilGenGolden):
block:
let recs = replay(loadStates(), loadRoundStarts())
var g = "# TFIL default-path parity golden.\n"
g.add "# Originally generated from commit f842ac0 (shipped mover, BEFORE the commit knobs).\n"
g.add "# REGENERATED from the NEW default after the DELIBERATE removal of the virtual\n"
g.add "# centre pillar (PillarHotness/PillarRadiance 30/10 -> 0/0). Set\n"
g.add "# TR_TFIL_PILLAR_ON=1 to restore the old field. Not a regression: the shipped\n"
g.add "# default change is intentional, so the default path legitimately differs.\n"
g.add "# Format: call speed turnRate targetX targetY commitTicks\n"
for r in recs: g.add recLine(r) & "\n"
createDir(goldenPath.parentDir)
writeFile(goldenPath, g)
echo "wrote ", goldenPath, " (", recs.len, " ticks)"
quit(0)
# ── 1. default-path parity against the pre-change build ──────────────────────
proc testDefaultParity() =
# NOTE: the golden below was regenerated when the virtual centre pillar was
# removed from the shipped DEFAULT (DELIBERATE change, not an accidental
# regression — see the file header). With the pillar restored via
# TR_TFIL_PILLAR_ON=1 the old golden would no longer match, by design.
doAssert fileExists(goldenPath), "missing golden: " & goldenPath
# j134: the golden was generated from the SHIPPED detector. Force the shipped
# fire-detection path (`TR_FIRE_FIX=0`) for this check so it still proves the
# PRE-CHANGE path is byte-identical. The correction ON legitimately changes
# the path (it no longer drops an over-cap energy delta), which is the whole
# point of j134 — see `test_strafe_fire_fix.nim` for the per-behaviour checks.
when declared(TfilFireFix):
let savedFireFix = TfilFireFix
TfilFireFix = false
let recs = replay(loadStates(), loadRoundStarts())
when declared(TfilFireFix):
TfilFireFix = savedFireFix
var golden: seq[string]
for rawLine in lines(goldenPath):
if rawLine.startsWith("#"): continue
let line = rawLine.strip()
if line.len > 0: golden.add line
check "golden covers the whole fixture (>= 15000 ticks)", golden.len >= 15000
check "default replay covers the same number of ticks", recs.len == golden.len
var firstDiff = -1
for i in 0..<min(recs.len, golden.len):
if recLine(recs[i]) != golden[i]:
firstDiff = i
break
check "DEFAULT PATH IS BYTE-FOR-BYTE THE PRE-CHANGE MOVER (call/speed/turnRate/target/commitTicks)",
firstDiff < 0
if firstDiff >= 0:
echo " first divergence at tick index ", firstDiff, ": got [",
recLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
# ── everything below needs the NEW knob API ──────────────────────────────────
when declared(loadTfilCommitEnv):
type ArmStats = object
ticks: int
picks: int
byReason: array[TfilReplanReason, int]
intervals: seq[int]
reversals: int
revSlow: int ## reversal picks made at |speed| < MaxSpeed/2
revMidSlow:int ## ... on a target we had NOT yet reached (j144: the
## owner's failure mode: the target flips opposite
## while the bot is still accelerating)
meanSpeed: float
proc setArm(tileReplan: string, commitTicks: string, noRev: string) =
putEnv("TR_TFIL_TILE_REPLAN", tileReplan)
putEnv("TR_TFIL_COMMIT_TICKS", commitTicks)
putEnv("TR_TFIL_NO_REV", noRev)
loadTfilCommitEnv()
proc clearJ144() =
## The shipped default for every j144 knob: present but OFF. Setting the
## var directly (not through the env) is the honest way to prove the
## defaults, because putEnv("") is indistinguishable from unset.
when declared(TfilCommitArrival):
TfilCommitArrival = false
TfilCommitMargin = 0.0
TfilNoRevSpeed = 0.0
proc setJ144(arrival: bool, margin: float, norevSpeed: float) =
when declared(TfilCommitArrival):
TfilCommitArrival = arrival
TfilCommitMargin = margin
TfilNoRevSpeed = norevSpeed
proc parseLog(path: string): seq[JsonNode] =
if not fileExists(path): return
for rawLine in lines(path):
let line = rawLine.strip()
if line.len > 0: result.add parseJson(line)
proc stats(log: seq[JsonNode]): ArmStats =
var spSum = 0.0
result.ticks = log.len
for o in log:
let sp = o["sp"].getFloat()
spSum += sp
if o["pick"].getInt() == 1:
inc result.picks
result.intervals.add o["interval"].getInt()
let rev = o["rev"].getInt() == 1
if rev: inc result.reversals
# `mid` is j144's field: the pick replaced a target we had not reached.
let mid = o.hasKey("mid") and o["mid"].getInt() == 1
if rev and mid and abs(sp) < MaxSpeed / 2.0: inc result.revMidSlow
if rev and abs(sp) < MaxSpeed / 2.0: inc result.revSlow
let r = o["reason"].getStr()
for rr in TfilReplanReason:
if reasonName(rr) == r: inc result.byReason[rr]
if log.len > 0: result.meanSpeed = spSum / log.len.float
proc meanInterval(s: ArmStats): float =
if s.intervals.len == 0: return 0.0
var t = 0
for v in s.intervals: t += v
t.float / s.intervals.len.float
proc reversalRate(s: ArmStats): float =
if s.picks == 0: return 0.0
100.0 * s.reversals.float / s.picks.float
proc replayLogged(tag: string): ArmStats =
let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl")
removeFile(logPath)
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", logPath)
loadTfilCommitEnv()
discard replay(loadStates(), loadRoundStarts())
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", "")
loadTfilCommitEnv()
result = stats(parseLog(logPath))
proc replayJ144(tag: string, arrival: bool, margin, norevSpeed: float): ArmStats =
## The same replay, but the j144 knobs are forced through the module vars
## AFTER the env reload (these arms are not env-driven here, so nothing can
## be confused with a `.env` the owner might have lying around).
let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl")
removeFile(logPath)
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", logPath)
loadTfilCommitEnv()
setJ144(arrival, margin, norevSpeed)
discard replay(loadStates(), loadRoundStarts())
closeTfilCommitLog()
putEnv("TR_TFIL_COMMIT_LOG", "")
loadTfilCommitEnv()
clearJ144()
result = stats(parseLog(logPath))
# ── 2. knob parsing ────────────────────────────────────────────────────────
proc testKnobParsing() =
setArm("off", "15", "0")
check "TR_TFIL_TILE_REPLAN=off -> ttrOff", TfilTileReplanMode == ttrOff
setArm("enemy", "15", "0")
check "TR_TFIL_TILE_REPLAN=enemy -> ttrEnemy", TfilTileReplanMode == ttrEnemy
setArm("self", "15", "0")
check "TR_TFIL_TILE_REPLAN=self -> ttrSelf", TfilTileReplanMode == ttrSelf
setArm("bogus", "15", "0")
check "unknown TR_TFIL_TILE_REPLAN falls back to the shipped ttrSelf",
TfilTileReplanMode == ttrSelf
setArm("self", "30", "0")
check "TR_TFIL_COMMIT_TICKS=30 is honoured", TfilCommitTicks == 30
setArm("self", "0", "0")
check "TR_TFIL_COMMIT_TICKS=0 is clamped to >= 1", TfilCommitTicks == 1
setArm("self", "15", "1")
check "TR_TFIL_NO_REV=1 -> TfilNoRev", TfilNoRev
setArm("self", "15", "0")
check "TR_TFIL_NO_REV unset -> false (shipped)", not TfilNoRev
setArm("self", "15", "0")
# ── 3. the treatments actually bite ────────────────────────────────────────
proc testArms() =
# arm A: control (shipped defaults)
setArm("self", "15", "0")
let a = replayLogged("a")
check "arm A (control): tile-change replans dominate the picks",
a.byReason[rrTileSelf].float / max(1, a.picks).float > 0.5
check "arm A (control): mean decision interval is ~5 ticks (commitment cancelled by motion)",
meanInterval(a) < 8.0
check "arm A (control): reversal-pick rate is high (> 20%)",
reversalRate(a) > 20.0
# arm B: honour the commitment
setArm("off", "15", "0")
let b = replayLogged("b")
check "arm B (TR_TFIL_TILE_REPLAN=off): ZERO tile-change replans",
b.byReason[rrTileSelf] == 0 and b.byReason[rrTileEnemy] == 0
check "arm B: commitment is only ended by expiry or the danger valve (plus the first pick of each round)",
b.byReason[rrExpiry] + b.byReason[rrDanger] + b.byReason[rrInit] == b.picks
check "arm B: the danger valve is a real but minor part of the replans (<10%)",
b.byReason[rrDanger].float / max(1, b.picks).float < 0.10
check "arm B: decision interval rises to the commit length (~15)",
meanInterval(b) > 12.0
check "arm B: FEWER decisions than arm A", b.picks < a.picks
# arm C: B + soft no-reversal
setArm("off", "15", "1")
let c = replayLogged("c")
check "arm C: reversal-pick rate drops vs arm B",
reversalRate(c) < reversalRate(b)
check "arm C: the candidate pool is never emptied (pick count within 2% of arm B)",
abs(c.picks - b.picks).float <= 0.02 * b.picks.float
check "arm C: no tile-change replans (same commitment as B)",
c.byReason[rrTileSelf] == 0
# The preference is a WEIGHT, never a filter: no candidate can get weight 0.
let wAllBack = noRevWeights(@[91.0, 120.0, 180.0, -179.0, -91.0], 0.0)
check "arm C: no-reversal weights are never 0 (pool cannot empty)",
wAllBack.allIt(it >= 1)
check "arm C: an all-backward pool falls back to uniform (every weight 1)",
wAllBack.allIt(it == 1)
let wMixed = noRevWeights(@[0.0, 45.0, 90.0, 90.1, 180.0], 0.0)
check "arm C: forward tiles (<=90 deg) are down-weighted by 3:1",
wMixed[0] == NoRevForwardWeight and wMixed[1] == NoRevForwardWeight and
wMixed[2] == NoRevForwardWeight and wMixed[3] == 1 and wMixed[4] == 1
# arm D: keyed to the enemy's tile
setArm("enemy", "15", "0")
let d = replayLogged("d")
check "arm D (TR_TFIL_TILE_REPLAN=enemy): some replans are keyed to the TARGET's tile",
d.byReason[rrTileEnemy] > 0
check "arm D: no self-tile cancels", d.byReason[rrTileSelf] == 0
check "arm D: the replan trigger differs from arm A (different pick count)",
d.picks != a.picks and d.byReason[rrTileEnemy] > d.byReason[rrTileSelf]
# arm E: commit duration
setArm("off", "30", "0")
let e = replayLogged("e")
check "arm E (TR_TFIL_COMMIT_TICKS=30): interval rises to ~30",
meanInterval(e) > 27.0
check "arm E: fewer decisions than arm B (15)", e.picks < b.picks
setArm("self", "15", "0")
echo "\n arm diagnostics (offline fixture replay):"
for (nm, s) in [("A control", a), ("B commit", b), ("C commit+noRev", c),
("D enemyTile", d), ("E commit30", e)]:
echo " ", nm.alignLeft(15), " ticks=", s.ticks, " picks=", s.picks,
" interval=", meanInterval(s).formatFloat(ffDecimal, 2),
" tileSelf=", s.byReason[rrTileSelf],
" tileEnemy=", s.byReason[rrTileEnemy],
" danger=", s.byReason[rrDanger],
" expiry=", s.byReason[rrExpiry],
" rev=", reversalRate(s).formatFloat(ffDecimal, 1), "%"
# ── 4. j144: arrival-based commitment + hysteresis + no-reversal (default OFF) ──
proc testJ144() =
# 4a. the shipped default is OFF for all three knobs
clearJ144()
check "j144: every new knob defaults to today's behaviour (off / 0 / 0)",
not TfilCommitArrival and TfilCommitMargin == 0.0 and TfilNoRevSpeed == 0.0
# 4b. the CONTROL carries the owner's reported pathology — without this the
# checks below would be vacuously true.
setArm("self", "15", "0")
let ctl = replayJ144("j144_ctl", false, 0.0, 0.0)
check "j144 CONTROL: opposite-direction switches DO happen while the bot is " &
"still accelerating toward an unreached target (> 20) — the owner's bug",
ctl.revMidSlow > 20
check "j144 CONTROL: the commitment is abandoned long before it could arrive " &
"(mean hold < 6 ticks)", meanInterval(ctl) < 6.0
# 4c. arrival: the tile-boundary crossing no longer ends the commitment
let ar = replayJ144("j144_arrive", true, 0.0, 0.0)
check "j144 ARRIVAL: zero tile-change replans (the boundary no longer cancels)",
ar.byReason[rrTileSelf] == 0
check "j144 ARRIVAL: commitments DO end by reaching the tile",
ar.byReason[rrArrival] > 0
check "j144 ARRIVAL: the mean hold rises well past the ~19 ticks the distance needs",
meanInterval(ar) > 15.0 and meanInterval(ar) > meanInterval(ctl) * 2.0
check "j144 ARRIVAL: fewer decisions than the control", ar.picks < ctl.picks
check "j144 ARRIVAL: the danger valve is still live (a genuine threat can break it)",
ar.byReason[rrDanger] > 0
# 4d. hysteresis: "the path is still good" must not be able to switch us
let hy = replayJ144("j144_hyst", true, 10.0, 0.0)
check "j144 HYSTERESIS: margin-triggered switches exist and are labelled",
hy.byReason[rrHyst] > 0
check "j144 HYSTERESIS: the margin releases the commitment EARLIER than waiting " &
"for arrival, never on a boundary crossing",
hy.byReason[rrTileSelf] == 0 and meanInterval(hy) < meanInterval(ar)
check "j144 HYSTERESIS: the mean hold stays far above the control's",
meanInterval(hy) > meanInterval(ctl) * 2.0
check "j144 HYSTERESIS: a margin of 0 leaves the arrival arm's decisions unchanged",
replayJ144("j144_hyst0", true, 0.0, 0.0).picks == ar.picks
# 4e. THE decisive guard. `norevPool` carries the invariant in PURE form:
# whenever a forward (<=90 deg) candidate exists, a slow mid-flight
# switch can never take a rearward one. These three fail on any
# implementation that filters without the all-rearward escape.
check "j144 NO-REV: with a forward candidate available the slow switch is " &
"NEVER rearward", norevPool(@[10.0, 200.0], 4.0) == @[0]
check "j144 NO-REV: the all-rearward case is never empty (no starvation) and " &
"takes the LEAST-bad turn", norevPool(@[170.0, 100.0, 140.0], 4.0) == @[1]
check "j144 NO-REV: the knob off (threshold 0) keeps every candidate",
norevPool(@[10.0, 200.0], 0.0) == @[0, 1]
check "j144 NO-REV: exactly 90 deg either way still counts as forward",
norevPool(@[90.0, -90.0, 91.0], 4.0) == @[0, 1]
# 4f. and the same guarantee measured on the recorded fixture
let full = replayJ144("j144_full", true, 10.0, 4.0)
let nrOnly = replayJ144("j144_nr_only", false, 0.0, 4.0)
check "j144 FULL FIX: opposite-direction slow mid-flight switches fall by " &
">=75% vs the control (" & $ctl.revMidSlow & " -> " & $full.revMidSlow & ")",
full.revMidSlow * 4 <= ctl.revMidSlow
check "j144 FULL FIX: no-rev cuts them further on top of arrive+hyst (" &
$hy.revMidSlow & " -> " & $full.revMidSlow & ")",
full.revMidSlow < hy.revMidSlow
check "j144 NO-REV ALONE: the same holds with only the knob set (no arrival, " &
"no margin): " & $nrOnly.revMidSlow & " vs control " & $ctl.revMidSlow &
" (>=40% cut)", nrOnly.revMidSlow * 10 <= ctl.revMidSlow * 6
check "j144 FULL FIX: total reversal picks at low speed are down vs control",
full.revSlow < ctl.revSlow
check "j144 NO-REV: the pool is never emptied — decisions stay within 5% of the " &
"same-margin arm without it", abs(full.picks.float - hy.picks.float) <= 0.05 * hy.picks.float
echo "\n j144 diagnostics (offline fixture replay):"
for (nm, s) in [("control", ctl), ("arrive", ar), ("arrive+hyst", hy),
("arrive+hyst+norev", full), ("norev alone", nrOnly)]:
echo " ", nm.alignLeft(18), " picks=", s.picks,
" interval=", meanInterval(s).formatFloat(ffDecimal, 2),
" revSlow=", s.revSlow,
" revMidSlow=", s.revMidSlow,
" arrival=", s.byReason[rrArrival],
" hyst=", s.byReason[rrHyst],
" danger=", s.byReason[rrDanger]
# ── 5. j145: the turn-cost TIEBREAK among SAFE tiles (default OFF) ─────────
type PickRec = object
turn: float ## |heading change| from the travel direction to the pick
minTurn: float ## the smallest |turn| AVAILABLE in that candidate set —
## turn - minTurn is the regret of the draw, which is
## the confound-free form of the mechanism metric
pathHeat: float ## max lava on the straight path the bot was told to walk
promoted: bool ## the pick had to break the hard heat filter
reached: bool ## the commitment ended with the bot on the tile
const DangerThreshold = 10.0 ## PathDangerThreshold inside the mover
proc probePathHeat(m: TFILModule, fx, fy, tx, ty: float): float =
## Mirrors the mover's own sampler (PathSampleStep = 18, ~half a tile). The
## field is rebuilt from scratch every computeMove, so the `m.lava` visible
## just after the call is exactly the field the pick was made against.
let ddx = tx - fx
let ddy = ty - fy
let lineDist = sqrt(ddx*ddx + ddy*ddy)
if lineDist <= 0.1: return 0.0
let steps = max(1, int(lineDist / 18.0))
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 replayJ145(tag: string, bias, refDeg: float,
arrive: bool, norevSpeed: float): seq[PickRec] =
## Drive the REAL computeMove with the j145 knobs set through the env (and
## the j144 knobs forced through the vars, so no `.env` can be in the way),
## and record what each pick actually cost.
putEnv("TR_TFIL_TURN_BIAS", $bias)
putEnv("TR_TFIL_TURN_REF_DEG", $refDeg)
putEnv("TR_TFIL_COMMIT_LOG", "")
loadTfilCommitEnv()
TfilCommitArrival = arrive
TfilCommitMargin = 0.0
TfilNoRevSpeed = norevSpeed
randomize(Seed)
var m = initTFIL()
let states = loadStates()
let starts = loadRoundStarts()
var prev = (x: 0.0, y: 0.0)
var hadPicks = false
for i in 0..<states.len:
let ws = states[i]
if i == 0 or i in starts:
m.resetRound()
hadPicks = false
let before = m.picks
discard m.computeMove(ws)
if m.picks != before:
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
180.0 / PI - travelDeg
while rd > 180.0: rd -= 360.0
while rd < -180.0: rd += 360.0
result.add PickRec(turn: abs(rd), minTurn: m.lastPickMinTurn,
pathHeat: probePathHeat(m, ws.selfX, ws.selfY,
m.commitTarget.x, m.commitTarget.y),
promoted: m.lastPickPromoted,
reached: hadPicks and
sqrt((ws.selfX-prev.x)^2 + (ws.selfY-prev.y)^2) < 18.0)
prev = m.commitTarget
hadPicks = true
putEnv("TR_TFIL_TURN_BIAS", "")
putEnv("TR_TFIL_TURN_REF_DEG", "")
loadTfilCommitEnv()
TfilCommitArrival = false
TfilNoRevSpeed = 0.0
discard tag
type TurnStats = object
picks: int
turnSum: float
minTurnSum: float
tookMin: int ## the draw landed on the smallest-turn candidate
bigTurn: int ## |turn| > 90 deg
flip: int ## |turn| > 135 deg — the opposite side
heatSum: float ## mean path heat of the tile we actually walked to
hotPicks: int ## ... over the hard threshold
badHot: int ## ... over the threshold WITHOUT the filter being
## broken — MUST be 0 at any turn bias
broken: int ## picks that had to promote a hot tile (fewer than
## two safe tiles existed) — the shipped fallback
reached: int
proc turnStats(p: seq[PickRec]): TurnStats =
result.picks = p.len
for r in p:
result.turnSum += r.turn
result.minTurnSum += r.minTurn
if r.turn <= r.minTurn + 0.5: inc result.tookMin
if r.turn > 90.0: inc result.bigTurn
if r.turn > 135.0: inc result.flip
result.heatSum += r.pathHeat
if r.pathHeat > DangerThreshold:
inc result.hotPicks
if not r.promoted: inc result.badHot
if r.promoted: inc result.broken
if r.reached: inc result.reached
proc meanTurn(s: TurnStats): float =
if s.picks == 0: return 0.0
s.turnSum / s.picks.float
proc meanRegret(s: TurnStats): float =
## How many degrees WORSE than the best available tile the draw actually was.
## Immune to the composition confound that the raw mean |turn| has (a bias
## arm makes different picks, so the two arms' candidate sets differ).
if s.picks == 0: return 0.0
(s.turnSum - s.minTurnSum) / s.picks.float
proc meanPathHeat(s: TurnStats): float =
if s.picks == 0: return 0.0
s.heatSum / s.picks.float
proc pct(n, d: int): string =
if d == 0: return "-"
(100.0 * n.float / d.float).formatFloat(ffDecimal, 1) & "%"
proc testJ145() =
# 5a. the shipped default is OFF — the parity check above is the proof
check "j145: the turn bias defaults to today's uniform draw (bias 0)",
TfilTurnBias == 0.0 and TfilTurnRefDeg == 45.0
# 5b. the weighting, in pure form
check "j145: bias 0 gives every safe tile weight 1 (byte-identical to the " &
"shipped uniform draw)", turnWeights(@[0.0, 91.0, 180.0], 0.0, 45.0) ==
@[1, 1, 1]
check "j145: the penalty is CONTINUOUS past the reference angle, where the " &
"binary TR_TFIL_NO_REV cannot see (bias 9, ref 45: 45/90/135/180 deg " &
"-> 10/8/6/3)", turnWeights(@[45.0, 90.0, 135.0, 180.0], 9.0, 45.0) ==
@[10, 8, 6, 3]
check "j145: a turn inside the reference angle is never penalised",
turnWeights(@[0.0, 20.0, 45.0], 5.0, 45.0) == @[6, 6, 6]
check "j145: the weight falls monotonically with the turn (ref 0, bias 9: " &
"0/30/60/90/120/180 deg -> 10/9/8/8/7/1)",
turnWeights(@[0.0, 30.0, 60.0, 90.0, 120.0, 180.0], 9.0, 0.0) ==
@[10, 9, 7, 6, 4, 1]
check "j145: the weight is floored at 1, so the pool can never be starved",
turnWeights(@[0.0, 180.0, 179.0], 9.0, 0.0)[1] >= 1 and
turnWeights(@[0.0, 180.0, 179.0], 9.0, 0.0) == @[10, 1, 1]
check "j145: `bias` IS the odds ratio — with ref 0 a straight-ahead safe tile " &
"is drawn 1+bias times as often as a 180 deg one (9 -> 10:1)",
turnWeights(@[0.0, 180.0], 9.0, 0.0) == @[10, 1]
# 5c. THE GATE: an absurd turn cost must not rescue a hot tile. The heat
# filter is UPSTREAM of the weighting, so an over-threshold pick can
# only ever be one the mover had to promote because nothing was safe.
let wild = turnStats(replayJ145("wild", 99.0, 45.0, true, 4.0))
check "j145: with an absurd turn bias (" & $wild.picks & " picks) NO tile " &
"over the heat threshold is ever chosen unless the filter had to be " &
"broken (" & $wild.badHot & " violations)",
wild.badHot == 0
check "j145: the over-threshold picks that do happen are only the promoted " &
"ones (" & $wild.hotPicks & "/" & $wild.picks & ", the shipped " &
"fewer-than-2-safe-tiles fallback)", wild.badHot == 0
# 5d. the mechanism: the turn really gets smaller, without paying for it in
# heat, and without emptying the pool
let off = turnStats(replayJ145("off", 0.0, 45.0, true, 4.0))
let mild = turnStats(replayJ145("mild", 9.0, 0.0, true, 4.0))
let firm = turnStats(replayJ145("firm", 39.0, 0.0, true, 4.0))
check "j145: with the bias on, the mean |turn| to the chosen tile falls " &
"(" & meanTurn(off).formatFloat(ffDecimal, 1) & " -> " &
meanTurn(mild).formatFloat(ffDecimal, 1) & " -> " &
meanTurn(firm).formatFloat(ffDecimal, 1) & " deg)",
meanTurn(mild) < meanTurn(off) * 0.95 and
meanTurn(firm) < meanTurn(off) * 0.95
check "j145: the REGRET of the draw (how many degrees worse than the best " &
"AVAILABLE candidate) falls " &
"(" & meanRegret(off).formatFloat(ffDecimal, 1) & " -> " &
meanRegret(mild).formatFloat(ffDecimal, 1) & " -> " &
meanRegret(firm).formatFloat(ffDecimal, 1) & " deg) — the " &
"confound-free form of the mechanism",
meanRegret(mild) < meanRegret(off) * 0.9 and
meanRegret(firm) < meanRegret(off) * 0.9
check "j145: the share of picks needing >90 deg of turn falls " &
"(" & pct(off.bigTurn, off.picks) & " -> " & pct(mild.bigTurn, mild.picks) &
" -> " & pct(firm.bigTurn, firm.picks) & ")",
mild.bigTurn < off.bigTurn
check "j145: a mirror-image tile no longer beats a straight-ahead one as " &
"readily — opposite-side picks fall " & pct(off.flip, off.picks) & " -> " &
pct(mild.flip, mild.picks) & " -> " & pct(firm.flip, firm.picks),
mild.flip.float < off.flip.float * 0.95
check "j145: SAFETY COST — the mean path heat of the chosen tile does not " &
"rise (bias off " & meanPathHeat(off).formatFloat(ffDecimal, 2) &
" vs bias 9 " & meanPathHeat(mild).formatFloat(ffDecimal, 2) &
" vs bias 39 " & meanPathHeat(firm).formatFloat(ffDecimal, 2) & ")",
meanPathHeat(mild) <= meanPathHeat(off) * 1.05 and
meanPathHeat(firm) <= meanPathHeat(off) * 1.05
check "j145: the bias never empties the pool — decisions stay within 5% of " &
"the same arm without it (" & $off.picks & " -> " & $mild.picks & " / " &
$firm.picks & ")",
abs(firm.picks.float - off.picks.float) <= 0.05 * off.picks.float
echo "\n j145 diagnostics (offline fixture replay, arrive+norev base):"
for (nm, s) in [("bias 0 (shipped)", off), ("bias 9 ref0", mild), ("bias 39 ref0", firm)]:
echo " ", nm.alignLeft(18), " picks=", s.picks,
" mean|turn|=", meanTurn(s).formatFloat(ffDecimal, 1),
" regret=", meanRegret(s).formatFloat(ffDecimal, 1),
" tookMin=", pct(s.tookMin, s.picks),
" >90deg=", pct(s.bigTurn, s.picks),
" >135deg=", pct(s.flip, s.picks),
" filter broken=", pct(s.broken, s.picks),
" mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2),
" reached=", pct(s.reached, s.picks)
# ── 6. j146: the bullet's OWN heat is tunable (default = today's const) ─────
const ShapeBase = "TR_MOVEMENT=tfil TR_TFIL_COMMIT_ARRIVAL=1 TR_TFIL_NOREV_SPEED=4"
const ShapeShipped = ShapeBase &
" TR_TFIL_CORRIDOR_HEAT=20 TR_TFIL_WALL_HOTNESS=30 TR_TFIL_WALL_RADIANCE=10" &
" TR_TFIL_BULLET_CORE=10 TR_TFIL_BULLET_AURA=5"
const ShapeMiddle = ShapeBase &
" TR_TFIL_CORRIDOR_HEAT=10 TR_TFIL_WALL_HOTNESS=15 TR_TFIL_WALL_RADIANCE=5" &
" TR_TFIL_BULLET_CORE=20 TR_TFIL_BULLET_AURA=10"
proc setShape(corridor, wallHot, wallRad, bCore, bAura: float) =
putEnv("TR_TFIL_CORRIDOR_HEAT", $corridor)
putEnv("TR_TFIL_WALL_HOTNESS", $wallHot)
putEnv("TR_TFIL_WALL_RADIANCE", $wallRad)
putEnv("TR_TFIL_BULLET_CORE", $bCore)
putEnv("TR_TFIL_BULLET_AURA", $bAura)
loadTfilHeatShapeEnv()
proc clearShape() =
for n in ["TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS",
"TR_TFIL_WALL_RADIANCE", "TR_TFIL_BULLET_CORE", "TR_TFIL_BULLET_AURA"]:
putEnv(n, "")
loadTfilHeatShapeEnv()
proc replayWithShape(envspec: string): seq[TickRec] =
## `replay` with the heat-shape knobs written out in full. Used to prove
## DEFAULT-OFF-EFFECT without the golden: the shipped shape spelled out as
## env must give the SAME move commands as the build with them unset.
for tok in envspec.splitWhitespace():
putEnv(tok.split('=', 1)[0], tok.split('=', 1)[1])
loadTfilHeatShapeEnv()
result = replay(loadStates(), loadRoundStarts())
for tok in envspec.splitWhitespace():
putEnv(tok.split('=', 1)[0], "")
loadTfilHeatShapeEnv()
proc replayShape(tag, envspec: string): seq[PickRec] =
## replayJ145's replay, but the FIELD SHAPE comes from `envspec` instead of
## the turn knobs, and the turn bias is left OFF — so the only thing that
## moves between two calls is the shape.
for tok in envspec.splitWhitespace():
putEnv(tok.split('=', 1)[0], tok.split('=', 1)[1])
loadTfilHeatShapeEnv() # the shape this arm actually wants
putEnv("TR_TFIL_COMMIT_LOG", "")
putEnv("TR_TFIL_TURN_BIAS", "")
putEnv("TR_TFIL_TURN_REF_DEG", "")
loadTfilCommitEnv()
TfilCommitArrival = true
TfilCommitMargin = 0.0
TfilNoRevSpeed = 4.0
randomize(Seed)
var m = initTFIL()
let states = loadStates()
let starts = loadRoundStarts()
var prev = (x: 0.0, y: 0.0)
var hadPicks = false
for i in 0..<states.len:
let ws = states[i]
if i == 0 or i in starts:
m.resetRound()
hadPicks = false
let before = m.picks
discard m.computeMove(ws)
if m.picks != before:
result.add PickRec(turn: m.lastPickMinTurn,
minTurn: m.lastPickMinTurn,
pathHeat: probePathHeat(m, ws.selfX, ws.selfY,
m.commitTarget.x, m.commitTarget.y),
promoted: m.lastPickPromoted,
reached: hadPicks and
sqrt((ws.selfX-prev.x)^2 + (ws.selfY-prev.y)^2) < 18.0)
prev = m.commitTarget
hadPicks = true
for tok in envspec.splitWhitespace():
putEnv(tok.split('=', 1)[0], "")
clearShape()
loadTfilCommitEnv()
TfilCommitArrival = false
TfilNoRevSpeed = 0.0
discard tag
## Heat the field paints on a tile a single tracked bullet sits on, with the
## corridor and the wall field switched OFF so the number is the BULLET's own
## contribution and nothing else. `aheadPx` walks forward along the bullet's
## travel, where the CORRIDOR (drawn only from the bullet position forward)
## actually paints. One enemy-free world, one bullet.
proc bulletTileHeat(bCore, bAura, corridor, aheadPx: float): float =
setShape(corridor, 0.0, 0.0, bCore, bAura)
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
enemySpeed: 0.0, enemyEnergy: 100.0,
selfX: 400.0, selfY: 120.0, selfHeading: 0.0,
selfSpeed: 0.0, selfEnergy: 100.0,
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1,
enemies: @[])
var m = initTFIL()
randomize(Seed)
discard m.computeMove(ws) # initGrid
# heading up the arena, the bot BELOW it, so `advanceBullets` keeps it alive
m.bullets = @[TrackedBullet(originX: 410.0, originY: 320.0, x: 410.0, y: 320.0,
velX: 0.0, velY: -20.0, power: 1.0,
alive: true, age: 0)]
discard m.computeMove(ws)
if m.bullets.len == 0: return 0.0
let (c, r) = m.tileAt(m.bullets[0].x, m.bullets[0].y - aheadPx)
result = m.lavaAt(c, r)
proc testJ146() =
# 6a. DEFAULT-OFF-EFFECT: the defaults are today's `const`s, so the shipped
# shape spelled out in full as env and the knobs left UNSET must be the
# same build, tick for tick. (The golden above proves it against the
# pre-change build; this proves it against today's defaults too.)
clearShape()
check "j146: TR_TFIL_BULLET_CORE/AURA default to today's consts (10.0/5.0)",
BulletCore == 10.0 and BulletAura == 5.0
let explicit = replayWithShape("TR_TFIL_CORRIDOR_HEAT=20 TR_TFIL_WALL_HOTNESS=30" &
" TR_TFIL_WALL_RADIANCE=10 TR_TFIL_BULLET_CORE=10" &
" TR_TFIL_BULLET_AURA=5")
let unset = replay(loadStates(), loadRoundStarts())
var firstDiff = -1
if explicit.len != unset.len:
firstDiff = min(explicit.len, unset.len)
else:
for i in 0..<unset.len:
if recLine(explicit[i]) != recLine(unset[i]):
firstDiff = i
break
check "j146: the SHIPPED SHAPE written out in full as env (20/30/10/10/5) is " &
"BYTE-FOR-BYTE the unset build over " & $unset.len & " ticks — the new " &
"knobs are default-off-effect",
firstDiff < 0
# 6b. knob parsing, including the fallbacks
setShape(20.0, 30.0, 10.0, 20.0, 10.0)
check "j146: TR_TFIL_BULLET_CORE=20 / TR_TFIL_BULLET_AURA=10 are read",
BulletCore == 20.0 and BulletAura == 10.0
putEnv("TR_TFIL_BULLET_CORE", "not-a-number")
putEnv("TR_TFIL_BULLET_AURA", "")
loadTfilHeatShapeEnv()
check "j146: a malformed / empty value falls back to the DEFAULT (10.0/5.0)",
BulletCore == 10.0 and BulletAura == 5.0
putEnv("TR_TFIL_BULLET_CORE", "-5")
loadTfilHeatShapeEnv()
check "j146: a negative value clamps to 0 (heat can never go backwards)",
BulletCore == 0.0
putEnv("TR_TFIL_BULLET_CORE", "")
loadTfilHeatShapeEnv()
# 6c. THE MECHANISM CLAIM: with the shipped core 10 a bullet is never
# dangerous ON ITS OWN (10 == PathDangerThreshold, and `safe` is
# `<=`), while a corridor at 10 is not over threshold either. Raise the
# core to 20 and the bullet's core IS over the threshold by itself.
let coreShipped = bulletTileHeat(10.0, 5.0, 0.0, 0.0)
let coreRaised = bulletTileHeat(20.0, 10.0, 0.0, 0.0)
let corr10Ahead = bulletTileHeat(0.0, 0.0, 10.0, 72.0)
check "j146: with the SHIPPED bullet core (10) a bullet's own core tile is " &
"NOT over PathDangerThreshold (" & $coreShipped & " <= " &
$DangerThreshold & ") — the corridor carries the whole field",
coreShipped <= DangerThreshold
check "j146: TR_TFIL_BULLET_CORE=20 puts the bullet's core ALONE over the " &
"threshold (" & $coreRaised & " > " & $DangerThreshold & "), while a " &
"corridor at 10 paints " & $corr10Ahead & " on its own and never " &
"reaches it", coreRaised > DangerThreshold and
corr10Ahead <= DangerThreshold
check "j146: the knobs really change the field — the same bullet paints " &
$coreShipped & " with the shipped core and " & $coreRaised &
" with the retune", coreRaised > coreShipped
# 6d. and the shape as a whole restores a REAL safe set (the j145 cause)
let sh = turnStats(replayShape("shipped", ShapeShipped))
let mid = turnStats(replayShape("middle", ShapeMiddle))
check "j146: the middle shape really restores a safe set — the picks that " &
"have to break the hard filter fall " & pct(sh.broken, sh.picks) &
" -> " & pct(mid.broken, mid.picks),
mid.broken.float < sh.broken.float * 0.75
check "j146: and it does NOT buy that with more lava — the mean heat of the " &
"path we were told to walk FALLS " &
meanPathHeat(sh).formatFloat(ffDecimal, 2) & " -> " &
meanPathHeat(mid).formatFloat(ffDecimal, 2),
meanPathHeat(mid) < meanPathHeat(sh)
clearShape()
check "j146: clearing the knobs restores the shipped field exactly",
BulletCore == 10.0 and BulletAura == 5.0 and
CorridorHeat == 20.0 and WallHotness == 30.0 and WallRadiance == 10.0
echo "\n j146 diagnostics (offline fixture replay, arrive+norev base):"
for (nm, s) in [("shipped 20/30/10/10/5", sh), ("middle 10/15/5/20/10", mid)]:
echo " ", nm.alignLeft(22), " picks=", s.picks,
" filter broken=", pct(s.broken, s.picks),
" mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2),
" >90deg=", pct(s.bigTurn, s.picks)
## j147: TR_FIRE_LAG back-dates the ghost by the MEASURED detection lag (1
## tick live: 1777/1777 matched spawns, `measure_fire_ghost_lag.py`). Default
## 0 must be byte-for-byte today's spawn, and lag=n must move the ghost exactly
## n bullet steps downrange — which is what shortens the arrival deadline,
## because every mover derives the deadline from the ghost's own position.
proc testJ147() =
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
enemySpeed: 0.0, enemyEnergy: 100.0,
selfX: 400.0, selfY: 320.0, selfHeading: 0.0,
selfSpeed: 0.0, selfEnergy: 100.0,
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1,
enemies: @[])
let ei = EnemyInfo(id: 1, x: 200.0, y: 320.0, heading: 0.0,
speed: 0.0, energy: 100.0)
const Power = 1.0
let speed = 20.0 - 3.0 * Power # 17 px/tick
proc spawnGhost(): TrackedBullet =
var m = initTFIL()
discard m.computeMove(ws) # initGrid
m.spawnTrackedWave(ws, ei, Power)
m.bullets[^1]
# 1. default parity: unset -> 0, and the ghost is EXACTLY the scanned origin
delEnv("TR_FIRE_LAG")
loadFireTrackerEnv()
check "j147: TR_FIRE_LAG unset -> FireLag 0", FireLag == 0
let g0 = spawnGhost()
check "j147: default (lag 0) puts the ghost exactly on the scanned enemy",
g0.x == ei.x and g0.y == ei.y
# 2. lag 1 back-dates by EXACTLY one bullet step, on the ghost's own heading
putEnv("TR_FIRE_LAG", "1")
loadFireTrackerEnv()
let g1 = spawnGhost()
check "j147: TR_FIRE_LAG=1 places the ghost one bullet step downrange",
FireLag == 1 and
abs((g1.x - g0.x) - g1.velX) < 1e-9 and
abs((g1.y - g0.y) - g1.velY) < 1e-9
check "j147: ... and that step is the true bullet speed, not a scaled one",
abs(sqrt(g1.velX * g1.velX + g1.velY * g1.velY) - speed) < 1e-9
# 3. the ARRIVAL DEADLINE shortens by exactly `lag` ticks. The mover's own
# arrival proxy is dist(self, ghost) / speed (tfil `heatDecay(along/speed)`,
# the `dot < 0` reap); the true bullet is one step further along.
let etaGhost = sqrt((ws.selfX - g1.x)^2 + (ws.selfY - g1.y)^2) /
sqrt(g1.velX * g1.velX + g1.velY * g1.velY)
let etaTrue0 = sqrt((ws.selfX - g0.x)^2 + (ws.selfY - g0.y)^2) / speed
let etaTrue1 = sqrt((ws.selfX - (g0.x + g0.velX))^2 +
(ws.selfY - (g0.y + g0.velY))^2) / speed
check "j147: with lag=1 the mover's deadline equals the TRUE remaining " &
"flight (" & etaGhost.formatFloat(ffDecimal, 6) & " vs " &
etaTrue1.formatFloat(ffDecimal, 6) & "), the lag-0 deadline being " &
etaTrue0.formatFloat(ffDecimal, 6) & " — a full tick late",
abs(etaGhost - etaTrue1) < 1e-9 and
abs((etaTrue0 - etaTrue1) - 1.0) < 1e-9
# 4. lag n is n steps, and n=2 shortens the deadline by exactly 2
putEnv("TR_FIRE_LAG", "2")
loadFireTrackerEnv()
let g2 = spawnGhost()
check "j147: TR_FIRE_LAG=2 back-dates by two steps",
abs((g2.x - g0.x) - 2.0 * g0.velX) < 1e-9 and
abs((g2.y - g0.y) - 2.0 * g0.velY) < 1e-9
let etaTrue2 = sqrt((ws.selfX - (g0.x + 2.0 * g0.velX))^2 +
(ws.selfY - (g0.y + 2.0 * g0.velY))^2) / speed
check "j147: ... so the deadline shortens by exactly 2 ticks",
abs((etaTrue0 - etaTrue2) - 2.0) < 1e-9
# 5. a junk value falls back to 0, never to a negative/garbage back-date
putEnv("TR_FIRE_LAG", "junk")
loadFireTrackerEnv()
let gj = spawnGhost()
putEnv("TR_FIRE_LAG", "-4")
loadFireTrackerEnv()
let gn = spawnGhost()
check "j147: a junk / negative TR_FIRE_LAG degrades to the shipped lag 0",
FireLag == 0 and gj.x == ei.x and gn.x == ei.x
# 6. the ARRIVAL DEADLINE end-to-end: the ghost is reaped (`dot < 0`, the
# geometric arrival) exactly `lag` ticks earlier, because it is `lag`
# steps further along. This is the deadline the decision actually uses.
proc ticksToReap(): int =
var m = initTFIL()
randomize(Seed)
discard m.computeMove(ws)
m.spawnTrackedWave(ws, ei, Power)
for t in 1..80:
discard m.computeMove(ws)
if m.bullets.len == 0: return t
99
putEnv("TR_FIRE_LAG", "0")
loadFireTrackerEnv()
let reap0 = ticksToReap()
putEnv("TR_FIRE_LAG", "1")
loadFireTrackerEnv()
let reap1 = ticksToReap()
check "j147: the ghost arrives — and is reaped — exactly 1 tick earlier " &
"with the back-date (" & $reap0 & " -> " & $reap1 & " ticks)",
reap0 > 0 and reap1 > 0 and reap0 - reap1 == 1
# 7. restore the shipped default for every later check in this process
delEnv("TR_FIRE_LAG")
loadFireTrackerEnv()
check "j147: clearing the knob restores the shipped spawn exactly",
FireLag == 0 and spawnGhost().x == ei.x
# ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity()
when declared(loadTfilCommitEnv):
testKnobParsing()
testArms()
testJ144()
testJ145()
testJ146()
testJ147()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll TFIL commit-env checks passed."