diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 87e70a2..c6c9149 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -125,6 +125,10 @@ let VBulletAdmitOnly* = ## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring). ## UNPROVEN: it must never become the default silently. let MovementName* = getEnv("TR_MOVEMENT", "strafe").strip().toLowerAscii() +## TEMPORARY j134 diagnostic (TASK B): when `TR_FIRE_DIAG` is set, print the tick +## of every enemy-hit / our-hit EVENT so it can be checked against the energy +## reading tick logged by the mover. OFF by default; observability only. +let FireDiag* = existsEnv("TR_FIRE_DIAG") ## Per-process output paths so concurrent A/B runs do not clobber each other. let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl") let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl") @@ -599,10 +603,17 @@ method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) = method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) = inc bot.realHits - # j133 fire-detection fix: our damage to the enemy inflates the enemy's - # energy drop this tick (it can push the enemy's own fire past the 3.0 power - # cap and get it rejected). Tell the strafe detector, which undoes it. + # j134 fire-detection fix (shared by EVERY mover): our damage to the enemy + # inflates the enemy's energy drop this tick (it can push the enemy's own + # fire past the 3.0 power cap and get it rejected). Each mover's detector + # undoes it. + bot.mover.noteDamageDealt(e.damage) + bot.ringMover.noteDamageDealt(e.damage) bot.strafeMover.noteDamageDealt(e.damage) + bot.surfMover.noteDamageDealt(e.damage) + bot.learnedMover.noteDamageDealt(e.damage) + if FireDiag: + echo "[firediag] EV dmg tick=", bot.tick, " damage=", e.damage # Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot. let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1) # onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim. @@ -652,11 +663,17 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = e.hitBullet.ownerId, bot.tick, false) method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) = - # j133 fire-detection fix: server rules.kt gives the SHOOTER a `3*power` - # energy bonus when its bullet hits us, which masks the cost of a fire in the - # same tick. Forward the enemy bullet's power so the strafe detector adds it - # back before classifying the energy delta. + # j134 fire-detection fix (shared by EVERY mover): server rules.kt gives the + # SHOOTER a `3*power` energy bonus when its bullet hits us, which masks the + # cost of a fire in the same tick. Forward the enemy bullet's power to every + # detector, which adds it back before classifying the energy delta. + bot.mover.noteEnemyBulletHit(e.bullet.power) + bot.ringMover.noteEnemyBulletHit(e.bullet.power) bot.strafeMover.noteEnemyBulletHit(e.bullet.power) + bot.surfMover.noteEnemyBulletHit(e.bullet.power) + bot.learnedMover.noteEnemyBulletHit(e.bullet.power) + if FireDiag: + echo "[firediag] EV hit tick=", bot.tick, " power=", e.bullet.power # Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's # 4p/6p-2 damage), not raw firepower, so `damageRatePerTurn` is a real # energy/turn rate (see movements/ram_decision.bulletDamage). diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 8968ecb..a6c75e5 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -315,6 +315,11 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_STRAFE_WALL_SAFE", $StrafeWallSafe, sourceOf("TR_STRAFE_WALL_SAFE")) emit("TR_STRAFE_ESCAPE", onOff(StrafeEscape), sourceOf("TR_STRAFE_ESCAPE")) emit("TR_STRAFE_FIRE_FIX", onOff(StrafeFireFix), sourceOf("TR_STRAFE_FIRE_FIX")) + # j134: the global switch the shared `movement_harness/fire_tracker` reads. + # Every mover's effective value is this AND (for STRAFE) TR_STRAFE_FIRE_FIX. + emit("TR_FIRE_FIX", onOff(TfilFireFix), sourceOf("TR_FIRE_FIX")) + # j134 TASK B diagnostic (off by default): per-reading tick/raw/correction trace. + emit("TR_FIRE_DIAG", onOff(StrafeFireDiag), sourceOf("TR_FIRE_DIAG")) emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID")) # STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall # 15/5), override-able per run so the shipped field can be A/B'd on one @@ -504,6 +509,8 @@ proc knownEnvNames*(): seq[string] = "TR_STRAFE_KAPPA", "TR_STRAFE_WALL_MARGIN", "TR_STRAFE_WING_MAX", "TR_STRAFE_WALL_BIAS", "TR_STRAFE_WALL_SAFE", "TR_STRAFE_ESCAPE", "TR_STRAFE_FIRE_FIX", + "TR_FIRE_FIX", + "TR_FIRE_DIAG", "TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA", "TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS", "TR_STRAFE_WALL_RADIANCE", diff --git a/common_libs/movement_harness/fire_tracker.nim b/common_libs/movement_harness/fire_tracker.nim new file mode 100644 index 0000000..5357b42 --- /dev/null +++ b/common_libs/movement_harness/fire_tracker.nim @@ -0,0 +1,117 @@ +## 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 diff --git a/common_libs/movements/learned_surfer.nim b/common_libs/movements/learned_surfer.nim index 5bfb2f5..ea6106e 100644 --- a/common_libs/movements/learned_surfer.nim +++ b/common_libs/movements/learned_surfer.nim @@ -84,6 +84,7 @@ import std/[math, os] from std/strutils import parseFloat, parseInt, strip, toLowerAscii import gun_harness/gun_interface import movement_harness/movement_interface +import movement_harness/fire_tracker import bitbrain/sbc const @@ -163,6 +164,9 @@ var LearnedLabel* = llHistogram LearnedRealEvents* = false LearnedLog* = false + ## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on). + ## Off = the shipped `prev - energy` detector byte-for-byte. + LearnedFireFix* = true proc getEnvFloat(name: string, default: float): float = let s = getEnv(name, "") @@ -195,6 +199,7 @@ proc loadLearnedEnv*() = LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0)) LearnedGlobal = envOn(LearnedGlobalEnv) LearnedRealEvents = envOn(LearnedRealEventsEnv) + LearnedFireFix = envOn("TR_FIRE_FIX", true) LearnedLog = envOn(LearnedLogEnv) LearnedLabel = case getEnv(LearnedLabelEnv, "").strip().toLowerAscii() @@ -272,7 +277,7 @@ type missGlobal: int ## llOutcome: non-hits seen (for the prior) scores: seq[float] waves: seq[LSWave] - prevEnergy: seq[tuple[id: int, energy: float]] + fire: FireTracker ## shared energy-drop detector (j134) strafeDir: float64 dir: float64 ## direction commanded last tick (+-1) prevX, prevY: float64 ## our position one tick ago @@ -290,7 +295,7 @@ proc resetRound*(m: var LearnedSurferModule) = ## it survives a round boundary without becoming a battle-long static average ## (the j115 defect). Use `resetBattle` for a hard wipe. m.waves = @[] - m.prevEnergy = @[] + m.fire.reset() m.strafeDir = 1.0 m.dir = 1.0 m.prevX = 0.0 @@ -307,6 +312,7 @@ proc resetRound*(m: var LearnedSurferModule) = proc initLearnedSurfer*(): LearnedSurferModule = result.debugGraphics = false + result.fire = initFireTracker(LearnedFireFix) result.sbc = initCountedSbc(LS_NADE, LS_BINS, max(0, LearnedDecayEvery), max(0, LearnedDecayShift)) @@ -485,58 +491,57 @@ proc predictState*(m: var LearnedSurferModule, row, col: int, # ── fire detection + state ────────────────────────────────────────────────── -proc prevEnergyGet(m: LearnedSurferModule, id: int): float = - for e in m.prevEnergy: - if e.id == id: return e.energy - 100.0 +proc prevEnergyGet(m: LearnedSurferModule, id: int): float = m.fire.prevEnergyGet(id) proc prevEnergySet(m: var LearnedSurferModule, id: int, energy: float) = - for i in 0.. the shooter's `3*power` bonus (no-op when off). + m.fire.noteEnemyBulletHit(power) + +proc noteDamageDealt*(m: var LearnedSurferModule, damage: float) = + ## `onBulletHit` -> our same-tick damage to the enemy (no-op when off). + m.fire.noteDamageDealt(damage) proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float, ws: WorldState) = - ## One enemy's energy sample. A plausible one-tick firepower drop IS a wave; - ## the state is the wave-relative state at THIS tick (the fire tick). - let prev = m.prevEnergyGet(id) - let drop = prev - eenergy - m.prevEnergySet(id, eenergy) - if drop < 0.09 or drop > 3.01: return + ## One enemy's energy sample. A plausible (corrected) one-tick firepower + ## drop IS a wave; the state is the wave-relative state at THIS tick (the + ## fire tick). Window 0.09..3.01, LEARNED's shipped window; the shared + ## tracker corrects the delta and splits an over-cap drop across waves. + for drop in m.fire.detect(id, eenergy, 0.09, 3.01): + let botX = ws.selfX + let botY = ws.selfY + let bspeed = 20.0 - 3.0 * drop + let d = hypot(botX - ex, botY - ey) + let bearing = arctan2(botY - ey, botX - ex) # centre line + let ux = cos(bearing) + let uy = sin(bearing) - let botX = ws.selfX - let botY = ws.selfY - let bspeed = 20.0 - 3.0 * drop - let d = hypot(botX - ex, botY - ey) - let bearing = arctan2(botY - ey, botX - ex) # centre line - let ux = cos(bearing) - let uy = sin(bearing) + # vlat: lateral velocity in the wave frame. The centre line passes through us + # at this tick, so lat(now) == 0 and vlat == -lat(prev). + let dxp = m.prevX - ex + let dyp = m.prevY - ey + var vlat = -(dxp * (-uy) + dyp * ux) + if m.prevX == 0.0 and m.prevY == 0.0 and m.prevHeading == 0.0: vlat = 0.0 + let roomDx = if vlat >= 0.0: -uy else: uy + let roomDy = if vlat >= 0.0: ux else: -ux + let room = roomToWall(botX, botY, roomDx, roomDy, ws.arenaWidth, ws.arenaHeight) + let turn = wrap180(float(ws.selfHeading) - float(m.prevHeading)) - # vlat: lateral velocity in the wave frame. The centre line passes through us - # at this tick, so lat(now) == 0 and vlat == -lat(prev). - let dxp = m.prevX - ex - let dyp = m.prevY - ey - var vlat = -(dxp * (-uy) + dyp * ux) - if m.prevX == 0.0 and m.prevY == 0.0 and m.prevHeading == 0.0: vlat = 0.0 - let roomDx = if vlat >= 0.0: -uy else: uy - let roomDy = if vlat >= 0.0: ux else: -ux - let room = roomToWall(botX, botY, roomDx, roomDy, ws.arenaWidth, ws.arenaHeight) - let turn = wrap180(float(ws.selfHeading) - float(m.prevHeading)) + let row = code(vlat, VlatEdges) * LS_Q + code(d, DistEdges) + let col = code(room, RoomEdges) * LS_Q + code(turn, TurnEdges) - let row = code(vlat, VlatEdges) * LS_Q + code(d, DistEdges) - let col = code(room, RoomEdges) * LS_Q + code(turn, TurnEdges) - - m.waves.add LSWave( - ownerId: id, fireTick: ws.tick, - originX: ex, originY: ey, bearing: bearing, speed: bspeed, - startDist: d, power: drop, - ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))), - fresh: true, - selfEnergyAtFire: ws.selfEnergy, - stateRow: row, stateCol: col, - ) + m.waves.add LSWave( + ownerId: id, fireTick: ws.tick, + originX: ex, originY: ey, bearing: bearing, speed: bspeed, + startDist: d, power: drop, + ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))), + fresh: true, + selfEnergyAtFire: ws.selfEnergy, + stateRow: row, stateCol: col, + ) # ── the mover ─────────────────────────────────────────────────────────────── @@ -551,6 +556,7 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand = m.detectFire(ei.id, ei.x, ei.y, ei.energy, ws) if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0): m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, ws) + m.fire.endScan() # ── 2. advance + resolve waves; every resolution is a training sample ──── var i = 0 diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim index 88484e6..d2f6c72 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -182,6 +182,7 @@ from std/strutils import parseFloat, parseInt, strip, toLowerAscii import std/strformat import gun_harness/gun_interface import movement_harness/movement_interface +import movement_harness/fire_tracker import robocode_tankroyale_botapi/graphics import robocode_tankroyale_botapi/color # j105/j106 reuse: the exported time-indexed heat helpers + pillar globals. @@ -351,8 +352,15 @@ var ## subtracts it. ## MEASURED on 70 recorded battles (67065 true enemy fires): catches ## 98.888% of enemy fires with the knob OFF and 100.000% with it ON. - ## Default ON; `TR_STRAFE_FIRE_FIX=0` restores the shipped detector exactly. + ## j134: the detector now lives ONCE in `movement_harness/fire_tracker.nim` + ## and every mover calls it. This flag is STRAFE's local gate: it is ON only + ## when BOTH `TR_STRAFE_FIRE_FIX` and the global `TR_FIRE_FIX` are on, so + ## either knob set to an off value restores the shipped detector exactly. StrafeFireFix*: bool = true + ## TEMPORARY j134 diagnostic (TASK B): when `TR_FIRE_DIAG` is set, print one + ## `[firediag] READ tick=…` line per enemy energy reading so the event/reading + ## tick alignment can be checked live. OFF by default; observability only. + StrafeFireDiag*: bool = false ## GUI: draw the full lava field (every non-zero tile, value-labelled) the ## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the ## strafe overlays can be read on their own. @@ -386,7 +394,9 @@ proc loadStrafeEnv*() = StrafeWallBias = max(0.0, min(1.0, getEnvFloat("TR_STRAFE_WALL_BIAS", DefaultStrafeWallBias))) StrafeWallSafe = max(0.0, getEnvFloat("TR_STRAFE_WALL_SAFE", DefaultStrafeWallSafe)) StrafeEscape = getEnvBool("TR_STRAFE_ESCAPE", true) - StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true) + StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true) and + getEnvBool("TR_FIRE_FIX", true) + StrafeFireDiag = existsEnv("TR_FIRE_DIAG") StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true) loadStrafeHeatEnv() @@ -419,7 +429,6 @@ type arenaWidth*, arenaHeight*: float lava: seq[float] bullets: seq[TrackedBullet] - prevEnergy: seq[tuple[id: int, energy: float]] # ── decision state ── targetX*, targetY*: float ## chosen tile centre (world coords) targetValid*: bool @@ -444,11 +453,8 @@ type wallEscapePicks*: int ## picks forced inward by the all-hot escape escapeModeTicks*: int ## ticks the escape bearing was in effect lastMode*: string ## "pick" | "fallback" | "escape" | "radial" - # ── fire-detection fix (j133): event-fed energy-delta corrections ── - hitBonusPending: float ## 3 * power of enemy bullets that hit us this tick - dealtPending: float ## damage our bullets dealt to the enemy this tick - fireFixSplitWaves*: int ## waves emitted by splitting a too-large drop - fireFixCorrectedTicks*: int ## ticks whose drop was non-trivially corrected + # ── fire detection (j134): the shared enemy-fire tracker ── + fire: FireTracker ## ONE detector for every mover (movement_harness/fire_tracker) # ── diagnostics (gate B + GUI) ── callCount*: int picks*: int @@ -468,7 +474,7 @@ type lastTileCol, lastTileRow: int proc initStrafe*(): StrafeModule = - StrafeModule(debugGraphics: false) + StrafeModule(debugGraphics: false, fire: initFireTracker(StrafeFireFix)) proc removeBulletNear*(m: var StrafeModule, x, y: float) = ## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead. @@ -482,17 +488,12 @@ proc removeBulletNear*(m: var StrafeModule, x, y: float) = if bestIdx >= 0: m.bullets.del(bestIdx) -proc prevEnergyGet(m: StrafeModule, id: int): float = - for e in m.prevEnergy: - if e.id == id: return e.energy - 100.0 +proc prevEnergyGet(m: StrafeModule, id: int): float = m.fire.prevEnergyGet(id) proc prevEnergySet(m: var StrafeModule, id: int, energy: float) = - for i in 0..= 0 reads as ## "no fire"). ModularBot forwards `onHitByBullet`'s `e.bullet.power` here so ## `detectFires` can add the bonus back before classifying the delta. - ## No-op when `TR_STRAFE_FIRE_FIX` is off (shipped detector preserved). - if StrafeFireFix: - m.hitBonusPending += 3.0 * power + ## No-op when the fix is off (shipped detector preserved). + m.fire.noteEnemyBulletHit(power) proc noteDamageDealt*(m: var StrafeModule, damage: float) = ## The mirror contamination: OUR bullet damaging the enemy this tick adds ## `damage` to the enemy's energy drop, which can push it above the 3.0 power ## cap and get the enemy's OWN shot rejected by the shipped `<= 3.01` test. ## ModularBot forwards `onBulletHit`'s `e.damage` here. - ## No-op when `TR_STRAFE_FIRE_FIX` is off. - if StrafeFireFix: - m.dealtPending += damage + ## No-op when the fix is off. + m.fire.noteDamageDealt(damage) proc detectFires(m: var StrafeModule, ws: WorldState) = + ## The shared tracker does the delta correction + split; STRAFE supplies its + ## shipped window (0.09 .. 3.01) and its own wave geometry. for ei in ws.enemies: - let prev = m.prevEnergyGet(ei.id) - let raw = prev - ei.energy - var drop = raw - if StrafeFireFix: - # Undo the server's known energy contaminations. The bonus raises the - # enemy's energy (shrinking/negating the drop); our damage lowers it - # (inflating the drop). Both are known exactly from the events. - drop += m.hitBonusPending - m.dealtPending - if abs(drop - raw) > 1e-9: inc m.fireFixCorrectedTicks - m.prevEnergySet(ei.id, ei.energy) - if StrafeFireFix and drop > 3.01: - # NEVER silently drop a drop. A delta above the power cap is either - # several fires folded into one reading (unobserved radar latency) or - # un-modelled contamination; either way SOME heat beats none. Split into - # the fewest waves each <= 3.0, all from the same origin. - let n = int(ceil(drop / 3.0)) - let p = drop / n.float - for _ in 0..= 0.09 and drop <= 3.01: - m.spawnTrackedWave(ws, ei, drop) - if StrafeFireFix: - # Consumed: each event-tick correction applies to exactly one reading. - m.hitBonusPending = 0.0 - m.dealtPending = 0.0 + if StrafeFireDiag: + let raw = m.fire.prevEnergyGet(ei.id) - ei.energy + echo "[firediag] READ tick=", ws.tick, " id=", ei.id, + " raw=", raw, + " bonus=", m.fire.hitBonusPending, + " dealt=", m.fire.dealtPending + for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01): + m.spawnTrackedWave(ws, ei, p) + m.fire.endScan() proc advanceBullets(m: var StrafeModule, selfX, selfY: float) = var i = 0 @@ -1099,9 +1079,9 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = m.dwell = 0 m.targetValid = false m.bullets = @[] - m.prevEnergy = @[] + m.fire.prevEnergy = @[] for ei in ws.enemies: - m.prevEnergySet(ei.id, ei.energy) + m.fire.prevEnergySet(ei.id, ei.energy) m.advanceBullets(ws.selfX, ws.selfY) m.detectFires(ws) diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index 482e4fb..26c335a 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -6,6 +6,7 @@ import std/os import std/strutils except fromHex # `fromHex` would clash with color.fromHex import gun_harness/gun_interface import movement_harness/movement_interface +import movement_harness/fire_tracker import robocode_tankroyale_botapi/graphics import robocode_tankroyale_botapi/color @@ -104,6 +105,9 @@ var TfilCommitTicks*: int = DefaultTfilCommitTicks TfilNoRev*: bool = false TfilCommitLogPath*: string = "" + ## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on). + ## Off = the shipped `prev - energy` detector byte-for-byte. + TfilFireFix*: bool = true proc getEnvInt(name: string, default: int): int = let s = getEnv(name, "") @@ -132,6 +136,7 @@ proc loadTfilCommitEnv*() = TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks)) TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false) TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "") + TfilFireFix = getEnvBool("TR_FIRE_FIX", true) loadTfilCommitEnv() @@ -243,7 +248,7 @@ type arenaWidth, arenaHeight: float lava: seq[float] # flat row-major, index = row*cols + col bullets: seq[TrackedBullet] - prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy + fire: FireTracker ## shared energy-drop detector (j134) commitTarget: tuple[x, y: float] ## world coords of committed dodge point commitTicks: int ## ticks remaining on commitment commitLava: float ## lava at commit time (for spike detection) @@ -258,7 +263,7 @@ type lastPickCall: int ## callCount at the last pick (log only) picks: int ## number of picks this round (log only) -proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false) +proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false, fire: initFireTracker(TfilFireFix)) proc removeBulletNear*(m: var TFILModule, x, y: float) = ## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead. @@ -272,17 +277,10 @@ proc removeBulletNear*(m: var TFILModule, x, y: float) = if bestIdx >= 0: m.bullets.del(bestIdx) -proc prevEnergyGet(m: TFILModule, id: int): float = - for e in m.prevEnergy: - if e.id == id: return e.energy - 100.0 +proc prevEnergyGet(m: TFILModule, id: int): float = m.fire.prevEnergyGet(id) proc prevEnergySet(m: var TFILModule, id: int, energy: float) = - for i in 0..= MaxTrackedBullets: + m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap + m.bullets.add TrackedBullet( + originX: ei.x, originY: ei.y, + x: ei.x, y: ei.y, + velX: speed * cos(heading), + velY: speed * sin(heading), + power: power, + alive: true, + age: 0) + +proc noteEnemyBulletHit*(m: var TFILModule, power: float) = + ## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off). + m.fire.noteEnemyBulletHit(power) + +proc noteDamageDealt*(m: var TFILModule, damage: float) = + ## `onBulletHit` -> our same-tick damage to the enemy (no-op when off). + m.fire.noteDamageDealt(damage) + proc detectFires(m: var TFILModule, ws: WorldState) = - ## Check all enemies for energy drops; spawn a tracked bullet per confirmed fire. + ## Check all enemies for energy drops; spawn a tracked bullet per confirmed + ## fire. The shared tracker corrects the delta and splits over-cap drops. for ei in ws.enemies: - let prev = m.prevEnergyGet(ei.id) - let drop = prev - ei.energy - m.prevEnergySet(ei.id, ei.energy) - if drop >= 0.09 and drop <= 3.01: - let speed = 20.0 - 3.0 * drop - # Linear prediction: aim at where we will be when the bullet arrives - let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2) - let travelTime = dist / speed - let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime - let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime - let heading = arctan2(predY - ei.y, predX - ei.x) - if m.bullets.len >= MaxTrackedBullets: - m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap - m.bullets.add TrackedBullet( - originX: ei.x, originY: ei.y, - x: ei.x, y: ei.y, - velX: speed * cos(heading), - velY: speed * sin(heading), - power: drop, - alive: true, - age: 0) + for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01): + m.spawnTrackedWave(ws, ei, p) + m.fire.endScan() proc advanceBullets(m: var TFILModule, selfX, selfY: float) = ## Advance positions and reap bullets that are: passed us, out of bounds, or too old. @@ -519,9 +530,9 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = m.bullets = @[] # bullet positions are hopelessly stale m.blockedTile = (col: 0, row: 0, active: false) # Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires - m.prevEnergy = @[] + m.fire.prevEnergy = @[] for ei in ws.enemies: - m.prevEnergySet(ei.id, ei.energy) + m.fire.prevEnergySet(ei.id, ei.energy) # Tile-change replan — see the knob rationale at the top of the file. if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0): diff --git a/common_libs/movements/the_floor_is_lava_ring.nim b/common_libs/movements/the_floor_is_lava_ring.nim index 9ef2f03..f0fb279 100644 --- a/common_libs/movements/the_floor_is_lava_ring.nim +++ b/common_libs/movements/the_floor_is_lava_ring.nim @@ -82,9 +82,10 @@ ## 10.0 / 15.0; the comment now says so. import std/[math, random, os, strformat] -from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes with color.fromHex +from std/strutils import parseFloat, strip, toLowerAscii # selective: strutils.fromHex clashes with color.fromHex import gun_harness/gun_interface import movement_harness/movement_interface +import movement_harness/fire_tracker import robocode_tankroyale_botapi/graphics import robocode_tankroyale_botapi/color @@ -128,6 +129,18 @@ proc getEnvFloat(name: string, default: float): float = except ValueError: result = default +proc getEnvBool(name: string, default: bool): bool = + let s = getEnv(name, "").strip().toLowerAscii() + if s.len == 0: return default + s in ["1", "true", "on", "yes"] + +## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on). +## Off = the shipped `prev - energy` detector byte-for-byte. +var TfilRingFireFix* = true +proc loadTfilRingFireEnv*() = + TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true) +loadTfilRingFireEnv() + const DefaultRangeLo = 100.0 DefaultRangeHi = 200.0 @@ -223,7 +236,7 @@ type arenaWidth, arenaHeight: float lava: seq[float] # flat row-major, index = row*cols + col bullets: seq[TrackedBullet] - prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy + fire: FireTracker ## shared energy-drop detector (j134) commitTarget: tuple[x, y: float] ## world coords of committed dodge point commitTicks: int ## ticks remaining on commitment commitLava: float ## lava at commit time (for spike detection) @@ -241,7 +254,8 @@ type loggedOnce: bool proc initTFILRing*(): TFILRingModule = - TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi)) + TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi), + fire: initFireTracker(TfilRingFireFix)) proc removeBulletNear*(m: var TFILRingModule, x, y: float) = ## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead. @@ -255,17 +269,10 @@ proc removeBulletNear*(m: var TFILRingModule, x, y: float) = if bestIdx >= 0: m.bullets.del(bestIdx) -proc prevEnergyGet(m: TFILRingModule, id: int): float = - for e in m.prevEnergy: - if e.id == id: return e.energy - 100.0 +proc prevEnergyGet(m: TFILRingModule, id: int): float = m.fire.prevEnergyGet(id) proc prevEnergySet(m: var TFILRingModule, id: int, energy: float) = - for i in 0..= MaxTrackedBullets: + m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap + m.bullets.add TrackedBullet( + originX: ei.x, originY: ei.y, + x: ei.x, y: ei.y, + velX: speed * cos(heading), + velY: speed * sin(heading), + power: power, + alive: true, + age: 0) + +proc noteEnemyBulletHit*(m: var TFILRingModule, power: float) = + ## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off). + m.fire.noteEnemyBulletHit(power) + +proc noteDamageDealt*(m: var TFILRingModule, damage: float) = + ## `onBulletHit` -> our same-tick damage to the enemy (no-op when off). + m.fire.noteDamageDealt(damage) + proc detectFires(m: var TFILRingModule, ws: WorldState) = - ## Check all enemies for energy drops; spawn a tracked bullet per confirmed fire. + ## Check all enemies for energy drops; spawn a tracked bullet per confirmed + ## fire. The shared tracker corrects the delta and splits over-cap drops. for ei in ws.enemies: - let prev = m.prevEnergyGet(ei.id) - let drop = prev - ei.energy - m.prevEnergySet(ei.id, ei.energy) - if drop >= 0.09 and drop <= 3.01: - let speed = 20.0 - 3.0 * drop - # Linear prediction: aim at where we will be when the bullet arrives - let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2) - let travelTime = dist / speed - let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime - let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime - let heading = arctan2(predY - ei.y, predX - ei.x) - if m.bullets.len >= MaxTrackedBullets: - m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap - m.bullets.add TrackedBullet( - originX: ei.x, originY: ei.y, - x: ei.x, y: ei.y, - velX: speed * cos(heading), - velY: speed * sin(heading), - power: drop, - alive: true, - age: 0) + for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01): + m.spawnTrackedWave(ws, ei, p) + m.fire.endScan() proc advanceBullets(m: var TFILRingModule, selfX, selfY: float) = ## Advance positions and reap bullets that are: passed us, out of bounds, or too old. @@ -456,9 +475,9 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand = m.bullets = @[] # bullet positions are hopelessly stale m.blockedTile = (col: 0, row: 0, active: false) # Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires - m.prevEnergy = @[] + m.fire.prevEnergy = @[] for ei in ws.enemies: - m.prevEnergySet(ei.id, ei.energy) + m.fire.prevEnergySet(ei.id, ei.energy) # Tile-change replan: catches gradual displacement that position threshold misses if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0): diff --git a/common_libs/movements/wave_surfer.nim b/common_libs/movements/wave_surfer.nim index e9aa70a..2d72df1 100644 --- a/common_libs/movements/wave_surfer.nim +++ b/common_libs/movements/wave_surfer.nim @@ -50,6 +50,7 @@ import std/[math, os] from std/strutils import parseFloat, strip, toLowerAscii import gun_harness/gun_interface import movement_harness/movement_interface +import movement_harness/fire_tracker const WS_BINS = 31 @@ -76,6 +77,9 @@ var SurfWallMargin* = DefaultWallMargin SurfRadialFrac* = DefaultRadialFrac SurfLog* = false + ## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on). + ## Off = the shipped `prev - energy` detector byte-for-byte. + SurfFireFix* = true proc getEnvFloat(name: string, default: float): float = let s = getEnv(name, "") @@ -83,6 +87,11 @@ proc getEnvFloat(name: string, default: float): float = try: result = parseFloat(s.strip()) except ValueError: result = default +proc envOn(name: string, default = false): bool = + let s = getEnv(name, "").strip().toLowerAscii() + if s.len == 0: return default + s notin ["0", "false", "no", "off"] + proc loadSurfEnv*() = ## Read the surfer knobs; callable again after `putEnv` so a gate can ## exercise arms in one process. @@ -91,6 +100,7 @@ proc loadSurfEnv*() = SurfWallMargin = max(0.0, getEnvFloat(SurfWallMarginEnv, DefaultWallMargin)) SurfRadialFrac = clamp(getEnvFloat(SurfRadialFracEnv, DefaultRadialFrac), 0.0, 1.0) SurfLog = existsEnv(SurfLogEnv) + SurfFireFix = envOn("TR_FIRE_FIX", true) loadSurfEnv() @@ -106,7 +116,7 @@ type WaveSurferModule* = object bins: array[WS_BINS, float64] waves: seq[WSWave] - prevEnergy: seq[tuple[id: int, energy: float]] + fire: FireTracker ## shared energy-drop detector (j134) strafeDir: float64 ## +1.0 or -1.0 debugGraphics*: bool @@ -114,12 +124,12 @@ proc resetRound*(m: var WaveSurferModule) = ## Wipe per-round state. The danger histogram IS reset here (defect 2): it is ## a per-round learner, not a battle-long static average. m.waves = @[] - m.prevEnergy = @[] + m.fire.reset() m.strafeDir = 1.0 for i in 0.. the shooter's `3*power` bonus (no-op when off). + m.fire.noteEnemyBulletHit(power) + +proc noteDamageDealt*(m: var WaveSurferModule, damage: float) = + ## `onBulletHit` -> our same-tick damage to the enemy (no-op when off). + m.fire.noteDamageDealt(damage) proc gfToBin(gf: float64): int {.inline.} = clamp(int(round((gf.clamp(-1.0, 1.0) + 1.0) * 0.5 * float64(WS_BINS - 1))), @@ -176,13 +187,12 @@ proc nearestWave(m: WaveSurferModule, botX, botY: float64): int = proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy, botX, botY: float64) = - ## One enemy's energy sample: emit a wave iff its energy dropped by a - ## plausible firepower in one tick. `drop` is the firepower exactly. - let prev = m.prevEnergyGet(id) - let drop = prev - eenergy - m.prevEnergySet(id, eenergy) - if drop >= 0.1 and drop <= 3.0: - let bspeed = 20.0 - 3.0 * drop + ## One enemy's energy sample: emit a wave per plausible firepower in the + ## (corrected) one-tick drop. `drop` IS the firepower. Window 0.1..3.0, + ## SURF's shipped window; the shared tracker corrects the delta and splits an + ## over-cap drop across several waves. + for power in m.fire.detect(id, eenergy, 0.1, 3.0): + let bspeed = 20.0 - 3.0 * power let bearing = arctan2(botY - ey, botX - ex) let d = hypot(botX - ex, botY - ey) m.waves.add WSWave( @@ -191,7 +201,7 @@ proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy, speed: bspeed, radius: 0.0, startDist: d, - power: drop, + power: power, ) proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand = @@ -206,6 +216,7 @@ proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand = if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0): # Fallback to the tracked target when `enemies` is empty. m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, botX, botY) + m.fire.endScan() # ── Advance waves; record the GF of each wave that reaches us ───────────── var i = 0 diff --git a/common_libs/tests/fixtures/strafe_fire_catch_report.txt b/common_libs/tests/fixtures/strafe_fire_catch_report.txt index 1193d72..7c937fb 100644 --- a/common_libs/tests/fixtures/strafe_fire_catch_report.txt +++ b/common_libs/tests/fixtures/strafe_fire_catch_report.txt @@ -1,16 +1,24 @@ corpus: /tmp/tfil_ab2/out (70 runs, 5 arms) -== SHIPPED (TR_STRAFE_FIRE_FIX=0) == +== SHIPPED (TR_FIRE_FIX=0) == true enemy fires: 67065 caught (wave on the fire's own tick): 66319 catch rate = 0.98888 missed: 746 (drop > 3.01 [multi-fire/contamination]: 290, drop < 0.09 [masked by the +3*power bonus]: 456) latency (ticks after the fire's own tick): -1=737, 0=66319, 2=1, 3=2, 4=1, 5=5 -== FIXED (TR_STRAFE_FIRE_FIX=1) == +== FIXED (TR_FIRE_FIX=1) == true enemy fires: 67065 caught (wave on the fire's own tick): 67065 catch rate = 1.00000 missed: 0 (drop > 3.01 [multi-fire/contamination]: 0, drop < 0.09 [masked by the +3*power bonus]: 0) latency (ticks after the fire's own tick): 0=67065 -== live scan interval (3024 consecutive readings over 6 captures) == -lst increment histogram: 1:3024 (100.00%) +== live scan interval (1639 consecutive readings over 2 captures) == +lst increment histogram: 1:1639 (100.00%) + +== per-mover catch rate (shared fire_tracker, j134) == +mover window shipped fixed blind_before blind_after +tfil 0.09-3.01 0.98888 1.00000 746 0 +ring 0.09-3.01 0.98888 1.00000 746 0 +strafe 0.09-3.01 0.98888 1.00000 746 0 +learned 0.09-3.01 0.98888 1.00000 746 0 +surf 0.1-3.0 0.98886 1.00000 747 0 diff --git a/common_libs/tests/measure_strafe_fire_catch.py b/common_libs/tests/measure_strafe_fire_catch.py index 2b0f82f..c5ec537 100644 --- a/common_libs/tests/measure_strafe_fire_catch.py +++ b/common_libs/tests/measure_strafe_fire_catch.py @@ -48,6 +48,19 @@ import sys # a corpus artifact only. CORPUS_EVENT_SHIFT = 1 +# Per-mover detector windows, as wired in j134. Every mover now calls the SAME +# shared `movement_harness/fire_tracker.nim`; only the window it supplies differs +# (`strafe` is the j133 reference). The correction and the over-cap SPLIT are +# shared, so the FIXED catch rate must be identical for all of them; the SHIPPED +# rate can differ slightly where the windows differ. +MOVER_WINDOWS = { + "tfil": (0.09, 3.01), + "ring": (0.09, 3.01), + "strafe": (0.09, 3.01), + "learned": (0.09, 3.01), + "surf": (0.10, 3.00), +} + def load_run(path: str): rows, events = [], [] @@ -66,7 +79,7 @@ def load_run(path: str): return rows, events, rounds -def detect(rows, events, rounds, fix: bool): +def detect(rows, events, rounds, fix: bool, lo: float = 0.09, hi: float = 3.01): """Replay the detector; return {global_tick: [powers]} of created waves.""" starts = {r["round"]: r["startTick"] for r in rounds} by_global = {r["startTick"] + k: (r["round"], k) @@ -118,11 +131,11 @@ def detect(rows, events, rounds, fix: bool): if fix: drop = raw + bonus.get((rn, rt + CORPUS_EVENT_SHIFT), 0.0) \ - dealt.get((rn, rt + CORPUS_EVENT_SHIFT), 0.0) - if fix and drop > 3.01: + if fix and drop > hi: n = int(math.ceil(drop / 3.0)) for _ in range(n): waves[t].append(drop / n) - elif 0.09 <= drop <= 3.01: + elif lo <= drop <= hi: waves[t].append(drop) return waves, energy, eowner @@ -139,8 +152,8 @@ def fires_of(events, rounds, eowner): return out -def score(rows, events, rounds, fix: bool): - waves, energy, eowner = detect(rows, events, rounds, fix) +def score(rows, events, rounds, fix: bool, lo: float = 0.09, hi: float = 3.01): + waves, energy, eowner = detect(rows, events, rounds, fix, lo, hi) starts = {r["round"]: r["startTick"] for r in rounds} by_global = {r["startTick"] + k: (r["round"], k) for r in rounds for k in range(r["count"])} @@ -163,7 +176,7 @@ def score(rows, events, rounds, fix: bool): drop = raw if fix: drop = raw # classification below only needs the miss kind - if raw > 3.01: + if raw > hi: cats["reject_high"] += 1 else: cats["reject_low"] += 1 @@ -228,7 +241,7 @@ def main() -> int: caught += c lat.update(l) cats.update(k) - tag = "FIXED (TR_STRAFE_FIRE_FIX=1)" if fix else "SHIPPED (TR_STRAFE_FIRE_FIX=0)" + tag = "FIXED (TR_FIRE_FIX=1)" if fix else "SHIPPED (TR_FIRE_FIX=0)" out(f"\n== {tag} ==") out(f"true enemy fires: {total}") out(f"caught (wave on the fire's own tick): {caught} " @@ -247,6 +260,28 @@ def main() -> int: out("lst increment histogram: " + ", ".join( f"{k}:{v} ({100.0 * v / total:.2f}%)" for k, v in sorted(incs.items()))) + # ── per-mover table (j134) ────────────────────────────────────────────── + # Every mover now calls the shared `movement_harness/fire_tracker.nim`; only + # the window differs. This is the propagation proof: the SHIPPED path leaves + # each mover blind to the same 1.11%, the FIXED path is 100% for all. + out("\n== per-mover catch rate (shared fire_tracker, j134) ==") + out(f"{'mover':<8} {'window':<14} {'shipped':>9} {'fixed':>9} " + f"{'blind_before':>13} {'blind_after':>12}") + for name, (wlo, whi) in MOVER_WINDOWS.items(): + per = {} + for fix in (False, True): + total = caught = 0 + for path in runs: + rows, events, rounds = load_run(path) + t, c, _, _ = score(rows, events, rounds, fix, wlo, whi) + total += t + caught += c + per[fix] = (total, caught) + total, caught0 = per[False] + _, caught1 = per[True] + out(f"{name:<8} {f'{wlo}-{whi}':<14} {caught0 / total:>9.5f} " + f"{caught1 / total:>9.5f} {total - caught0:>13} {total - caught1:>12}") + if args.report: os.makedirs(os.path.dirname(args.report) or ".", exist_ok=True) with open(args.report, "w") as f: diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index 34d9682..06944b3 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -131,7 +131,17 @@ proc testDefaultParity() = # regression — see the file header). With the pillar restored via # TR_TFIL_PILLAR_ON=1 the old golden would no longer match, by design. doAssert fileExists(goldenPath), "missing golden: " & goldenPath + # j134: the golden was generated from the SHIPPED detector. Force the shipped + # fire-detection path (`TR_FIRE_FIX=0`) for this check so it still proves the + # PRE-CHANGE path is byte-identical. The correction ON legitimately changes + # the path (it no longer drops an over-cap energy delta), which is the whole + # point of j134 — see `test_strafe_fire_fix.nim` for the per-behaviour checks. + when declared(TfilFireFix): + let savedFireFix = TfilFireFix + TfilFireFix = false let recs = replay(loadStates(), loadRoundStarts()) + when declared(TfilFireFix): + TfilFireFix = savedFireFix var golden: seq[string] for rawLine in lines(goldenPath): if rawLine.startsWith("#"): continue