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).
This commit is contained in:
@@ -142,6 +142,7 @@ TR_STRAFE_ESCAPE=on # the guaranteed wall escape when every candidate is hot
|
||||
TR_STRAFE_FIRE_FIX=on # strafe's share of the shared TR_FIRE_FIX switch
|
||||
TR_FIRE_FIX=on # 0 = the shipped previous-energy bullet detector
|
||||
#TR_FIRE_DIAG=1 # presence-only: per-reading tick/raw/correction trace
|
||||
#TR_FIRE_LAG=0 # ticks to back-date each detected fire at spawn (0=shipped; 1=the measured live detection lag)
|
||||
TR_STRAFE_HEAT_GRID=on # draw the whole heat grid; 0 leaves only the chosen tile
|
||||
TR_STRAFE_BULLET_CORE=20.0 # strafe's own retune: lava per bullet-overlapping tile
|
||||
TR_STRAFE_BULLET_AURA=10.0 # strafe's own retune: lava for the bullet aura ring
|
||||
|
||||
@@ -878,6 +878,11 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
|
||||
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.roundNumber = e.roundNumber
|
||||
if FireDiag:
|
||||
# j147 timeline anchor: the (bot.tick -> server getTurn) offset, once per
|
||||
# round, so a recorded capture's event sidecar can be aligned to the bot's
|
||||
# own tick stream without guessing.
|
||||
echo "[firediag] ROUND round=", getRound(), " getTurn=", getTurn()
|
||||
# Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1
|
||||
# reports and a target/enemy change mid-round cannot leak a stale flag.
|
||||
bot.weDiedThisRound = false
|
||||
|
||||
@@ -25,6 +25,7 @@ import module_switches
|
||||
import gun_harness/virtual_bullets
|
||||
import gun_harness/selector
|
||||
import movements/ram_decision
|
||||
import movement_harness/fire_tracker
|
||||
import movements/the_floor_is_lava
|
||||
import movements/the_floor_is_lava_ring
|
||||
import movements/strafe
|
||||
@@ -362,6 +363,9 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_FIRE_FIX", onOff(TfilFireFix), sourceOf("TR_FIRE_FIX"))
|
||||
# j134 TASK B diagnostic (off by default): per-reading tick/raw/correction trace.
|
||||
emit("TR_FIRE_DIAG", onOff(StrafeFireDiag), sourceOf("TR_FIRE_DIAG"))
|
||||
# j147: the back-date (ticks) applied to every detected enemy fire at spawn.
|
||||
# 0 = shipped (the ghost is born at the scanned enemy position).
|
||||
emit("TR_FIRE_LAG", $FireLag, sourceOf("TR_FIRE_LAG"))
|
||||
emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID"))
|
||||
# STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall
|
||||
# 15/5), override-able per run so the shipped field can be A/B'd on one
|
||||
@@ -657,6 +661,7 @@ proc knownEnvNames*(): seq[string] =
|
||||
"TR_STRAFE_FIRE_FIX",
|
||||
"TR_FIRE_FIX",
|
||||
"TR_FIRE_DIAG",
|
||||
"TR_FIRE_LAG",
|
||||
"TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA",
|
||||
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
|
||||
"TR_STRAFE_WALL_RADIANCE",
|
||||
|
||||
@@ -134,6 +134,38 @@ proc detect*(t: var FireTracker, id: int, energy: float,
|
||||
elif drop >= lo and drop <= hi:
|
||||
result = @[drop]
|
||||
|
||||
import std/[math, os, strutils]
|
||||
|
||||
## ── j147: the DETECTION LAG back-date (`TR_FIRE_LAG`, default 0) ─────────────
|
||||
## MEASURED LIVE (`common_libs/tests/measure_fire_ghost_lag.py`, 1171/1171 ghost
|
||||
## spawns over two movers x 4 rounds, `TR_FIRE_DIAG=1`): the server dispatches a
|
||||
## turn's fire AFTER our `go()` for that same turn, so the energy drop of a
|
||||
## turn-T shot first reaches our scan at turn T+1 (our bot tick T). A bullet
|
||||
## takes its FIRST step during the turn it is fired, so by then the true bullet
|
||||
## is already `speed` px (11..20 px, one whole bullet step) downrange and the
|
||||
## arrival deadline is a full tick shorter than the ghost's. Both movers place
|
||||
## the ghost at the SCANNED enemy position, i.e. exactly where the bullet was
|
||||
## born: the whole ghost trajectory is the true one shifted one turn later.
|
||||
## The per-tick `advanceBullets` then keeps it there for the bullet's whole life.
|
||||
##
|
||||
## The compensation is the inverse: at SPAWN, back-date the shot by `lag` ticks
|
||||
## (`x = origin + dir * speed * lag`, `y = ...`). The arrival deadline needs no
|
||||
## separate change — every mover derives it from the ghost's own position
|
||||
## (`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
|
||||
## correct deadline.
|
||||
##
|
||||
## DEFAULT 0 = the shipped behaviour, byte for byte (`x` is only touched when
|
||||
## `lag > 0`), so the default-parity guard stays green.
|
||||
var FireLag*: int = 0
|
||||
|
||||
proc loadFireTrackerEnv*() =
|
||||
## Read the shared fire knobs. Called once at module init; callable again
|
||||
## after `putEnv` so a guard test can exercise the arms in one process.
|
||||
let s = getEnv("TR_FIRE_LAG", "").strip()
|
||||
FireLag = (try: max(0, parseInt(s)) except ValueError: 0)
|
||||
|
||||
loadFireTrackerEnv()
|
||||
|
||||
proc endScan*(t: var FireTracker) =
|
||||
## Call once after the per-enemy scan. Rotates the event corrections one
|
||||
## slot: the events noted since the previous `endScan` become the corrections
|
||||
|
||||
@@ -577,9 +577,16 @@ proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
|
||||
let heading = arctan2(predY - ei.y, predX - ei.x)
|
||||
if m.bullets.len >= MaxTrackedBullets:
|
||||
m.bullets.del(0)
|
||||
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
|
||||
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
|
||||
var gx = ei.x
|
||||
var gy = ei.y
|
||||
if FireLag > 0:
|
||||
gx += (speed * cos(heading)) * FireLag.float
|
||||
gy += (speed * sin(heading)) * FireLag.float
|
||||
m.bullets.add TrackedBullet(
|
||||
originX: ei.x, originY: ei.y,
|
||||
x: ei.x, y: ei.y,
|
||||
x: gx, y: gy,
|
||||
velX: speed * cos(heading),
|
||||
velY: speed * sin(heading),
|
||||
power: power, alive: true, age: 0)
|
||||
@@ -616,6 +623,16 @@ proc detectFires(m: var StrafeModule, ws: WorldState) =
|
||||
" dealt=", m.fire.dealtPending
|
||||
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix):
|
||||
m.spawnTrackedWave(ws, ei, p)
|
||||
if StrafeFireDiag and m.bullets.len > 0:
|
||||
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
|
||||
# position (the timeline anchor) and the ghost's DRAWN position.
|
||||
let b = m.bullets[^1]
|
||||
let sp = 20.0 - 3.0 * p
|
||||
echo "[firediag] SPAWN tick=", ws.tick,
|
||||
" sx=", ws.selfX, " sy=", ws.selfY,
|
||||
" gx=", b.x, " gy=", b.y,
|
||||
" p=", p,
|
||||
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
|
||||
m.fire.endScan()
|
||||
|
||||
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
|
||||
|
||||
@@ -152,6 +152,10 @@ var
|
||||
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
|
||||
## Off = the shipped `prev - energy` detector byte-for-byte.
|
||||
TfilFireFix*: bool = true
|
||||
## j134/j147: the env-gated live trace (`TR_FIRE_DIAG`) — one `SPAWN` line per
|
||||
## detected enemy fire, for the ghost-vs-observer position probe. Observability
|
||||
## only; off by default.
|
||||
TfilFireDiag*: bool = false
|
||||
|
||||
proc getEnvInt(name: string, default: int): int =
|
||||
let s = getEnv(name, "")
|
||||
@@ -186,6 +190,7 @@ proc loadTfilCommitEnv*() =
|
||||
TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0))
|
||||
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
|
||||
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
|
||||
TfilFireDiag = existsEnv("TR_FIRE_DIAG")
|
||||
|
||||
loadTfilCommitEnv()
|
||||
|
||||
@@ -426,9 +431,16 @@ proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
|
||||
let heading = arctan2(predY - ei.y, predX - ei.x)
|
||||
if m.bullets.len >= MaxTrackedBullets:
|
||||
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
|
||||
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
|
||||
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
|
||||
var gx = ei.x
|
||||
var gy = ei.y
|
||||
if FireLag > 0:
|
||||
gx += (speed * cos(heading)) * FireLag.float
|
||||
gy += (speed * sin(heading)) * FireLag.float
|
||||
m.bullets.add TrackedBullet(
|
||||
originX: ei.x, originY: ei.y,
|
||||
x: ei.x, y: ei.y,
|
||||
x: gx, y: gy,
|
||||
velX: speed * cos(heading),
|
||||
velY: speed * sin(heading),
|
||||
power: power,
|
||||
@@ -451,6 +463,16 @@ proc detectFires(m: var TFILModule, ws: WorldState) =
|
||||
for ei in ws.enemies:
|
||||
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
|
||||
m.spawnTrackedWave(ws, ei, p)
|
||||
if TfilFireDiag and m.bullets.len > 0:
|
||||
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
|
||||
# position (the timeline anchor) and the ghost's DRAWN position.
|
||||
let b = m.bullets[^1]
|
||||
let sp = 20.0 - 3.0 * p
|
||||
echo "[firediag] SPAWN tick=", ws.tick,
|
||||
" sx=", ws.selfX, " sy=", ws.selfY,
|
||||
" gx=", b.x, " gy=", b.y,
|
||||
" p=", p,
|
||||
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
|
||||
m.fire.endScan()
|
||||
|
||||
proc advanceBullets(m: var TFILModule, selfX, selfY: float) =
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ghost-vs-observer probe (j147) — is the 1-tick aura lag a DECISION lag?
|
||||
|
||||
For every enemy fire in the recorded event sidecar we locate the ghost our
|
||||
mover spawned for it (the `[firediag] SPAWN` line) and compare
|
||||
|
||||
* WHEN the ghost was spawned (its tick) vs WHEN the enemy fired, and
|
||||
* WHERE the ghost was drawn vs WHERE the true bullet is at that instant.
|
||||
|
||||
Timeline anchoring is done in ABSOLUTE arena coordinates: the bot logs its own
|
||||
position at the spawn tick, and the capture row carrying that position pins
|
||||
`captureRow = botTick + k` (a per-round constant = the server's delivery
|
||||
offset). With k known,
|
||||
|
||||
trueBullet(row) = fireOrigin + (row - fireRow) * v (v = 20 - 3*power)
|
||||
ghost(row) = spawnGhostPos + (row - spawnRow) * v
|
||||
|
||||
so both the time lag and the pixel displacement are directly measurable.
|
||||
"""
|
||||
import json, math, re, sys, collections
|
||||
|
||||
SPAWN_RE = re.compile(
|
||||
r"SPAWN tick=(\d+) sx=([-\d.eE+]+) sy=([-\d.eE+]+) gx=([-\d.eE+]+) "
|
||||
r"gy=([-\d.eE+]+) p=([-\d.eE+]+) eta=([-\d.eE+]+)")
|
||||
|
||||
|
||||
def load(arm_dir):
|
||||
rows = [json.loads(l) for l in open(f"{arm_dir}/run1.jsonl") if '"tick"' in l]
|
||||
ev = [json.loads(l) for l in open(f"{arm_dir}/run1.events.jsonl") if l.strip()]
|
||||
rounds = json.load(open(f"{arm_dir}/run1.jsonl.rounds.json"))["rounds"]
|
||||
spawns = []
|
||||
for line in open(f"{arm_dir}/run1.bot.stdout.log"):
|
||||
m = SPAWN_RE.search(line)
|
||||
if m:
|
||||
g = [float(x) for x in m.groups()]
|
||||
spawns.append(dict(tick=int(g[0]), sx=g[1], sy=g[2], gx=g[3], gy=g[4],
|
||||
p=g[5], eta=g[6]))
|
||||
return rows, ev, rounds, spawns
|
||||
|
||||
|
||||
def split_rounds(spawns):
|
||||
"""The bot's tick restarts every round -> segment on a tick decrease."""
|
||||
out, cur = [], []
|
||||
for s in spawns:
|
||||
if cur and s["tick"] <= cur[-1]["tick"]:
|
||||
out.append(cur); cur = []
|
||||
cur.append(s)
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def analyse(arm_dir, verbose=True):
|
||||
rows, ev, rounds, spawns = load(arm_dir)
|
||||
pos = {r["tick"]: r for r in rows}
|
||||
starts = {r["round"]: r["startTick"] for r in rounds}
|
||||
counts = {r["round"]: r["count"] for r in rounds}
|
||||
|
||||
# who is the enemy, and which capture row does a fire turn correspond to
|
||||
votes = collections.Counter()
|
||||
for e in ev:
|
||||
if e["type"] != "fire":
|
||||
continue
|
||||
gt = starts[e["round"]] + e["tick"]
|
||||
for off in (-3, -2, -1, 0, 1):
|
||||
d = pos.get(gt + off)
|
||||
if not d:
|
||||
continue
|
||||
de = math.hypot(d["ex"] - e["x"], d["ey"] - e["y"])
|
||||
ds = math.hypot(d["sx"] - e["x"], d["sy"] - e["y"])
|
||||
votes[(e["owner"], "e" if de < ds else "s")] += 1
|
||||
break
|
||||
enemy_owner = max(votes.items(), key=lambda kv: kv[1])[0][0]
|
||||
fires = [e for e in ev if e["type"] == "fire" and e["owner"] == enemy_owner]
|
||||
by_round = collections.defaultdict(list)
|
||||
for e in fires:
|
||||
by_round[e["round"]].append(e)
|
||||
|
||||
recs, unmatched = [], 0
|
||||
chunks = split_rounds(spawns)
|
||||
# which capture row holds a given position (the bot's own, for the anchor)
|
||||
where = collections.defaultdict(list)
|
||||
for r in rows:
|
||||
where[(round(r["sx"], 3), round(r["sy"], 3))].append(r["tick"])
|
||||
for chunk in chunks:
|
||||
for s in chunk:
|
||||
# TIMELINE, no guessing. MEASURED LIVE (`[firediag] EV hit` lines vs
|
||||
# the capture's event sidecar, exact matches): the sidecar's per-round
|
||||
# `tick` IS the server getTurn and the bot runs with
|
||||
# `getTurn = bot.tick + 1` (j134), so a ghost logged at bot tick `t`
|
||||
# was placed during server turn `t + 1` of its round. The round is
|
||||
# found from the bot's OWN logged position (unique per round).
|
||||
hits = where.get((round(s["sx"], 3), round(s["sy"], 3)), ())
|
||||
rnd = None
|
||||
for hrow in hits:
|
||||
for r_ in rounds:
|
||||
if r_["startTick"] <= hrow < r_["startTick"] + r_["count"]:
|
||||
rnd = r_["round"]
|
||||
break
|
||||
if rnd is not None:
|
||||
break
|
||||
if rnd is None:
|
||||
unmatched += 1
|
||||
continue
|
||||
sturn = s["tick"] + 1
|
||||
cands = []
|
||||
for e in by_round[rnd]:
|
||||
if abs(e["tick"] - sturn) > 2 or abs(e["power"] - s["p"]) > 1e-6:
|
||||
continue
|
||||
oerr = math.hypot(s["gx"] - e["x"], s["gy"] - e["y"])
|
||||
if oerr < 32.0: # the scanned enemy IS the shooter
|
||||
cands.append((abs(e["tick"] - sturn), oerr, e, e["tick"]))
|
||||
if not cands:
|
||||
unmatched += 1
|
||||
continue
|
||||
cands.sort()
|
||||
_, oerr, e, eturn = cands[0]
|
||||
sp = 20.0 - 3.0 * e["power"]
|
||||
th = math.radians(e["dir"])
|
||||
vx, vy = sp * math.cos(th), sp * math.sin(th)
|
||||
d = sturn - eturn # +ve = ghost spawned LATE
|
||||
# a bullet takes its FIRST step during the turn it is fired, so at
|
||||
# the start of server turn `eturn + n` the true bullet sits at
|
||||
# origin + n * v.
|
||||
tx, ty = e["x"] + vx * d, e["y"] + vy * d
|
||||
# arrival deadline: the mover's own eta (logged) vs the true remaining
|
||||
# flight time to the TRUE bullet from the same reference point.
|
||||
etaTrue = math.hypot(s["sx"] - tx, s["sy"] - ty) / sp
|
||||
recs.append(dict(round=rnd, tick=s["tick"], lag=d, power=e["power"],
|
||||
speed=sp, origin_err=oerr, eta=s["eta"],
|
||||
err=math.hypot(s["gx"] - tx, s["gy"] - ty),
|
||||
lagerr=abs(s["eta"] - etaTrue)))
|
||||
|
||||
if verbose:
|
||||
print(f"\n=== {arm_dir}")
|
||||
print(f"enemy owner id={enemy_owner} spawns={len(spawns)} matched={len(recs)}"
|
||||
f" unmatched={unmatched}")
|
||||
if not recs:
|
||||
return None, []
|
||||
print("DETECTION LAG (capture row of the spawn - capture row of the fire),"
|
||||
" +ve = detected LATE:")
|
||||
for lag, n in sorted(collections.Counter(r["lag"] for r in recs).items()):
|
||||
print(f" lag={lag:+d} ticks : {n:4d} ({100.0*n/len(recs):5.1f}%)")
|
||||
errs = sorted(r["err"] for r in recs)
|
||||
n = len(errs)
|
||||
print(f"GHOST-vs-TRUTH displacement px: mean={sum(errs)/n:.2f} median={errs[n//2]:.2f}"
|
||||
f" p90={errs[int(0.9*n)]:.2f} max={errs[-1]:.2f}")
|
||||
oe = sorted(r["origin_err"] for r in recs)
|
||||
print(f" of which ghost ORIGIN vs fire origin (the scanned enemy position):"
|
||||
f" mean={sum(oe)/n:.2f} median={oe[n//2]:.2f} max={oe[-1]:.2f}")
|
||||
# the pure time part: lag * speed
|
||||
pure = sorted(abs(r["lag"]) * r["speed"] for r in recs)
|
||||
print(f"TIME part only (|lag| * speed): mean={sum(pure)/n:.2f} "
|
||||
f"median={pure[n//2]:.2f} max={pure[-1]:.2f}")
|
||||
le = sorted(r["lagerr"] for r in recs)
|
||||
print(f"ARRIVAL-DEADLINE error (mover's eta - the true remaining flight),"
|
||||
f" ticks: mean={sum(le)/n:.3f} median={le[n//2]:.3f}"
|
||||
f" p90={le[int(0.9*n)]:.3f} max={le[-1]:.3f}")
|
||||
return None, recs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for d in sys.argv[1:]:
|
||||
analyse(d)
|
||||
@@ -848,6 +848,113 @@ when declared(loadTfilCommitEnv):
|
||||
" 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()
|
||||
@@ -857,6 +964,7 @@ when declared(loadTfilCommitEnv):
|
||||
testJ144()
|
||||
testJ145()
|
||||
testJ146()
|
||||
testJ147()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
|
||||
@@ -268,6 +268,7 @@ name, with no new knob:
|
||||
| `TR_RACK_<GUN>` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_<GUN>=off`: the gun is removed from the rack |
|
||||
| `TR_POWER_POLICY` | energy-aware power caps (default) | uncapped: the gun's own preferred power |
|
||||
| `TR_FIRE_FIX` | the corrected enemy-fire detector (default) | the shipped `prev - energy` detector |
|
||||
| `TR_FIRE_LAG` = `<int>` | back-date every detected enemy fire by N ticks at spawn (0 = shipped; **1 = the measured live detection lag**, j147) | n/a — it is a value knob |
|
||||
| `TR_RADAR_FORCE_SPIN` | force the old stateless full-spin melee radar (**off by default**) | the adaptive arc-narrowing radar (default) |
|
||||
| `TR_TFIL_HEAT_TIME` | time-indexed bullet heat (**off by default**) | flat, time-independent heat (default) |
|
||||
| `TR_VBULLET_DEBUG` | draw the virtual-bullet overlay (**off by default**) | nothing drawn |
|
||||
|
||||
@@ -3454,3 +3454,120 @@ middle 30.4% · corr10 32.1% · bullets 65.0% · nofield 3.9%.
|
||||
**The shipped default is untouched.** `TR_MOVEMENT=strafe` remains the default;
|
||||
`TR_TFIL_BULLET_CORE` / `TR_TFIL_BULLET_AURA` default to today's `10.0` / `5.0`,
|
||||
so `TR_MOVEMENT=tfil` still means today's tfil, byte-for-byte (guard check 1).
|
||||
|
||||
# Batch 8 — the fire-detection lag (j147)
|
||||
|
||||
*Pre-registered BEFORE any battle of this batch was launched. No battle of this
|
||||
batch existed when this section was written; the frozen binary for it is the
|
||||
commit that adds `TR_FIRE_LAG` and the `TR_FIRE_DIAG` ghost-spawn trace.*
|
||||
|
||||
## The owner's report, and what was measured
|
||||
|
||||
*"i don't know if is the drawing only the arrives 1 tick later in the gui, but
|
||||
the bullet auras looks like are all 1 tick-ish behind the real bullet!"*
|
||||
|
||||
The first job was to answer **drawing or decision**, not to fix anything. Three
|
||||
measurements, in order, each one able to stop the next:
|
||||
|
||||
### 1. The corpus says the ENERGY DROP is on the fire's own row (lag 0)
|
||||
|
||||
`/tmp/tfil_ab2/out` (70 battles, `runN.jsonl` + `runN.events.jsonl`): for every
|
||||
true fire event, the row at which the shooter's energy drop becomes visible is
|
||||
`fireTick - 1` for **702/702** self fires in round 1 and 100% over the corpus —
|
||||
i.e. in the recorded frame the drop and the shot are the SAME instant (a bullet
|
||||
takes its first step during the turn it is fired, MEASURED: 1293/1293 `hitwall`
|
||||
events have their first out-of-bounds bullet position at step
|
||||
`hitwallTick - fireTick + 1`, which is only consistent with a first step inside
|
||||
the firing turn). So the corpus alone cannot see a lag: it has no view of WHEN
|
||||
our scan runs relative to the dispatch.
|
||||
|
||||
### 2. The corpus is NOT the bot's view, so the lag had to be measured LIVE
|
||||
|
||||
`common_libs/tests/measure_fire_ghost_lag.py`. The bot logs one
|
||||
`[firediag] SPAWN tick=… sx=… sy=… gx=… gy=… p=… eta=…` line per detected fire
|
||||
(the ghost's DRAWN position and our own position, the timeline anchor). The
|
||||
capture supplies the true fire events (origin, direction, power) and the rounds.
|
||||
The timeline is anchored without guessing: `[firediag] EV hit tick=… getTurn=…`
|
||||
lines vs. the sidecar's own event turns match exactly, and give
|
||||
`getTurn = bot.tick + 1` (j134, re-verified) — so a ghost logged at bot tick `t`
|
||||
was placed during server turn `t + 1`.
|
||||
|
||||
| arm | matched spawns | detection lag | ghost-vs-observer px (mean / median / p90) | arrival-deadline error (ticks, mean / median) |
|
||||
|---|---:|---|---:|---:|
|
||||
| tfil, lag 0 | 413 | **+1 tick, 100%** | **19.06 / 19.16 / 22.00** | **0.987 / 0.991** |
|
||||
| tfil, `TR_FIRE_LAG=1` | 446 | +1 tick, 100% | **5.37 / 5.65 / 8.96** | **0.063 / 0.051** |
|
||||
| strafe, lag 0 | 497 | +1 tick (77.9%; the rest are duplicate/split waves of a fire already counted) | **16.08 / 18.23 / 21.81** | 0.771 / 0.944 |
|
||||
| strafe, `TR_FIRE_LAG=1` | 466 | +1 tick, 100% | **6.01 / 5.91 / 9.80** | **0.065 / 0.051** |
|
||||
|
||||
**The answer to the owner: it is NOT only the drawing — the decision is late.**
|
||||
The aura is displaced by exactly **one whole bullet step (11..20 px, 19.1 px
|
||||
mean for tfil)**, in the direction of travel, and the arrival deadline the
|
||||
mover reads is **a full tick late (0.99 ticks)**. The mechanism is measured, not
|
||||
guessed: the server dispatches a turn's fire **after** our `go()` for that turn,
|
||||
so the energy drop of a turn-`T` shot first reaches our scan at turn `T+1`; and
|
||||
because a bullet takes its first step during the turn it is fired, the true
|
||||
bullet is already one step downrange when we see it. Both movers place the ghost
|
||||
at the SCANNED enemy position — where the bullet was *born* — and then advance
|
||||
it once per tick, so the entire ghost trajectory is the true one shifted one
|
||||
turn later, for the bullet's whole life.
|
||||
|
||||
**It is OURS.** The draw/advance order was checked and is correct (both movers
|
||||
`advanceBullets()` -> `detectFires()` -> build the field, i.e. a ghost spawned
|
||||
this tick is drawn at its age-0 position and every older ghost has been advanced
|
||||
exactly once: build-then-advance, which is the correct direction; an
|
||||
advance-then-build order would have shown the aura one tick AHEAD). With
|
||||
`TR_FIRE_LAG=1` the ghosts land on the observer's bullet to within the enemy's
|
||||
own scan staleness (5.4 px mean, max 8 px = the enemy's top speed), which is the
|
||||
floor this design can reach: the origin is the enemy's *scanned* position, not
|
||||
its fire-time position.
|
||||
|
||||
### The treatment
|
||||
|
||||
`TR_FIRE_LAG` (int, **default 0 = today's behaviour byte-for-byte**, `x` is only
|
||||
touched when `lag > 0`), read once in the shared
|
||||
`common_libs/movement_harness/fire_tracker.nim` and applied by BOTH movers at
|
||||
spawn: `x = origin + dir * speed * lag`, `y = …`. The arrival deadline needs no
|
||||
separate change — every mover derives it from the ghost's own position
|
||||
(`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
|
||||
correct deadline. Guard: `test_tfil_commit_env.nim` 77 -> **87 checks**, all pass
|
||||
(default parity on the golden replay, exact n-step back-date, deadline shortens
|
||||
by exactly `lag`, junk/negative degrade to 0, the ghost is reaped exactly one
|
||||
tick earlier).
|
||||
|
||||
## Arms (frozen, `tools/ab/arms_fire_lag.txt`)
|
||||
|
||||
| # | arm | mover | `TR_FIRE_LAG` | what it isolates |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `tfil_off` | tfil | 0 (default) | **the reference** — today's tfil |
|
||||
| 2 | `tfil_lag1` | tfil | 1 | the back-date, on tfil |
|
||||
| 3 | `strafe_off` | strafe | 0 (default) | **the reference** — today's strafe |
|
||||
| 4 | `strafe_lag1` | strafe | 1 | the back-date, on strafe |
|
||||
|
||||
Panel: the FROZEN 15-opponent `tools/ab/panel_movement.txt`. Harness:
|
||||
`tools/ab/tournament_run.sh` + `tournament_analyze.py`.
|
||||
|
||||
## Pre-registered prediction, MDE and decision rule
|
||||
|
||||
* **MDE, stated up front.** The verdict layer is the paired per-opponent
|
||||
difference over 15 opponents, exactly as batches 4-7. Batch 7 (5 runs/arm)
|
||||
measured **MDE = 12.8 damage/run and 0.28 wins/run**; this batch runs **3
|
||||
runs/arm**, so by `sqrt(5/3)` the MDE degrades to roughly **16 damage/run and
|
||||
0.36 wins/run** — and the incoming-hit-rate MDE to roughly **1.9 points**.
|
||||
**Any true effect smaller than that is invisible here by construction, and a
|
||||
null will be recorded as "not distinguishable", never as "no effect".**
|
||||
* **Prediction.** `tfil_lag1` > `tfil_off` and `strafe_lag1` > `strafe_off` on
|
||||
damage/run and round wins, because the field the mover decides on is displaced
|
||||
by a whole bullet step today and stops being after the fix. The **mechanism is
|
||||
the incoming hit rate** (the dodge should survive strictly more), and the
|
||||
offline gate already measured the mechanism geometrically (19.1 -> 5.4 px,
|
||||
0.99 -> 0.06 ticks), so a mechanism-positive / outcome-null result is the
|
||||
EXPECTED shape given the MDE, and is recorded as such — the same verdict
|
||||
pattern as j144, j145 and j146.
|
||||
* **Verdict rule (unchanged, not re-interpreted afterwards).** The cross-opponent
|
||||
sign test p < 0.05 on one primary metric (damage/run or round wins) with the
|
||||
other not down, SD/SE/95% CI/MDE reported.
|
||||
* **Nothing separates -> nothing changes.** `TR_FIRE_LAG` stays default 0 and
|
||||
the shipped movers are untouched. A mechanism-positive outcome-null does NOT
|
||||
retract the geometric measurement, and does NOT change `TR_MOVEMENT=strafe`.
|
||||
|
||||
*(results appended below after the battles)*
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# arms_fire_lag.txt — j147: the ONE-TICK DETECTION LAG, four arms on the frozen
|
||||
# movement panel (tools/ab/panel_movement.txt). Two movers (tfil, strafe) x
|
||||
# {lag 0, lag 1}; the lag knob is the ONLY difference inside a mover pair.
|
||||
#
|
||||
# WHY. MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions,
|
||||
# 1777 matched ghost spawns): the server dispatches a turn's fire AFTER our
|
||||
# go() for that same turn, so the energy drop of a turn-T shot first reaches our
|
||||
# scan at turn T+1 (our bot tick T). A bullet takes its FIRST step during the
|
||||
# turn it is fired, so by then the true bullet is already `speed` px (11..20 px,
|
||||
# one whole bullet step) downrange and the arrival deadline is a full tick short.
|
||||
# Both movers place the ghost at the SCANNED enemy position, i.e. exactly where
|
||||
# the bullet was born: the whole ghost trajectory is the true one shifted one
|
||||
# turn later. The measured ghost-vs-observer displacement is 19.1 px mean /
|
||||
# 22.0 p90 (tfil) and 16.1 / 21.8 (strafe), deadline error 0.99 / 0.77 ticks.
|
||||
# With TR_FIRE_LAG=1 the displacement falls to 5.4 / 9.0 px (the residue is the
|
||||
# ENEMY's own scan staleness, <= 8 px) and the deadline error to 0.06 ticks.
|
||||
#
|
||||
# `TR_FIRE_LAG` default 0 = today's behaviour byte for byte, so arm 1 and arm 3
|
||||
# ARE the shipped movers.
|
||||
#
|
||||
# Pre-registered in docs/movement_campaign.md ("Batch 8 — the fire-detection
|
||||
# lag") BEFORE any of these battles ran. References: `tfil_off`, `strafe_off`.
|
||||
#
|
||||
# Format: name | ENV=value ENV=value | label
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 1. tfil as shipped (the reference for arm 2).
|
||||
tfil_off | TR_MOVEMENT=tfil | today's tfil, the ghost is born at the scanned enemy (reference)
|
||||
|
||||
# 2. tfil with the measured lag back-dated at spawn.
|
||||
tfil_lag1 | TR_MOVEMENT=tfil TR_FIRE_LAG=1 | tfil, every detected fire back-dated one bullet step
|
||||
|
||||
# 3. strafe as shipped (the reference for arm 4).
|
||||
strafe_off | TR_MOVEMENT=strafe | today's strafe, the ghost is born at the scanned enemy (reference)
|
||||
|
||||
# 4. strafe with the measured lag back-dated at spawn.
|
||||
strafe_lag1 | TR_MOVEMENT=strafe TR_FIRE_LAG=1 | strafe, every detected fire back-dated one bullet step
|
||||
Reference in New Issue
Block a user