7104645f5d
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).
261 lines
10 KiB
Nim
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()
|