## 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` 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"). ## `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 ────────────────────────────────────────────────────────────────── ## 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. 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. 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 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 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). t.prevEnergy = @[] t.hitBonusPending = 0.0 t.dealtPending = 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`. No-op when the fix is off. if t.enabled: t.hitBonusPending += 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 proc detect*(t: var FireTracker, id: int, energy: float, lo, hi: float): 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 ## still-too-large delta is split rather than dropped. let prev = t.prevEnergyGet(id) let raw = prev - energy var drop = raw if t.enabled: drop += t.hitBonusPending - t.dealtPending if abs(drop - raw) > 1e-9: inc t.correctedTicks t.prevEnergySet(id, energy) if t.enabled 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: each event correction applies to ## exactly one reading. t.hitBonusPending = 0.0 t.dealtPending = 0.0