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:
@@ -0,0 +1,54 @@
|
||||
## Corpse measurement: run a real melee where bots kill each other and log
|
||||
## ModularBot's tracker-vs-server disagreement per tick.
|
||||
##
|
||||
## The tracker probe (ModularBot TR_TRACKER_PROBE=1) records, each tick:
|
||||
## tracker_alive (bot.enemyTracker.allAlive().len), server_ec (getEnemyCount()),
|
||||
## each tracked enemy's lastSeenTick, the dead map and any reconcile prunes.
|
||||
## The queue probe records the event queue BEFORE removeOldEvents deletes events.
|
||||
##
|
||||
## TR_TRACKER_RECONCILE defaults to 0 here so the RAW corpse behaviour is
|
||||
## measured (a corpse can only be removed by a BotDeathEvent fast-path).
|
||||
##
|
||||
## MELEE_ROUNDS=20 TR_SERVER_JAR=<jar> \
|
||||
## nim c -r --path:common_libs common_libs/tests/measure_corpse_melee.nim
|
||||
|
||||
import std/[os, strformat, strutils]
|
||||
import test_framework/server_manager
|
||||
import test_framework/bot_compiler
|
||||
import test_framework/runner_process
|
||||
import test_framework/battle_result
|
||||
|
||||
const
|
||||
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||
modularBotDir = repoRoot / "ModularBot_garage"
|
||||
samplesDir = "/home/davide/Downloads/robocode-tankroyale/sample-bots-nim-linux-1.0.7"
|
||||
|
||||
let drussDir = getEnv("DRUSSGT_BOTDIR", "/tmp/tr_bots/DrussGT")
|
||||
let rounds = parseInt(getEnv("MELEE_ROUNDS", "20"))
|
||||
let probePath = getEnv("TR_TRACKER_PROBE_PATH", "/tmp/tracker_probe_melee.jsonl")
|
||||
|
||||
# RAW behaviour: no reconciliation, so any corpse persists until a BotDeathEvent.
|
||||
putEnv("TR_TRACKER_RECONCILE", getEnv("TR_TRACKER_RECONCILE", "0"))
|
||||
putEnv("TR_TRACKER_PROBE", "1")
|
||||
putEnv("TR_TRACKER_PROBE_PATH", probePath)
|
||||
if fileExists(probePath): removeFile(probePath)
|
||||
|
||||
var bots = @[modularBotDir]
|
||||
for name in ["Fire", "Corners", "Crazy", "RamFire", "SpinBot"]:
|
||||
let d = samplesDir / name
|
||||
if dirExists(d): bots.add d
|
||||
if fileExists(drussDir / "DrussGT.sh"): bots.add drussDir
|
||||
|
||||
echo "server jar : ", getEnv("TR_SERVER_JAR", DefaultServerJar)
|
||||
echo "probe path : ", probePath
|
||||
echo "reconcile : ", getEnv("TR_TRACKER_RECONCILE")
|
||||
echo "bots : ", bots
|
||||
echo "rounds : ", rounds
|
||||
|
||||
ensureServer()
|
||||
discard compileBots(@[modularBotDir])
|
||||
let raw = runBattleRunner(getServerUrl(), bots, rounds, 1_800_000, true)
|
||||
let r = parseServerOutput(raw)
|
||||
for b in r.results:
|
||||
echo fmt" {b.name:<12} totalScore={b.totalScore} rank={b.rank} survival={b.survivalCount}"
|
||||
echo "rounds played: ", r.rounds.len
|
||||
@@ -318,9 +318,11 @@ proc testExpectedDisabled() =
|
||||
discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 0.0, 300.0, float(t))]))
|
||||
check "gate: expectedEnemies = -1 does not block acquisition", m.phase == rpTrack
|
||||
|
||||
proc testCorpseIgnored() =
|
||||
# A dead-but-unmarked enemy (unseen > CorpseTicks) must not keep the radar in
|
||||
# acquisition, nor widen the arc.
|
||||
proc testStaleLiveEnemyTrusted() =
|
||||
# With the corpse filter retired the radar trusts the tracker: a tracked
|
||||
# enemy with a very old lastSeenTick is a stale LIVE enemy, not a corpse. It
|
||||
# is still included in the covering arc (so the sweep covers it) and, being
|
||||
# stale, keeps the radar in acquisition until it has been re-scanned.
|
||||
var m = initAdaptiveMeleeRadar()
|
||||
m.setExpectedEnemies(1)
|
||||
var t = 0
|
||||
@@ -328,27 +330,20 @@ proc testCorpseIgnored() =
|
||||
inc t
|
||||
discard m.computeScan(ws(t, 0.0, @[
|
||||
enemyAt(1, 100.0, 300.0, float(t)),
|
||||
enemyAt(2, 300.0, 300.0, float(t - 100)), # corpse, far bearing
|
||||
enemyAt(2, 300.0, 300.0, float(t - 100)), # stale, but alive
|
||||
]))
|
||||
check "corpse: a long-unseen enemy is ignored and tracking is reached",
|
||||
m.phase == rpTrack
|
||||
check "corpse: the corpse does not widen the swept arc",
|
||||
approx(m.lastSweptWidth, 2.0 * MarginDeg)
|
||||
|
||||
proc testCorpseExpectedGate() =
|
||||
# expectedEnemies = 2 but only one LIVE enemy (the other is a corpse): the
|
||||
# live count is 1, so the gate keeps the radar in acquisition.
|
||||
var m = initAdaptiveMeleeRadar()
|
||||
m.setExpectedEnemies(2)
|
||||
var t = 0
|
||||
for _ in 0 ..< FreshStreakTicks + 5:
|
||||
inc t
|
||||
discard m.computeScan(ws(t, 0.0, @[
|
||||
enemyAt(1, 100.0, 300.0, float(t)),
|
||||
enemyAt(2, 300.0, 300.0, float(t - 100)),
|
||||
]))
|
||||
check "corpse: the expected gate counts LIVE enemies only",
|
||||
# Bearings 100 and 300: the minimal covering arc runs 300 -> 100 (width 160).
|
||||
check "trust: a long-unseen tracked enemy is not dropped from the arc",
|
||||
approx(m.lastSweptWidth, 160.0 + 2.0 * MarginDeg)
|
||||
check "trust: a long-unseen tracked enemy keeps the radar in acquisition",
|
||||
m.phase == rpAcquire
|
||||
# When the tracker stops reporting it (onBotDeath marked it dead, so it is
|
||||
# absent from `state.enemies`) the radar trusts that and tracks the survivor.
|
||||
for _ in 0 ..< FreshStreakTicks + 2:
|
||||
inc t
|
||||
discard m.computeScan(ws(t, 0.0, @[enemyAt(1, 100.0, 300.0, float(t))]))
|
||||
check "trust: once the dead enemy leaves the tracker the radar tracks",
|
||||
m.phase == rpTrack
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -373,8 +368,7 @@ testWideArcFallback()
|
||||
testWideArcExitAndEnterHysteresis()
|
||||
testNoKnownEnemyStaysAcquire()
|
||||
testExpectedDisabled()
|
||||
testCorpseIgnored()
|
||||
testCorpseExpectedGate()
|
||||
testStaleLiveEnemyTrusted()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
## Unit tests for EnemyTracker's death invariant: once an enemy is marked dead
|
||||
## it stays dead, and every alive view excludes it.
|
||||
##
|
||||
## This is the invariant that actually matters in production. The earlier
|
||||
## premise — "BotDeathEvent never reaches the bot, so corpses persist" — was
|
||||
## MEASURED false (0 dropped BotDeath events, 0 corpse ticks; see
|
||||
## docs/tracker_death_events.md). The reconciliation feature that used to be
|
||||
## tested here was retired with it. The event-drop MECHANISM is real and is
|
||||
## covered separately by test_event_drop_mechanism.nim.
|
||||
##
|
||||
## Headless: no Java, no server, no battle.
|
||||
## nim c -r common_libs/tests/test_enemy_tracker_death.nim
|
||||
|
||||
import std/[tables, algorithm]
|
||||
import targeting/enemy_tracker
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else: echo "FAIL: ", name; inc failures
|
||||
|
||||
proc see(et: var EnemyTracker, id, tick: int) =
|
||||
et.update(id, float(id) * 100.0, 0.0, 0.0, 0.0, 100.0, tick)
|
||||
|
||||
proc testMarkDeadImmediate() =
|
||||
var et: EnemyTracker
|
||||
et.see(5, 10)
|
||||
check "precondition: a scanned enemy is alive", et.isAlive(5)
|
||||
et.markDead(5)
|
||||
# Same tick: no intervening update, no reconciliation.
|
||||
check "immediate: markDead flips the flag in the same tick", not et.isAlive(5)
|
||||
check "immediate: aliveCount drops the dead enemy at once", et.aliveCount == 0
|
||||
check "immediate: allAlive drops the dead enemy at once", et.allAlive.len == 0
|
||||
|
||||
proc testNoResurrection() =
|
||||
var et: EnemyTracker
|
||||
et.see(5, 10)
|
||||
et.markDead(5)
|
||||
# A later scan of the same id (queued/duplicate scan) must not revive it.
|
||||
et.update(5, 1.0, 2.0, 3.0, 4.0, 100.0, 11)
|
||||
check "no-resurrect: a later update does not revive a dead enemy",
|
||||
not et.isAlive(5)
|
||||
check "no-resurrect: the dead enemy is not counted alive", et.aliveCount == 0
|
||||
check "no-resurrect: allAlive still excludes it", et.allAlive.len == 0
|
||||
|
||||
proc testAliveViewsExcludeDead() =
|
||||
var et: EnemyTracker
|
||||
et.see(1, 10)
|
||||
et.see(2, 10)
|
||||
et.see(3, 10)
|
||||
et.markDead(2)
|
||||
check "views: isAlive is false only for the dead id",
|
||||
et.isAlive(1) and not et.isAlive(2) and et.isAlive(3)
|
||||
check "views: aliveCount counts only the living", et.aliveCount == 2
|
||||
var ids: seq[int]
|
||||
for s in et.allAlive(): ids.add s.id
|
||||
ids.sort()
|
||||
check "views: allAlive returns exactly the living ids", ids == @[1, 3]
|
||||
|
||||
proc testMarkDeadUnknownIsNoop() =
|
||||
var et: EnemyTracker
|
||||
et.see(1, 10)
|
||||
et.markDead(99)
|
||||
check "noop: markDead on an unknown id creates nothing",
|
||||
et.enemies.len == 1 and not et.isAlive(99) and et.aliveCount == 1
|
||||
|
||||
proc testDoubleMarkDead() =
|
||||
var et: EnemyTracker
|
||||
et.see(7, 10)
|
||||
et.markDead(7)
|
||||
et.markDead(7)
|
||||
check "idempotent: marking the same enemy dead twice is harmless",
|
||||
not et.isAlive(7) and et.aliveCount == 0
|
||||
|
||||
proc testResetRoundClears() =
|
||||
var et: EnemyTracker
|
||||
et.see(1, 10)
|
||||
et.see(2, 10)
|
||||
et.markDead(2)
|
||||
et.resetRound()
|
||||
check "resetRound: clears every tracked enemy (alive and dead)",
|
||||
et.enemies.len == 0 and et.aliveCount == 0 and not et.isAlive(1) and
|
||||
not et.isAlive(2)
|
||||
|
||||
testMarkDeadImmediate()
|
||||
testNoResurrection()
|
||||
testAliveViewsExcludeDead()
|
||||
testMarkDeadUnknownIsNoop()
|
||||
testDoubleMarkDead()
|
||||
testResetRoundClears()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
quit(1)
|
||||
echo "\nAll enemy-tracker death checks passed."
|
||||
@@ -0,0 +1,77 @@
|
||||
## Direct proof of the BotDeath drop mechanism in `robocode_tankroyale_botapi`
|
||||
## 1.0.7, at the API level (no battle required).
|
||||
##
|
||||
## `event_queue.isCritical` contains only `ekDeath`, `ekWonRound` and
|
||||
## `ekSkippedTurn` — NOT `ekBotDeath`. `removeOldEvents` deletes every
|
||||
## non-critical event whose `turnNumber < currentTurn - MAX_EVENTS_AGE`.
|
||||
## Therefore a `BotDeathEvent` for ANOTHER bot is silently dropped whenever the
|
||||
## bot falls more than two turns behind, which is why `onBotDeath` cannot be the
|
||||
## tracker's only death signal.
|
||||
##
|
||||
## Run: nim c -r common_libs/tests/test_event_drop_mechanism.nim
|
||||
|
||||
import robocode_tankroyale_botapi
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else: echo "FAIL: ", name; inc failures
|
||||
|
||||
const CurrentTurn = 8
|
||||
|
||||
proc botDeathAt(turn: int): BotEvent =
|
||||
BotEvent(kind: ekBotDeath, turnNumber: turn,
|
||||
botDeath: BotDeathEvent(turnNumber: turn, victimId: 2))
|
||||
|
||||
proc deathAt(turn: int): BotEvent =
|
||||
BotEvent(kind: ekDeath, turnNumber: turn,
|
||||
death: BotDeathEvent(turnNumber: turn, victimId: 1))
|
||||
|
||||
proc testBotDeathIsNotCritical() =
|
||||
let e = botDeathAt(5)
|
||||
check "ekBotDeath is not critical", not e.isCritical
|
||||
check "ekDeath IS critical", deathAt(5).isCritical
|
||||
|
||||
proc testBotDeathDroppedWhenOlderThanMaxAge() =
|
||||
var eq = initEventQueue()
|
||||
eq.addEvent(botDeathAt(CurrentTurn - MAX_EVENTS_AGE - 1)) # age 3
|
||||
eq.removeOldEvents(CurrentTurn)
|
||||
check "ekBotDeath older than MAX_EVENTS_AGE is dropped",
|
||||
eq.events.len == 0
|
||||
|
||||
proc testBotDeathKeptAtMaxAge() =
|
||||
var eq = initEventQueue()
|
||||
eq.addEvent(botDeathAt(CurrentTurn - MAX_EVENTS_AGE)) # age 2
|
||||
eq.removeOldEvents(CurrentTurn)
|
||||
check "ekBotDeath at exactly MAX_EVENTS_AGE age is kept",
|
||||
eq.events.len == 1
|
||||
|
||||
proc testCriticalDeathSurvives() =
|
||||
var eq = initEventQueue()
|
||||
eq.addEvent(deathAt(CurrentTurn - MAX_EVENTS_AGE - 5))
|
||||
eq.removeOldEvents(CurrentTurn)
|
||||
check "critical ekDeath survives any age",
|
||||
eq.events.len == 1
|
||||
|
||||
proc testScannedBotAlsoDropped() =
|
||||
# Every other non-critical event is dropped too, so a lagging bot also loses
|
||||
# scans/hits; BotDeath is the one with a permanent consequence.
|
||||
var eq = initEventQueue()
|
||||
eq.addEvent(BotEvent(kind: ekScannedBot, turnNumber: CurrentTurn - 3,
|
||||
scannedBot: ScannedBotEvent(`type`: "ScannedBotEvent",
|
||||
turnNumber: CurrentTurn - 3, scannedByBotId: 0, scannedBotId: 2,
|
||||
energy: 100.0, x: 0.0, y: 0.0, direction: 0.0, speed: 0.0)))
|
||||
eq.removeOldEvents(CurrentTurn)
|
||||
check "ekScannedBot older than MAX_EVENTS_AGE is dropped too",
|
||||
eq.events.len == 0
|
||||
|
||||
testBotDeathIsNotCritical()
|
||||
testBotDeathDroppedWhenOlderThanMaxAge()
|
||||
testBotDeathKeptAtMaxAge()
|
||||
testCriticalDeathSurvives()
|
||||
testScannedBotAlsoDropped()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
quit(1)
|
||||
echo "\nAll event-drop mechanism checks passed."
|
||||
Reference in New Issue
Block a user