j134 fire fix: share ONE fire_tracker across tfil/ring/strafe/learned/surf (TR_FIRE_FIX, default on); env-gated TR_FIRE_DIAG alignment trace
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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..<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`. 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..<n: result[i] = p
|
||||
elif drop >= 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
|
||||
@@ -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..<m.prevEnergy.len:
|
||||
if m.prevEnergy[i].id == id:
|
||||
m.prevEnergy[i].energy = energy
|
||||
return
|
||||
m.prevEnergy.add((id: id, energy: energy))
|
||||
m.fire.prevEnergySet(id, energy)
|
||||
|
||||
proc noteEnemyBulletHit*(m: var LearnedSurferModule, power: float) =
|
||||
## `onHitByBullet` -> 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
|
||||
|
||||
@@ -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..<m.prevEnergy.len:
|
||||
if m.prevEnergy[i].id == id:
|
||||
m.prevEnergy[i].energy = energy
|
||||
return
|
||||
m.prevEnergy.add((id: id, energy: energy))
|
||||
## Kept for the guard test / callers that seed an energy reading directly;
|
||||
## delegates to the shared tracker.
|
||||
m.fire.prevEnergySet(id, energy)
|
||||
|
||||
proc clearGraphics*(m: var StrafeModule) =
|
||||
## No-op: the SVG buffer is a module-level global cleared by the framework
|
||||
@@ -501,7 +502,7 @@ proc clearGraphics*(m: var StrafeModule) =
|
||||
|
||||
proc resetRound*(m: var StrafeModule) =
|
||||
m.bullets = @[]
|
||||
m.prevEnergy = @[]
|
||||
m.fire.reset()
|
||||
m.targetValid = false
|
||||
m.targetLava = 0.0
|
||||
m.targetPathHeat = 0.0
|
||||
@@ -521,10 +522,6 @@ 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
|
||||
@@ -594,47 +591,30 @@ proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
|
||||
## `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
|
||||
## 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..<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
|
||||
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)
|
||||
|
||||
@@ -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..<m.prevEnergy.len:
|
||||
if m.prevEnergy[i].id == id:
|
||||
m.prevEnergy[i].energy = energy
|
||||
return
|
||||
m.prevEnergy.add((id: id, energy: energy))
|
||||
m.fire.prevEnergySet(id, energy)
|
||||
|
||||
proc clearGraphics*(m: var TFILModule) =
|
||||
## No-op: the SVG buffer is a module-level global cleared by the framework
|
||||
@@ -291,7 +289,7 @@ proc clearGraphics*(m: var TFILModule) =
|
||||
|
||||
proc resetRound*(m: var TFILModule) =
|
||||
m.bullets = @[]
|
||||
m.prevEnergy = @[]
|
||||
m.fire.reset()
|
||||
m.commitTicks = 0
|
||||
m.cachedHull = @[]
|
||||
m.cachedInsideTiles = @[]
|
||||
@@ -349,30 +347,43 @@ proc initGrid(m: var TFILModule, arenaWidth, arenaHeight: float) =
|
||||
m.arenaHeight = arenaHeight
|
||||
m.lava = newSeq[float](m.cols * m.rows) # all 0.0
|
||||
|
||||
proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
|
||||
power: float) =
|
||||
## One tracked bullet/wave for a confirmed fire of `power`: linear prediction
|
||||
## of our position at arrival becomes the aim heading. (Was inlined in
|
||||
## `detectFires`; extracted so the shared split can spawn several.)
|
||||
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 * 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: 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):
|
||||
|
||||
@@ -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..<m.prevEnergy.len:
|
||||
if m.prevEnergy[i].id == id:
|
||||
m.prevEnergy[i].energy = energy
|
||||
return
|
||||
m.prevEnergy.add((id: id, energy: energy))
|
||||
m.fire.prevEnergySet(id, energy)
|
||||
|
||||
proc clearGraphics*(m: var TFILRingModule) =
|
||||
## No-op: the SVG buffer is a module-level global cleared by the framework
|
||||
@@ -274,7 +281,7 @@ proc clearGraphics*(m: var TFILRingModule) =
|
||||
|
||||
proc resetRound*(m: var TFILRingModule) =
|
||||
m.bullets = @[]
|
||||
m.prevEnergy = @[]
|
||||
m.fire.reset()
|
||||
m.commitTicks = 0
|
||||
m.cachedHull = @[]
|
||||
m.cachedInsideTiles = @[]
|
||||
@@ -299,30 +306,42 @@ proc initGrid(m: var TFILRingModule, arenaWidth, arenaHeight: float) =
|
||||
m.arenaHeight = arenaHeight
|
||||
m.lava = newSeq[float](m.cols * m.rows) # all 0.0
|
||||
|
||||
proc spawnTrackedWave(m: var TFILRingModule, ws: WorldState, ei: EnemyInfo,
|
||||
power: float) =
|
||||
## One tracked bullet/wave for a confirmed fire of `power` (extracted from
|
||||
## `detectFires` so the shared split can spawn several).
|
||||
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 * 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: 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):
|
||||
|
||||
@@ -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..<WS_BINS: m.bins[i] = 1.0
|
||||
|
||||
proc initWaveSurfer*(): WaveSurferModule =
|
||||
var m = WaveSurferModule(debugGraphics: false)
|
||||
var m = WaveSurferModule(debugGraphics: false, fire: initFireTracker(SurfFireFix))
|
||||
m.resetRound()
|
||||
m
|
||||
|
||||
@@ -132,17 +142,18 @@ proc removeBulletNear*(m: var WaveSurferModule, x, y: float) {.inline.} =
|
||||
## No-op: the surfer tracks WAVES (energy drops), not bullet bodies.
|
||||
discard
|
||||
|
||||
proc prevEnergyGet(m: WaveSurferModule, id: int): float =
|
||||
for e in m.prevEnergy:
|
||||
if e.id == id: return e.energy
|
||||
100.0
|
||||
proc prevEnergyGet(m: WaveSurferModule, id: int): float = m.fire.prevEnergyGet(id)
|
||||
|
||||
proc prevEnergySet(m: var WaveSurferModule, id: int, energy: float) =
|
||||
for i in 0..<m.prevEnergy.len:
|
||||
if m.prevEnergy[i].id == id:
|
||||
m.prevEnergy[i].energy = energy
|
||||
return
|
||||
m.prevEnergy.add((id: id, energy: energy))
|
||||
m.fire.prevEnergySet(id, energy)
|
||||
|
||||
proc noteEnemyBulletHit*(m: var WaveSurferModule, power: float) =
|
||||
## `onHitByBullet` -> 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
|
||||
|
||||
+12
-4
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user