j152 (default-off): geometry shapes the tile DRAW (TR_TFIL_GEO_MODE/TAU) + the offline sweep with the diversity cost
The owner: choose the tile pool not only from the heat point but from the geometric position too. Heat stays the hard filter; the draw over the survivors is re-weighted by turn and/or distance. Three weighting forms (soft softmax / top-K third / rejection band), one env name carrying both axes. Default = off = today's uniform draw, byte-for-byte (golden parity). WHAT DIFFERS FROM j9 (TR_TFIL_TURN_BIAS, a live null): that was a tiebreak weight among the non-empty safe set only. This runs on the WHOLE pool the draw already runs on, including the 2 promoted least-hot tiles the ~65% forced picks choose from. OFFLINE (8 fixtures x 3 seeds, no java): headline 'tile actually reached at tta' 4.5% -> 16.9% at TR_TFIL_GEO_MODE=both-soft TR_TFIL_GEO_TAU=45, with no diversity collapse (distinct tiles 220 -> 219, normalised entropy 0.87 -> 0.86, top-tile share 7.0% -> 8.6%). perpE — the perpendicular rate in the FORCED population — does not move in ANY arm: that pool is 2 tiles ranked by heat alone and the only lever is a coin flip. Also registers j150's TR_TFIL_DIAG / TR_TFIL_DANGER_THRESHOLD in env_report + knownEnvNames + .env.example (test_env_report was red) and documents DANGER_THRESHOLD as quantisation-limited: heat comes in 5s, so the effective steps are 10/15/20 and 10-14 admits zero extra tiles. No battle, no server, no A/B run.
This commit is contained in:
@@ -14,6 +14,11 @@
|
||||
## safePre size of the safe set BEFORE the "keep 2" promotion
|
||||
## hotAtTta the destination tile was OVER the threshold `tta` ticks
|
||||
## later, on the recorded (true) future <- the feasibility test
|
||||
## tile the chosen (col,row) <- j152: pick DIVERSITY
|
||||
## j152: every row of the sweep table also reports the DIVERSITY cost (distinct
|
||||
## tiles, entropy, top-tile share). j51 measured the randomness in this draw as
|
||||
## LOAD-BEARING, so a geometry weight that improves the geometry numbers while
|
||||
## collapsing the distribution is a regression, not a win.
|
||||
## No battle, no Java, no server, no behaviour change.
|
||||
##
|
||||
## Run:
|
||||
@@ -21,7 +26,7 @@
|
||||
## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...]
|
||||
## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ...
|
||||
|
||||
import std/[os, strformat, math, algorithm, json, sets, random, sequtils]
|
||||
import std/[os, strformat, math, algorithm, json, sets, random, sequtils, tables]
|
||||
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||||
import gun_harness/offline_range
|
||||
# Private-field access: include (do NOT import) the shipped mover.
|
||||
@@ -36,6 +41,7 @@ type Pick = object
|
||||
safePre, cand: int
|
||||
hotAtTta: bool ## destination over threshold when we would arrive
|
||||
reached: bool ## we actually got within ArriveRadius by then
|
||||
col, row: int ## the chosen tile (diversity)
|
||||
|
||||
proc loadRoundStarts(path: string): HashSet[int] =
|
||||
result = initHashSet[int]()
|
||||
@@ -100,7 +106,8 @@ proc replay(path: string, seed: int): seq[Pick] =
|
||||
destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed,
|
||||
promoted: m.lastPickPromoted,
|
||||
safePre: safeSetSize(m, TfilDangerThreshold),
|
||||
cand: m.lastPickSafe, hotAtTta: false, reached: false)
|
||||
cand: m.lastPickSafe, hotAtTta: false, reached: false,
|
||||
col: cc, row: cr)
|
||||
pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high)
|
||||
# 2. the arrival probe on the recorded true future
|
||||
var keep: seq[tuple[col, row: int; at: int; idx: int]]
|
||||
@@ -122,6 +129,7 @@ proc mean(x: seq[float]): float =
|
||||
|
||||
proc pc(x: float): string = &"{100.0 * x:.1f}%"
|
||||
proc f1(x: float): string = &"{x:.1f}"
|
||||
proc f2(x: float): string = &"{x:.2f}"
|
||||
|
||||
proc report(label, path: string, picks: seq[Pick]) =
|
||||
echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}"
|
||||
@@ -158,25 +166,126 @@ proc report(label, path: string, picks: seq[Pick]) =
|
||||
|
||||
# ── 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[Pick]
|
||||
for f in fixtures:
|
||||
if not fileExists(f):
|
||||
echo "skip (missing): ", f; continue
|
||||
for seed in [7, 8, 9]:
|
||||
let p = replay(f, seed)
|
||||
total.add p
|
||||
if seed == 7: report("seed 7", f, p) # per-fixture detail, first seed only
|
||||
report("ALL FIXTURES x 3 SEEDS", "", total)
|
||||
let detail = "--detail" in args
|
||||
let fixtures: seq[string] =
|
||||
block:
|
||||
if detail:
|
||||
var v: seq[string]
|
||||
for a in args:
|
||||
if not a.startsWith("--"): v.add a
|
||||
v
|
||||
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"]
|
||||
|
||||
# ── j152: the sweep, with the DIVERSITY cost on every row ────────────────────
|
||||
## (label, TR_TFIL_GEO_MODE, TR_TFIL_GEO_TAU, TR_TFIL_ARRIVE_TICKS)
|
||||
type Arm = tuple[label, mode, tau, arrive: string]
|
||||
|
||||
proc diversity(picks: seq[Pick]): tuple[distinctN, topShare, entBits, normEnt: float] =
|
||||
## Shannon entropy (bits) of the CHOICE distribution over tiles. `normEnt` is
|
||||
## H / log2(distinct): 1.0 = the arm spreads its picks over exactly as many
|
||||
## tiles as the baseline, 0.0 = every pick is the same tile.
|
||||
var counts: Table[(int, int), int]
|
||||
for p in picks: counts[(p.col, p.row)] = counts.getOrDefault((p.col, p.row)) + 1
|
||||
result.distinctN = counts.len.float
|
||||
if picks.len == 0: return
|
||||
var h = 0.0
|
||||
var top = 0
|
||||
for _, n in counts.pairs:
|
||||
let q = n.float / picks.len.float
|
||||
h -= q * log2(q)
|
||||
top = max(top, n)
|
||||
result.topShare = top.float / picks.len.float
|
||||
result.entBits = h
|
||||
result.normEnt = if result.distinctN > 1.0: h / log2(result.distinctN) else: 0.0
|
||||
|
||||
proc pctS(x, n: int): string =
|
||||
if n == 0: return " n/a"
|
||||
pc(x.float / n.float)
|
||||
proc isPerp(p: Pick): bool = p.turn > PerpDeg
|
||||
proc isPromoted(p: Pick): bool = p.promoted
|
||||
proc isFar(p: Pick): bool = p.tta > 15.0
|
||||
proc isReached(p: Pick): bool = p.reached
|
||||
proc isHotAtTta(p: Pick): bool = p.hotAtTta
|
||||
|
||||
proc row(label: string, picks: seq[Pick]): string =
|
||||
let n = picks.len
|
||||
let emp = picks.filterIt(isPromoted(it))
|
||||
let nes = picks.filterIt(not isPromoted(it))
|
||||
let perp = picks.filterIt(isPerp(it)).len
|
||||
let perpE = emp.filterIt(isPerp(it)).len
|
||||
let perpN = nes.filterIt(isPerp(it)).len
|
||||
let far = picks.filterIt(isFar(it)).len
|
||||
let reach = picks.filterIt(isReached(it)).len
|
||||
let hot = picks.filterIt(isHotAtTta(it)).len
|
||||
let d = diversity(picks)
|
||||
&"{label:<22} {pctS(perp, n):>7} {pctS(perpE, emp.len):>7} {pctS(perpN, nes.len):>7}" &
|
||||
&" {pctS(far, n):>7} {f1(mean(picks.mapIt(it.tta))):>6}" &
|
||||
&" {pctS(reach, n):>7} {pctS(hot, n):>7} {pctS(emp.len, n):>7}" &
|
||||
&" {d.distinctN.int:>6} {f2(d.entBits):>6} {f2(d.normEnt):>6} {pc(d.topShare):>7}" &
|
||||
&" {f1(mean(picks.mapIt(it.cand.float))):>5}"
|
||||
|
||||
proc header(): string =
|
||||
result = "arm".align(22, ' ')
|
||||
for (h, w) in [("perp", 7), ("perpE", 7), ("perpN", 7), ("far", 7), ("mtta", 6),
|
||||
("REACH", 7), ("hotArr", 7), ("empty", 7), ("tiles", 6),
|
||||
("Hbits", 6), ("H/", 6), ("top1", 7), ("cand", 5)]:
|
||||
result &= " " & h.align(w, ' ')
|
||||
|
||||
# `perpE`/`perpN` = the perpendicular rate in the FORCED (empty safe set) and the
|
||||
# non-empty populations; `REACH` = the headline (tile actually stood on at tta);
|
||||
# `tiles`/`Hbits`/`H/`/`top1` = the diversity cost; `cand` = mean draw-set size.
|
||||
let arms: seq[Arm] = @[
|
||||
("BASELINE (off)", "off", "0", "0"),
|
||||
("turn-soft tau90", "turn-soft", "90", "0"),
|
||||
("turn-soft tau45", "turn-soft", "45", "0"),
|
||||
("turn-soft tau20", "turn-soft", "20", "0"),
|
||||
("turn-topk", "turn-topk", "45", "0"),
|
||||
("turn-rej tau60", "turn-rej", "60", "0"),
|
||||
("dist-soft tau90", "dist-soft", "90", "0"),
|
||||
("dist-soft tau30", "dist-soft", "30", "0"),
|
||||
("both-soft tau90", "both-soft", "90", "0"),
|
||||
("both-soft tau45", "both-soft", "45", "0"),
|
||||
("both-soft tau20", "both-soft", "20", "0"),
|
||||
("both-topk", "both-topk", "45", "0"),
|
||||
("both-rej tau60", "both-rej", "60", "0"),
|
||||
# j151 interaction: a soft distance preference vs the HARD arrival bound.
|
||||
("arrive15 (j151)", "off", "0", "15"),
|
||||
("arrive15+both t45", "both-soft", "45", "15")]
|
||||
|
||||
echo "\n", header()
|
||||
for a in arms:
|
||||
putEnv("TR_TFIL_GEO_MODE", a.mode)
|
||||
putEnv("TR_TFIL_GEO_TAU", a.tau)
|
||||
putEnv("TR_TFIL_ARRIVE_TICKS", a.arrive)
|
||||
var picks: seq[Pick]
|
||||
for f in fixtures:
|
||||
if not fileExists(f): continue
|
||||
for seed in [7, 8, 9]: picks.add replay(f, seed)
|
||||
echo row(a.label, picks)
|
||||
|
||||
# ── per-fixture detail (--detail only), for the BASELINE arm ────────────────
|
||||
if detail:
|
||||
putEnv("TR_TFIL_GEO_MODE", arms[0].mode)
|
||||
putEnv("TR_TFIL_GEO_TAU", arms[0].tau)
|
||||
putEnv("TR_TFIL_ARRIVE_TICKS", arms[0].arrive)
|
||||
var total: seq[Pick]
|
||||
for f in fixtures:
|
||||
if not fileExists(f):
|
||||
echo "skip (missing): ", f; continue
|
||||
for seed in [7, 8, 9]:
|
||||
let p = replay(f, seed)
|
||||
total.add p
|
||||
if seed == 7: report("seed 7", f, p)
|
||||
report("ALL FIXTURES x 3 SEEDS", "", total)
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
## 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/[os, json, random, math, sequtils, sets]
|
||||
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.
|
||||
@@ -1078,6 +1078,407 @@ proc testJ151() =
|
||||
check "j151: clearing the knob restores today's pick stream exactly",
|
||||
replayJ151(0.0).picks == off.picks
|
||||
|
||||
# ── j152: the GEOMETRIC DRAW (TR_TFIL_GEO_MODE / TR_TFIL_GEO_TAU, default off) ─
|
||||
## Heat still gates the pool with the same hard filter; geometry only re-weights
|
||||
## the survivors of that filter — INCLUDING the 2 promoted least-hot tiles the
|
||||
## ~65% forced picks choose from, which is what j9 (`TR_TFIL_TURN_BIAS`) could
|
||||
## not see. What must hold:
|
||||
## 1. OFF by default and the OFF path is today's uniform draw byte-for-byte
|
||||
## (the golden check #1 above runs with the knobs unset and is that proof;
|
||||
## the last check here adds "off" == "unset" for the same seed).
|
||||
## 2. NO STARVATION: a pool in which EVERY tile is perpendicular still returns
|
||||
## a pick, in every form — the weight may never empty or index past the
|
||||
## pool, whatever the TAU.
|
||||
## 3. `off` and an all-ties pool both degenerate to the uniform draw.
|
||||
## 4. The form is parsed, and junk stays off.
|
||||
proc testJ152() =
|
||||
delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU")
|
||||
loadTfilCommitEnv()
|
||||
check "j152: both geometry knobs default to OFF (today's uniform draw)",
|
||||
TfilGeoMode == gdoOff and TfilGeoTau == 0.0
|
||||
|
||||
check "j152: the mode string parses both axes (dim + form)",
|
||||
parseGeo("both-rej") == (gdoBoth, gfRej) and
|
||||
parseGeo("turn-topk") == (gdoTurn, gfTopK) and
|
||||
parseGeo("dist-soft") == (gdoDist, gfSoft) and
|
||||
parseGeo("turn") == (gdoTurn, gfSoft)
|
||||
check "j152: junk and 'off' both parse to OFF, never to a live arm",
|
||||
parseGeo("off").dim == gdoOff and parseGeo("sideways").dim == gdoOff
|
||||
|
||||
# 2. NO STARVATION: a pool where EVERY tile is 150 deg off the heading, at
|
||||
# three different distances. No form may return an index outside the pool.
|
||||
randomize(1)
|
||||
let allPerpT = @[150.0, 150.0, 150.0]
|
||||
let allPerpD = @[2.0, 30.0, 48.0]
|
||||
for form in [gfSoft, gfTopK, gfRej]:
|
||||
for tau in [1.0, 20.0, 5000.0]:
|
||||
var seen: seq[int]
|
||||
for _ in 0..<300:
|
||||
seen.add geoPick(allPerpT, allPerpD, gdoTurn, form, tau)
|
||||
# NO STARVATION = a pick always exists and is in range. It is NOT "every
|
||||
# tile stays reachable": topk and rej are hard forms BY DESIGN and may
|
||||
# legitimately return one tile forever when the whole pool is bad.
|
||||
check "j152: no starvation — an all-perpendicular pool still returns " &
|
||||
"an in-range pick (" & $form & ", tau " & $tau & ")",
|
||||
seen.len == 300 and seen.allIt(it in 0..2)
|
||||
|
||||
# 3. every tile costs the same => every weight ties => the uniform draw
|
||||
randomize(2)
|
||||
var tieSeen: seq[int]
|
||||
for _ in 0..<300:
|
||||
tieSeen.add geoPick(@[40.0, 40.0, 40.0], @[10.0, 10.0, 10.0], gdoTurn, gfSoft, 45.0)
|
||||
check "j152: an all-ties pool degenerates to the uniform draw (all 3 seen, " &
|
||||
"none starved)", tieSeen.toHashSet().len == 3
|
||||
var hitFar = 0
|
||||
randomize(3)
|
||||
for _ in 0..<400:
|
||||
if geoPick(@[0.0, 180.0], @[1.0, 1.0], gdoTurn, gfSoft, 10.0) == 0: inc hitFar
|
||||
check "j152: the soft form really tilts (a straight-ahead tile is drawn " &
|
||||
">" & $hitFar & "/400 of the time with tau=10)", hitFar > 300
|
||||
|
||||
# 4. "off" == "unset" for the same seed: the shipped stream, unchanged.
|
||||
let a = replayJ151(0.0)
|
||||
delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU")
|
||||
loadTfilCommitEnv()
|
||||
let b = replayJ151(0.0)
|
||||
check "j152: geometry off reproduces the shipped draw exactly (same picks, " &
|
||||
"same mean tta, same pool)", a.picks == b.picks and
|
||||
a.meanTta == b.meanTta and a.meanPool == b.meanPool
|
||||
|
||||
# ── j153: HOLD WHEN TRAPPED (TR_TFIL_HOLD_WHEN_TRAPPED, default 0 = off) ────
|
||||
## The owner's rule: "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."
|
||||
## What must hold, and nothing more:
|
||||
## 1. OFF by default, and the OFF stream is byte-for-byte today's (the golden
|
||||
## check #1 above already proves the default path; this adds the explicit
|
||||
## "unset == 0 == 1-off-by-parsing" arm).
|
||||
## 2. ON + EMPTY safe set => no movement command for that tick.
|
||||
## 3. The hold is ONE tick: it never latches, and a later safe tile IS taken
|
||||
## (no stuck bot, no held-then-forever-silent).
|
||||
## 4. Holding does not skip the rest of the tick: the bullet tracking the GUN
|
||||
## and the lava field are updated exactly as on a non-held tick. (The gun
|
||||
## itself lives in the bot loop, not in this module — computeMove never
|
||||
## emits a fire command — so the real risk is a hold that `return`s too
|
||||
## early and freezes the bullet tracker; that is what this checks.)
|
||||
type HoldRec = object
|
||||
call: int
|
||||
spd, trn: float
|
||||
held: bool
|
||||
picked: bool
|
||||
bullets: int ## tracked bullets after this tick (the fire tracker's)
|
||||
|
||||
proc replayJ153(hold: bool): seq[HoldRec] =
|
||||
putEnv("TR_TFIL_HOLD_WHEN_TRAPPED", (if hold: "1" else: "0"))
|
||||
loadTfilCommitEnv()
|
||||
let states = loadStates()
|
||||
let starts = loadRoundStarts()
|
||||
randomize(Seed)
|
||||
var m = initTFIL()
|
||||
var lastPicks = 0
|
||||
for i in 0..<states.len:
|
||||
if i == 0 or i in starts:
|
||||
m.resetRound()
|
||||
lastPicks = 0 # resetRound zeroes `picks`: not a new pick
|
||||
let cmd = m.computeMove(states[i])
|
||||
result.add HoldRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate, held: m.lastHeld,
|
||||
picked: m.picks != lastPicks, bullets: m.bullets.len)
|
||||
lastPicks = m.picks
|
||||
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
|
||||
loadTfilCommitEnv()
|
||||
|
||||
proc testJ153() =
|
||||
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
|
||||
loadTfilCommitEnv()
|
||||
check "j153: TR_TFIL_HOLD_WHEN_TRAPPED defaults to OFF (today's " &
|
||||
"promote-the-2-least-hot fallback)", not TfilHoldWhenTrapped
|
||||
|
||||
let off = replayJ153(false) # knob explicitly 0
|
||||
let unset = replay(loadStates(), loadRoundStarts()) # knob never set
|
||||
var diff = -1
|
||||
if off.len != unset.len: diff = min(off.len, unset.len)
|
||||
else:
|
||||
for i in 0..<off.len:
|
||||
if unset[i].spd != off[i].spd or unset[i].trn != off[i].trn or
|
||||
unset[i].call != off[i].call:
|
||||
diff = i; break
|
||||
check "j153: with the knob unset the move stream is BYTE-FOR-BYTE the " &
|
||||
"knob-0 one over " & $off.len & " ticks — the default is today's",
|
||||
diff < 0 and off.len > 0
|
||||
|
||||
let on = replayJ153(true)
|
||||
var held, heldMoved, heldPicked = 0
|
||||
var nonHeldMoving = 0
|
||||
for i in 0..<on.len:
|
||||
if on[i].held:
|
||||
inc held
|
||||
if abs(on[i].spd) > 0.001: inc heldMoved
|
||||
if on[i].picked: inc heldPicked
|
||||
elif abs(on[i].spd) > 0.001: inc nonHeldMoving
|
||||
check "j153: with the knob ON the safe set really is empty often enough to " &
|
||||
"matter (" & $held & " held ticks of " & $on.len & ")",
|
||||
held > on.len div 100
|
||||
check "j153: a held tick emits NO movement (speed 0) and no pick " &
|
||||
"(" & $heldMoved & " moving holds, " & $heldPicked & " held picks)",
|
||||
held > 0 and heldMoved == 0 and heldPicked == 0
|
||||
check "j153: the hold is not a freeze — " & $nonHeldMoving & " non-held " &
|
||||
"ticks still move and the bot still picks",
|
||||
nonHeldMoving > 0 and on.filterIt(it.picked).len > 0
|
||||
|
||||
# no latch: a held tick must be followed by movement again (within a couple of
|
||||
# ticks), and a pick must still be taken somewhere after the holds.
|
||||
# A HOLD is not a latch: the hold is decided at the pick site, and the pick
|
||||
# site only runs when the commitment has expired, so every held tick is a FRESH
|
||||
# evaluation of the field. Observable consequence: hold runs end, and the tick
|
||||
# after a run is a moving tick again. (A latching implementation would show ONE
|
||||
# run per round and ~0 resumptions.) A long run therefore means a long trap, not
|
||||
# a stuck bot — that is why the run LENGTH is deliberately not asserted.
|
||||
var runs = 0
|
||||
var resumed = 0
|
||||
var worst = 0
|
||||
var run = 0
|
||||
for i in 0..<on.len:
|
||||
if on[i].held:
|
||||
inc run
|
||||
else:
|
||||
if run > 0:
|
||||
inc runs
|
||||
worst = max(worst, run)
|
||||
if abs(on[i].spd) > 0.001: inc resumed
|
||||
run = 0
|
||||
if run > 0:
|
||||
inc runs
|
||||
worst = max(worst, run)
|
||||
var pickedAfter = 0
|
||||
var sawHold = false
|
||||
for r in on:
|
||||
if r.held: sawHold = true
|
||||
elif sawHold and r.picked: inc pickedAfter
|
||||
check "j153: the hold is NOT a latch — " & $resumed & " of " & $runs &
|
||||
" maximal hold runs resume moving on the very next tick (longest run " &
|
||||
$worst & " ticks = a trap that lasts, not a stuck bot) and " &
|
||||
$pickedAfter & " picks happen after a hold",
|
||||
runs > 0 and resumed * 2 > runs and pickedAfter > 0
|
||||
|
||||
# the gun path: a held tick must leave the bullet tracker exactly where a
|
||||
# non-held tick would. If the hold returned before the tracker update, the
|
||||
# bullet counts would diverge from the first hold onwards.
|
||||
var firstDiv = -1
|
||||
for i in 0..<min(on.len, off.len):
|
||||
if on[i].bullets != off[i].bullets:
|
||||
if on[i].held or off[i].held: firstDiv = i
|
||||
break
|
||||
check "j153: holding does not freeze the fire/bullet bookkeeping the GUN " &
|
||||
"reads (bullet counts identical on held vs non-held ticks)",
|
||||
firstDiv < 0
|
||||
|
||||
# ── j154: the BOUNDED hold (TR_TFIL_HOLD_MAX_TICKS, default 0 = off) ────────
|
||||
#
|
||||
# The budget is DERIVED from the enemy's own rate of fire, not chosen. Server
|
||||
# `rules/math.kt`: `calcGunHeat(p) = 1 + p/5`, `calcBulletDamage(3.0) = 16`;
|
||||
# `core/GunEngine.kt`: the gun cools 0.1 per tick and may only fire at heat == 0.
|
||||
# So two 3.0-power shots are 1.6/0.1 = 16 ticks apart, and a brute-force search
|
||||
# over the 0.1 power quantisation says 32 damage is the most the enemy can land
|
||||
# in any 16-tick window (8/11/16/24/32 ticks -> 16/18/32/32/48). 16 is also the
|
||||
# FIRST window that admits the enemy's SECOND shot at all, so nothing shorter
|
||||
# can be surprised by a third bullet.
|
||||
#
|
||||
# A fully hot field is painted with the virtual pillar at radiance 0
|
||||
# (`max(0, hotness - d*0) = hotness` on every tile) — the one heat source that
|
||||
# covers the whole reachable hull at once, so the safe set is provably empty.
|
||||
proc testJ154() =
|
||||
delEnv("TR_TFIL_HOLD_MAX_TICKS")
|
||||
loadTfilCommitEnv()
|
||||
check "j154: TR_TFIL_HOLD_MAX_TICKS defaults to 0 = today's behaviour exactly",
|
||||
TfilHoldMaxTicks == 0 and not TfilHoldWhenTrapped
|
||||
|
||||
# 1. DEFAULT PARITY: an explicit 0 is indistinguishable from unset, over the
|
||||
# whole fixture, tick for tick. (The golden above covers UNSET; this covers
|
||||
# the explicit zero the owner would put in an arm.)
|
||||
let unset = replay(loadStates(), loadRoundStarts())
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "0")
|
||||
loadTfilCommitEnv()
|
||||
let zero = replay(loadStates(), loadRoundStarts())
|
||||
var diff = -1
|
||||
if unset.len != zero.len: diff = min(unset.len, zero.len)
|
||||
else:
|
||||
for i in 0..<unset.len:
|
||||
if recLine(unset[i]) != recLine(zero[i]): diff = i; break
|
||||
check "j154: TR_TFIL_HOLD_MAX_TICKS=0 is BYTE-FOR-BYTE the unset build over " &
|
||||
$unset.len & " ticks (default path unchanged)",
|
||||
diff < 0 and unset.len > 0
|
||||
|
||||
# 2. knob parsing
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "16"); loadTfilCommitEnv()
|
||||
check "j154: TR_TFIL_HOLD_MAX_TICKS=16 is read", TfilHoldMaxTicks == 16
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "junk"); loadTfilCommitEnv()
|
||||
check "j154: a malformed value falls back to 0 (off)", TfilHoldMaxTicks == 0
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "-8"); loadTfilCommitEnv()
|
||||
check "j154: a negative value clamps to 0", TfilHoldMaxTicks == 0
|
||||
delEnv("TR_TFIL_HOLD_MAX_TICKS"); loadTfilCommitEnv()
|
||||
|
||||
const HoldN = 4 ## the budget under test; any small N exercises it
|
||||
const Hot = 100.0 ## every tile at 100 >> PathDangerThreshold 10
|
||||
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
|
||||
enemySpeed: 0.0, enemyEnergy: 100.0,
|
||||
selfX: 400.0, selfY: 300.0, selfHeading: 0.0,
|
||||
selfSpeed: 8.0, selfEnergy: 100.0,
|
||||
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 0,
|
||||
enemies: @[])
|
||||
|
||||
## One tick. `hot` = the pillar heat (0 = a clean field). A non-nil `bullet`
|
||||
## is installed as the tracked set, which is the ONLY way an inbound bullet
|
||||
## ever exists here (the enemy is out of the arena in these worlds).
|
||||
proc tick(m: var TFILModule, hot: float, t: int,
|
||||
bullet: TrackedBullet = TrackedBullet(alive: false)): MoveCommand =
|
||||
PillarHotness = hot
|
||||
PillarRadiance = 0.0
|
||||
var w = ws
|
||||
w.tick = t
|
||||
if bullet.alive: m.bullets = @[bullet]
|
||||
result = m.computeMove(w)
|
||||
|
||||
## A module with the grid initialised and NO live commitment, so tick 0 of a
|
||||
## scenario is a REPLAN tick (where, and only where, a hold may be taken).
|
||||
proc fresh(): TFILModule =
|
||||
PillarHotness = 0.0; PillarRadiance = 0.0
|
||||
result = initTFIL()
|
||||
randomize(Seed)
|
||||
discard tick(result, 0.0, 0)
|
||||
result.commitTicks = 0
|
||||
result.picks = 0
|
||||
result.commitTarget = (x: 400.0, y: 300.0)
|
||||
|
||||
type Rec = tuple[held: bool, ht: int, picked: bool]
|
||||
|
||||
## The held/pick pattern of a run, plus the counter at each tick.
|
||||
proc run(m: var TFILModule, hot: seq[float]): seq[Rec] =
|
||||
for t, h in hot:
|
||||
let before = m.picks
|
||||
discard tick(m, h, t + 1)
|
||||
result.add (held: m.lastHeld, ht: m.holdTicks, picked: m.picks != before)
|
||||
|
||||
# A 1-tick commitment makes every tick a replan tick, so the scenario is a
|
||||
# clean read of the hold rule alone (no commitment state leaking in).
|
||||
putEnv("TR_TFIL_COMMIT_TICKS", "1")
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", $HoldN)
|
||||
loadTfilCommitEnv()
|
||||
|
||||
# 3. the BOUND: at most N consecutive held ticks, then the normal promote-the-2
|
||||
# fallback takes over — the hold can never latch.
|
||||
let hotAll = @[Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0,
|
||||
Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, 0.0]
|
||||
var m = fresh()
|
||||
let r = run(m, hotAll)
|
||||
var firstPick = -1
|
||||
for i, e in r:
|
||||
if e.picked: firstPick = i; break
|
||||
echo "\n j154 run (held/ht/picked per tick, index: value):"
|
||||
for i, e in r:
|
||||
echo " ", i, ": ", (if e.held: "H" else: "."), e.ht,
|
||||
(if e.picked: " P" else: " ")
|
||||
check "j154: with the safe set EMPTY the mover HOLDS (" & $HoldN &
|
||||
" ticks) instead of promoting, and releases into a pick on tick " &
|
||||
$(firstPick + 1) & " — the bound is N, not 'until a tile appears'",
|
||||
firstPick == HoldN and r[0].held and r[HoldN - 1].held and
|
||||
r[HoldN - 1].ht == HoldN and not r[HoldN].held and r[HoldN].picked
|
||||
|
||||
# 4. RELEASE THE MOMENT A SAFE TILE EXISTS: the field cools at index 10, and
|
||||
# that very tick is a pick, not a hold — no tick of latency.
|
||||
check "j154: the hold releases on the SAME tick a safe tile appears " &
|
||||
"(index 10 cooled -> picked=" & $r[10].picked & ", held=" &
|
||||
$r[10].held & ", counter=" & $r[10].ht & ")",
|
||||
r[10].picked and not r[10].held and r[10].ht == 0
|
||||
# ... and the budget REFILLS: a fresh empty streak holds a full N again,
|
||||
# i.e. the bound is per streak and the counter is not cumulative.
|
||||
let streak2 = r[11 .. ^1]
|
||||
var held2 = 0
|
||||
for e in streak2:
|
||||
if e.held: inc held2
|
||||
check "j154: the counter RESET when the safe tile was taken — the second " &
|
||||
"empty streak holds a full N again (" & $held2 & " ticks), never the " &
|
||||
"accumulated " & $r[10].ht & "+" & $r[11].ht,
|
||||
streak2[0].held and held2 >= HoldN and r[11].ht == 1
|
||||
|
||||
# 5. the held command is the SAME stop the mover already emits at its target,
|
||||
# and the gun path is untouched: on a held tick the fire detector still
|
||||
# latches the enemy's wave (the bot aims and fires from tracked state after
|
||||
# go(), on every tick, whatever speed it just commanded).
|
||||
m = fresh()
|
||||
PillarHotness = Hot; PillarRadiance = 0.0
|
||||
var wFar = ws
|
||||
wFar.enemies = @[EnemyInfo(id: 1, x: 760.0, y: 300.0, heading: 180.0,
|
||||
speed: 0.0, energy: 100.0)]
|
||||
wFar.tick = 1
|
||||
discard m.computeMove(wFar) # enemy seen at 100.0 energy
|
||||
m.commitTicks = 0 # armed: a replan tick, as above
|
||||
wFar.tick = 2
|
||||
wFar.enemies[0].energy = 98.5 # a 1.5 drop = a 1.5-power shot
|
||||
let gunCmd = m.computeMove(wFar)
|
||||
check "j154: the gun still fires while holding — the mover held (" &
|
||||
$m.lastHeld & ") on the very tick the enemy fired, and the fire " &
|
||||
"detector still latched the wave (" & $m.bullets.len & " tracked)",
|
||||
m.lastHeld and m.bullets.len > 0
|
||||
check "j154: the held command is the stop the mover already emits at its " &
|
||||
"target (speed 0, turn 0) — no new signal, no movement side effect",
|
||||
gunCmd.speed == 0.0 and gunCmd.turnRate == 0.0
|
||||
|
||||
# 6. PANIC RELEASE (required). A tracked bullet on a collision course, 9 ticks
|
||||
# out, overrides the hold on the tick it exists. The same bullet offset
|
||||
# laterally does NOT, so the release is specific, not "any bullet".
|
||||
# The budget is the DERIVED 16 here, so the horizon is min(16, 16) = 16.
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "16")
|
||||
loadTfilCommitEnv()
|
||||
let inbound = TrackedBullet(originX: 570.0, originY: 300.0, x: 570.0, y: 300.0,
|
||||
velX: -17.0, velY: 0.0, power: 1.0,
|
||||
alive: true, age: 0)
|
||||
let missing = TrackedBullet(originX: 570.0, originY: 500.0, x: 570.0,
|
||||
y: 500.0, velX: -17.0, velY: 0.0, power: 1.0,
|
||||
alive: true, age: 0)
|
||||
m = fresh()
|
||||
discard tick(m, Hot, 1, inbound)
|
||||
check "j154: PANIC RELEASE — a tracked bullet 9 ticks from our position " &
|
||||
"overrides the hold on the same tick (held=" & $m.lastHeld &
|
||||
", picked=" & $(m.picks > 0) & ")",
|
||||
(not m.lastHeld) and m.picks > 0 and m.holdTicks == 0
|
||||
m = fresh()
|
||||
let cmdMiss = tick(m, Hot, 1, missing)
|
||||
check "j154: ... and it is SPECIFIC: the same bullet 200px off our line " &
|
||||
"still holds (held=" & $m.lastHeld & "), so the release is an arrival " &
|
||||
"test, not a bullet count",
|
||||
m.lastHeld and m.picks == 0 and cmdMiss.speed == 0.0
|
||||
|
||||
proc withBullet(b: TrackedBullet): TFILModule =
|
||||
result = initTFIL()
|
||||
result.bullets = @[b]
|
||||
check "j154: the panic horizon is the DERIVED budget min(N, 16) ticks — the " &
|
||||
"arrival test fires inside it and not outside",
|
||||
bulletPanic(initTFIL(), 400.0, 300.0, 16.0) == false and
|
||||
bulletPanic(withBullet(inbound), 400.0, 300.0, 16.0) == true and
|
||||
bulletPanic(withBullet(inbound), 400.0, 300.0, 4.0) == false
|
||||
|
||||
# 7. a hold NEVER interrupts a live commitment (j153's comment claimed that;
|
||||
# j154's code enforces it). Take a pick, keep the field hot, and the mover
|
||||
# must keep driving to its committed target.
|
||||
putEnv("TR_TFIL_COMMIT_TICKS", "15")
|
||||
loadTfilCommitEnv()
|
||||
m = fresh()
|
||||
discard tick(m, 0.0, 1) # a clean field first: that tick PICKS
|
||||
let pickedFirst = m.picks > 0
|
||||
let live = m.commitTicks
|
||||
let cmdLive = tick(m, Hot, 2) # now the field goes fully hot
|
||||
check "j154: a hold never interrupts a live commitment — with " & $live &
|
||||
" ticks on the clock the mover keeps driving to its target (speed " &
|
||||
$cmdLive.speed & "), it does not freeze",
|
||||
pickedFirst and live > 0 and (not m.lastHeld) and cmdLive.speed != 0.0
|
||||
|
||||
putEnv("TR_TFIL_COMMIT_TICKS", "15")
|
||||
delEnv("TR_TFIL_HOLD_MAX_TICKS")
|
||||
loadTfilCommitEnv()
|
||||
PillarHotness = 0.0; PillarRadiance = 0.0
|
||||
check "j154: clearing the knob restores today's behaviour exactly",
|
||||
TfilHoldMaxTicks == 0
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
testDefaultParity()
|
||||
@@ -1089,7 +1490,10 @@ when declared(loadTfilCommitEnv):
|
||||
testJ146()
|
||||
testJ147()
|
||||
testJ151()
|
||||
testJ152()
|
||||
testJ150()
|
||||
testJ154()
|
||||
testJ153()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
|
||||
Reference in New Issue
Block a user