## 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] import std/[math, os, strutils] ## ── j147: the DETECTION LAG back-date (`TR_FIRE_LAG`, default 0) ───────────── ## MEASURED LIVE (`common_libs/tests/measure_fire_ghost_lag.py`, 4 sessions, ## 1777 matched ghost spawns over both movers, `TR_FIRE_DIAG=1`): the server ## dispatches a turn's fire AFTER our `go()` for that same turn, so the energy ## drop of a turn-T shot first reaches our scan at turn T+1 (our bot tick T). A bullet ## takes its FIRST step during the turn it is fired, so by then the true bullet ## is already `speed` px (11..20 px, one whole bullet step) downrange and the ## arrival deadline is a full tick shorter than the ghost's. Both movers place ## the ghost at the SCANNED enemy position, i.e. exactly where the bullet was ## born: the whole ghost trajectory is the true one shifted one turn later. ## The per-tick `advanceBullets` then keeps it there for the bullet's whole life. ## ## The compensation is the inverse: at SPAWN, back-date the shot by `lag` ticks ## (`x = origin + dir * speed * lag`, `y = ...`). The arrival deadline needs no ## separate change — every mover derives it from the ghost's own position ## (`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a ## correct deadline. ## ## DEFAULT 0 = the shipped behaviour, byte for byte (`x` is only touched when ## `lag > 0`), so the default-parity guard stays green. var FireLag*: int = 0 proc loadFireTrackerEnv*() = ## Read the shared fire knobs. Called once at module init; callable again ## after `putEnv` so a guard test can exercise the arms in one process. let s = getEnv("TR_FIRE_LAG", "").strip() FireLag = (try: max(0, parseInt(s)) except ValueError: 0) loadFireTrackerEnv() 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