chore: rename libs→common_libs, all bot dirs to _garage suffix, fix all path refs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:18:41 +02:00
parent f8c0c871c6
commit b509195ee9
832 changed files with 4967 additions and 368 deletions
+38
View File
@@ -0,0 +1,38 @@
## Standalone radar lock module for Robocode Tank Royale (1v1).
## No bot-specific imports — takes plain floats, returns radarTurnRate.
##
## Tank Royale radar uses standard math convention: 0° = east, CCW positive.
## Angles are in degrees.
import std/math
const
MaxRadarTurn* = 45.0
DefaultOvershootDeg* = 5.0
var overShootDeg* = DefaultOvershootDeg
proc init*() =
## Reset module to defaults.
## In your bot's constructor set:
## adjustRadarForBodyTurn = true
## adjustRadarForGunTurn = true
overShootDeg = DefaultOvershootDeg
proc normalizeRelative(angle: float64): float64 {.inline.} =
result = angle mod 360.0
if result >= 180.0: result -= 360.0
elif result < -180.0: result += 360.0
proc doRadar*(currentRadarHeading, enemyBearing: float64): float64 =
## Returns radarTurnRate (degrees/tick, positive = clockwise).
##
## currentRadarHeading: current radar direction in degrees (0=east, CCW+).
## enemyBearing: absolute bearing to enemy in same coordinate system.
##
## Handles angle wrapping, clamps to [-45, +45], adds overshoot sweep.
var turn = normalizeRelative(enemyBearing - currentRadarHeading)
# Add overshoot in the same direction as the turn to maintain lock
if turn < 0.0: turn -= overShootDeg
else: turn += overShootDeg
result = turn.clamp(-MaxRadarTurn, MaxRadarTurn)
+8
View File
@@ -0,0 +1,8 @@
# Package
version = "1.0.0"
author = "Davide Cappellini"
description = "Standalone radar lock module for Robocode Tank Royale"
license = "Apache-2.0"
# Dependencies
requires "nim >= 2.0.0"
Binary file not shown.
@@ -0,0 +1,74 @@
import std/unittest
import std/math
import ../radar_lock
suite "radar_lock":
setup:
init()
# Basic lock: radar already on enemy — overshoot pushes it slightly
test "radar on enemy — returns overshoot only":
let rate = doRadar(90.0, 90.0)
check rate == DefaultOvershootDeg
# Cardinal directions
test "lock from 0 degrees":
let rate = doRadar(0.0, 0.0)
check rate == DefaultOvershootDeg
test "lock from 90 degrees":
let rate = doRadar(90.0, 90.0)
check rate == DefaultOvershootDeg
test "lock from 180 degrees":
let rate = doRadar(180.0, 180.0)
check rate == DefaultOvershootDeg
test "lock from 270 degrees":
let rate = doRadar(270.0, 270.0)
check rate == DefaultOvershootDeg
# Angle wrapping: radar at 350°, enemy at 10° → shortest path is +20°
test "wrapping 350 to 10 — turns right 20 + overshoot":
let rate = doRadar(350.0, 10.0)
check abs(rate - (20.0 + DefaultOvershootDeg)) < 1e-9
# Angle wrapping: radar at 10°, enemy at 350° → shortest path is -20°
test "wrapping 10 to 350 — turns left 20 + overshoot":
let rate = doRadar(10.0, 350.0)
check abs(rate - (-20.0 - DefaultOvershootDeg)) < 1e-9
# Clamping: enemy 100° away → raw turn+overshoot > 45°, must clamp
test "clamp positive — large gap":
let rate = doRadar(0.0, 100.0)
check rate == 45.0
test "clamp negative — large gap":
let rate = doRadar(100.0, 0.0)
check rate == -45.0
# Overshoot direction: turn right → positive overshoot
test "overshoot direction right":
let rate = doRadar(0.0, 30.0) # needs +30°, overshoot adds +5°
check abs(rate - 35.0) < 1e-9
# Overshoot direction: turn left → negative overshoot
test "overshoot direction left":
let rate = doRadar(30.0, 0.0) # needs -30°, overshoot adds -5°
check abs(rate - (-35.0)) < 1e-9
# Output never exceeds ±45
test "output bounded above":
let rate = doRadar(0.0, 179.0)
check rate <= 45.0
test "output bounded below":
let rate = doRadar(179.0, 0.0)
check rate >= -45.0
# init() resets overShootDeg
test "init resets overshoot":
overShootDeg = 20.0
init()
check overShootDeg == DefaultOvershootDeg
@@ -0,0 +1,260 @@
## Main entry-point module for Robocode Tank Royale Nim bot API.
##
## Usage:
## import tankroyale_botapi
##
## type MyBot = ref object of Bot
## method run(bot: MyBot) =
## forward(100)
## ...
##
## var bot = MyBot()
## start(bot, "MyBot.json")
import std/[os, json]
import ./tankroyale_botapi/constants
import ./tankroyale_botapi/color
import ./tankroyale_botapi/schemas
import ./tankroyale_botapi/utils
import ./tankroyale_botapi/bot_info
import ./tankroyale_botapi/ws_client
import ./tankroyale_botapi/json_parse
import ./tankroyale_botapi/event_queue
import ./tankroyale_botapi/bot
import ./tankroyale_botapi/graphics
export constants
export color
export schemas
export utils
export bot_info
export json_parse
export event_queue
export bot
export graphics
# ---------------------------------------------------------------------------
# WebSocket receive loop (main thread)
# ---------------------------------------------------------------------------
proc handleServerHandshake(ws: SyncWebSocket; node: JsonNode; info: BotInfo; secret: string) =
let sessionId = node{"sessionId"}.getStr
setServerInfo(node{"variant"}.getStr, node{"version"}.getStr)
# Build bot handshake
var h = newJObject()
h["type"] = %"BotHandshake"
h["sessionId"] = %sessionId
h["name"] = %info.name
h["version"] = %info.version
h["authors"] = %info.authors
h["description"] = %info.description
h["homepage"] = %info.homepage
h["countryCodes"] = %info.countryCodes
h["gameTypes"] = %info.gameTypes
h["platform"] = %info.platform
h["programmingLang"]= %info.programmingLang
h["isDroid"] = %info.isDroid
if secret.len > 0:
h["secret"] = %secret
let ip = info.initialPosition
if ip.x != 0.0 or ip.y != 0.0 or ip.direction != 0.0:
var ipObj = newJObject()
if ip.x != 0.0: ipObj["x"] = %ip.x
if ip.y != 0.0: ipObj["y"] = %ip.y
if ip.direction != 0.0: ipObj["direction"] = %ip.direction
h["initialPosition"] = ipObj
ws.send($h)
proc parseGameSetup(node: JsonNode): GameSetup =
if node.isNil: return
result.gameType = node{"gameType"}.getStr("classic")
result.arenaWidth = node{"arenaWidth"}.getInt(800)
result.isArenaWidthLocked = node{"isArenaWidthLocked"}.getBool(false)
result.arenaHeight = node{"arenaHeight"}.getInt(600)
result.isArenaHeightLocked = node{"isArenaHeightLocked"}.getBool(false)
result.numberOfRounds = node{"numberOfRounds"}.getInt(10)
result.isNumberOfRoundsLocked = node{"isNumberOfRoundsLocked"}.getBool(false)
result.minNumberOfParticipants = node{"minNumberOfParticipants"}.getInt(2)
result.isMinNumberOfParticipantsLocked = node{"isMinNumberOfParticipantsLocked"}.getBool(false)
result.maxNumberOfParticipants = node{"maxNumberOfParticipants"}.getInt(10)
result.isMaxNumberOfParticipantsLocked = node{"isMaxNumberOfParticipantsLocked"}.getBool(false)
result.gunCoolingRate = node{"gunCoolingRate"}.getFloat(0.1)
result.isGunCoolingRateLocked = node{"isGunCoolingRateLocked"}.getBool(false)
result.maxInactivityTurns = node{"maxInactivityTurns"}.getInt(450)
result.isMaxInactivityTurnsLocked = node{"isMaxInactivityTurnsLocked"}.getBool(false)
result.turnTimeout = node{"turnTimeout"}.getInt(30000)
result.isTurnTimeoutLocked = node{"isTurnTimeoutLocked"}.getBool(false)
result.readyTimeout = node{"readyTimeout"}.getInt(1000000)
result.isReadyTimeoutLocked = node{"isReadyTimeoutLocked"}.getBool(false)
result.defaultTurnsPerSecond = node{"defaultTurnsPerSecond"}.getInt(30)
proc handleGameStarted(ws: SyncWebSocket; node: JsonNode) =
let setup = parseGameSetup(node{"gameSetup"})
var teammateIds: seq[int] = @[]
if not node{"teammateIds"}.isNil and node["teammateIds"].kind == JArray:
for id in node["teammateIds"]: teammateIds.add id.getInt
let myId = node{"myId"}.getInt
setGameStarted(myId, setup, teammateIds)
# Build event object manually — GameStartedEventForBot has no turnNumber
let e = GameStartedEventForBot(
`type`: "GameStartedEventForBot",
myId: myId,
startX: node{"startX"}.getFloat(0.0),
startY: node{"startY"}.getFloat(0.0),
startDirection: node{"startDirection"}.getFloat(0.0),
teammateIds: teammateIds,
gameSetup: setup
)
gBot.onGameStarted(e)
# Send BotReady
ws.send("""{"type":"BotReady"}""")
proc handleTick(node: JsonNode) =
# Build TickEventForBot manually to handle optional fields safely
var tick: TickEventForBot
tick.`type` = "TickEventForBot"
tick.turnNumber = node{"turnNumber"}.getInt(0)
tick.roundNumber = node{"roundNumber"}.getInt(0)
tick.botState = parseBotState(node{"botState"})
tick.bulletStates = @[]
if not node{"bulletStates"}.isNil and node["bulletStates"].kind == JArray:
for bs in node["bulletStates"]:
tick.bulletStates.add parseBulletState(bs)
tick.events = @[] # sub-events parsed separately into typed BotEvent
# Parse embedded events into typed BotEvent for priority-based dispatch
var events: seq[BotEvent] = @[]
let myId = getMyId()
if node.hasKey("events") and node["events"].kind == JArray:
for ev in node["events"]:
events.add parseBotEvent(ev, myId)
signalTick(tick, events) # update shared state
processTickOnMainThread() # motion tracking (while bot is blocked)
wakeBotThread() # wake bot — state + motion ready
proc runReceiveLoop*(ws: SyncWebSocket; info: BotInfo; secret: string; serverUrl: string) =
## Main WebSocket receive loop. Blocks until disconnected.
while ws.connected:
var msg: string
try:
msg = ws.receive()
except Exception as e:
stderr.writeLine "[ws] receive error: " & e.msg
gBot.onConnectionError(ConnectionErrorEvent(serverUrl: serverUrl, error: e.msg))
break
if msg.len == 0:
break # connection closed
var node: JsonNode
try:
node = parseJson(msg)
except Exception as e:
stderr.writeLine "[ws] json parse error: " & e.msg
continue
let msgType = node{"type"}.getStr
try:
case msgType
of "ServerHandshake":
handleServerHandshake(ws, node, info, secret)
of "GameStartedEventForBot":
handleGameStarted(ws, node)
of "RoundStartedEvent":
let e = node.to(RoundStartedEvent)
debugLog("[NS-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
# Start (or restart) the bot thread each round
startRound()
startBotThread()
gBot.onRoundStarted(e)
debugLog("[NS-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
of "TickEventForBot":
handleTick(node)
of "RoundEndedEventForBot":
setRunning(false)
let e = node.to(RoundEndedEventForBot)
debugLog("[RE-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
signalStop() # unblock bot thread blocked in go()
debugLog("[WT-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
waitForBotThread()
debugLog("[WT-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
debugLog("[DR-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
drainTickChan() # drain stop signal if bot exited via isRunning() check
drainIntentChan() # drain AFTER thread joined — no more writes possible
drainEventChan() # drop any unconsumed tick events (stale into next round)
debugLog("[DR-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
debugLog("[ONRE-ENTER] round=" & $e.roundNumber & " tid=" & $getThreadId())
gBot.onRoundEnded(e)
debugLog("[ONRE-EXIT] round=" & $e.roundNumber & " tid=" & $getThreadId())
of "GameEndedEventForBot":
setRunning(false)
let e = node.to(GameEndedEventForBot)
drainIntentChan()
gBot.onGameEnded(e)
of "GameAbortedEvent":
setRunning(false)
signalStop() # unblock bot thread (game aborted mid-round)
waitForBotThread()
drainTickChan() # drain stop signal if bot exited via isRunning() check
drainIntentChan() # drain AFTER thread joined — no more writes possible
drainEventChan() # drop any unconsumed tick events (stale into next round)
gBot.onGameAborted()
of "SkippedTurnEvent":
let e = node.to(SkippedTurnEvent)
gBot.onSkippedTurn(e)
of "BotListUpdate":
updateBotNames(node)
else:
discard # unknown message type — ignore
except Exception as e:
# A raised handler/callback (e.g. an OSError from sync training inside
# onRoundEnded) must not kill the receive loop — that is the silent
# corpse path (process lives, no intents ever again). Log and continue.
stderr.writeLine "[ws] handler error (" & msgType & "): " & e.msg
debugLog("[WS-HANDLER-ERR] " & msgType & ": " & e.msg)
# Loop exited: server disconnected or ws error. Make sure the bot thread is
# stopped and joined so the process exits cleanly instead of hanging forever
# with a blocked bot (corpse). ponytail: signalStop + join; the bot's go()
# consumes the stop as a non-tick and exits via the isRunning() check.
if isRunning():
debugLog("[DBG] receive loop exited while running — stopping bot thread")
setRunning(false)
signalStop()
waitForBotThread()
drainTickChan()
drainIntentChan()
drainEventChan()
gBot.onDisconnected(DisconnectedEvent(serverUrl: serverUrl))
# ---------------------------------------------------------------------------
# Public start() procedure
# ---------------------------------------------------------------------------
proc start*(bot: Bot; jsonFile: string = "") =
## Connect to the server and start the bot.
## jsonFile: path to bot JSON profile (optional; falls back to env vars).
gBot = bot
gBotInfo = loadBotInfo(jsonFile)
initGlobals()
let serverUrl = getEnv("SERVER_URL", "ws://localhost:7654")
let serverSecret = getEnv("SERVER_SECRET", "")
try:
gWs = newSyncWebSocket(serverUrl)
except Exception as e:
stderr.writeLine "[start] Cannot connect to " & serverUrl & ": " & e.msg
quit(1)
bot.onConnected(ConnectedEvent(serverUrl: serverUrl))
startSenderThread()
runReceiveLoop(gWs, gBotInfo, serverSecret, serverUrl)
stopSenderThread()
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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,108 @@
## 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)
# ---------------------------------------------------------------------------
# ponytail: static char array + length instead of a heap string. A fresh bot
# thread runs each round; a module-level string grown by thread N and cleared
# ("") by thread N+1 free/reallocs a dead thread's allocator block ->
# rawDealloc SIGSEGV (same crash class as the event queue seq; gdb-confirmed
# in drawText mid-campaign). Static storage: no heap block crosses threads.
const SVG_BUFFER_CAP = 16384
var gSvgLen: int
var gSvgBuffer: array[SVG_BUFFER_CAP, char]
var gStrokeColor: Color = WHITE
var gFillColor: Color = WHITE
var gStrokeWidth: float = 1.0
var gFontFamily: string = "Arial" # never rebound at runtime (setFont unused)
var gFontSize: float = 12.0
proc appendSvg(s: string) =
## Append an SVG fragment, dropping anything past the static cap.
let room = SVG_BUFFER_CAP - gSvgLen
if room > 0:
let n = min(room, s.len)
for i in 0 ..< n: gSvgBuffer[gSvgLen + i] = s[i]
inc gSvgLen, n
# ---------------------------------------------------------------------------
# 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 gSvgLen == 0: return ""
"<g>" & $gSvgBuffer[0 ..< gSvgLen] & "</g>"
proc clearGraphics*() =
## Reset buffer and all style globals to defaults. Called after each tick.
gSvgLen = 0
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) =
appendSvg(&"<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}\""
appendSvg(&"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>")
proc fillRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
appendSvg(&"<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}\""
appendSvg(&"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>")
proc fillCircle*(x, y, r: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
appendSvg(&"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>")
proc drawText*(text: string; x, y: float) =
appendSvg(&"<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}\""
appendSvg(&"<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}\""
appendSvg(&"<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()