j133 strafe fire detection: correct the enemy energy delta for the server's +3*power hit bonus and our own damage, never drop a too-large drop (TR_STRAFE_FIRE_FIX, default on); catch 98.89%->100% of enemy fires on the 70-battle corpus

This commit is contained in:
2026-09-26 12:18:21 +02:00
parent 0566264b06
commit 799438bbe9
8 changed files with 640 additions and 16 deletions
+88 -16
View File
@@ -338,6 +338,21 @@ var
StrafeWallBias* = DefaultStrafeWallBias
StrafeWallSafe* = DefaultStrafeWallSafe
StrafeEscape* = true
## ── fire-detection fix (j133) ──────────────────────────────────────────
## Corrects the enemy energy delta for the two SERVER effects that
## contaminate it before deciding whether a fire happened, and SPLITS a
## too-large drop instead of silently rejecting it:
## * `BULLET_HIT_ENERGY_GAIN_FACTOR = 3` (server rules.kt): when an enemy
## bullet hits US the SHOOTER gains `3*power`, which hides the `power`
## the enemy spent firing the same tick (a net >= 0 delta reads as "no
## fire"). `noteEnemyBulletHit` adds the bonus back.
## * our own bullet damaging the enemy the same tick inflates the drop
## above 3.0 and gets the enemy's own shot rejected. `noteDamageDealt`
## subtracts it.
## MEASURED on 70 recorded battles (62376 true enemy fires): catches
## 98.89% of enemy fires with the knob OFF and 99.999% with it ON.
## Default ON; `TR_STRAFE_FIRE_FIX=0` restores the shipped detector exactly.
StrafeFireFix*: bool = true
## 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.
@@ -371,6 +386,7 @@ 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)
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv()
@@ -428,6 +444,11 @@ 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
# ── diagnostics (gate B + GUI) ──
callCount*: int
picks*: int
@@ -500,6 +521,10 @@ proc resetRound*(m: var StrafeModule) =
m.wallEscapePicks = 0
m.escapeModeTicks = 0
m.lastMode = ""
m.hitBonusPending = 0.0
m.dealtPending = 0.0
m.fireFixSplitWaves = 0
m.fireFixCorrectedTicks = 0
m.callCount = 0
m.picks = 0
m.lastPickCall = 0
@@ -543,26 +568,73 @@ proc bulletRadii(power: float): tuple[core, aura: float] =
# ── bullet tracking (copied from the shipped TFIL mover) ─────────────────────
proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave: the enemy's CURRENT (scanned) position as origin,
## direction guessed at our predicted position, power as passed in.
let speed = 20.0 - 3.0 * power
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 * DegToRad) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0)
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 StrafeModule, power: float) =
## SERVER FACT (`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 enemy energy delta we read this tick and can MASK the
## `power` the enemy spent firing in the same tick (net delta >= 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
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
proc detectFires(m: var StrafeModule, ws: WorldState) =
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let drop = prev - ei.energy
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 drop >= 0.09 and drop <= 3.01:
let speed = 20.0 - 3.0 * drop
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 * DegToRad) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0)
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)
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..<n:
m.spawnTrackedWave(ws, ei, p)
inc m.fireFixSplitWaves
elif drop >= 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
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
var i = 0