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