ModularBot: [result] round/battle outcome log lines (TR_RESULT_LOG, default on)

One greppable '[result]' line per round plus one at battle end, stdout only:
  [result] round 3/7  WE WON    (enemy destroyed)  | us 42.1 energy, them 0.0, 812 ticks | rounds won 3/7
  [result] battle END: rounds won 4/7

Outcome is authoritative from RoundEndedEventForBot.results.rank (1 = winner);
death observations (our onDeath, enemy onBotDeath) and onWonRound refine it
into WE WON (enemy destroyed) / WE DIED (killed) / BOTH DIED (score decided) /
TIMEOUT (score decided). Our own death is reported the instant it happens.
TR_RESULT_LOG registers in the boot env report; default on, only explicit
off-values disable it. No behaviour change - logging/state only.
This commit is contained in:
2026-09-25 22:50:46 +02:00
parent a50c0125d5
commit dc071b83f3
2 changed files with 122 additions and 0 deletions
+119
View File
@@ -123,6 +123,13 @@ let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl")
## shows why the cap moved. The policy itself lives in the shared gun harness
## (`applyPowerPolicy`), so both live and offline paths see the same rule.
let PowerLog = existsEnv("TR_POWER_LOG")
## Round-outcome observability (TR_RESULT_LOG): one `[result]` line per round
## (win / our death / mutual kill / timeout) plus one at battle end. Default ON;
## only the explicit off-values disable it. Read once at process start.
let ResultLog* =
block:
let v = getEnv("TR_RESULT_LOG", "1").strip().toLowerAscii()
not (v == "0" or v == "false" or v == "no" or v == "off")
const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect", "TMPattern", "TMHorizon", "BitBrain"]
## Rack id of the new TM pattern gun. It defaults to `TR_RACK_TMPATTERN=off`;
@@ -246,6 +253,18 @@ type
# once-per-change `[rack]` log line.
rackMode: vb.RackMode
lastRackLogKey: string
# Round-outcome logging (TR_RESULT_LOG). Pure observability: these fields
# never feed a movement/gun/radar decision. Reset per round in
# onRoundStarted (and the counters per battle in onGameStarted).
battleRounds: int ## rounds configured for this battle (GameSetup)
roundsWon: int ## cumulative rounds won this battle
roundsPlayed: int ## cumulative rounds fully accounted for
roundCounted: bool ## roundsPlayed already bumped for this round
weDiedThisRound: bool
enemyDiedThisRound: bool ## an enemy BotDeath was observed this round
wonRoundSeen: bool ## onWonRound fired (authoritative "last alive")
roundOutcomePrinted: bool ## a `[result]` line already covered this round
roundPrintedLabel: string ## its outcome label (for reconciliation)
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
## Task A: append one JSON line per resolved real shot. The write is fully
@@ -588,8 +607,65 @@ method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
bot.ramDmgAccum += bulletDamage(e.bullet.power)
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
proc enemyEnergyForReport(bot: ModularBot): float =
## Last-scanned energy of the enemy the report is about (current target,
## else the last known target, else any enemy in the tracker). Returns -1.0
## when nothing has been scanned yet, so a report never invents a number;
## returns 0.0 when that enemy is already known dead.
let tid = if bot.currentTargetId >= 0: bot.currentTargetId else: bot.lastKnownTargetId
if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
let es = bot.enemyTracker.enemies[tid]
return (if es.alive: es.energy else: 0.0)
for _, es in bot.enemyTracker.enemies:
return (if es.alive: es.energy else: 0.0)
-1.0
proc emitRoundResult(bot: ModularBot, label, detail: string, ticks: int,
usDead, themDead: bool) =
## The ONE `[result]` line for one round. Every line starts with the greppable
## `[result]` prefix; the outcome word is left-padded so successive lines
## align. `rounds won X/Y` is the cumulative count including this round.
if not ResultLog: return
let rn = if bot.battleRounds > 0: $bot.roundNumber & "/" & $bot.battleRounds
else: $bot.roundNumber
let usE = if usDead: 0.0 else: getEnergy()
var themE = enemyEnergyForReport(bot)
if themDead: themE = 0.0
let themStr = if themE < 0.0: "?" else: fmt"{themE:.1f}"
let total = if bot.battleRounds > 0: bot.battleRounds else: bot.roundsPlayed
echo "[result] round ", rn, " ", alignLeft(label, 10), "(", detail, ")",
" | us ", fmt"{usE:.1f}", " energy, them ", themStr, ", ",
ticks, " ticks | rounds won ", bot.roundsWon, "/", total
bot.roundOutcomePrinted = true
bot.roundPrintedLabel = label
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
## Dump per-gun virtual bullet stats to /tmp/gun_stats.jsonl (one line per round).
# Round outcome: `e.results.rank` (1 = winner) is the server's authoritative
# win/lose signal and does not depend on BotDeathEvent. Death observations
# (our `onDeath`, enemy `onBotDeath`) and `onWonRound` refine it into the four
# distinct outcomes. If our death already printed this round's line (immediate
# report), only reprint when the final label CHANGED (e.g. a mutual kill).
if ResultLog:
let rank1 = e.results.rank == 1
if not bot.roundCounted:
inc bot.roundsPlayed
bot.roundCounted = true
if rank1: inc bot.roundsWon
let weDied = bot.weDiedThisRound
let enemyGone = bot.enemyDiedThisRound or bot.wonRoundSeen
let decided = ": " & (if rank1: "WE WON" else: "WE LOST")
var label, detail: string
if weDied and enemyGone:
label = "BOTH DIED"; detail = "score decided" & decided
elif weDied:
label = "WE DIED"; detail = "killed"
elif enemyGone:
label = "WE WON"; detail = "enemy destroyed"
else:
label = "TIMEOUT"; detail = "score decided" & decided
if not bot.roundOutcomePrinted or bot.roundPrintedLabel != label:
bot.emitRoundResult(label, detail, e.turnNumber, weDied, enemyGone)
if RecordWorldState:
bot.finishWorldStateRecord()
# Per-round TM-horizon summary (change-gated behind TR_TMHORIZON_LOG=1) so the
@@ -697,6 +773,14 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.roundNumber = e.roundNumber
# Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1
# reports and a target/enemy change mid-round cannot leak a stale flag.
bot.weDiedThisRound = false
bot.enemyDiedThisRound = false
bot.wonRoundSeen = false
bot.roundCounted = false
bot.roundOutcomePrinted = false
bot.roundPrintedLabel = ""
if RecordWorldState:
bot.startWorldStateRecord()
bot.realShotsFired = 0
@@ -800,6 +884,8 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
# tracker fitness persists across rounds (rolling window carries over)
method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
## An ENEMY died (`ekBotDeath`; our own death goes to `onDeath`).
bot.enemyDiedThisRound = true
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
@@ -812,6 +898,25 @@ method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
bot.isRamming = false
bot.currentTargetId = -1
method onDeath*(bot: ModularBot, e: BotDeathEvent) =
## OUR OWN death (`ekDeath`, victimId == myId). Reported the instant it
## happens so the user tailing the GUI log sees it without waiting for the
## round-end event (which may not arrive). If the enemy is already known dead,
## the round is a mutual kill and is labelled as such (rank/score only arrives
## at round end and is reconciled there if it contradicts this line).
bot.weDiedThisRound = true
inc bot.roundsPlayed
bot.roundCounted = true
if bot.enemyDiedThisRound or bot.wonRoundSeen:
bot.emitRoundResult("BOTH DIED", "score decided", e.turnNumber, true, true)
else:
bot.emitRoundResult("WE DIED", "killed", e.turnNumber, true, false)
method onWonRound*(bot: ModularBot, e: WonRoundEvent) =
## The server says we were the last bot alive: authoritative "enemy
## destroyed", independent of whether a BotDeathEvent was delivered.
bot.wonRoundSeen = true
method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
# A NEW BATTLE begins: wipe the horizon TM's machines and learned statistics
# (no persistence across battles). `onRoundStarted` only clears per-round
@@ -821,6 +926,11 @@ method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
bot.bitbrain.resetLearning("game_start")
# minNumberOfParticipants == maxNumberOfParticipants for fixed battles; self is -1
bot.initialEnemyCount = e.gameSetup.minNumberOfParticipants - 1
# New battle: reset the cumulative outcome counters and remember how many
# rounds the server will run (0 = unlimited; the report then shows played count).
bot.battleRounds = e.gameSetup.numberOfRounds
bot.roundsWon = 0
bot.roundsPlayed = 0
# 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.
@@ -828,6 +938,14 @@ method onGameStarted*(bot: ModularBot, e: GameStartedEventForBot) =
addCustomEvent("tracker_queue_probe", probeQueue)
gProbeEventRegistered = true
method onGameEnded*(bot: ModularBot, e: GameEndedEventForBot) =
## Battle summary, ONE line, after the server reports the game over.
if not ResultLog: return
let total = if e.numberOfRounds > 0: e.numberOfRounds
elif bot.battleRounds > 0: bot.battleRounds
else: bot.roundsPlayed
echo "[result] battle END: rounds won ", bot.roundsWon, "/", total
proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
## Hysteresis: only switch when there is a clear reason.
if bot.currentTargetId < 0: return true
@@ -1311,6 +1429,7 @@ when isMainModule:
trackerProbe: TrackerProbe,
trackerProbePath: TrackerProbePath,
powerLog: PowerLog,
resultLog: ResultLog,
gunStatsPath: GunStatsPath,
shotLogPath: ShotLogPath,
disabledGuns: DisabledGuns,
+3
View File
@@ -48,6 +48,7 @@ type
trackerProbe*: bool
trackerProbePath*: string
powerLog*: bool
resultLog*: bool
gunStatsPath*: string
shotLogPath*: string
disabledGuns*: HashSet[int]
@@ -186,6 +187,7 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_VBULLET_ADMIT_ONLY", onOff(ctx.vBulletAdmitOnly),
sourceOf("TR_VBULLET_ADMIT_ONLY"))
emit("TR_POWER_LOG", onOff(ctx.powerLog), sourceOfPresence("TR_POWER_LOG"))
emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG"))
emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState),
sourceOfPresence("TR_RECORD_WORLDSTATE"))
emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin),
@@ -417,6 +419,7 @@ proc knownEnvNames*(): seq[string] =
"TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH",
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
"TR_POWER_LOG",
"TR_RESULT_LOG",
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",