diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 04c782a..c6ea471 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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, diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index d22ea35..da8bd4c 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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",