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
+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