## Unit checks for the j133/j134 fire-detection fix (`TR_STRAFE_FIRE_FIX`). ## ## NO battle, NO Java, NO server. Run with: ## nim c -r --nimcache:/tmp/nc_j133 --path:common_libs \ ## common_libs/tests/test_strafe_fire_fix.nim ## ## The shipped detector classified the enemy energy delta `prev - cur` with the ## single window `0.09 <= drop <= 3.01`. Two SERVER facts break that window: ## ## 1. `BULLET_HIT_ENERGY_GAIN_FACTOR = 3` (server rules.kt): when an ENEMY ## bullet hits us, the SHOOTER's energy RISES by `3*power`. If it fired in ## the same tick the two cancel and the net delta reads as "no fire" -> ## the bullet gets NO heat. ## 2. our own bullet damaging the enemy in the same tick adds `damage` to the ## delta, so it can exceed 3.01 -> REJECTED, no heat for the enemy's shot. ## ## The fix undoes both from the events (`noteEnemyBulletHit` / `noteDamageDealt`) ## and splits any still-too-large delta instead of dropping it. These checks pin ## the four behaviours plus the OFF-switch parity with the shipped detector. ## ## ── The one-SCAN delay (j134, MEASURED LIVE) ──────────────────────────────── ## The correction is NOT applied to the immediately-next reading. The server ## emits the hit event on turn N but applies the energy change to turn N+1's ## reading (live evidence: `EV hit getTurn=67` -> the `raw=-5.803` gain appears ## in the `getTurn=68` reading). Because `note*` runs during `go()`, AFTER that ## turn's `endScan`, the tracker keeps a one-slot double buffer so the ## correction lands on the reading that carries the change. A unit test must ## therefore feed: (1) a scan to rotate the event in, then (2) the reading whose ## raw shows the effect. `advance` below does exactly that. import std/[math, os] import gun_harness/gun_interface # Private-field access: include (do NOT import) the mover, exactly as # `measure_strafe_heat_display.nim` does for the display check. include movements/strafe var failures = 0 proc check(name: string, ok: bool) = if ok: echo "PASS: ", name else: echo "FAIL: ", name; inc failures proc ws(ee: float): WorldState = WorldState( enemyX: 200.0, enemyY: 200.0, enemyEnergy: ee, selfX: 400.0, selfY: 300.0, selfHeading: 0.0, selfSpeed: 6.0, arenaWidth: 800.0, arenaHeight: 600.0, tick: 10, enemies: @[EnemyInfo(id: 1, x: 200.0, y: 200.0, energy: ee)]) proc freshModule(): StrafeModule = var m = initStrafe() m.initGrid(800.0, 600.0) m.resetRound() m # ── 1. plain fire (unchanged behaviour) ─────────────────────────────────────── block: var mm = freshModule() mm.detectFires(ws(100.0)) mm.detectFires(ws(97.95)) # 2.05 drop, in range -> one wave check "plain fire creates exactly one wave", mm.bullets.len == 1 check "plain fire wave power == drop", mm.bullets.len == 1 and abs(mm.bullets[0].power - 2.05) < 1e-9 # ── 2. the +3*power bonus masks a fire (the owner's "bullet without heat") ──── block: # Enemy at 100 fires 0.35 while an EARLIER 0.45 bullet of its own hits us # (+3*0.45 = +1.35). Net energy = 100 - 0.35 + 1.35 = 101.0 -> raw drop = -1.0. var mm = freshModule() mm.detectFires(ws(100.0)) # establish prev = 100 mm.noteEnemyBulletHit(0.45) # event noted (staged) mm.detectFires(ws(100.0)) # rotates the staged correction in mm.detectFires(ws(101.0)) # the reading carrying the change check "masked fire: WITH fix a wave is created", mm.bullets.len == 1 check "masked fire: WITH fix the power is recovered", mm.bullets.len == 1 and abs(mm.bullets[0].power - 0.35) < 1e-9 # ── 3. our own damage inflates the drop past the cap ───────────────────────── block: # Enemy at 50 fires 2.0 and our bullet hits it for 1.8 -> energy 46.2, # raw drop 3.8 > 3.01 (shipped detector rejects it outright). var mm = freshModule() mm.prevEnergySet(1, 50.0) mm.noteDamageDealt(1.8) mm.detectFires(ws(50.0)) # rotates the staged correction in mm.detectFires(ws(46.2)) # raw 3.8 - 1.8 = 2.0 -> wave check "inflated drop: WITH fix a wave is created", mm.bullets.len == 1 check "inflated drop: WITH fix the power is recovered", mm.bullets.len == 1 and abs(mm.bullets[0].power - 2.0) < 1e-9 # ── 4. never silently drop a too-large delta (unobserved contamination) ─────── block: var mm = freshModule() mm.detectFires(ws(100.0)) mm.detectFires(ws(93.0)) # 7.0 with no events -> split, not dropped check "too-large drop is SPLIT, never dropped", mm.bullets.len == 3 check "split waves all within the power cap", mm.bullets.len == 3 and mm.bullets[0].power <= 3.0 and mm.bullets[1].power <= 3.0 check "split waves sum back to the drop", mm.bullets.len == 3 and abs(mm.bullets[0].power * 3.0 - 7.0) < 1e-9 # ── 5. OFF switch == shipped detector, byte for byte ───────────────────────── block: putEnv("TR_STRAFE_FIRE_FIX", "0") loadStrafeEnv() # (a) masked fire: shipped detector sees raw = -1.0 -> NO wave. var a = freshModule() a.detectFires(ws(100.0)) a.noteEnemyBulletHit(0.45) # must be a no-op when the fix is off a.detectFires(ws(100.0)) a.detectFires(ws(101.0)) check "OFF: masked fire is NOT detected (shipped behaviour)", a.bullets.len == 0 # (b) inflated drop: shipped detector sees 3.8 -> rejected -> NO wave. var b = freshModule() b.prevEnergySet(1, 50.0) b.noteDamageDealt(1.8) b.detectFires(ws(50.0)) b.detectFires(ws(46.2)) check "OFF: inflated drop is rejected (shipped behaviour)", b.bullets.len == 0 # (c) in-range drop is still caught exactly once. var c = freshModule() c.detectFires(ws(100.0)) c.detectFires(ws(98.0)) check "OFF: plain fire still caught once", c.bullets.len == 1 and abs(c.bullets[0].power - 2.0) < 1e-9 delEnv("TR_STRAFE_FIRE_FIX") loadStrafeEnv() # ── 6. default is ON ───────────────────────────────────────────────────────── block: delEnv("TR_STRAFE_FIRE_FIX") delEnv("TR_FIRE_FIX") loadStrafeEnv() check "default TR_STRAFE_FIRE_FIX is ON", StrafeFireFix # ── 7. the global TR_FIRE_FIX also gates STRAFE ────────────────────────────── block: putEnv("TR_FIRE_FIX", "0") loadStrafeEnv() check "TR_FIRE_FIX=0 disables STRAFE too", not StrafeFireFix delEnv("TR_FIRE_FIX") loadStrafeEnv() if failures == 0: echo "\nAll STRAFE fire-detection fix checks passed." else: echo "\n", failures, " STRAFE fire-detection fix check(s) FAILED." quit(1)