fix(botapi): static event queue storage + end-of-battle train wait
The event queue's heap seq was the last GC'd block surviving across rounds: each round runs on a freshly spawned bot thread, so the N+1 thread realloc'd a block grown by dead thread N's allocator mid-round (at the next capacity doubling, ~turn 104) -> rawDealloc SIGSEGV in addEvent (7 gdb-confirmed coredumps). Replace with a static array[MAX_QUEUE_SIZE, BotEvent] + eventsLen: no heap block crosses threads, realloc can never happen. Also fix the harness aborting the final round mid-train: PPO_Bot's onRoundEnded trains synchronously after the runner's RoundEndedEvent, so the counter read right after awaitResults() is the stale pre-train value and System.exit killed the bot inside ppoUpdate. Poll up to 60s for the counter to catch up before declaring the battle incomplete. Verified: 72 consecutive rounds vs Fire, 100% wins, all rounds trained (counter advanced 1:1), zero coredumps since the fix.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
## Main entry-point module for Robocode Tank Royale Nim bot API.
|
||||
##
|
||||
## Usage:
|
||||
## import tankroyale_botapi
|
||||
##
|
||||
## type MyBot = ref object of Bot
|
||||
## method run(bot: MyBot) =
|
||||
## forward(100)
|
||||
## ...
|
||||
##
|
||||
## var bot = MyBot()
|
||||
## start(bot, "MyBot.json")
|
||||
|
||||
import std/[os, json]
|
||||
|
||||
import ./tankroyale_botapi/constants
|
||||
import ./tankroyale_botapi/color
|
||||
import ./tankroyale_botapi/schemas
|
||||
import ./tankroyale_botapi/utils
|
||||
import ./tankroyale_botapi/bot_info
|
||||
import ./tankroyale_botapi/ws_client
|
||||
import ./tankroyale_botapi/json_parse
|
||||
import ./tankroyale_botapi/event_queue
|
||||
import ./tankroyale_botapi/bot
|
||||
import ./tankroyale_botapi/graphics
|
||||
|
||||
export constants
|
||||
export color
|
||||
export schemas
|
||||
export utils
|
||||
export bot_info
|
||||
export json_parse
|
||||
export event_queue
|
||||
export bot
|
||||
export graphics
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket receive loop (main thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleServerHandshake(ws: SyncWebSocket; node: JsonNode; info: BotInfo; secret: string) =
|
||||
let sessionId = node{"sessionId"}.getStr
|
||||
setServerInfo(node{"variant"}.getStr, node{"version"}.getStr)
|
||||
|
||||
# Build bot handshake
|
||||
var h = newJObject()
|
||||
h["type"] = %"BotHandshake"
|
||||
h["sessionId"] = %sessionId
|
||||
h["name"] = %info.name
|
||||
h["version"] = %info.version
|
||||
h["authors"] = %info.authors
|
||||
h["description"] = %info.description
|
||||
h["homepage"] = %info.homepage
|
||||
h["countryCodes"] = %info.countryCodes
|
||||
h["gameTypes"] = %info.gameTypes
|
||||
h["platform"] = %info.platform
|
||||
h["programmingLang"]= %info.programmingLang
|
||||
h["isDroid"] = %info.isDroid
|
||||
if secret.len > 0:
|
||||
h["secret"] = %secret
|
||||
let ip = info.initialPosition
|
||||
if ip.x != 0.0 or ip.y != 0.0 or ip.direction != 0.0:
|
||||
var ipObj = newJObject()
|
||||
if ip.x != 0.0: ipObj["x"] = %ip.x
|
||||
if ip.y != 0.0: ipObj["y"] = %ip.y
|
||||
if ip.direction != 0.0: ipObj["direction"] = %ip.direction
|
||||
h["initialPosition"] = ipObj
|
||||
ws.send($h)
|
||||
|
||||
proc parseGameSetup(node: JsonNode): GameSetup =
|
||||
if node.isNil: return
|
||||
result.gameType = node{"gameType"}.getStr("classic")
|
||||
result.arenaWidth = node{"arenaWidth"}.getInt(800)
|
||||
result.isArenaWidthLocked = node{"isArenaWidthLocked"}.getBool(false)
|
||||
result.arenaHeight = node{"arenaHeight"}.getInt(600)
|
||||
result.isArenaHeightLocked = node{"isArenaHeightLocked"}.getBool(false)
|
||||
result.numberOfRounds = node{"numberOfRounds"}.getInt(10)
|
||||
result.isNumberOfRoundsLocked = node{"isNumberOfRoundsLocked"}.getBool(false)
|
||||
result.minNumberOfParticipants = node{"minNumberOfParticipants"}.getInt(2)
|
||||
result.isMinNumberOfParticipantsLocked = node{"isMinNumberOfParticipantsLocked"}.getBool(false)
|
||||
result.maxNumberOfParticipants = node{"maxNumberOfParticipants"}.getInt(10)
|
||||
result.isMaxNumberOfParticipantsLocked = node{"isMaxNumberOfParticipantsLocked"}.getBool(false)
|
||||
result.gunCoolingRate = node{"gunCoolingRate"}.getFloat(0.1)
|
||||
result.isGunCoolingRateLocked = node{"isGunCoolingRateLocked"}.getBool(false)
|
||||
result.maxInactivityTurns = node{"maxInactivityTurns"}.getInt(450)
|
||||
result.isMaxInactivityTurnsLocked = node{"isMaxInactivityTurnsLocked"}.getBool(false)
|
||||
result.turnTimeout = node{"turnTimeout"}.getInt(30000)
|
||||
result.isTurnTimeoutLocked = node{"isTurnTimeoutLocked"}.getBool(false)
|
||||
result.readyTimeout = node{"readyTimeout"}.getInt(1000000)
|
||||
result.isReadyTimeoutLocked = node{"isReadyTimeoutLocked"}.getBool(false)
|
||||
result.defaultTurnsPerSecond = node{"defaultTurnsPerSecond"}.getInt(30)
|
||||
|
||||
proc handleGameStarted(ws: SyncWebSocket; node: JsonNode) =
|
||||
let setup = parseGameSetup(node{"gameSetup"})
|
||||
|
||||
var teammateIds: seq[int] = @[]
|
||||
if not node{"teammateIds"}.isNil and node["teammateIds"].kind == JArray:
|
||||
for id in node["teammateIds"]: teammateIds.add id.getInt
|
||||
|
||||
let myId = node{"myId"}.getInt
|
||||
setGameStarted(myId, setup, teammateIds)
|
||||
|
||||
# Build event object manually — GameStartedEventForBot has no turnNumber
|
||||
let e = GameStartedEventForBot(
|
||||
`type`: "GameStartedEventForBot",
|
||||
myId: myId,
|
||||
startX: node{"startX"}.getFloat(0.0),
|
||||
startY: node{"startY"}.getFloat(0.0),
|
||||
startDirection: node{"startDirection"}.getFloat(0.0),
|
||||
teammateIds: teammateIds,
|
||||
gameSetup: setup
|
||||
)
|
||||
gBot.onGameStarted(e)
|
||||
|
||||
# Send BotReady
|
||||
ws.send("""{"type":"BotReady"}""")
|
||||
|
||||
proc handleTick(node: JsonNode) =
|
||||
# Build TickEventForBot manually to handle optional fields safely
|
||||
var tick: TickEventForBot
|
||||
tick.`type` = "TickEventForBot"
|
||||
tick.turnNumber = node{"turnNumber"}.getInt(0)
|
||||
tick.roundNumber = node{"roundNumber"}.getInt(0)
|
||||
tick.botState = parseBotState(node{"botState"})
|
||||
tick.bulletStates = @[]
|
||||
if not node{"bulletStates"}.isNil and node["bulletStates"].kind == JArray:
|
||||
for bs in node["bulletStates"]:
|
||||
tick.bulletStates.add parseBulletState(bs)
|
||||
tick.events = @[] # sub-events parsed separately into typed BotEvent
|
||||
|
||||
# Parse embedded events into typed BotEvent for priority-based dispatch
|
||||
var events: seq[BotEvent] = @[]
|
||||
let myId = getMyId()
|
||||
if node.hasKey("events") and node["events"].kind == JArray:
|
||||
for ev in node["events"]:
|
||||
events.add parseBotEvent(ev, myId)
|
||||
|
||||
signalTick(tick, events) # update shared state
|
||||
processTickOnMainThread() # motion tracking (while bot is blocked)
|
||||
wakeBotThread() # wake bot — state + motion ready
|
||||
|
||||
proc runReceiveLoop*(ws: SyncWebSocket; info: BotInfo; secret: string; serverUrl: string) =
|
||||
## Main WebSocket receive loop. Blocks until disconnected.
|
||||
while ws.connected:
|
||||
var msg: string
|
||||
try:
|
||||
msg = ws.receive()
|
||||
except Exception as e:
|
||||
stderr.writeLine "[ws] receive error: " & e.msg
|
||||
gBot.onConnectionError(ConnectionErrorEvent(serverUrl: serverUrl, error: e.msg))
|
||||
break
|
||||
|
||||
if msg.len == 0:
|
||||
break # connection closed
|
||||
|
||||
var node: JsonNode
|
||||
try:
|
||||
node = parseJson(msg)
|
||||
except Exception as e:
|
||||
stderr.writeLine "[ws] json parse error: " & e.msg
|
||||
continue
|
||||
|
||||
let msgType = node{"type"}.getStr
|
||||
try:
|
||||
case msgType
|
||||
of "ServerHandshake":
|
||||
handleServerHandshake(ws, node, info, secret)
|
||||
of "GameStartedEventForBot":
|
||||
handleGameStarted(ws, node)
|
||||
of "RoundStartedEvent":
|
||||
let e = node.to(RoundStartedEvent)
|
||||
debugLog("[NS-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
# Start (or restart) the bot thread each round
|
||||
startRound()
|
||||
startBotThread()
|
||||
gBot.onRoundStarted(e)
|
||||
debugLog("[NS-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
of "TickEventForBot":
|
||||
handleTick(node)
|
||||
of "RoundEndedEventForBot":
|
||||
setRunning(false)
|
||||
let e = node.to(RoundEndedEventForBot)
|
||||
debugLog("[RE-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
signalStop() # unblock bot thread blocked in go()
|
||||
debugLog("[WT-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
waitForBotThread()
|
||||
debugLog("[WT-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
debugLog("[DR-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
drainTickChan() # drain stop signal if bot exited via isRunning() check
|
||||
drainIntentChan() # drain AFTER thread joined — no more writes possible
|
||||
drainEventChan() # drop any unconsumed tick events (stale into next round)
|
||||
debugLog("[DR-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
debugLog("[ONRE-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
gBot.onRoundEnded(e)
|
||||
debugLog("[ONRE-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
|
||||
of "GameEndedEventForBot":
|
||||
setRunning(false)
|
||||
let e = node.to(GameEndedEventForBot)
|
||||
drainIntentChan()
|
||||
gBot.onGameEnded(e)
|
||||
of "GameAbortedEvent":
|
||||
setRunning(false)
|
||||
signalStop() # unblock bot thread (game aborted mid-round)
|
||||
waitForBotThread()
|
||||
drainTickChan() # drain stop signal if bot exited via isRunning() check
|
||||
drainIntentChan() # drain AFTER thread joined — no more writes possible
|
||||
drainEventChan() # drop any unconsumed tick events (stale into next round)
|
||||
gBot.onGameAborted()
|
||||
of "SkippedTurnEvent":
|
||||
let e = node.to(SkippedTurnEvent)
|
||||
gBot.onSkippedTurn(e)
|
||||
else:
|
||||
discard # unknown message type — ignore
|
||||
except Exception as e:
|
||||
# A raised handler/callback (e.g. an OSError from sync training inside
|
||||
# onRoundEnded) must not kill the receive loop — that is the silent
|
||||
# corpse path (process lives, no intents ever again). Log and continue.
|
||||
stderr.writeLine "[ws] handler error (" & msgType & "): " & e.msg
|
||||
debugLog("[WS-HANDLER-ERR] " & msgType & ": " & e.msg)
|
||||
|
||||
# Loop exited: server disconnected or ws error. Make sure the bot thread is
|
||||
# stopped and joined so the process exits cleanly instead of hanging forever
|
||||
# with a blocked bot (corpse). ponytail: signalStop + join; the bot's go()
|
||||
# consumes the stop as a non-tick and exits via the isRunning() check.
|
||||
if isRunning():
|
||||
debugLog("[DBG] receive loop exited while running — stopping bot thread")
|
||||
setRunning(false)
|
||||
signalStop()
|
||||
waitForBotThread()
|
||||
drainTickChan()
|
||||
drainIntentChan()
|
||||
drainEventChan()
|
||||
gBot.onDisconnected(DisconnectedEvent(serverUrl: serverUrl))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public start() procedure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc start*(bot: Bot; jsonFile: string = "") =
|
||||
## Connect to the server and start the bot.
|
||||
## jsonFile: path to bot JSON profile (optional; falls back to env vars).
|
||||
gBot = bot
|
||||
gBotInfo = loadBotInfo(jsonFile)
|
||||
initGlobals()
|
||||
|
||||
let serverUrl = getEnv("SERVER_URL", "ws://localhost:7654")
|
||||
let serverSecret = getEnv("SERVER_SECRET", "")
|
||||
|
||||
try:
|
||||
gWs = newSyncWebSocket(serverUrl)
|
||||
except Exception as e:
|
||||
stderr.writeLine "[start] Cannot connect to " & serverUrl & ": " & e.msg
|
||||
quit(1)
|
||||
|
||||
bot.onConnected(ConnectedEvent(serverUrl: serverUrl))
|
||||
startSenderThread()
|
||||
runReceiveLoop(gWs, gBotInfo, serverSecret, serverUrl)
|
||||
stopSenderThread()
|
||||
Reference in New Issue
Block a user