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:
2026-08-19 03:12:22 +02:00
parent 766b9e03ee
commit 64697f917e
66 changed files with 2927 additions and 127 deletions
@@ -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()
@@ -0,0 +1,11 @@
# Package
version = "1.0.1"
author = "Davide Cappellini"
description = "Nim bot API for Robocode Tank Royale"
license = "Apache-2.0"
srcDir = "src"
skipDirs = @["sample_bots"]
# Dependencies
requires "nim >= 2.0.0"
requires "jsony >= 1.1.5"
@@ -0,0 +1,969 @@
## Core bot implementation for Robocode Tank Royale Nim bot API.
##
## Threading model
## ---------------
## Main thread: WebSocket receive loop. Receives all server messages,
## updates shared state, runs processTurn, then wakes bot thread.
## Bot thread: Wakes, dispatches events, runs user's `run()` / go() loop,
## sends intent JSON via gIntentChan.
## Sender thread: Owns all WebSocket writes during gameplay — reads from
## gIntentChan and calls ws.send.
##
## Synchronisation uses two channels:
## tickChan main → bot (true = new tick ready; false = stop)
## intentChan bot → sender (JSON string to send to server; "" = stop)
import std/[json, locks, math, os, posix, syncio]
import ./constants
import ./schemas
import ./color
import ./utils as botutils
import ./ws_client
import ./bot_info
import ./event_queue
import ./graphics
proc toInfiniteValue(rate: float): float {.inline.} =
if rate > 0.0: Inf
elif rate < 0.0: NegInf
else: 0.0
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
type
Bot* = ref object of RootObj
## Override `run()` and event handler methods in your bot subtype.
BotState* = object
## Snapshot of shared state, read by the bot thread.
# ---------------------------------------------------------------------------
# Global mutable state (module-level; single bot per process)
# ---------------------------------------------------------------------------
# WebSocket
var gWs*: SyncWebSocket
# Thread handles
var gBotThread: Thread[void]
var gSenderThread: Thread[void]
var gFirstTickOfRound: bool # main-thread only, no lock needed
# Channels — must be opened before use
var gTickChan: Channel[bool] # main → bot: tick arrived (false = stop)
var gIntentChan: Channel[string] # bot → main: send this JSON
# Shared state protected by lock
var gLock: Lock
var gRunning {.guard: gLock.}: bool
var gMyId {.guard: gLock.}: int
var gRound {.guard: gLock.}: int
var gTurn {.guard: gLock.}: int
var gEnemyCount {.guard: gLock.}: int
var gState {.guard: gLock.}: schemas.BotState
var gBullets {.guard: gLock.}: seq[BulletState]
var gGameSetup {.guard: gLock.}: GameSetup
var gTeammateIds{.guard: gLock.}: seq[int]
var gVariant {.guard: gLock.}: string
var gServerVersion {.guard: gLock.}: string
# Events handed main -> bot thread. Channel move, NOT a locked shared seq:
# the old locked seq[BotEvent] copied GC'd payloads (strings/teamMessages)
# across threads on every tick -> refcount churn under ORC + --threads:on ->
# heap freeList corruption + SIGSEGV inside prepareSeqAddUninit at tps=-1
# (gdb-confirmed). Channels move ownership: zero cross-thread refcount traffic.
var gEventChan: Channel[seq[BotEvent]]
# Saved state for stop/resume
var gStopped: bool
var gSavedTurnRate: float
var gSavedGunTurnRate: float
var gSavedRadarTurnRate: float
var gSavedTargetSpeed: float
# Motion tracking (bot thread only)
var gDistanceRemaining: float
var gTurnRemaining: float
var gGunTurnRemaining: float
var gRadarTurnRemaining: float
var gPreviousDirection: float
var gPreviousGunDirection: float
var gPreviousRadarDirection: float
var gOverrideTurnRate: bool
var gOverrideGunTurnRate: bool
var gOverrideRadarTurnRate: bool
var gOverrideTargetSpeed: bool
var gContinuousTurnRate: float
var gContinuousGunTurnRate: float
var gContinuousRadarTurnRate: float
var gContinuousTargetSpeed: float
var gIsOverDriving: bool
var gMaxSpeed = MAX_SPEED
var gMaxTurnRate = MAX_TURN_RATE
var gMaxGunTurnRate = MAX_GUN_TURN_RATE
var gMaxRadarTurnRate = MAX_RADAR_TURN_RATE
# The bot instance (set by start())
var gBot*: Bot
var gEventQueue: EventQueue # bot-thread-only, no lock needed
var gInterrupted: bool # flag-based interruptibility (checked by blocking calls)
# BotInfo (set by start())
var gBotInfo*: BotInfo
# ---------------------------------------------------------------------------
# Safe readers (can be called from bot thread without lock in most cases
# because they read after the tick channel signal — happens-before is enough)
# ---------------------------------------------------------------------------
proc getMyId*(): int = withLock(gLock): result = gMyId
proc getRound*(): int = withLock(gLock): result = gRound
proc getTurn*(): int = withLock(gLock): result = gTurn
proc getEnemyCount*(): int = withLock(gLock): result = gEnemyCount
proc getEnergy*(): float = withLock(gLock): result = gState.energy
proc getX*(): float = withLock(gLock): result = gState.x
proc getY*(): float = withLock(gLock): result = gState.y
proc getDirection*(): float = withLock(gLock): result = gState.direction
proc getGunDirection*(): float = withLock(gLock): result = gState.gunDirection
proc getRadarDirection*(): float= withLock(gLock): result = gState.radarDirection
proc getRadarSweep*(): float = withLock(gLock): result = gState.radarSweep
proc getSpeed*(): float = withLock(gLock): result = gState.speed
proc getTurnRate*(): float = withLock(gLock): result = gState.turnRate
proc getGunTurnRate*(): float = withLock(gLock): result = gState.gunTurnRate
proc getRadarTurnRate*(): float = withLock(gLock): result = gState.radarTurnRate
proc getGunHeat*(): float = withLock(gLock): result = gState.gunHeat
proc getBodyColor*(): Color = withLock(gLock): result = gState.bodyColor
proc getTurretColor*(): Color = withLock(gLock): result = gState.turretColor
proc getRadarColor*(): Color = withLock(gLock): result = gState.radarColor
proc getBulletColor*(): Color = withLock(gLock): result = gState.bulletColor
proc getScanColor*(): Color = withLock(gLock): result = gState.scanColor
proc getTracksColor*(): Color = withLock(gLock): result = gState.tracksColor
proc getGunColor*(): Color = withLock(gLock): result = gState.gunColor
proc getArenaWidth*(): int = withLock(gLock): result = gGameSetup.arenaWidth
proc getArenaHeight*(): int = withLock(gLock): result = gGameSetup.arenaHeight
proc getGameType*(): string = withLock(gLock): result = gGameSetup.gameType
proc getNumberOfRounds*(): int = withLock(gLock): result = gGameSetup.numberOfRounds
proc getGunCoolingRate*(): float= withLock(gLock): result = gGameSetup.gunCoolingRate
proc getMaxInactivityTurns*(): int = withLock(gLock): result = gGameSetup.maxInactivityTurns
proc getTurnTimeout*(): int = withLock(gLock): result = gGameSetup.turnTimeout
proc getTimeLeft*(): int = getTurnTimeout() # ponytail: returns turnTimeout as ceiling; precise impl needs tick timestamp + elapsed tracking
proc getVariant*(): string = withLock(gLock): result = gVariant
proc getServerVersion*(): string= withLock(gLock): result = gServerVersion
proc isRunning*(): bool = withLock(gLock): result = gRunning
proc isDroid*(): bool = withLock(gLock): result = gState.isDroid
proc isDisabled*(): bool = getEnergy() == 0.0
proc isStopped*(): bool = gStopped
proc getBulletStates*(): seq[BulletState] = withLock(gLock): result = gBullets
proc getTeammateIds*(): seq[int]= withLock(gLock): result = gTeammateIds
proc isTeammate*(botId: int): bool =
withLock(gLock): result = gTeammateIds.contains(botId)
proc getDistanceRemaining*(): float = gDistanceRemaining
proc getTurnRemaining*(): float = gTurnRemaining
proc getGunTurnRemaining*(): float = gGunTurnRemaining
proc getRadarTurnRemaining*(): float = gRadarTurnRemaining
proc getMaxSpeed*(): float = gMaxSpeed
proc getMaxTurnRate*(): float = gMaxTurnRate
proc getMaxGunTurnRate*(): float = gMaxGunTurnRate
proc getMaxRadarTurnRate*(): float = gMaxRadarTurnRate
proc setMaxSpeed*(v: float) = gMaxSpeed = v.clamp(0, MAX_SPEED)
proc setMaxTurnRate*(v: float) = gMaxTurnRate = v.clamp(0, MAX_TURN_RATE)
proc setMaxGunTurnRate*(v: float) = gMaxGunTurnRate = v.clamp(0, MAX_GUN_TURN_RATE)
proc setMaxRadarTurnRate*(v: float)= gMaxRadarTurnRate = v.clamp(0, MAX_RADAR_TURN_RATE)
# ---------------------------------------------------------------------------
# Intent building
# ---------------------------------------------------------------------------
# Intent fields (bot thread writes, main thread reads + clears)
var gIntentTurnRate: float = 0.0
var gIntentGunTurnRate: float = 0.0
var gIntentRadarTurnRate: float = 0.0
var gIntentTargetSpeed: float = 0.0
var gIntentFirepower: float = 0.0
var gIntentRescan: bool = false
var gIntentFireAssist: bool = false
var gIntentBodyColor: Color = Color(0)
var gIntentTurretColor: Color = Color(0)
var gIntentRadarColor: Color = Color(0)
var gIntentBulletColor: Color = Color(0)
var gIntentScanColor: Color = Color(0)
var gIntentTracksColor: Color = Color(0)
var gIntentGunColor: Color = Color(0)
var gIntentAdjGunBody: bool = false
var gIntentAdjRadarBody: bool = false
var gIntentAdjRadarGun: bool = false
var gIntentTeamMessages: seq[TeamMessage] = @[]
var gIntentStdOut: string = ""
var gIntentStdErr: string = ""
proc buildIntentJson*(): string =
## Serialise current intent to JSON for sending to server.
var obj = newJObject()
obj["type"] = %"BotIntent"
obj["turnRate"] = %gIntentTurnRate
obj["gunTurnRate"] = %gIntentGunTurnRate
obj["radarTurnRate"] = %gIntentRadarTurnRate
obj["targetSpeed"] = %gIntentTargetSpeed
if gIntentFirepower > 0.0:
obj["firepower"] = %gIntentFirepower
if gIntentRescan:
obj["rescan"] = %true
gIntentRescan = false # one-shot
if gIntentFireAssist:
obj["fireAssist"] = %true
if gIntentAdjGunBody:
obj["adjustGunForBodyTurn"] = %true
if gIntentAdjRadarBody:
obj["adjustRadarForBodyTurn"] = %true
if gIntentAdjRadarGun:
obj["adjustRadarForGunTurn"] = %true
if gIntentBodyColor != Color(0):
obj["bodyColor"] = %gIntentBodyColor.toHex
if gIntentTurretColor != Color(0):
obj["turretColor"] = %gIntentTurretColor.toHex
if gIntentRadarColor != Color(0):
obj["radarColor"] = %gIntentRadarColor.toHex
if gIntentBulletColor != Color(0):
obj["bulletColor"] = %gIntentBulletColor.toHex
if gIntentScanColor != Color(0):
obj["scanColor"] = %gIntentScanColor.toHex
if gIntentTracksColor != Color(0):
obj["tracksColor"] = %gIntentTracksColor.toHex
if gIntentGunColor != Color(0):
obj["gunColor"] = %gIntentGunColor.toHex
if gIntentTeamMessages.len > 0:
var msgs = newJArray()
for m in gIntentTeamMessages:
var mo = newJObject()
mo["message"] = %m.message
mo["messageType"] = %m.messageType
if m.receiverId != 0:
mo["receiverId"] = %m.receiverId
msgs.add mo
obj["teamMessages"] = msgs
gIntentTeamMessages.setLen 0
if gIntentStdOut.len > 0:
obj["stdOut"] = %gIntentStdOut
gIntentStdOut = ""
if gIntentStdErr.len > 0:
obj["stdErr"] = %gIntentStdErr
gIntentStdErr = ""
let svg = svgOutput()
if svg.len > 0:
obj["debugGraphics"] = %svg
result = $obj
# ---------------------------------------------------------------------------
# Intent setters (bot thread)
# ---------------------------------------------------------------------------
proc setTurnRate*(rate: float) =
gIntentTurnRate = rate.clamp(-gMaxTurnRate, gMaxTurnRate)
gOverrideTurnRate = false
gContinuousTurnRate = rate
gTurnRemaining = toInfiniteValue(rate)
proc setGunTurnRate*(rate: float) =
gIntentGunTurnRate = rate.clamp(-gMaxGunTurnRate, gMaxGunTurnRate)
gOverrideGunTurnRate = false
gContinuousGunTurnRate = rate
gGunTurnRemaining = toInfiniteValue(rate)
proc setRadarTurnRate*(rate: float) =
gIntentRadarTurnRate = rate.clamp(-gMaxRadarTurnRate, gMaxRadarTurnRate)
gOverrideRadarTurnRate = false
gContinuousRadarTurnRate = rate
gRadarTurnRemaining = toInfiniteValue(rate)
proc setTargetSpeed*(speed: float) =
gIntentTargetSpeed = speed.clamp(-gMaxSpeed, gMaxSpeed)
gOverrideTargetSpeed = false
gContinuousTargetSpeed = speed
if speed > 0:
gDistanceRemaining = Inf
elif speed < 0:
gDistanceRemaining = NegInf
else:
gDistanceRemaining = 0.0
proc setFire*(firepower: float): bool =
let fp = firepower.clamp(MIN_FIRE_POWER, MAX_FIRE_POWER)
if getEnergy() < fp or getGunHeat() > 0.0:
return false
gIntentFirepower = fp
return true
proc setRescan*() = gIntentRescan = true
proc setBodyColor*(color: Color) = gIntentBodyColor = color
proc setTurretColor*(color: Color) = gIntentTurretColor = color
proc setRadarColor*(color: Color) = gIntentRadarColor = color
proc setBulletColor*(color: Color) = gIntentBulletColor = color
proc setScanColor*(color: Color) = gIntentScanColor = color
proc setTracksColor*(color: Color) = gIntentTracksColor = color
proc setGunColor*(color: Color) = gIntentGunColor = color
proc printToStdOut*(s: string) =
## Append s to this tick's stdOut payload (sent to server in BotIntent).
gIntentStdOut.add s
proc printToStdErr*(s: string) =
## Append s to this tick's stdErr payload (sent to server in BotIntent).
gIntentStdErr.add s
proc broadcastTeamMessage*(message: string) =
## Send a message to all teammates this tick.
gIntentTeamMessages.add TeamMessage(message: message, messageType: "String")
proc sendTeamMessage*(botId: int; message: string) =
## Send a message to a specific teammate this tick.
gIntentTeamMessages.add TeamMessage(message: message, messageType: "String", receiverId: botId)
proc setAdjustGunForBodyTurn*(v: bool) = gIntentAdjGunBody = v
proc setAdjustRadarForBodyTurn*(v: bool) = gIntentAdjRadarBody = v
proc setFireAssist*(enable: bool) = gIntentFireAssist = enable
proc setAdjustRadarForGunTurn*(v: bool) =
gIntentAdjRadarGun = v
setFireAssist(not v)
proc isAdjustGunForBodyTurn*(): bool = gIntentAdjGunBody
proc isAdjustRadarForBodyTurn*(): bool = gIntentAdjRadarBody
proc isAdjustRadarForGunTurn*(): bool = gIntentAdjRadarGun
proc getTargetSpeed*(): float = gIntentTargetSpeed
proc getFirepower*(): float = gIntentFirepower
# Convenience math re-exports (state-aware wrappers; pure-math helpers come from utils)
proc calcBearing*(direction: float): float = botutils.calcDeltaAngle(direction, getDirection())
proc calcGunBearing*(direction: float): float = botutils.calcDeltaAngle(direction, getGunDirection())
proc calcRadarBearing*(direction: float): float = botutils.calcDeltaAngle(direction, getRadarDirection())
proc bearingTo*(x, y: float): float = botutils.bearingTo(getX(), getY(), getDirection(), x, y)
proc gunBearingTo*(x, y: float): float = botutils.bearingTo(getX(), getY(), getGunDirection(), x, y)
proc radarBearingTo*(x, y: float): float = botutils.normalizeRelativeAngle(botutils.directionTo(getX(), getY(), x, y) - getRadarDirection())
proc directionTo*(x, y: float): float = botutils.directionTo(getX(), getY(), x, y)
proc distanceTo*(x, y: float): float = botutils.distanceTo(getX(), getY(), x, y)
# ---------------------------------------------------------------------------
# Bot motion processing (called on first turn and each subsequent turn)
# NOTE: must be defined before go() so it can be called inside go()
# ---------------------------------------------------------------------------
var gDebugLog: File # nil unless PPOB_DEBUG_LOG=1 (debug-only knob)
var gDebugLogBytes: int64 # written bytes since last truncation
const gDebugLogCap = 100 * 1024 * 1024 # 100 MB ceiling
const gDebugLogPath = "/tmp/walls_debug.log"
proc resetDebugLog() =
## Truncate the debug log to zero (Nim's stdlib has no File truncate, so do
## it by path via POSIX — the open fmAppend handle stays valid).
discard truncate(gDebugLogPath, 0)
gDebugLogBytes = 0
proc debugLog*(msg: string) =
## Debug tracing, only active when PPOB_DEBUG_LOG=1. Called from both the
## main and bot threads, so writes are serialized under gLock (File writes
## are not thread-safe); the log is truncated and restarted at the cap so a
## long campaign can't grow a GB-scale file again.
if gDebugLog == nil: return
withLock(gLock):
if gDebugLogBytes >= gDebugLogCap:
resetDebugLog()
gDebugLog.writeLine(msg)
gDebugLog.flushFile()
gDebugLogBytes += int64(msg.len) + 1
proc clearRemaining*() =
gDistanceRemaining = 0.0
gTurnRemaining = 0.0
gGunTurnRemaining = 0.0
gRadarTurnRemaining = 0.0
gContinuousTurnRate = 0.0
gContinuousGunTurnRate = 0.0
gContinuousRadarTurnRate = 0.0
gContinuousTargetSpeed = 0.0
# Reset override flags — prevents stale state carrying across rounds
gOverrideTurnRate = false
gOverrideGunTurnRate = false
gOverrideRadarTurnRate = false
gOverrideTargetSpeed = false
gIsOverDriving = false
# Reset intent values to zero for a clean slate each round
gIntentTurnRate = 0.0
gIntentGunTurnRate = 0.0
gIntentRadarTurnRate = 0.0
gIntentTargetSpeed = 0.0
# Reset stop/resume state — stale gStopped=true would hijack forward/turn calls
gStopped = false
gSavedTurnRate = 0.0
gSavedGunTurnRate = 0.0
gSavedRadarTurnRate = 0.0
gSavedTargetSpeed = 0.0
# Reset prevDir to current tick values — prevents wrong delta on first processTurn
gPreviousDirection = getDirection()
gPreviousGunDirection = getGunDirection()
gPreviousRadarDirection = getRadarDirection()
# Reset event queue state for new round
gEventQueue.clear()
gInterrupted = false
proc updateTurnRemaining() =
let delta = calcDeltaAngle(getDirection(), gPreviousDirection)
gPreviousDirection = getDirection()
if not gOverrideTurnRate:
gIntentTurnRate = gContinuousTurnRate.clamp(-gMaxTurnRate, gMaxTurnRate)
return
if abs(gTurnRemaining) <= abs(delta):
gTurnRemaining = 0.0
else:
gTurnRemaining -= delta
if isNearZero(gTurnRemaining): gTurnRemaining = 0.0
gIntentTurnRate = gTurnRemaining.clamp(-gMaxTurnRate, gMaxTurnRate)
proc updateGunTurnRemaining() =
let delta = calcDeltaAngle(getGunDirection(), gPreviousGunDirection)
gPreviousGunDirection = getGunDirection()
if not gOverrideGunTurnRate:
gIntentGunTurnRate = gContinuousGunTurnRate.clamp(-gMaxGunTurnRate, gMaxGunTurnRate)
return
if abs(gGunTurnRemaining) <= abs(delta):
gGunTurnRemaining = 0.0
else:
gGunTurnRemaining -= delta
if isNearZero(gGunTurnRemaining): gGunTurnRemaining = 0.0
gIntentGunTurnRate = gGunTurnRemaining.clamp(-gMaxGunTurnRate, gMaxGunTurnRate)
proc updateRadarTurnRemaining() =
let delta = calcDeltaAngle(getRadarDirection(), gPreviousRadarDirection)
gPreviousRadarDirection = getRadarDirection()
if not gOverrideRadarTurnRate:
gIntentRadarTurnRate = gContinuousRadarTurnRate.clamp(-gMaxRadarTurnRate, gMaxRadarTurnRate)
return
if abs(gRadarTurnRemaining) <= abs(delta):
gRadarTurnRemaining = 0.0
else:
gRadarTurnRemaining -= delta
if isNearZero(gRadarTurnRemaining): gRadarTurnRemaining = 0.0
gIntentRadarTurnRate = gRadarTurnRemaining.clamp(-gMaxRadarTurnRate, gMaxRadarTurnRate)
proc updateMovement() =
if not gOverrideTargetSpeed:
gIntentTargetSpeed = gContinuousTargetSpeed.clamp(-gMaxSpeed, gMaxSpeed)
if abs(gDistanceRemaining) < abs(getSpeed()):
gDistanceRemaining = 0.0
else:
gDistanceRemaining -= getSpeed()
elif gDistanceRemaining == Inf:
gIntentTargetSpeed = gMaxSpeed
elif gDistanceRemaining == NegInf:
gIntentTargetSpeed = -gMaxSpeed
else:
let dist = gDistanceRemaining
let newSpeed = getNewTargetSpeed(gMaxSpeed, getSpeed(), dist)
gIntentTargetSpeed = newSpeed.clamp(-gMaxSpeed, gMaxSpeed)
if isNearZero(newSpeed) and gIsOverDriving:
gDistanceRemaining = 0.0
gIsOverDriving = false
else:
if math.sgn(dist * newSpeed).float != -1.0:
gIsOverDriving = getDistanceTraveledUntilStop(gMaxSpeed, newSpeed) > abs(dist)
gDistanceRemaining = dist - newSpeed
proc processTurn*() =
## Update motion tracking at the start of each tick (called from go() and
## botThreadEntry after each tick signal).
if isDisabled():
clearRemaining()
else:
updateTurnRemaining()
updateGunTurnRemaining()
updateRadarTurnRemaining()
updateMovement()
# ---------------------------------------------------------------------------
# Default event handlers (no-ops; override in your Bot subtype)
# NOTE: must be defined before dispatchEvent() below
# ---------------------------------------------------------------------------
method run*(bot: Bot) {.base.} = discard
method onConnected*(bot: Bot, e: ConnectedEvent) {.base.} = discard
method onDisconnected*(bot: Bot, e: DisconnectedEvent) {.base.} = discard
method onConnectionError*(bot: Bot, e: ConnectionErrorEvent) {.base.} = discard
method onGameStarted*(bot: Bot, e: GameStartedEventForBot) {.base.} = discard
method onGameEnded*(bot: Bot, e: GameEndedEventForBot) {.base.} = discard
method onGameAborted*(bot: Bot) {.base.} = discard
method onRoundStarted*(bot: Bot, e: RoundStartedEvent) {.base.} = discard
method onRoundEnded*(bot: Bot, e: RoundEndedEventForBot) {.base.} = discard
method onTick*(bot: Bot, e: TickEventForBot) {.base.} = discard
method onSkippedTurn*(bot: Bot, e: SkippedTurnEvent) {.base.} = discard
method onBotDeath*(bot: Bot, e: BotDeathEvent) {.base.} = discard
method onBulletFired*(bot: Bot, e: BulletFiredEvent) {.base.} = discard
method onBulletHit*(bot: Bot, e: BulletHitBotEvent) {.base.} = discard
method onBulletHitBullet*(bot: Bot, e: BulletHitBulletEvent) {.base.} = discard
method onBulletHitWall*(bot: Bot, e: BulletHitWallEvent) {.base.} = discard
method onHitByBullet*(bot: Bot, e: HitByBulletEvent) {.base.} = discard
method onHitBot*(bot: Bot, e: BotHitBotEvent) {.base.} = discard
method onHitWall*(bot: Bot, e: BotHitWallEvent) {.base.} = discard
method onScannedBot*(bot: Bot, e: ScannedBotEvent) {.base.} = discard
method onWonRound*(bot: Bot, e: WonRoundEvent) {.base.} = discard
method onTeamMessage*(bot: Bot, e: TeamMessageEvent) {.base.} = discard
method onDeath*(bot: Bot, e: BotDeathEvent) {.base.} = discard
method onCustomEvent*(bot: Bot, e: Condition) {.base.} = discard
# ---------------------------------------------------------------------------
# Event dispatch (called from bot thread)
# NOTE: must be defined before go() below
# ---------------------------------------------------------------------------
proc dispatchSingleEvent(bot: Bot; e: BotEvent) =
## Dispatch a typed BotEvent to the appropriate handler.
case e.kind
of ekTick: bot.onTick(e.tick)
of ekSkippedTurn: bot.onSkippedTurn(e.skippedTurn)
of ekBotDeath: bot.onBotDeath(e.botDeath)
of ekDeath: bot.onDeath(e.death)
of ekBulletFired:
bot.onBulletFired(e.bulletFired)
gIntentFirepower = 0.0
of ekBulletHitBot: bot.onBulletHit(e.bulletHitBot)
of ekBulletHitBullet: bot.onBulletHitBullet(e.bulletHitBullet)
of ekBulletHitWall: bot.onBulletHitWall(e.bulletHitWall)
of ekHitByBullet: bot.onHitByBullet(e.hitByBullet)
of ekHitBot:
if e.hitBot.rammed: gDistanceRemaining = 0.0
bot.onHitBot(e.hitBot)
of ekHitWall:
gDistanceRemaining = 0.0
bot.onHitWall(e.hitWall)
of ekScannedBot: bot.onScannedBot(e.scannedBot)
of ekWonRound: bot.onWonRound(e.wonRound)
of ekTeamMessage: bot.onTeamMessage(e.teamMessage)
of ekCustom: bot.onCustomEvent(e.condition)
proc dispatchPendingEvents*(bot: Bot) =
var pending: seq[BotEvent]
let (hasEvents, evs) = gEventChan.tryRecv() # non-blocking: stop signals carry no events
if hasEvents: pending = evs
for e in pending:
gEventQueue.addEvent(e)
let turnNumber = getTurn()
gEventQueue.addCustomEvents(turnNumber)
gEventQueue.removeOldEvents(turnNumber)
gEventQueue.sortEvents()
while gEventQueue.eventsLen > 0:
let e = gEventQueue.events[0]
let p = gEventQueue.getPriority(e.kind)
if p < gEventQueue.currentTopPriority:
break
if p == gEventQueue.currentTopPriority:
if gEventQueue.currentTopEventKind in gEventQueue.interruptible:
gEventQueue.setInterruptible(gEventQueue.currentTopEventKind, false)
gInterrupted = true
break
discard gEventQueue.popFirst()
let oldPriority = gEventQueue.currentTopPriority
let oldKind = gEventQueue.currentTopEventKind
gEventQueue.currentTopPriority = p
gEventQueue.currentTopEventKind = e.kind
try: dispatchSingleEvent(bot, e)
except Exception as ex:
stderr.writeLine "[bot] event dispatch error: " & ex.msg
gInterrupted = false
gEventQueue.currentTopPriority = oldPriority
gEventQueue.currentTopEventKind = oldKind
# ---------------------------------------------------------------------------
# go() — send intent and wait for next tick
# ---------------------------------------------------------------------------
var gDroppedIntents: int # bot-thread only; rate-limits drop logging
proc go*() =
## Send current intent to the server and block until the next tick arrives.
## This is the fundamental time-step primitive — all blocking methods use it.
if not isRunning():
raise newException(CatchableError, "Bot is not running")
# Stop-signal check BEFORE emitting an intent: a pending `false` means the
# round/game ended while we were computing. Consume it and bail without an
# intent so a stop is never treated as a tick (kills the duplicate-intent /
# duplicate-GO-RECV storm at round boundaries).
let (hasStop, stopVal) = gTickChan.tryRecv()
if hasStop and not stopVal:
debugLog("[GO-STOP] pending stop consumed — no intent emitted")
return
let json = buildIntentJson()
debugLog("[GO-SEND] turn=" & $getTurn() &
" intentTR=" & $gIntentTurnRate &
" intentGTR=" & $gIntentGunTurnRate &
" intentSpd=" & $gIntentTargetSpeed &
" turnRem=" & $gTurnRemaining &
" gunTurnRem=" & $gGunTurnRemaining &
" distRem=" & $gDistanceRemaining &
" overTR=" & $gOverrideTurnRate &
" overGTR=" & $gOverrideGunTurnRate)
clearGraphics() # reset SVG buffer and style state for next tick
# ponytail: trySend, drop if the cap-1 channel is full — a stalled sender
# must never wedge the bot thread mid-round (corpse signature). Sender
# drains unconditionally (see senderThreadEntry); the drop is pure belt.
if not gIntentChan.trySend(json):
inc gDroppedIntents
if gDroppedIntents mod 100 == 1:
debugLog("[GO-DROP] intent chan full (sender stalled/dead) — dropped " &
$gDroppedIntents & " intents")
let gotTick = gTickChan.recv() # block until main thread finishes processTurn + wake
if not gotTick:
# Stop signal: round/game ended while we were blocked. No tick dispatch,
# no further intent — the caller's isRunning() check exits the loop.
debugLog("[GO-STOP] stop consumed in recv — no dispatch")
return
debugLog("[GO-RECV] turn=" & $getTurn() &
" dir=" & $getDirection() &
" gunDir=" & $getGunDirection() &
" spd=" & $getSpeed() &
" prevDir=" & $gPreviousDirection &
" prevGunDir=" & $gPreviousGunDirection)
# processTurn already ran on main thread — just dispatch events
dispatchPendingEvents(gBot) # fire event handlers for this tick
# ---------------------------------------------------------------------------
# Stop / Resume
# ---------------------------------------------------------------------------
proc setStop*(overwrite: bool = false) =
## Non-blocking: save current movement state (IBaseBot API).
if not gStopped or overwrite:
gStopped = true
gSavedTurnRate = gIntentTurnRate
gSavedGunTurnRate = gIntentGunTurnRate
gSavedRadarTurnRate = gIntentRadarTurnRate
gSavedTargetSpeed = gIntentTargetSpeed
gIntentTurnRate = 0.0
gIntentGunTurnRate = 0.0
gIntentRadarTurnRate = 0.0
gIntentTargetSpeed = 0.0
proc setResume*() =
## Non-blocking: restore saved movement state (IBaseBot API).
if gStopped:
gIntentTurnRate = gSavedTurnRate
gIntentGunTurnRate = gSavedGunTurnRate
gIntentRadarTurnRate = gSavedRadarTurnRate
gIntentTargetSpeed = gSavedTargetSpeed
gStopped = false
proc stop*(overwrite: bool = false) =
## Blocking: save movement state, then call go() (IBot API).
setStop(overwrite)
proc resume*() =
## Blocking: restore saved movement state, then call go() (IBot API).
setResume()
# ---------------------------------------------------------------------------
# Blocking movement methods
# ---------------------------------------------------------------------------
proc setForward*(distance: float) =
gOverrideTargetSpeed = true
let speed = getNewTargetSpeed(gMaxSpeed, getSpeed(), distance)
gIntentTargetSpeed = speed.clamp(-gMaxSpeed, gMaxSpeed)
gDistanceRemaining = distance
proc setTurnLeft*(degrees: float) =
gOverrideTurnRate = true
gTurnRemaining = degrees
gIntentTurnRate = degrees.clamp(-gMaxTurnRate, gMaxTurnRate)
proc setTurnRight*(degrees: float) = setTurnLeft(-degrees)
proc setBack*(distance: float) = setForward(-distance)
proc setTurnGunLeft*(degrees: float) =
gOverrideGunTurnRate = true
gGunTurnRemaining = degrees
gIntentGunTurnRate = degrees.clamp(-gMaxGunTurnRate, gMaxGunTurnRate)
proc setTurnGunRight*(degrees: float) = setTurnGunLeft(-degrees)
proc setTurnRadarLeft*(degrees: float) =
gOverrideRadarTurnRate = true
gRadarTurnRemaining = degrees
gIntentRadarTurnRate = degrees.clamp(-gMaxRadarTurnRate, gMaxRadarTurnRate)
proc setTurnRadarRight*(degrees: float) = setTurnRadarLeft(-degrees)
proc forward*(distance: float) =
debugLog("[FORWARD] distance=" & $distance & " dir=" & $getDirection())
if gStopped:
go()
else:
setForward(distance)
while isRunning() and not gInterrupted and
not (gDistanceRemaining == 0.0 and getSpeed() == 0.0):
go()
debugLog("[FORWARD-DONE] dir=" & $getDirection() & " distRem=" & $gDistanceRemaining)
proc back*(distance: float) = forward(-distance)
proc turnLeft*(degrees: float) =
debugLog("[TURNLEFT] degrees=" & $degrees & " dir=" & $getDirection() & " gunDir=" & $getGunDirection())
if gStopped:
go()
else:
setTurnLeft(degrees)
while isRunning() and not gInterrupted and gTurnRemaining != 0.0:
go()
debugLog("[TURNLEFT-DONE] dir=" & $getDirection() & " gunDir=" & $getGunDirection() & " turnRem=" & $gTurnRemaining)
proc turnRight*(degrees: float) = turnLeft(-degrees)
proc turnGunLeft*(degrees: float) =
debugLog("[GUNLEFT] degrees=" & $degrees & " gunDir=" & $getGunDirection())
if gStopped:
go()
else:
setTurnGunLeft(degrees)
while isRunning() and not gInterrupted and gGunTurnRemaining != 0.0:
go()
debugLog("[GUNLEFT-DONE] gunDir=" & $getGunDirection() & " gunTurnRem=" & $gGunTurnRemaining)
proc turnGunRight*(degrees: float) = turnGunLeft(-degrees)
proc turnRadarLeft*(degrees: float) =
if gStopped:
go()
else:
setTurnRadarLeft(degrees)
while isRunning() and not gInterrupted and gRadarTurnRemaining != 0.0:
go()
proc turnRadarRight*(degrees: float) = turnRadarLeft(-degrees)
proc fire*(firepower: float) =
discard setFire(firepower)
go()
proc rescan*() =
setRescan()
go()
proc waitFor*(condition: proc(): bool) =
while isRunning() and not condition():
go()
# ---------------------------------------------------------------------------
# Event queue public API
# ---------------------------------------------------------------------------
proc addCustomEvent*(name: string; test: proc(): bool) =
## Register a custom event condition. Evaluated each tick; fires onCustomEvent when true.
gEventQueue.addCondition(Condition(name: name, test: test))
proc removeCustomEvent*(name: string) =
## Remove a custom event condition by name.
gEventQueue.removeConditionByName(name)
proc setInterruptible*(v: bool) =
## Mark the current event handler as interruptible by same-priority events.
if v: gEventQueue.interruptible.incl gEventQueue.currentTopEventKind
else: gEventQueue.interruptible.excl gEventQueue.currentTopEventKind
proc getEventPriority*(kind: EventKind): int =
## Get the dispatch priority for an event kind.
gEventQueue.getPriority(kind)
proc setEventPriority*(kind: EventKind; p: int) =
## Set the dispatch priority for an event kind.
gEventQueue.setPriority(kind, p)
proc getEvents*(): seq[BotEvent] =
## Get all events currently in the queue.
gEventQueue.getEvents()
proc clearEvents*() =
## Clear all events from the queue.
gEventQueue.clearEvents()
# ---------------------------------------------------------------------------
# Bot thread entry point
# ---------------------------------------------------------------------------
proc botThreadEntry() {.thread.} =
## Runs `bot.run()` after waiting for the first tick.
{.cast(gcsafe).}:
# Wait for first tick signal from main thread
# (clearRemaining + processTurn already ran on main thread)
let firstTick = gTickChan.recv()
if not firstTick:
debugLog("[DBG] botThreadEntry: stop before first tick — exiting")
return
debugLog("[DBG] botThreadEntry: first tick" &
" dir=" & $getDirection() &
" gunDir=" & $getGunDirection() &
" prevDir=" & $gPreviousDirection &
" prevGunDir=" & $gPreviousGunDirection)
dispatchPendingEvents(gBot) # dispatch events embedded in the first tick
try:
gBot.run()
except Exception as e:
stderr.writeLine "[bot] run() exception: " & e.msg
# After run() exits, keep calling go() to skip turns until game ends
while isRunning():
try: go()
except: break
# ---------------------------------------------------------------------------
# Exported initialiser (called from start() in tankroyale_botapi.nim)
# ---------------------------------------------------------------------------
proc initGlobals*() =
gTickChan.open(1)
gIntentChan.open(1)
gEventChan.open(8)
initLock(gLock)
gEventQueue = initEventQueue()
# Debug log is opt-in: it is written every tick from two threads, so leaving
# it on by default is a disk hog and an I/O stall source. Enable with
# PPOB_DEBUG_LOG=1 to debug; starts fresh (truncated) each run.
if getEnv("PPOB_DEBUG_LOG", "") == "1":
gDebugLog = open(gDebugLogPath, fmAppend)
resetDebugLog()
gDebugLog.writeLine("=== PROCESS START pid=" & $getpid() & " ===")
gDebugLog.flushFile()
proc setServerInfo*(variant, version: string) =
withLock(gLock):
gVariant = variant
gServerVersion = version
proc setGameStarted*(myId: int; setup: GameSetup; teammateIds: seq[int]) =
withLock(gLock):
gMyId = myId
gGameSetup = setup
gTeammateIds = teammateIds
debugLog("=== GAME START myId=" & $myId & " ===")
proc startRound*() =
withLock(gLock):
gRunning = true
gTurn = 0
gFirstTickOfRound = true
proc setRunning*(v: bool) =
withLock(gLock): gRunning = v
proc signalTick*(tick: TickEventForBot; events: seq[BotEvent]) =
## Called from main thread when a new tick arrives.
## Updates shared state only — caller must call processTickOnMainThread + wakeBotThread.
withLock(gLock):
gTurn = tick.turnNumber
gRound = tick.roundNumber
gEnemyCount = tick.botState.enemyCount # enemyCount lives in BotState
gState = tick.botState
gBullets = tick.bulletStates
# Prepend tick event, then sub-events — queue sorts by priority.
# Moved through a Channel: ownership transfer, no cross-thread refcounts.
# Send happens-before wakeBotThread's tickChan signal, so the bot thread
# always finds its events waiting when it wakes.
var pending = @[BotEvent(kind: ekTick, turnNumber: tick.turnNumber, tick: tick)]
pending.add events
gEventChan.send(move(pending))
proc processTickOnMainThread*() =
## Run motion tracking on the main thread while bot is blocked.
## Must be called after signalTick and before wakeBotThread.
if gFirstTickOfRound:
clearRemaining()
gFirstTickOfRound = false
processTurn()
proc wakeBotThread*() =
## Wake the bot thread after state + motion tracking are ready.
gTickChan.send(true)
var gWsFailed = false # set by sender thread when a ws.send dies
proc senderThreadEntry() {.thread.} =
## Sender thread: owns all WebSocket writes during gameplay.
## ponytail: never exits — keeps draining gIntentChan so the bot thread's
## trySend never wedges. A dead socket just discards intents (the main
## receive loop detects the broken connection and exits cleanly).
{.cast(gcsafe).}:
while true:
let json = gIntentChan.recv()
if json.len == 0: break # sentinel: stop
try:
gWs.send(json)
except Exception as e:
gWsFailed = true
stderr.writeLine "[sender] send error (keeping drain): " & e.msg
debugLog("[SENDER-ERR] " & e.msg)
# drain without blocking: bot's go() uses trySend, so the channel is
# either empty or has at most one fresh intent — the next recv takes it
continue
proc startSenderThread*() =
createThread(gSenderThread, senderThreadEntry)
proc stopSenderThread*() =
gIntentChan.send("") # sentinel
joinThread(gSenderThread)
proc signalStop*() =
## Unblock the bot thread when a round or the game ends.
## Sends a dummy false to gTickChan so the bot wakes from go().
## gTickChan has capacity 1 and the server can deliver the final
## TickEventForBot + RoundEndedEventForBot back-to-back, leaving the tick's
## `true` unconsumed: the bot exits via `raise` on the isRunning() check in
## the next go() instead of another recv(), so send(false) would block
## forever and freeze the main receive loop (silent corpse). The main thread
## is the only gTickChan sender, so draining right before the send cannot
## race; [true,false] and [false] orderings both unblock cleanly.
debugLog("[SS-ENTER] tid=" & $getThreadId())
let (drained, val) = gTickChan.tryRecv()
debugLog("[SS-DRAIN] drained=" & $drained & " val=" & $val & " tid=" & $getThreadId())
gTickChan.send(false)
debugLog("[SS-SENT] false tid=" & $getThreadId())
debugLog("[SS-EXIT] tid=" & $getThreadId())
proc recvIntent*(): string =
## Called by main thread after signalling a tick.
## Blocks until the bot thread sends its intent JSON via go().
let (ok, json) = gIntentChan.tryRecv()
if ok: return json
return gIntentChan.recv()
proc drainIntentChan*() =
## Discard any pending intent (used when round/game ends).
discard gIntentChan.tryRecv()
proc drainTickChan*() =
## Discard any pending tick/stop signal left in gTickChan.
## Needed when the bot thread exits via isRunning() check instead of
## consuming the stop signal from go() — the false sits in the channel
## and would be mistaken for the first real tick of the next round.
let (drained, val) = gTickChan.tryRecv()
if drained:
debugLog("[DBG] drainTickChan: drained signal=" & $val)
proc drainEventChan*() =
## Discard any unconsumed tick events left in gEventChan (round/game end).
## Called after the bot thread joined — no more recv's possible, so this
## prevents stale previous-round events leaking into the next round.
while true:
let (hasEvents, _) = gEventChan.tryRecv()
if not hasEvents: break
proc startBotThread*() =
createThread(gBotThread, botThreadEntry)
proc waitForBotThread*() =
joinThread(gBotThread)
@@ -0,0 +1,79 @@
## BotInfo: bot identification loaded from a JSON file or environment variables.
import std/[os, json, strutils, sequtils]
import ./schemas
type
BotInfo* = object
name*: string
version*: string
authors*: seq[string]
description*: string
homepage*: string
countryCodes*: seq[string]
gameTypes*: seq[string]
platform*: string
programmingLang*: string
initialPosition*: InitialPosition
isDroid*: bool
proc botInfoFromJson*(path: string): BotInfo =
let data = parseJson(readFile(path))
result.name = data{"name"}.getStr
result.version = data{"version"}.getStr
if data.hasKey("authors"):
for a in data["authors"]: result.authors.add a.getStr
result.description = data{"description"}.getStr
result.homepage = data{"homepage"}.getStr
if data.hasKey("countryCodes"):
for c in data["countryCodes"]: result.countryCodes.add c.getStr
if data.hasKey("gameTypes"):
for g in data["gameTypes"]: result.gameTypes.add g.getStr
result.platform = data{"platform"}.getStr("Nim " & NimVersion)
result.programmingLang = data{"programmingLang"}.getStr("Nim")
if data.hasKey("initialPosition"):
let ip = data["initialPosition"]
result.initialPosition.x = ip{"x"}.getFloat
result.initialPosition.y = ip{"y"}.getFloat
result.initialPosition.direction = ip{"direction"}.getFloat
result.isDroid = data{"isDroid"}.getBool(false)
proc botInfoFromEnv*(): BotInfo =
## Fall back to environment variables when no JSON file is given.
result.name = getEnv("BOT_NAME", "Unnamed Bot")
result.version = getEnv("BOT_VERSION", "1.0")
let authorsStr = getEnv("BOT_AUTHORS", "Unknown")
result.authors = authorsStr.split(',').mapIt(it.strip)
result.description = getEnv("BOT_DESCRIPTION", "")
result.homepage = getEnv("BOT_HOMEPAGE", "")
let ccStr = getEnv("BOT_COUNTRY_CODES", "")
if ccStr.len > 0:
result.countryCodes = ccStr.split(',').mapIt(it.strip)
let gtStr = getEnv("BOT_GAME_TYPES", "classic,melee,1v1")
result.gameTypes = gtStr.split(',').mapIt(it.strip)
result.platform = getEnv("BOT_PLATFORM", "Nim " & NimVersion)
result.programmingLang = getEnv("BOT_PROGRAMMING_LANG", "Nim")
result.isDroid = getEnv("BOT_IS_DROID", "false").toLowerAscii == "true"
proc loadBotInfo*(jsonFile: string = ""): BotInfo =
var resolved = ""
if jsonFile.len > 0:
if fileExists(jsonFile):
resolved = jsonFile
else:
# Try alongside the executable
let appPath = getAppDir() / jsonFile
if fileExists(appPath):
resolved = appPath
if resolved.len > 0:
result = botInfoFromJson(resolved)
else:
result = botInfoFromEnv()
# Ensure gameTypes has at least one entry
if result.gameTypes.len == 0:
result.gameTypes = @["classic", "melee", "1v1"]
if result.platform.len == 0:
result.platform = "Nim " & NimVersion
if result.programmingLang.len == 0:
result.programmingLang = "Nim"
@@ -0,0 +1,205 @@
## Color type for Robocode Tank Royale — RGBA packed as uint32 (R<<24|G<<16|B<<8|A),
## matching the layout of the Java Color class.
import std/strutils
type Color* = distinct uint32
# ---------------------------------------------------------------------------
# Factory procs
# ---------------------------------------------------------------------------
proc fromRgb*(r, g, b: uint8): Color {.inline.} =
Color((r.uint32 shl 24) or (g.uint32 shl 16) or (b.uint32 shl 8) or 0xFF)
proc fromRgba*(r, g, b, a: uint8): Color {.inline.} =
Color((r.uint32 shl 24) or (g.uint32 shl 16) or (b.uint32 shl 8) or a.uint32)
proc fromHex*(s: string): Color =
## Parse "#RRGGBB" or "#RRGGBBAA". Raises ValueError on bad input.
let h = if s.len > 0 and s[0] == '#': s[1..^1] else: s
case h.len
of 6:
let v = parseHexInt(h)
result = Color((v.uint32 shl 8) or 0xFF)
of 8:
result = Color(parseHexInt(h).uint32)
else:
raise newException(ValueError, "invalid color string: " & s)
# ---------------------------------------------------------------------------
# Accessors
# ---------------------------------------------------------------------------
proc r*(c: Color): uint8 {.inline.} = uint8(c.uint32 shr 24)
proc g*(c: Color): uint8 {.inline.} = uint8((c.uint32 shr 16) and 0xFF)
proc b*(c: Color): uint8 {.inline.} = uint8((c.uint32 shr 8) and 0xFF)
proc a*(c: Color): uint8 {.inline.} = uint8(c.uint32 and 0xFF)
# ---------------------------------------------------------------------------
# Serialisation
# ---------------------------------------------------------------------------
proc toHex*(c: Color): string =
## Returns "#RRGGBB" when alpha=255, "#RRGGBBAA" otherwise.
if c.a == 0xFF:
result = '#' & toHex(c.r.int, 2) & toHex(c.g.int, 2) & toHex(c.b.int, 2)
else:
result = '#' & toHex(c.r.int, 2) & toHex(c.g.int, 2) & toHex(c.b.int, 2) & toHex(c.a.int, 2)
proc `$`*(c: Color): string = c.toHex
proc `==`*(a, b: Color): bool {.borrow.}
# ---------------------------------------------------------------------------
# Backward compat: implicit conversion from string literal / variable
# ---------------------------------------------------------------------------
converter toColor*(s: string): Color = fromHex(s)
# ---------------------------------------------------------------------------
# Named constants (all 141 from Java Color class)
# ---------------------------------------------------------------------------
const
TRANSPARENT* = fromRgba(255, 255, 255, 0)
ALICE_BLUE* = fromRgb(240, 248, 255)
ANTIQUE_WHITE* = fromRgb(250, 235, 215)
AQUA* = fromRgb(0, 255, 255)
AQUAMARINE* = fromRgb(127, 255, 212)
AZURE* = fromRgb(240, 255, 255)
BEIGE* = fromRgb(245, 245, 220)
BISQUE* = fromRgb(255, 228, 196)
BLACK* = fromRgb(0, 0, 0)
BLANCHED_ALMOND* = fromRgb(255, 235, 205)
BLUE* = fromRgb(0, 0, 255)
BLUE_VIOLET* = fromRgb(138, 43, 226)
BROWN* = fromRgb(165, 42, 42)
BURLY_WOOD* = fromRgb(222, 184, 135)
CADET_BLUE* = fromRgb(95, 158, 160)
CHARTREUSE* = fromRgb(127, 255, 0)
CHOCOLATE* = fromRgb(210, 105, 30)
CORAL* = fromRgb(255, 127, 80)
CORNFLOWER_BLUE* = fromRgb(100, 149, 237)
CORNSILK* = fromRgb(255, 248, 220)
CRIMSON* = fromRgb(220, 20, 60)
CYAN* = fromRgb(0, 255, 255)
DARK_BLUE* = fromRgb(0, 0, 139)
DARK_CYAN* = fromRgb(0, 139, 139)
DARK_GOLDENROD* = fromRgb(184, 134, 11)
DARK_GRAY* = fromRgb(169, 169, 169)
DARK_GREEN* = fromRgb(0, 100, 0)
DARK_KHAKI* = fromRgb(189, 183, 107)
DARK_MAGENTA* = fromRgb(139, 0, 139)
DARK_OLIVE_GREEN* = fromRgb(85, 107, 47)
DARK_ORANGE* = fromRgb(255, 140, 0)
DARK_ORCHID* = fromRgb(153, 50, 204)
DARK_RED* = fromRgb(139, 0, 0)
DARK_SALMON* = fromRgb(233, 150, 122)
DARK_SEA_GREEN* = fromRgb(143, 188, 139)
DARK_SLATE_BLUE* = fromRgb(72, 61, 139)
DARK_SLATE_GRAY* = fromRgb(47, 79, 79)
DARK_TURQUOISE* = fromRgb(0, 206, 209)
DARK_VIOLET* = fromRgb(148, 0, 211)
DEEP_PINK* = fromRgb(255, 20, 147)
DEEP_SKY_BLUE* = fromRgb(0, 191, 255)
DIM_GRAY* = fromRgb(105, 105, 105)
DODGER_BLUE* = fromRgb(30, 144, 255)
FIREBRICK* = fromRgb(178, 34, 34)
FLORAL_WHITE* = fromRgb(255, 250, 240)
FOREST_GREEN* = fromRgb(34, 139, 34)
FUCHSIA* = fromRgb(255, 0, 255)
GAINSBORO* = fromRgb(220, 220, 220)
GHOST_WHITE* = fromRgb(248, 248, 255)
GOLD* = fromRgb(255, 215, 0)
GOLDENROD* = fromRgb(218, 165, 32)
GRAY* = fromRgb(128, 128, 128)
GREEN* = fromRgb(0, 128, 0)
GREEN_YELLOW* = fromRgb(173, 255, 47)
HONEYDEW* = fromRgb(240, 255, 240)
HOT_PINK* = fromRgb(255, 105, 180)
INDIAN_RED* = fromRgb(205, 92, 92)
INDIGO* = fromRgb(75, 0, 130)
IVORY* = fromRgb(255, 255, 240)
KHAKI* = fromRgb(240, 230, 140)
LAVENDER* = fromRgb(230, 230, 250)
LAVENDER_BLUSH* = fromRgb(255, 240, 245)
LAWN_GREEN* = fromRgb(124, 252, 0)
LEMON_CHIFFON* = fromRgb(255, 250, 205)
LIGHT_BLUE* = fromRgb(173, 216, 230)
LIGHT_CORAL* = fromRgb(240, 128, 128)
LIGHT_CYAN* = fromRgb(224, 255, 255)
LIGHT_GOLDENROD_YELLOW* = fromRgb(250, 250, 210)
LIGHT_GRAY* = fromRgb(211, 211, 211)
LIGHT_GREEN* = fromRgb(144, 238, 144)
LIGHT_PINK* = fromRgb(255, 182, 193)
LIGHT_SALMON* = fromRgb(255, 160, 122)
LIGHT_SEA_GREEN* = fromRgb(32, 178, 170)
LIGHT_SKY_BLUE* = fromRgb(135, 206, 250)
LIGHT_SLATE_GRAY* = fromRgb(119, 136, 153)
LIGHT_STEEL_BLUE* = fromRgb(176, 196, 222)
LIGHT_YELLOW* = fromRgb(255, 255, 224)
LIME* = fromRgb(0, 255, 0)
LIME_GREEN* = fromRgb(50, 205, 50)
LINEN* = fromRgb(250, 240, 230)
MAGENTA* = fromRgb(255, 0, 255)
MAROON* = fromRgb(128, 0, 0)
MEDIUM_AQUAMARINE* = fromRgb(102, 205, 170)
MEDIUM_BLUE* = fromRgb(0, 0, 205)
MEDIUM_ORCHID* = fromRgb(186, 85, 211)
MEDIUM_PURPLE* = fromRgb(147, 112, 219)
MEDIUM_SEA_GREEN* = fromRgb(60, 179, 113)
MEDIUM_SLATE_BLUE* = fromRgb(123, 104, 238)
MEDIUM_SPRING_GREEN* = fromRgb(0, 250, 154)
MEDIUM_TURQUOISE* = fromRgb(72, 209, 204)
MEDIUM_VIOLET_RED* = fromRgb(199, 21, 133)
MIDNIGHT_BLUE* = fromRgb(25, 25, 112)
MINT_CREAM* = fromRgb(245, 255, 250)
MISTY_ROSE* = fromRgb(255, 228, 225)
MOCCASIN* = fromRgb(255, 228, 181)
NAVAJO_WHITE* = fromRgb(255, 222, 173)
NAVY* = fromRgb(0, 0, 128)
OLD_LACE* = fromRgb(253, 245, 230)
OLIVE* = fromRgb(128, 128, 0)
OLIVE_DRAB* = fromRgb(107, 142, 35)
ORANGE* = fromRgb(255, 165, 0)
ORANGE_RED* = fromRgb(255, 69, 0)
ORCHID* = fromRgb(218, 112, 214)
PALE_GOLDENROD* = fromRgb(238, 232, 170)
PALE_GREEN* = fromRgb(152, 251, 152)
PALE_TURQUOISE* = fromRgb(175, 238, 238)
PALE_VIOLET_RED* = fromRgb(219, 112, 147)
PAPAYA_WHIP* = fromRgb(255, 239, 213)
PEACH_PUFF* = fromRgb(255, 218, 185)
PERU* = fromRgb(205, 133, 63)
PINK* = fromRgb(255, 192, 203)
PLUM* = fromRgb(221, 160, 221)
POWDER_BLUE* = fromRgb(176, 224, 230)
PURPLE* = fromRgb(128, 0, 128)
RED* = fromRgb(255, 0, 0)
ROSY_BROWN* = fromRgb(188, 143, 143)
ROYAL_BLUE* = fromRgb(65, 105, 225)
SADDLE_BROWN* = fromRgb(139, 69, 19)
SALMON* = fromRgb(250, 128, 114)
SANDY_BROWN* = fromRgb(244, 164, 96)
SEA_GREEN* = fromRgb(46, 139, 87)
SEA_SHELL* = fromRgb(255, 245, 238)
SIENNA* = fromRgb(160, 82, 45)
SILVER* = fromRgb(192, 192, 192)
SKY_BLUE* = fromRgb(135, 206, 235)
SLATE_BLUE* = fromRgb(106, 90, 205)
SLATE_GRAY* = fromRgb(112, 128, 144)
SNOW* = fromRgb(255, 250, 250)
SPRING_GREEN* = fromRgb(0, 255, 127)
STEEL_BLUE* = fromRgb(70, 130, 180)
TAN* = fromRgb(210, 180, 140)
TEAL* = fromRgb(0, 128, 128)
THISTLE* = fromRgb(216, 191, 216)
TOMATO* = fromRgb(255, 99, 71)
TURQUOISE* = fromRgb(64, 224, 208)
VIOLET* = fromRgb(238, 130, 238)
WHEAT* = fromRgb(245, 222, 179)
WHITE* = fromRgb(255, 255, 255)
WHITE_SMOKE* = fromRgb(245, 245, 245)
YELLOW* = fromRgb(255, 255, 0)
YELLOW_GREEN* = fromRgb(154, 205, 50)
@@ -0,0 +1,44 @@
## Game constants for Robocode Tank Royale Nim bot API
const
# Infinity helpers
POSITIVE_INFINITY* = high(float)
NEGATIVE_INFINITY* = low(float)
# Event queue limits
MAX_QUEUE_SIZE* = 256
MAX_EVENTS_AGE* = 2
MIN_VALUE* = low(int32)
# Event priorities (higher = processed first)
PRIORITY_WON_ROUND* = 150
PRIORITY_SKIPPED_TURN* = 140
PRIORITY_TICK* = 130
PRIORITY_CUSTOM* = 120
PRIORITY_TEAM_MESSAGE* = 110
PRIORITY_BOT_DEATH* = 100
PRIORITY_BULLET_HIT_WALL* = 90
PRIORITY_BULLET_HIT_BULLET* = 80
PRIORITY_BULLET_HIT_BOT* = 70
PRIORITY_BULLET_FIRED* = 60
PRIORITY_HIT_BY_BULLET* = 50
PRIORITY_HIT_WALL* = 40
PRIORITY_HIT_BOT* = 30
PRIORITY_SCANNED_BOT* = 20
PRIORITY_DEATH* = 10
# Physics
ACCELERATION* = 1.0
DECELERATION* = -2.0
ABS_DECELERATION* = 2.0
MAX_SPEED* = 8.0
MAX_TURN_RATE* = 10.0
MAX_GUN_TURN_RATE* = 20.0
MAX_RADAR_TURN_RATE* = 45.0
MAX_FIRE_POWER* = 3.0
MIN_FIRE_POWER* = 0.1
BOT_RADIUS* = 18.0
RADAR_RADIUS* = 1200.0
@@ -0,0 +1,167 @@
## Priority-based event queue for Robocode Tank Royale bot API.
## Typed BotEvent variants wrapping schema types, priority-sorted dispatch.
import std/[algorithm, tables]
import ./constants
import ./schemas
type
EventKind* = enum
ekTick
ekSkippedTurn
ekBotDeath
ekDeath ## self-death; isCritical=true
ekBulletFired
ekBulletHitBot
ekBulletHitBullet
ekBulletHitWall
ekHitByBullet
ekHitBot
ekHitWall
ekScannedBot
ekWonRound
ekTeamMessage
ekCustom
Condition* = object
name*: string
test*: proc(): bool {.closure.}
BotEvent* = object
turnNumber*: int
case kind*: EventKind
of ekTick: tick*: TickEventForBot
of ekSkippedTurn: skippedTurn*: SkippedTurnEvent
of ekBotDeath: botDeath*: BotDeathEvent
of ekDeath: death*: BotDeathEvent
of ekBulletFired: bulletFired*: BulletFiredEvent
of ekBulletHitBot: bulletHitBot*: BulletHitBotEvent
of ekBulletHitBullet: bulletHitBullet*: BulletHitBulletEvent
of ekBulletHitWall: bulletHitWall*: BulletHitWallEvent
of ekHitByBullet: hitByBullet*: HitByBulletEvent
of ekHitBot: hitBot*: BotHitBotEvent
of ekHitWall: hitWall*: BotHitWallEvent
of ekScannedBot: scannedBot*: ScannedBotEvent
of ekWonRound: wonRound*: WonRoundEvent
of ekTeamMessage: teamMessage*: TeamMessageEvent
of ekCustom: condition*: Condition
EventQueue* = object
# ponytail: fixed static storage instead of a seq. The queue outlives bot
# threads (a fresh thread runs each round), so a heap seq's backing array
# is realloc'd by a *different* dead thread's allocator mid-round ->
# rawDealloc SIGSEGV in addEvent (7 gdb-confirmed dumps). Static array:
# no heap block crosses threads, realloc can never happen.
events*: array[MAX_QUEUE_SIZE, BotEvent]
eventsLen*: int
priorities: Table[EventKind, int] ## runtime-mutable overrides
interruptible*: set[EventKind]
currentTopEventKind*: EventKind
currentTopPriority*: int
conditions*: seq[Condition]
proc priorityOf*(kind: EventKind): int =
case kind
of ekTick: PRIORITY_TICK
of ekSkippedTurn: PRIORITY_SKIPPED_TURN
of ekBotDeath: PRIORITY_BOT_DEATH
of ekDeath: PRIORITY_DEATH
of ekBulletFired: PRIORITY_BULLET_FIRED
of ekBulletHitBot: PRIORITY_BULLET_HIT_BOT
of ekBulletHitBullet: PRIORITY_BULLET_HIT_BULLET
of ekBulletHitWall: PRIORITY_BULLET_HIT_WALL
of ekHitByBullet: PRIORITY_HIT_BY_BULLET
of ekHitBot: PRIORITY_HIT_BOT
of ekHitWall: PRIORITY_HIT_WALL
of ekScannedBot: PRIORITY_SCANNED_BOT
of ekWonRound: PRIORITY_WON_ROUND
of ekTeamMessage: PRIORITY_TEAM_MESSAGE
of ekCustom: PRIORITY_CUSTOM
proc isCritical*(e: BotEvent): bool =
e.kind in {ekDeath, ekWonRound, ekSkippedTurn}
proc initEventQueue*(): EventQueue =
result.currentTopPriority = MIN_VALUE
proc getPriority*(eq: EventQueue; kind: EventKind): int =
eq.priorities.getOrDefault(kind, priorityOf(kind))
proc setPriority*(eq: var EventQueue; kind: EventKind; p: int) =
eq.priorities[kind] = p
proc addEvent*(eq: var EventQueue; e: BotEvent) =
if eq.eventsLen < MAX_QUEUE_SIZE:
eq.events[eq.eventsLen] = e
inc eq.eventsLen
proc clear*(eq: var EventQueue) =
for i in 0 ..< eq.eventsLen:
eq.events[i].reset # destroy refcounted payloads before len drops to 0
eq.eventsLen = 0
eq.currentTopPriority = MIN_VALUE
proc clearEvents*(eq: var EventQueue) =
clear(eq)
proc removeOldEvents*(eq: var EventQueue; turnNumber: int) =
var i = 0
while i < eq.eventsLen:
if eq.events[i].turnNumber < turnNumber - MAX_EVENTS_AGE and
not eq.events[i].isCritical:
for j in i ..< eq.eventsLen - 1:
eq.events[j] = eq.events[j + 1]
dec eq.eventsLen
eq.events[eq.eventsLen].reset
else:
inc i
proc popFirst*(eq: var EventQueue): BotEvent =
## Remove and return the head element (replaces seq delete(0)).
if eq.eventsLen == 0: return
result = eq.events[0]
for i in 0 ..< eq.eventsLen - 1:
eq.events[i] = eq.events[i + 1]
dec eq.eventsLen
eq.events[eq.eventsLen].reset
proc addCustomEvents*(eq: var EventQueue; turnNumber: int) =
for c in eq.conditions:
try:
if c.test():
eq.addEvent(BotEvent(kind: ekCustom, turnNumber: turnNumber, condition: c))
except: discard
proc sortEvents*(eq: var EventQueue) =
# ponytail: copy priorities table for closure capture (cheap, overrides are rare)
let prio = eq.priorities
if eq.eventsLen > 1:
eq.events.toOpenArray(0, eq.eventsLen - 1).sort(proc(a, b: BotEvent): int =
let dc = b.isCritical.int - a.isCritical.int
if dc != 0: return dc
let dt = a.turnNumber - b.turnNumber
if dt != 0: return dt
let pa = prio.getOrDefault(a.kind, priorityOf(a.kind))
let pb = prio.getOrDefault(b.kind, priorityOf(b.kind))
pb - pa
)
proc setInterruptible*(eq: var EventQueue; kind: EventKind; v: bool) =
if v: eq.interruptible.incl kind
else: eq.interruptible.excl kind
proc isInterruptible*(eq: EventQueue; kind: EventKind): bool =
kind in eq.interruptible
proc addCondition*(eq: var EventQueue; c: Condition) =
eq.conditions.add c
proc removeConditionByName*(eq: var EventQueue; name: string) =
for i in countdown(eq.conditions.high, 0):
if eq.conditions[i].name == name:
eq.conditions.del i
return
proc getEvents*(eq: EventQueue): seq[BotEvent] =
for i in 0 ..< eq.eventsLen:
result.add eq.events[i]
@@ -0,0 +1,92 @@
## SVG debug graphics API for Robocode Tank Royale Nim bot API.
## Module-level procs write SVG into a buffer that is flushed into
## BotIntent.debugGraphics each tick and cleared afterward.
import std/strformat
import ./color
# ---------------------------------------------------------------------------
# Module-level state (single bot per process)
# ---------------------------------------------------------------------------
var gSvgBuffer: string
var gStrokeColor: Color = WHITE
var gFillColor: Color = WHITE
var gStrokeWidth: float = 1.0
var gFontFamily: string = "Arial"
var gFontSize: float = 12.0
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
proc svgAttrs(): string =
## Current stroke/fill/width as SVG attribute string.
&"stroke=\"{gStrokeColor.toHex}\" fill=\"{gFillColor.toHex}\" stroke-width=\"{gStrokeWidth}\""
proc svgOutput*(): string =
## Returns the SVG fragment for this tick, or "" if nothing was drawn.
if gSvgBuffer.len == 0: return ""
"<g>" & gSvgBuffer & "</g>"
proc clearGraphics*() =
## Reset buffer and all style globals to defaults. Called after each tick.
gSvgBuffer = ""
gStrokeColor = WHITE
gFillColor = WHITE
gStrokeWidth = 1.0
gFontFamily = "Arial"
gFontSize = 12.0
# ---------------------------------------------------------------------------
# State setters
# ---------------------------------------------------------------------------
proc setStrokeColor*(c: Color) = gStrokeColor = c
proc setFillColor*(c: Color) = gFillColor = c
proc setStrokeWidth*(w: float) = gStrokeWidth = w
proc setFont*(family: string; size: float) =
gFontFamily = family
gFontSize = size
# ---------------------------------------------------------------------------
# Draw procs — append raw SVG elements
# ---------------------------------------------------------------------------
proc drawLine*(x1, y1, x2, y2: float) =
gSvgBuffer.add &"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" {svgAttrs()}/>"
proc drawRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
gSvgBuffer.add &"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>"
proc fillRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
gSvgBuffer.add &"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>"
proc drawCircle*(x, y, r: float) =
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
gSvgBuffer.add &"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>"
proc fillCircle*(x, y, r: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
gSvgBuffer.add &"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>"
proc drawText*(text: string; x, y: float) =
gSvgBuffer.add &"<text x=\"{x}\" y=\"{y}\" font-family=\"{gFontFamily}\" font-size=\"{gFontSize}\">{text}</text>"
proc drawPolygon*(points: seq[(float, float)]) =
var pts = ""
for (px, py) in points:
if pts.len > 0: pts.add ' '
pts.add &"{px},{py}"
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
gSvgBuffer.add &"<polygon points=\"{pts}\" {attrs}/>"
proc fillPolygon*(points: seq[(float, float)]) =
var pts = ""
for (px, py) in points:
if pts.len > 0: pts.add ' '
pts.add &"{px},{py}"
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
gSvgBuffer.add &"<polygon points=\"{pts}\" {attrs}/>"
@@ -0,0 +1,106 @@
## Safe JSON-to-type parsing for Robocode Tank Royale protocol.
##
## Uses the {} accessor (returns nil on missing keys) and typed getters with
## default values so that optional schema fields never raise KeyError.
import std/json
import ./schemas
import ./color
import ./event_queue
proc parseBulletState*(node: JsonNode): BulletState =
## Parse a BulletState from JSON; missing optional fields default to zero.
if node.isNil: return
result.bulletId = node{"bulletId"}.getInt(0)
result.ownerId = node{"ownerId"}.getInt(0)
result.power = node{"power"}.getFloat(0.0)
result.x = node{"x"}.getFloat(0.0)
result.y = node{"y"}.getFloat(0.0)
result.direction = node{"direction"}.getFloat(0.0)
let bulletColorStr = node{"color"}.getStr("")
result.color = if bulletColorStr.len > 0: fromHex(bulletColorStr) else: Color(0)
proc parseBotState*(node: JsonNode): BotState =
## Parse a BotState from JSON; optional colour/flag fields default to empty/false.
if node.isNil: return
result.isDroid = node{"isDroid"}.getBool(false)
result.energy = node{"energy"}.getFloat(0.0)
result.x = node{"x"}.getFloat(0.0)
result.y = node{"y"}.getFloat(0.0)
result.direction = node{"direction"}.getFloat(0.0)
result.gunDirection = node{"gunDirection"}.getFloat(0.0)
result.radarDirection = node{"radarDirection"}.getFloat(0.0)
result.radarSweep = node{"radarSweep"}.getFloat(0.0)
result.speed = node{"speed"}.getFloat(0.0)
result.turnRate = node{"turnRate"}.getFloat(0.0)
result.gunTurnRate = node{"gunTurnRate"}.getFloat(0.0)
result.radarTurnRate = node{"radarTurnRate"}.getFloat(0.0)
result.gunHeat = node{"gunHeat"}.getFloat(0.0)
result.enemyCount = node{"enemyCount"}.getInt(0)
template parseColor(field: untyped) =
let s = node{astToStr(field)}.getStr("")
result.field = if s.len > 0: fromHex(s) else: Color(0)
parseColor(bodyColor)
parseColor(turretColor)
parseColor(radarColor)
parseColor(bulletColor)
parseColor(scanColor)
parseColor(tracksColor)
parseColor(gunColor)
proc parseBotEvent*(node: JsonNode; myId: int): BotEvent =
## Parse a JSON event node into a typed BotEvent.
let typeStr = node{"type"}.getStr
let tn = node{"turnNumber"}.getInt(0)
case typeStr
of "BotDeathEvent":
let victimId = node{"victimId"}.getInt(0)
if victimId == myId:
result = BotEvent(kind: ekDeath, turnNumber: tn,
death: BotDeathEvent(`type`: typeStr, turnNumber: tn, victimId: victimId))
else:
result = BotEvent(kind: ekBotDeath, turnNumber: tn,
botDeath: BotDeathEvent(`type`: typeStr, turnNumber: tn, victimId: victimId))
of "BulletFiredEvent":
result = BotEvent(kind: ekBulletFired, turnNumber: tn,
bulletFired: BulletFiredEvent(`type`: typeStr, turnNumber: tn,
bullet: parseBulletState(node{"bullet"})))
of "BulletHitBotEvent":
let victimId = node{"victimId"}.getInt(0)
let bullet = parseBulletState(node{"bullet"})
let damage = node{"damage"}.getFloat(0.0)
let energy = node{"energy"}.getFloat(0.0)
if victimId == myId:
result = BotEvent(kind: ekHitByBullet, turnNumber: tn,
hitByBullet: HitByBulletEvent(`type`: "HitByBulletEvent", turnNumber: tn,
bullet: bullet, damage: damage, energy: energy))
else:
result = BotEvent(kind: ekBulletHitBot, turnNumber: tn,
bulletHitBot: BulletHitBotEvent(`type`: typeStr, turnNumber: tn,
victimId: victimId, bullet: bullet, damage: damage, energy: energy))
of "BulletHitBulletEvent":
result = BotEvent(kind: ekBulletHitBullet, turnNumber: tn,
bulletHitBullet: BulletHitBulletEvent(`type`: typeStr, turnNumber: tn,
bullet: parseBulletState(node{"bullet"}),
hitBullet: parseBulletState(node{"hitBullet"})))
of "BulletHitWallEvent":
result = BotEvent(kind: ekBulletHitWall, turnNumber: tn,
bulletHitWall: BulletHitWallEvent(`type`: typeStr, turnNumber: tn,
bullet: parseBulletState(node{"bullet"})))
of "BotHitBotEvent":
result = BotEvent(kind: ekHitBot, turnNumber: tn,
hitBot: node.to(BotHitBotEvent))
of "BotHitWallEvent":
result = BotEvent(kind: ekHitWall, turnNumber: tn,
hitWall: node.to(BotHitWallEvent))
of "ScannedBotEvent":
result = BotEvent(kind: ekScannedBot, turnNumber: tn,
scannedBot: node.to(ScannedBotEvent))
of "WonRoundEvent":
result = BotEvent(kind: ekWonRound, turnNumber: tn,
wonRound: WonRoundEvent(`type`: typeStr, turnNumber: tn))
of "TeamMessageEvent":
result = BotEvent(kind: ekTeamMessage, turnNumber: tn,
teamMessage: node.to(TeamMessageEvent))
else:
discard
@@ -0,0 +1,275 @@
## Schema types for Robocode Tank Royale protocol messages.
## These mirror the YAML schemas in schema/schemas/.
import ./color
type
# ---- Shared / embedded types -----------------------------------------------
InitialPosition* = object
x*: float
y*: float
direction*: float
GameSetup* = object
gameType*: string
arenaWidth*: int
isArenaWidthLocked*: bool
arenaHeight*: int
isArenaHeightLocked*: bool
minNumberOfParticipants*: int
isMinNumberOfParticipantsLocked*: bool
maxNumberOfParticipants*: int
isMaxNumberOfParticipantsLocked*: bool
numberOfRounds*: int
isNumberOfRoundsLocked*: bool
gunCoolingRate*: float
isGunCoolingRateLocked*: bool
maxInactivityTurns*: int
isMaxInactivityTurnsLocked*: bool
turnTimeout*: int # microseconds
isTurnTimeoutLocked*: bool
readyTimeout*: int # microseconds
isReadyTimeoutLocked*: bool
defaultTurnsPerSecond*: int
BotState* = object
isDroid*: bool
energy*: float
x*: float
y*: float
direction*: float
gunDirection*: float
radarDirection*: float
radarSweep*: float
speed*: float
turnRate*: float
gunTurnRate*: float
radarTurnRate*: float
gunHeat*: float
enemyCount*: int
bodyColor*: Color
turretColor*: Color
radarColor*: Color
bulletColor*: Color
scanColor*: Color
tracksColor*: Color
gunColor*: Color
BulletState* = object
bulletId*: int
ownerId*: int
power*: float
x*: float
y*: float
direction*: float
color*: Color
ResultsForBot* = object
rank*: int
survival*: int
lastSurvivorBonus*: int
bulletDamage*: int
bulletKillBonus*: int
ramDamage*: int
ramKillBonus*: int
totalScore*: int
firstPlaces*: int
secondPlaces*: int
thirdPlaces*: int
TeamMessage* = object
message*: string
messageType*: string
receiverId*: int
# ---- Connection event types ------------------------------------------------
ConnectedEvent* = object
serverUrl*: string
DisconnectedEvent* = object
serverUrl*: string
remote*: bool
statusCode*: int ## 0 when not provided
reason*: string
ConnectionErrorEvent* = object
serverUrl*: string
error*: string
# ---- Server → Bot messages -------------------------------------------------
ServerHandshake* = object
`type`*: string
sessionId*: string
name*: string
variant*: string
version*: string
gameTypes*: seq[string]
GameStartedEventForBot* = object
`type`*: string
myId*: int
startX*: float
startY*: float
startDirection*: float
teammateIds*: seq[int]
gameSetup*: GameSetup
RoundStartedEvent* = object
`type`*: string
roundNumber*: int
RoundEndedEventForBot* = object
`type`*: string
turnNumber*: int
roundNumber*: int
results*: ResultsForBot
GameEndedEventForBot* = object
`type`*: string
numberOfRounds*: int
results*: ResultsForBot
GameAbortedEvent* = object
`type`*: string
SkippedTurnEvent* = object
`type`*: string
turnNumber*: int
# Events inside TickEventForBot.events
BotDeathEvent* = object
`type`*: string
turnNumber*: int
victimId*: int
BotHitBotEvent* = object
`type`*: string
turnNumber*: int
victimId*: int
botId*: int
energy*: float
x*: float
y*: float
rammed*: bool
BotHitWallEvent* = object
`type`*: string
turnNumber*: int
victimId*: int
BulletFiredEvent* = object
`type`*: string
turnNumber*: int
bullet*: BulletState
BulletHitBotEvent* = object
`type`*: string
turnNumber*: int
victimId*: int
bullet*: BulletState
damage*: float
energy*: float
BulletHitBulletEvent* = object
`type`*: string
turnNumber*: int
bullet*: BulletState
hitBullet*: BulletState
BulletHitWallEvent* = object
`type`*: string
turnNumber*: int
bullet*: BulletState
HitByBulletEvent* = object
`type`*: string
turnNumber*: int
bullet*: BulletState
damage*: float
energy*: float
ScannedBotEvent* = object
`type`*: string
turnNumber*: int
scannedByBotId*: int
scannedBotId*: int
energy*: float
x*: float
y*: float
direction*: float
speed*: float
WonRoundEvent* = object
`type`*: string
turnNumber*: int
TeamMessageEvent* = object
`type`*: string
turnNumber*: int
message*: string
messageType*: string
senderId*: int
TickEventForBot* = object
`type`*: string
turnNumber*: int
roundNumber*: int
botState*: BotState
bulletStates*: seq[BulletState]
events*: seq[RawEvent] # heterogeneous; decoded by type field
# A raw event with just a type field, for first-pass dispatch
RawEvent* = object
`type`*: string
turnNumber*: int
# ---- Bot → Server messages -------------------------------------------------
BotHandshake* = object
`type`*: string
sessionId*: string
name*: string
version*: string
authors*: seq[string]
description*: string
homepage*: string
countryCodes*: seq[string]
gameTypes*: seq[string]
platform*: string
programmingLang*: string
initialPosition*: InitialPosition
teamId*: int
teamName*: string
teamVersion*: string
isDroid*: bool
secret*: string
BotReady* = object
`type`*: string
BotIntent* = object
`type`*: string
turnRate*: float
gunTurnRate*: float
radarTurnRate*: float
targetSpeed*: float
firepower*: float
adjustGunForBodyTurn*: bool
adjustRadarForBodyTurn*: bool
adjustRadarForGunTurn*: bool
rescan*: bool
fireAssist*: bool
bodyColor*: Color
turretColor*: Color
radarColor*: Color
bulletColor*: Color
scanColor*: Color
tracksColor*: Color
gunColor*: Color
stdOut*: string
stdErr*: string
teamMessages*: seq[TeamMessage]
debugGraphics*: string
@@ -0,0 +1,75 @@
## Math / geometry utilities for Robocode Tank Royale bot API.
import std/math
import ./constants
proc isNearZero*(value: float): bool {.inline.} =
abs(value) < 0.00001
proc normalizeRelativeAngle*(angle: float): float =
## Normalise angle to [-180, 180).
result = angle mod 360.0
if result >= 180.0:
result -= 360.0
elif result < -180.0:
result += 360.0
proc normalizeAbsoluteAngle*(angle: float): float {.inline.} =
## Normalise angle to [0, 360).
(angle mod 360.0 + 360.0) mod 360.0
proc calcDeltaAngle*(targetAngle, sourceAngle: float): float {.inline.} =
normalizeRelativeAngle(targetAngle - sourceAngle)
proc calcMaxTurnRate*(speed: float): float {.inline.} =
## Maximum body turn rate at a given speed.
MAX_TURN_RATE - 0.75 * abs(speed.clamp(-MAX_SPEED, MAX_SPEED))
proc calcBulletSpeed*(firepower: float): float {.inline.} =
20.0 - 3.0 * firepower.clamp(MIN_FIRE_POWER, MAX_FIRE_POWER)
proc calcGunHeat*(firepower: float): float {.inline.} =
1.0 + firepower.clamp(MIN_FIRE_POWER, MAX_FIRE_POWER) / 5.0
proc directionTo*(fromX, fromY, toX, toY: float): float {.inline.} =
normalizeAbsoluteAngle(180.0 * arctan2(toY - fromY, toX - fromX) / PI)
proc distanceTo*(fromX, fromY, toX, toY: float): float {.inline.} =
let dx = toX - fromX
let dy = toY - fromY
sqrt(dx * dx + dy * dy)
proc bearingTo*(fromX, fromY, fromDir, toX, toY: float): float {.inline.} =
normalizeRelativeAngle(directionTo(fromX, fromY, toX, toY) - fromDir)
# ---- Speed / distance helpers -----------------------------------------------
proc getMaxDeceleration(speed: float): float =
let decelerationTime = speed / ABS_DECELERATION
let accelerationTime = 1.0 - decelerationTime
min(1.0, decelerationTime) * ABS_DECELERATION + max(0.0, accelerationTime) * ACCELERATION
proc getMaxSpeed(distance: float): float =
let decelerationTime = max(1.0, ceil((sqrt(4.0 * 2.0 / ABS_DECELERATION * distance + 1.0) - 1.0) / 2.0))
if decelerationTime == Inf: return MAX_SPEED
let decelerationDistance = (decelerationTime / 2.0) * (decelerationTime - 1.0) * ABS_DECELERATION
((decelerationTime - 1.0) * ABS_DECELERATION) + ((distance - decelerationDistance) / decelerationTime)
proc getNewTargetSpeed*(maxSpeed, speed, distance: float): float =
if distance < 0.0:
return -getNewTargetSpeed(maxSpeed, -speed, -distance)
let targetSpeed =
if distance == Inf: maxSpeed
else: min(maxSpeed, getMaxSpeed(distance))
if speed >= 0.0:
targetSpeed.clamp(speed - ABS_DECELERATION, speed + ACCELERATION)
else:
targetSpeed.clamp(speed - ACCELERATION, speed + getMaxDeceleration(-speed))
proc getDistanceTraveledUntilStop*(maxSpeed, speed: float): float =
var s = abs(speed)
var dist = 0.0
while s > 0.0:
s = getNewTargetSpeed(maxSpeed, s, 0.0)
dist += s
dist
@@ -0,0 +1,164 @@
## Minimal synchronous WebSocket client for Robocode Tank Royale bot API.
## Uses std/net (blocking TCP) so the bot thread model stays simple.
## Implements only what is needed: connect, send text frame, receive text frame.
import std/[net, base64, random, strutils, uri]
type
SyncWebSocket* = ref object
socket*: Socket
connected*: bool
WebSocketError* = object of IOError
# ---- WebSocket frame helpers -------------------------------------------------
proc genKey(): string =
var raw = newString(16)
for i in 0 ..< 16:
raw[i] = char(rand(255))
base64.encode(raw)
proc encodeTextFrame*(payload: string, masked: bool = true): string =
## Encode a WebSocket text frame (opcode 0x1, FIN=1).
## Clients MUST mask frames.
var frame = ""
frame.add(char(0x81)) # FIN + opcode=text
let plen = payload.len
var maskKey: array[4, uint8]
if masked:
for i in 0 ..< 4: maskKey[i] = uint8(rand(255))
if plen <= 125:
frame.add(char(if masked: 0x80 or plen else: plen))
elif plen <= 65535:
frame.add(char(if masked: 0x80 or 126 else: 126))
frame.add(char((plen shr 8) and 0xFF))
frame.add(char(plen and 0xFF))
else:
frame.add(char(if masked: 0x80 or 127 else: 127))
for shift in [56, 48, 40, 32, 24, 16, 8, 0]:
frame.add(char((plen shr shift) and 0xFF))
if masked:
for b in maskKey: frame.add(char(b))
for i, c in payload:
frame.add(char(uint8(c) xor maskKey[i mod 4]))
else:
frame.add(payload)
result = frame
proc decodeFrame*(socket: Socket): string =
## Read one complete WebSocket frame from the socket and return the payload.
## Handles text frames; pings are responded to automatically.
while true:
var header: array[2, uint8]
discard socket.recv(addr header[0], 2)
let fin = (header[0] and 0x80) != 0
let opcode = header[0] and 0x0F
let masked = (header[1] and 0x80) != 0
var plen = int(header[1] and 0x7F)
if plen == 126:
var ext: array[2, uint8]
discard socket.recv(addr ext[0], 2)
plen = int(ext[0]) shl 8 or int(ext[1])
elif plen == 127:
var ext: array[8, uint8]
discard socket.recv(addr ext[0], 8)
plen = 0
for b in ext: plen = (plen shl 8) or int(b)
var maskKey: array[4, uint8]
if masked:
discard socket.recv(addr maskKey[0], 4)
var payload = newString(plen)
if plen > 0:
discard socket.recv(addr payload[0], plen)
if masked:
for i in 0 ..< plen:
payload[i] = char(uint8(payload[i]) xor maskKey[i mod 4])
case opcode
of 0x8: # close
result = ""
return
of 0x9: # ping — send pong
let pong = char(0x8A) & char(plen) & payload
socket.send(pong)
continue
of 0xA: # pong — ignore
continue
else:
if not fin:
raise newException(WebSocketError, "Fragmented frames not supported")
result = payload
return
# ---- Public API --------------------------------------------------------------
proc newSyncWebSocket*(url: string): SyncWebSocket =
## Connect to a WebSocket server. url must be ws:// or wss://.
randomize()
let u = parseUri(url)
let host = u.hostname
let port = if u.port == "": "7654" else: u.port
let path = if u.path == "": "/" else: u.path
let sock = newSocket()
sock.connect(host, Port(parseInt(port)))
# HTTP upgrade handshake
let key = genKey()
let request = "GET " & path & " HTTP/1.1\r\n" &
"Host: " & host & ":" & port & "\r\n" &
"Upgrade: websocket\r\n" &
"Connection: Upgrade\r\n" &
"Sec-WebSocket-Key: " & key & "\r\n" &
"Sec-WebSocket-Version: 13\r\n\r\n"
sock.send(request)
# Read HTTP 101 response
var line = ""
var upgraded = false
while true:
line = sock.recvLine()
if line == "\r\n" or line == "":
break
if line.startsWith("HTTP/1.1 101"):
upgraded = true
if not upgraded:
raise newException(WebSocketError, "WebSocket upgrade failed for " & url)
result = SyncWebSocket(socket: sock, connected: true)
proc send*(ws: SyncWebSocket, text: string) =
## Send a text message (masked, as required for clients).
if not ws.connected:
raise newException(WebSocketError, "WebSocket is not connected")
let frame = encodeTextFrame(text, masked = true)
ws.socket.send(frame)
proc receive*(ws: SyncWebSocket): string =
## Block until a text message arrives. Returns "" on close.
if not ws.connected:
return ""
try:
result = decodeFrame(ws.socket)
except Exception as e:
ws.connected = false
raise newException(WebSocketError, "Receive failed: " & e.msg)
proc close*(ws: SyncWebSocket) =
if ws.connected:
ws.connected = false
try:
# Send close frame
ws.socket.send(char(0x88) & char(0))
except: discard
ws.socket.close()