tracker: corpses do not exist - revert the fix and retire the workaround

The belief "BotDeathEvent never reaches ModularBot, so enemyTracker keeps dead
enemies alive forever" was written into a code comment and then believed twice.
It is FALSE. Measured in a 7-bot melee with a per-tick probe comparing
enemyTracker's alive count against the server's getEnemyCount():

  metric                          1.3.1 (20 rd)   0.35.5 (15 rd)
  observed enemy deaths                83              68
  ...non-round-ending              83 (100%)       66 (97%)
  ekBotDeath events DROPPED             0               0
  max dispatch lag (turns behind)       1               1
  phantom ticks                  1 / 16,820      1 / 12,596
  MAX CORPSE LIFETIME               0 ticks         0 ticks
  victims still alive at round end      0               0

onBotDeath fires for every death, including non-round-ending ones. The
API-level event-drop mechanism IS real (test_event_drop_mechanism.nim proves
it: ekBotDeath is not in isCritical and MAX_EVENTS_AGE=2) - the bot simply
never falls far enough behind for it to trigger (max lag 1 turn).

Removed:
- reconcileWithServer + ReconcilePersistTicks/mismatchTicks/sawServerAlive
  (uncommitted, and ON BY DEFAULT despite the premise being false). Its own
  comment admitted a shorter window once KILLED A LIVE ENEMY ("it fired three
  more times after the tracker marked it dead") - a latent mis-prune path
  defending against a bug that does not exist.
- The radar's CorpseTicks=40 filter and the same-class age>60 filter in
  recordRadarStats, both carrying the false comment. Removal changes no real
  behaviour: buildState feeds the radar enemyTracker.allAlive(), so a dead
  enemy never reaches computeScan.

Kept:
- The TR_TRACKER_PROBE instrument (default OFF), which produced the table above.
- test_event_drop_mechanism.nim - the drop mechanism is a genuine library
  behaviour worth guarding.
- isAlive/aliveCount on the tracker.

Added: docs/tracker_death_events.md (the durable negative, so this is not
re-invented a third time) and test_enemy_tracker_death.nim (13 checks) in place
of the test for the deleted feature.

Guards: test_gun_harness 39/39, test_vbullet_metric 11, test_power_selection 3,
test_adaptive_radar 41/41, test_event_drop_mechanism 6, test_enemy_tracker_death
13, acceptance 12/12, ModularBot compiles.
This commit is contained in:
2026-09-21 22:41:11 +02:00
parent c091bf3c34
commit fb36a0a685
8 changed files with 429 additions and 44 deletions
+97 -7
View File
@@ -65,6 +65,12 @@ const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
let RadarForceSpin* = existsEnv("TR_RADAR_FORCE_SPIN")
let RadarScanLog* = existsEnv("TR_RADAR_SCANLOG")
let RadarScanLogPath = getEnv("TR_RADAR_SCAN_LOG_PATH", "/tmp/radar_scan_log.jsonl")
## Enemy-tracker probe (TR_TRACKER_PROBE=1): append one JSON line per tick
## (tracker state vs server enemy count) and one per event dispatch (the queue
## BEFORE `removeOldEvents` deletes dropped events) to TR_TRACKER_PROBE_PATH.
## Measurement-only; the default build writes nothing.
let TrackerProbe* = existsEnv("TR_TRACKER_PROBE")
let TrackerProbePath = getEnv("TR_TRACKER_PROBE_PATH", "/tmp/tracker_probe.jsonl")
## Runtime rack pruning for MEASURED A/B runs: comma-separated gun ids to remove
## from the virtual-bullet rack. A disabled gun never spawns virtual bullets, so
## its fitness window stays empty and the selector can never pick it (chooseFromFit
@@ -330,6 +336,80 @@ proc buildState(bot: ModularBot, ex, ey, espeed, eheading, eenergy: float): Worl
if RecordWorldState:
bot.recordWorldState(result)
## ── Enemy-tracker probe (TR_TRACKER_PROBE) ─────────────────────────────────
var gProbeFile: File
var gProbeReady = false
var gProbeEventRegistered = false
proc probeWrite(row: JsonNode) =
if not gProbeReady:
try:
gProbeFile = open(TrackerProbePath, fmWrite)
gProbeReady = true
except CatchableError:
return
try:
gProbeFile.writeLine($row)
gProbeFile.flushFile()
except CatchableError:
discard
proc probeQueue(): bool =
## Registered as a custom event. `addCustomEvents` runs it AFTER the tick's
## events are queued but BEFORE `removeOldEvents` deletes them, so this is the
## only place that can observe a `BotDeathEvent` the API is about to drop.
## Always returns false, so it is never dispatched.
if TrackerProbe:
var evs = newJArray()
var dropped = 0
var maxTurn = -1
let turn = getTurn()
for e in getEvents():
let drop = e.turnNumber < turn - MAX_EVENTS_AGE and not e.isCritical
if drop: inc dropped
if e.turnNumber > maxTurn: maxTurn = e.turnNumber
evs.add(%*{"k": $e.kind, "t": e.turnNumber, "drop": drop})
probeWrite(%*{
"probe": "queue",
"round": getRound(),
"turn": turn,
"queue_turn": maxTurn,
"dispatch_lag": turn - maxTurn,
"events": evs,
"dropped": dropped,
})
return false
proc probeTracker(bot: ModularBot) =
## Per-tick tracker snapshot (tracker alive-count vs the server's
## `getEnemyCount()`). The `pruned` field is kept for schema compatibility
## with the corpse measurement tooling; it is always empty now that
## reconciliation is gone (see docs/tracker_death_events.md).
if not TrackerProbe: return
var alive = newJObject()
var dead = newJObject()
for id, es in bot.enemyTracker.enemies:
if es.alive: alive[$id] = %es.lastSeenTick
else: dead[$id] = %es.lastSeenTick
let current = bot.currentTargetId
probeWrite(%*{
"probe": "tracker",
"round": bot.roundNumber,
"my_id": getMyId(),
"turn": getTurn(),
"bot_tick": bot.tick,
"lag": getTurn() - bot.tick,
"server_ec": getEnemyCount(),
"tracker_alive": bot.enemyTracker.aliveCount(),
"alive": alive,
"dead": dead,
"pruned": newJArray(),
"target": current,
"target_alive": bot.enemyTracker.isAlive(current),
"radar_mode": bot.radarMode,
"ramming": bot.isRamming,
})
proc recordRadarStats(bot: ModularBot) =
## Per-tick radar coverage sampler (no-op unless TR_RADAR_SCANLOG is set).
## "Fresh within N" means the tracker scanned that enemy at most N ticks ago.
@@ -337,10 +417,10 @@ proc recordRadarStats(bot: ModularBot) =
## histogram; it does not influence the radar.
##
## Only SERVER-confirmed melee ticks are sampled (2+ enemies alive), so the
## 1v1 lock mode cannot dilute the melee numbers. Enemies unseen for > 60
## ticks are treated as dead-but-unmarked and dropped from the coverage
## denominator (BotDeathEvent does not reach this bot in the current API, so
## the tracker keeps corpses alive forever).
## 1v1 lock mode cannot dilute the melee numbers. There is no corpse filter:
## the tracker's `alive` flag is reliable (measured 0 corpse ticks — see
## docs/tracker_death_events.md), so dead enemies are excluded by `es.alive`
## alone.
if not RadarScanLog: return
bot.radarMeleeActive = getEnemyCount() >= 2
if not bot.radarMeleeActive: return
@@ -351,7 +431,6 @@ proc recordRadarStats(bot: ModularBot) =
for id, es in bot.enemyTracker.enemies:
if not es.alive: continue
let age = bot.tick - es.lastSeenTick
if age > 60: continue # dead-but-unmarked corpse: exclude from coverage
bearings.add normalizeDeg(arctan2(es.y - getY(), es.x - getX()).radToDeg)
bot.radarAliveTicks[id] = bot.radarAliveTicks.getOrDefault(id) + 1
if age <= 8:
@@ -566,9 +645,11 @@ method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
echo "[death] victimId=", e.victimId, " wasTarget=", (e.victimId == bot.currentTargetId),
" trackerAlive=", (if bot.enemyTracker.enemies.contains(e.victimId): $bot.enemyTracker.enemies[e.victimId].alive else: "notInTracker")
echo CLR_MOVE & "[death] victimId=" & $e.victimId & " wasTarget=" & $(e.victimId == bot.currentTargetId) & CLR_RST
# `markDead` is the lone death path. The API-level drop mechanism is real
# (`ekBotDeath` is not `isCritical`), but it was MEASURED never to fire at
# this workload: 0 dropped BotDeath events and 0 corpse ticks over 151 deaths
# on two server versions. See docs/tracker_death_events.md.
bot.enemyTracker.markDead(e.victimId)
if bot.enemyCount > 0:
dec bot.enemyCount
if e.victimId == bot.currentTargetId:
bot.isRamming = false
bot.currentTargetId = -1
@@ -576,6 +657,12 @@ method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
# The custom event must be registered AFTER `start()` ran `initGlobals()`,
# which replaces the event queue (and its conditions). onGameStarted is the
# first bot callback that runs after that, still before any round/tick.
if TrackerProbe and not gProbeEventRegistered:
addCustomEvent("tracker_queue_probe", probeQueue)
gProbeEventRegistered = true
proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
## Hysteresis: only switch when there is a clear reason.
@@ -596,6 +683,9 @@ proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
method run*(bot: ModularBot) =
while isRunning():
inc bot.tick
if TrackerProbe: bot.probeTracker()
# `enemyCount` is derived from server truth every tick, not hand-kept.
bot.enemyCount = getEnemyCount()
if RadarScanLog: bot.recordRadarStats()
# Ram cooldown countdown