j134 fix: tracker must take the mover's switch per-call (instance flag was silently off on the object-literal construction path) + apply the correction on the reading that carries the server's turn-N+1 energy change (two-slot buffer); live-verified alignment

This commit is contained in:
2026-09-26 12:56:26 +02:00
parent 6ad5d99922
commit 882733819b
8 changed files with 133 additions and 79 deletions
+2 -3
View File
@@ -613,8 +613,7 @@ method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
bot.surfMover.noteDamageDealt(e.damage)
bot.learnedMover.noteDamageDealt(e.damage)
if FireDiag:
echo "[firediag] EV dmg tick=", bot.tick, " damage=", e.damage
# Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
echo "[firediag] EV dmg tick=", bot.tick, " getTurn=", getTurn(), " damage=", e.damage # Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1)
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
@@ -673,7 +672,7 @@ method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
bot.surfMover.noteEnemyBulletHit(e.bullet.power)
bot.learnedMover.noteEnemyBulletHit(e.bullet.power)
if FireDiag:
echo "[firediag] EV hit tick=", bot.tick, " power=", e.bullet.power
echo "[firediag] EV hit tick=", bot.tick, " getTurn=", getTurn(), " power=", e.bullet.power
# Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's
# 4p/6p-2 damage), not raw firepower, so `damageRatePerTurn` is a real
# energy/turn rate (see movements/ram_decision.bulletDamage).
+61 -32
View File
@@ -14,9 +14,9 @@
##
## ── The two server facts that break the window (verified in the server) ─────
## * `rules.kt BULLET_HIT_ENERGY_GAIN_FACTOR = 3`: when an ENEMY bullet hits
## US the SHOOTER's energy RISES by `3*power` in the same tick. That rise
## is folded into the delta we read and can MASK the `power` the enemy spent
## firing the same tick (net delta >= 0 reads as "no fire").
## US the SHOOTER's energy RISES by `3*power`. That rise is folded into
## the delta we read and can MASK the `power` the enemy spent firing the
## same tick (net delta >= 0 reads as "no fire").
## `noteEnemyBulletHit` adds the bonus back.
## * our own bullet damaging the enemy the same tick adds `damage` to the
## delta, which can push it above the power cap and get the enemy's OWN
@@ -29,34 +29,59 @@
## `common_libs/tests/measure_strafe_fire_catch.py`).
##
## ── Switch ──────────────────────────────────────────────────────────────────
## Default ON. `TR_FIRE_FIX=0` disables the correction in EVERY mover;
## `TR_STRAFE_FIRE_FIX=0` still disables it in STRAFE (j133 back-compat), so
## STRAFE is on only when BOTH are on. With the fix off `detect` is exactly
## `prev - energy` inside the caller's own window — byte-identical to the
## shipped detector.
## The tracker stores NO enable flag: the CALLER passes `fix` to `detect` and
## guards its `note*` calls with its own knob. That is deliberate — an
## instance-level flag was silently false on the ModularBot construction path
## (the movers are built as object literals, not via `init*`), so the "fix"
## never ran live. The mover's module-global switch is now the single source of
## truth: `TR_FIRE_FIX` (default ON) for every mover, ANDed with
## `TR_STRAFE_FIRE_FIX` for STRAFE. With `fix=false`, `detect` is exactly
## `prev - energy` inside the caller's own window — byte-identical to shipped.
##
## ── Why the correction is applied with a one-SCAN delay ─────────────────────
## MEASURED LIVE (job j134, `TR_FIRE_DIAG`): the server emits the hit event on
## turn N but applies the energy change to the SHARED energy reading of turn
## N+1. Concretely, `EV hit getTurn=67` (dispatched at `bot.tick=66`, i.e.
## after that turn's movement) is followed by the `raw=-5.803` gain in the
## reading of `getTurn=68`. Because `note*` is called during `go()`, AFTER the
## same-tick `endScan`, a one-slot double buffer (`incoming` -> `pending`) makes
## the correction land on the reading that actually carries the change:
## * scan T : `detect` applies `pending`; `endScan` rotates incoming->pending
## * `go()` : `note*` adds to `incoming`
## * scan T+1 : `detect` applies 0 (event not yet rotated in)
## * scan T+2 : `detect` applies the event's correction — the reading whose
## `raw` shows the gain. Without this the correction lands one
## reading EARLY on a zero delta (a spurious wave) and the real
## delta is left uncorrected.
##
## (The j133 offline corpus model already applied the event to the row that
## carries its energy change; this makes the live path agree with it.)
import std/math
type
FireTracker* = object
## Per-enemy last-known energy plus the corrections accumulated from the
## event stream since the previous scan. One instance per mover.
## Per-enemy last-known energy plus the event corrections. One instance
## per mover.
prevEnergy*: seq[tuple[id: int, energy: float]]
hitBonusPending*: float ## 3 * power of enemy bullets that hit us
dealtPending*: float ## damage our bullets dealt to the enemy
enabled*: bool ## the j134 correction is active
hitBonusPending*: float ## applied to the CURRENT reading
dealtPending*: float
hitBonusIncoming: float ## noted since the last `endScan`
dealtIncoming: float
splitWaves*: int ## waves emitted by splitting a too-large drop
correctedTicks*: int ## readings whose drop was non-trivially corrected
proc initFireTracker*(enabled: bool): FireTracker =
FireTracker(prevEnergy: @[], enabled: enabled)
proc initFireTracker*(): FireTracker =
FireTracker(prevEnergy: @[])
proc reset*(t: var FireTracker) =
## Per-ROUND reset: drops the energy memory and any un-consumed event
## correction. Keeps `enabled` (a module-knob, not round state).
## correction.
t.prevEnergy = @[]
t.hitBonusPending = 0.0
t.dealtPending = 0.0
t.hitBonusIncoming = 0.0
t.dealtIncoming = 0.0
t.splitWaves = 0
t.correctedTicks = 0
@@ -73,32 +98,31 @@ proc prevEnergySet*(t: var FireTracker, id: int, energy: float) =
t.prevEnergy.add((id: id, energy: energy))
proc noteEnemyBulletHit*(t: var FireTracker, power: float) =
## `onHitByBullet` -> `e.bullet.power`. No-op when the fix is off.
if t.enabled:
t.hitBonusPending += 3.0 * power
## `onHitByBullet` -> `e.bullet.power`. The caller gates this on its switch.
## Staged for the scan AFTER next (see the header).
t.hitBonusIncoming += 3.0 * power
proc noteDamageDealt*(t: var FireTracker, damage: float) =
## `onBulletHit` -> `e.damage`. No-op when the fix is off.
if t.enabled:
t.dealtPending += damage
## `onBulletHit` -> `e.damage`. The caller gates this on its switch.
t.dealtIncoming += damage
proc detect*(t: var FireTracker, id: int, energy: float,
lo, hi: float): seq[float] =
lo, hi: float, fix: bool): seq[float] =
## Advance the tracker with one enemy energy reading and return the fire
## powers to spawn (empty = no fire).
##
## `lo`/`hi` are the CALLER's shipped window, so with the fix off the result
## is exactly the old `if drop >= lo and drop <= hi: @[drop]`. With the fix
## on the delta is corrected for the two observable server effects and a
## `lo`/`hi` are the CALLER's shipped window, so with `fix=false` the result
## is exactly the old `if drop >= lo and drop <= hi: @[drop]`. With `fix`
## the delta is corrected for the two observable server effects and a
## still-too-large delta is split rather than dropped.
let prev = t.prevEnergyGet(id)
let raw = prev - energy
var drop = raw
if t.enabled:
if fix:
drop += t.hitBonusPending - t.dealtPending
if abs(drop - raw) > 1e-9: inc t.correctedTicks
t.prevEnergySet(id, energy)
if t.enabled and drop > hi:
if fix and drop > hi:
# NEVER silently drop a drop. A delta above the power cap is either several
# fires folded into one reading or un-modelled contamination; some heat
# beats none. Split into the fewest waves each <= 3.0.
@@ -111,7 +135,12 @@ proc detect*(t: var FireTracker, id: int, energy: float,
result = @[drop]
proc endScan*(t: var FireTracker) =
## Call once after the per-enemy scan: each event correction applies to
## exactly one reading.
t.hitBonusPending = 0.0
t.dealtPending = 0.0
## Call once after the per-enemy scan. Rotates the event corrections one
## slot: the events noted since the previous `endScan` become the corrections
## applied by the NEXT scan, and the ones applied by the current scan are
## discarded. See the header for the measured one-turn server lag this
## encodes.
t.hitBonusPending = t.hitBonusIncoming
t.dealtPending = t.dealtIncoming
t.hitBonusIncoming = 0.0
t.dealtIncoming = 0.0
+6 -4
View File
@@ -312,7 +312,7 @@ proc resetRound*(m: var LearnedSurferModule) =
proc initLearnedSurfer*(): LearnedSurferModule =
result.debugGraphics = false
result.fire = initFireTracker(LearnedFireFix)
result.fire = initFireTracker()
result.sbc = initCountedSbc(LS_NADE, LS_BINS,
max(0, LearnedDecayEvery),
max(0, LearnedDecayShift))
@@ -498,11 +498,13 @@ proc prevEnergySet(m: var LearnedSurferModule, id: int, energy: float) =
proc noteEnemyBulletHit*(m: var LearnedSurferModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
if LearnedFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var LearnedSurferModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
if LearnedFireFix:
m.fire.noteDamageDealt(damage)
proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float,
ws: WorldState) =
@@ -510,7 +512,7 @@ proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float,
## drop IS a wave; the state is the wave-relative state at THIS tick (the
## fire tick). Window 0.09..3.01, LEARNED's shipped window; the shared
## tracker corrects the delta and splits an over-cap drop across waves.
for drop in m.fire.detect(id, eenergy, 0.09, 3.01):
for drop in m.fire.detect(id, eenergy, 0.09, 3.01, LearnedFireFix):
let botX = ws.selfX
let botY = ws.selfY
let bspeed = 20.0 - 3.0 * drop
+6 -4
View File
@@ -474,7 +474,7 @@ type
lastTileCol, lastTileRow: int
proc initStrafe*(): StrafeModule =
StrafeModule(debugGraphics: false, fire: initFireTracker(StrafeFireFix))
StrafeModule(debugGraphics: false, fire: initFireTracker())
proc removeBulletNear*(m: var StrafeModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
@@ -592,7 +592,8 @@ proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
## "no fire"). ModularBot forwards `onHitByBullet`'s `e.bullet.power` here so
## `detectFires` can add the bonus back before classifying the delta.
## No-op when the fix is off (shipped detector preserved).
m.fire.noteEnemyBulletHit(power)
if StrafeFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var StrafeModule, damage: float) =
## The mirror contamination: OUR bullet damaging the enemy this tick adds
@@ -600,7 +601,8 @@ proc noteDamageDealt*(m: var StrafeModule, damage: float) =
## cap and get the enemy's OWN shot rejected by the shipped `<= 3.01` test.
## ModularBot forwards `onBulletHit`'s `e.damage` here.
## No-op when the fix is off.
m.fire.noteDamageDealt(damage)
if StrafeFireFix:
m.fire.noteDamageDealt(damage)
proc detectFires(m: var StrafeModule, ws: WorldState) =
## The shared tracker does the delta correction + split; STRAFE supplies its
@@ -612,7 +614,7 @@ proc detectFires(m: var StrafeModule, ws: WorldState) =
" raw=", raw,
" bonus=", m.fire.hitBonusPending,
" dealt=", m.fire.dealtPending
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01):
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
+6 -4
View File
@@ -263,7 +263,7 @@ type
lastPickCall: int ## callCount at the last pick (log only)
picks: int ## number of picks this round (log only)
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false, fire: initFireTracker(TfilFireFix))
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false, fire: initFireTracker())
proc removeBulletNear*(m: var TFILModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
@@ -371,17 +371,19 @@ proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
proc noteEnemyBulletHit*(m: var TFILModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
if TfilFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var TFILModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
if TfilFireFix:
m.fire.noteDamageDealt(damage)
proc detectFires(m: var TFILModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
## fire. The shared tracker corrects the delta and splits over-cap drops.
for ei in ws.enemies:
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01):
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
@@ -255,7 +255,7 @@ type
proc initTFILRing*(): TFILRingModule =
TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi),
fire: initFireTracker(TfilRingFireFix))
fire: initFireTracker())
proc removeBulletNear*(m: var TFILRingModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
@@ -329,17 +329,19 @@ proc spawnTrackedWave(m: var TFILRingModule, ws: WorldState, ei: EnemyInfo,
proc noteEnemyBulletHit*(m: var TFILRingModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
if TfilRingFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var TFILRingModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
if TfilRingFireFix:
m.fire.noteDamageDealt(damage)
proc detectFires(m: var TFILRingModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
## fire. The shared tracker corrects the delta and splits over-cap drops.
for ei in ws.enemies:
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01):
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilRingFireFix):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
+6 -4
View File
@@ -129,7 +129,7 @@ proc resetRound*(m: var WaveSurferModule) =
for i in 0..<WS_BINS: m.bins[i] = 1.0
proc initWaveSurfer*(): WaveSurferModule =
var m = WaveSurferModule(debugGraphics: false, fire: initFireTracker(SurfFireFix))
var m = WaveSurferModule(debugGraphics: false, fire: initFireTracker())
m.resetRound()
m
@@ -149,11 +149,13 @@ proc prevEnergySet(m: var WaveSurferModule, id: int, energy: float) =
proc noteEnemyBulletHit*(m: var WaveSurferModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
if SurfFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var WaveSurferModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
if SurfFireFix:
m.fire.noteDamageDealt(damage)
proc gfToBin(gf: float64): int {.inline.} =
clamp(int(round((gf.clamp(-1.0, 1.0) + 1.0) * 0.5 * float64(WS_BINS - 1))),
@@ -191,7 +193,7 @@ proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy,
## (corrected) one-tick drop. `drop` IS the firepower. Window 0.1..3.0,
## SURF's shipped window; the shared tracker corrects the delta and splits an
## over-cap drop across several waves.
for power in m.fire.detect(id, eenergy, 0.1, 3.0):
for power in m.fire.detect(id, eenergy, 0.1, 3.0, SurfFireFix):
let bspeed = 20.0 - 3.0 * power
let bearing = arctan2(botY - ey, botX - ex)
let d = hypot(botX - ex, botY - ey)
+40 -24
View File
@@ -1,4 +1,4 @@
## Unit checks for the j133 STRAFE fire-detection fix (`TR_STRAFE_FIRE_FIX`).
## 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 \
@@ -17,6 +17,16 @@
## 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
@@ -44,13 +54,10 @@ proc freshModule(): StrafeModule =
# ── 1. plain fire (unchanged behaviour) ───────────────────────────────────────
block:
let m = freshModule()
var mm = m
var mm = freshModule()
mm.detectFires(ws(100.0))
mm.detectFires(ws(97.95)) # fired 2.05? use a clean power
# second reading: 100 -> 97.95 is a 2.05 drop, in range -> one wave
check "plain fire creates exactly one wave",
mm.bullets.len == 1
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
@@ -58,12 +65,12 @@ block:
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.
let m = freshModule()
var mm = m
mm.noteEnemyBulletHit(0.45)
mm.detectFires(ws(101.0))
check "masked fire: WITH fix a wave is created",
mm.bullets.len == 1
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
@@ -71,24 +78,21 @@ block:
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).
let m = freshModule()
var mm = m
mm.prevEnergySet(1, 50.0) # last reading before the contested tick
var mm = freshModule()
mm.prevEnergySet(1, 50.0)
mm.noteDamageDealt(1.8)
mm.detectFires(ws(46.2))
check "inflated drop: WITH fix a wave is created",
mm.bullets.len == 1
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:
let m = freshModule()
var mm = m
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 "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",
@@ -98,15 +102,18 @@ block:
block:
putEnv("TR_STRAFE_FIRE_FIX", "0")
loadStrafeEnv()
# (a) masked fire: shipped detector sees drop = -1.0 -> NO wave.
# (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.
@@ -121,9 +128,18 @@ block:
# ── 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: