Files
SirRoboGarage/libs/tankroyale_botapi/tankroyale_botapi.nim
T
SirStone 7104645f5d chore(deps): update vendored tankroyale botapi to v1.0.1
Syncs libs/tankroyale_botapi with SirStone/robocode_tankroyale_botapi
v1.0.1 (extracted from tank-royale nim branch @ 03195a814). The local
SIGSEGV fixes (static event queue/SVG/intent buffers) were already
ported upstream in issue #24 — content is otherwise identical.

New capability (upstream #23): opponent name exposure for ticket #49.
- bot.nim: gBotNames id→name table + getBotName(id) / updateBotNames()
- umbrella module: dispatch BotListUpdate messages to updateBotNames()

Vendored layout and wiring unchanged (--path via config.nims, module
name stays tankroyale_botapi).
2026-08-21 21:03:17 +02:00

261 lines
10 KiB
Nim

## 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)
of "BotListUpdate":
updateBotNames(node)
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()