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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user