## fire_tracker.nim — the ONE enemy-fire detector every mover calls (job j134). ## ## Before this, each mover carried its own copy of the same energy-drop ## detector (`tfil`, `tfil_ring`, `strafe`, `learned`, `surf`). Job j133 found ## that shipped detector classifies the enemy energy delta with a single window ## ## if drop >= 0.09 and drop <= 3.01: spawn one wave with power = drop ## ## and fixed it in `strafe.nim` only. The other four movers stayed blind to a ## MEASURED 1.11% of enemy shots — the bug survived precisely because there ## were four copies. This module is the single implementation; the movers own ## their own wave geometry and spawn code, but the DELTA CLASSIFICATION (and ## the two server-fact corrections below) lives here, once. ## ## ── 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`. 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 ## shot rejected. `noteDamageDealt` subtracts it. ## * a still-too-large delta is SPLIT into the fewest waves each <= 3.0 ## instead of being silently dropped. ## ## MEASURED on the 70-battle corpus (67065 true enemy fires): the shipped path ## catches 98.888%, the corrected path 100.000% (see ## `common_libs/tests/measure_strafe_fire_catch.py`). ## ## ── Switch ────────────────────────────────────────────────────────────────── ## 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 event corrections. One instance ## per mover. prevEnergy*: seq[tuple[id: int, energy: float]] 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*(): FireTracker = FireTracker(prevEnergy: @[]) proc reset*(t: var FireTracker) = ## Per-ROUND reset: drops the energy memory and any un-consumed event ## 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 proc prevEnergyGet*(t: FireTracker, id: int): float = for e in t.prevEnergy: if e.id == id: return e.energy 100.0 proc prevEnergySet*(t: var FireTracker, id: int, energy: float) = for i in 0.. `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`. The caller gates this on its switch. t.dealtIncoming += damage proc detect*(t: var FireTracker, id: int, energy: 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 `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 fix: drop += t.hitBonusPending - t.dealtPending if abs(drop - raw) > 1e-9: inc t.correctedTicks t.prevEnergySet(id, energy) 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. let n = int(ceil(drop / 3.0)) let p = drop / n.float inc t.splitWaves result = newSeq[float](n) for i in 0..= lo and drop <= hi: result = @[drop] 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 ## 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