diff --git a/common_libs/movement_harness/fire_tracker.nim b/common_libs/movement_harness/fire_tracker.nim index c1f2e12..f182d23 100644 --- a/common_libs/movement_harness/fire_tracker.nim +++ b/common_libs/movement_harness/fire_tracker.nim @@ -137,10 +137,10 @@ proc detect*(t: var FireTracker, id: int, energy: float, 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 +## MEASURED LIVE (`common_libs/tests/measure_fire_ghost_lag.py`, 4 sessions, +## 1777 matched ghost spawns over both movers, `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 diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim index ce788b0..aab0766 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -579,16 +579,17 @@ proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo, 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`. + let vx = speed * cos(heading) + let vy = speed * sin(heading) var gx = ei.x var gy = ei.y if FireLag > 0: - gx += (speed * cos(heading)) * FireLag.float - gy += (speed * sin(heading)) * FireLag.float + gx += vx * FireLag.float + gy += vy * FireLag.float m.bullets.add TrackedBullet( originX: ei.x, originY: ei.y, x: gx, y: gy, - velX: speed * cos(heading), - velY: speed * sin(heading), + velX: vx, velY: vy, power: power, alive: true, age: 0) proc noteEnemyBulletHit*(m: var StrafeModule, power: float) = diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index a13eb4d..2a1a2b5 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -433,16 +433,18 @@ proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo, 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`. + let vx = speed * cos(heading) + let vy = speed * sin(heading) var gx = ei.x var gy = ei.y if FireLag > 0: - gx += (speed * cos(heading)) * FireLag.float - gy += (speed * sin(heading)) * FireLag.float + gx += vx * FireLag.float + gy += vy * FireLag.float m.bullets.add TrackedBullet( originX: ei.x, originY: ei.y, x: gx, y: gy, - velX: speed * cos(heading), - velY: speed * sin(heading), + velX: vx, + velY: vy, power: power, alive: true, age: 0) diff --git a/docs/movement_campaign.md b/docs/movement_campaign.md index 7127cb8..5715700 100644 --- a/docs/movement_campaign.md +++ b/docs/movement_campaign.md @@ -3571,3 +3571,92 @@ Panel: the FROZEN 15-opponent `tools/ab/panel_movement.txt`. Harness: retract the geometric measurement, and does NOT change `TR_MOVEMENT=strafe`. *(results appended below after the battles)* + +### MEASURED — gate B: the guard (`test_tfil_commit_env.nim`, 77 -> 87 checks) + +All 87 pass, including the byte-for-byte golden replay of the shipped mover with +`TR_FIRE_LAG` unset (check 1). The j147 ones: + +* `TR_FIRE_LAG` unset -> `FireLag 0`, and the ghost lands EXACTLY on the scanned + enemy (`b.x == ei.x` bit for bit — the position is only touched when `lag > 0`). +* `=1` -> the ghost is exactly one bullet step (17 px at power 1.0) downrange on + its own heading; `=2` -> exactly two; the step length is the true + `20 - 3*power`, not a scaled one. +* **the arrival deadline**: the mover's eta equals the TRUE remaining flight + (10.764706 vs 10.764706) where the lag-0 eta was 11.764706 — a full tick late; + at `lag=2` the deadline shortens by exactly 2 ticks. +* a junk or negative value degrades to the shipped lag 0 (never a negative + back-date); clearing the knob restores the shipped spawn exactly. +* end to end: the ghost is reaped (`dot < 0`, the geometric arrival the mover + actually uses) **exactly one tick earlier** — 12 -> 11 ticks. +* `test_env_report` + `test_env_dotenv` green with `TR_FIRE_LAG` registered in + `env_report.nim` + `knownEnvNames()` + `.env.example` + `docs/env_reference.md`. + +### MEASURED — gate C: the live A/B, 180 battles + +> **Provenance.** Session `/tmp/ab/j147_firelag`, frozen binary `d21f7ce` +> (sha256 `29571d4d…`), panel `tools/ab/panel_movement.txt` (15 opponents, +> FROZEN), arms file `tools/ab/arms_fire_lag.txt` registered above BEFORE any of +> these battles ran. **4 arms x 15 opponents x 3 runs x 3 rounds = 180 battles, +> 0 failed, 0 never started, 473 s.** The MDEs the analyzer actually reported at +> 3 runs/arm: **12.2-15.1 damage/run, 0.33-0.55 wins/run, 1.4-2.9 hit-rate +> points** — the pre-registered estimate (~16 / ~0.36 / ~1.9) was right. + +**Pooled dashboard (descriptive, NOT the verdict):** + +| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | incoming hit rate | mean distance | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| `tfil_off` | 45 | 109.7 | 187.8 | 1.24 | 56/135 | 41.5% | 16.91% | 394 | +| `tfil_lag1` | 45 | 111.8 | 192.9 | 1.20 | 54/135 | 40.0% | 17.45% | 396 | +| `strafe_off` | 45 | 106.3 | 160.1 | 1.44 | 65/135 | 48.1% | 12.84% | 434 | +| `strafe_lag1` | 45 | 110.1 | 159.0 | **1.62** | **73/135** | **54.1%** | 13.21% | 428 | + +**Verdict layer, each mover against ITS OWN reference (the only comparison that +isolates the knob):** + +| arm | metric | mean Δ | 95% CI | sign test | p(sign) | p(sign-flip) | Wilcoxon p | MDE | +|---|---|---:|---|---:|---:|---:|---:|---:| +| `tfil_lag1` vs `tfil_off` | damage | +2.08 | [-7.22, +11.38] | 6/15 | 0.6072 | 0.6375 | 0.9773 | 12.15 | +| `tfil_lag1` vs `tfil_off` | wins | -0.04 | [-0.29, +0.21] | 4/9 | 1 | 0.8516 | 0.5923 | 0.33 | +| `tfil_lag1` vs `tfil_off` | hit_rate | +0.94 | [-0.93, +2.81] | 10/15 | 0.3018 | 0.2984 | 0.222 | 2.45 | +| `strafe_lag1` vs `strafe_off` | damage | +3.77 | [-6.98, +14.53] | 9/15 | 0.6072 | 0.4598 | 0.6701 | 14.04 | +| `strafe_lag1` vs `strafe_off` | wins | +0.18 | [-0.13, +0.49] | 5/9 | 1 | 0.3359 | 0.1723 | 0.41 | +| `strafe_lag1` vs `strafe_off` | hit_rate | +0.33 | [-0.73, +1.39] | 9/15 | 0.6072 | 0.5403 | 0.5509 | 1.38 | + +### VERDICT — plain + +1. **Was it only the drawing? NO. The decision was late, by exactly one bullet + step, and the fix is now in.** Measured live on 1777 matched ghost spawns + across both movers: the detection lag is **+1 tick on 100%** of them, the + ghost-vs-observer displacement is **19.1 px mean / 22.0 p90** (tfil) and + **16.1 / 21.8** (strafe), and the arrival deadline the mover reads is + **0.99 / 0.77 ticks late**. With `TR_FIRE_LAG=1` the displacement is + **5.4 / 9.0 px** and the deadline error **0.06 ticks** — the residue is the + ENEMY's own scan staleness (<= 8 px, its top speed), which is the floor this + design can reach because the ghost's origin is the enemy's *scanned* position. + The draw/advance order was checked and is correct, so the GUI was faithfully + drawing a wrong field. +2. **The live OUTCOME is null, and that is recorded as "not distinguishable".** + `tfil_lag1` is -0.04 wins/run and `strafe_lag1` is +0.18 wins/run — both far + under the MDEs the analyzer reported (0.33 and 0.41). Nothing reaches the + pre-registered bar, so under the campaign's rule **nothing is changed**: + `TR_FIRE_LAG` stays **default 0** and both movers ship exactly as before. The + knob is there, measured and documented, for anyone who wants the arm. +3. **The live MECHANISM did not move either** — incoming hit rate +0.94 pp (tfil) + and +0.33 pp (strafe), neither significant. This is the fourth consecutive + movement job where a real, measured mechanism change does not show up as fewer + hits taken. Two readings, both worth keeping: the dodge is limited by the + 1-tick-stale enemy POSITION and by the 8-px scan staleness of the ghost's + origin, not by a 19-px translation of a field whose core is 18 px and whose + corridor is 40 px wide; and at 3 runs/arm a real few-percent effect in hit + rate sits under the ~1.4-point MDE. **What the fix does buy, provably, is + the arrival deadline**: every mover's heat, corridor and reap are now timed + off the bullet's real position, which is the input the next arrival-commit / + time-indexed-heat work needs to be correct at all. +4. **The one significant live result in this batch is the MOVER, not the knob**: + `strafe_lag1` vs `tfil_off` is +0.38 wins/run (sign 10/12, p = 0.0386) with + the incoming hit rate **-4.67 pp (sign 2/15, p = 0.0074, sign-flip + p = 0.0007, Wilcoxon p = 0.0024)** and mean distance +34 px (14/15). That is + the known strafe-over-tfil gap reproducing itself, and it is exactly why the + pre-registration demanded the within-mover reference: read against `tfil_off` + alone, the knob looks like a winner it is not.