32ec040e90
Within-mover references (the only comparison that isolates the knob): tfil_lag1 -0.04 wins/run, strafe_lag1 +0.18 wins/run, both far under the reported MDEs (0.33 / 0.41) -> NOT distinguishable, so TR_FIRE_LAG stays default 0. Incoming hit rate also unmoved (+0.94 / +0.33 pp). The one significant result is the mover (strafe_lag1 vs tfil_off +0.38 wins/run p=0.0386, hit rate -4.67pp p=0.0074), which is the known strafe-over-tfil gap and exactly why the within-mover reference was pre-registered. Also a cosmetic no-op refactor of the two spawn sites (compute velX/velY once; bit-identical, and it keeps the default-parity claim exact).
179 lines
8.7 KiB
Nim
179 lines
8.7 KiB
Nim
## 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..<t.prevEnergy.len:
|
|
if t.prevEnergy[i].id == id:
|
|
t.prevEnergy[i].energy = energy
|
|
return
|
|
t.prevEnergy.add((id: id, energy: energy))
|
|
|
|
proc noteEnemyBulletHit*(t: var FireTracker, power: float) =
|
|
## `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`. 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..<n: result[i] = p
|
|
elif drop >= 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
|