fix(botapi): static event queue storage + end-of-battle train wait

The event queue's heap seq was the last GC'd block surviving across
rounds: each round runs on a freshly spawned bot thread, so the N+1
thread realloc'd a block grown by dead thread N's allocator mid-round
(at the next capacity doubling, ~turn 104) -> rawDealloc SIGSEGV in
addEvent (7 gdb-confirmed coredumps). Replace with a static
array[MAX_QUEUE_SIZE, BotEvent] + eventsLen: no heap block crosses
threads, realloc can never happen.

Also fix the harness aborting the final round mid-train: PPO_Bot's
onRoundEnded trains synchronously after the runner's RoundEndedEvent,
so the counter read right after awaitResults() is the stale pre-train
value and System.exit killed the bot inside ppoUpdate. Poll up to 60s
for the counter to catch up before declaring the battle incomplete.

Verified: 72 consecutive rounds vs Fire, 100% wins, all rounds trained
(counter advanced 1:1), zero coredumps since the fix.
This commit is contained in:
2026-08-19 03:12:22 +02:00
parent 766b9e03ee
commit 64697f917e
66 changed files with 2927 additions and 127 deletions
BIN
View File
Binary file not shown.
+59 -104
View File
@@ -40,6 +40,7 @@ initialLogStd = getEnvFloat("PPOB_INITIAL_LOG_STD", 0.0'f32)
# ── Structured log output ─────────────────────────────────────────────────────
let logFile = getEnv("PPOB_LOG_FILE") # empty → no JSON logging
let evalOnly = getEnv("PPOB_EVAL_ONLY") == "1" # freeze training (pure evaluation)
proc appendJsonLine(path, line: string) =
## Append a JSON line to path; no-op if path is empty.
@@ -48,6 +49,10 @@ proc appendJsonLine(path, line: string) =
f.writeLine(line)
f.close()
proc jsonFloat(v: float32): string =
## Serialize a float for JSON; non-finite → null (keeps JSONL parseable).
if v == v and v > -1e30'f32 and v < 1e30'f32: $v else: "null"
proc hyperparmSnapshot(): string =
## Compact JSON object of current hyperparams (no outer braces).
&"\"lr\":{hpLr},\"clipEpsilon\":{hpClipEpsilon}," &
@@ -64,8 +69,8 @@ type PPOBot = ref object of Bot
buffer: TrajectoryBuffer
prevEnergy: float32 # own energy last tick
prevEnemyE: float32 # enemy energy last tick (from tracker)
lastState: Tensor[float32]
lastAction: Tensor[float32]
lastState: array[STATE_DIM, float32] # plain arrays — tensors NEVER cross threads
lastAction: array[ACTION_DIM, float32]
lastLogP: float32
lastValue: float32
hasLastTrans: bool
@@ -75,56 +80,7 @@ type PPOBot = ref object of Bot
var ac = initActorCritic()
var gAdamStates: ACAdamStates # persists across rounds
# ── Background training state ─────────────────────────────────────────────────
type
TrainingResult = object
ac: ActorCritic
adamStates: ACAdamStates
metrics: PPOMetrics
TrainingArgs = object
ac: ActorCritic
adamStates: ACAdamStates
buffer: TrajectoryBuffer
lastValue: float32
roundNum: int
weightsRoot: string
# hyperparams snapshot at launch time
lr: float32
clipEpsilon: float32
entropyCoeff: float32
valueLossCoeff: float32
maxGradNorm: float32
gamma: float32
lam: float32
epochs: int
miniBatchSize: int
var
trainingThread: Thread[TrainingArgs]
resultChan: Channel[TrainingResult]
threadLaunched: bool = false # true while training thread is running
roundCounter: int = 0
proc trainingThreadProc(args: TrainingArgs) {.thread.} =
var localAc = args.ac
var localAdam = args.adamStates
let m = ppoUpdate(localAc, args.buffer,
lastValue = args.lastValue,
adamStates = localAdam,
epochs = args.epochs,
miniBatchSize = args.miniBatchSize,
clipEpsilon = args.clipEpsilon,
entropyCoeff = args.entropyCoeff,
valueLossCoeff = args.valueLossCoeff,
lr = args.lr,
maxGradNorm = args.maxGradNorm,
gamma = args.gamma,
lam = args.lam)
saveCheckpoint(localAc, localAdam, args.weightsRoot, args.roundNum)
resultChan.send(TrainingResult(ac: localAc, adamStates: localAdam, metrics: m))
var roundCounter = 0
# ── Bot methods ───────────────────────────────────────────────────────────────
@@ -145,81 +101,78 @@ method onRoundStarted*(bot: PPOBot, e: RoundStartedEvent) =
method onRoundEnded*(bot: PPOBot, e: RoundEndedEventForBot) =
inc roundCounter
debugLog("[PO-ENTER] round=" & $roundCounter & " tid=" & $getThreadId())
# Add round-end score bonus to last transition (if any)
let roundReward = computeRoundReward(e.results.totalScore.float32)
if bot.hasLastTrans and bot.buffer.len > 0:
bot.buffer.transitions[^1].reward += roundReward
bot.buffer.transitions[bot.buffer.len - 1].reward += roundReward
# Training progress display — one line per round in the UI console
let ticks = bot.buffer.len
var avgR = 0.0'f32
if ticks > 0:
var rewardSum = 0.0'f32
for tr in bot.buffer.transitions: rewardSum += tr.reward
for i in 0 ..< bot.buffer.len: rewardSum += bot.buffer.transitions[i].reward
avgR = rewardSum / ticks.float32
let avgRStr = formatFloat(avgR.float, ffDecimal, 3)
printToStdOut(&"R:{roundCounter} ticks:{ticks} avgR:{avgRStr} score:{e.results.totalScore}\n")
echo &"R:{roundCounter} ticks:{ticks} avgR:{avgRStr} score:{e.results.totalScore}"
# Pick up result from previous training thread if available; channel IS the sync
if threadLaunched:
let (avail, trained) = resultChan.tryRecv()
if avail:
ac = trained.ac
gAdamStates = trained.adamStates
threadLaunched = false
let m = trained.metrics
printToStdOut(&" trained R:{roundCounter-1} aLoss:{formatFloat(m.actorLoss.float, ffDecimal, 4)} vLoss:{formatFloat(m.valueLoss.float, ffDecimal, 4)} gNorm:{formatFloat(m.gradNorm.float, ffDecimal, 3)}\n")
echo &" trained R:{roundCounter-1} aLoss:{formatFloat(m.actorLoss.float, ffDecimal, 4)} vLoss:{formatFloat(m.valueLoss.float, ffDecimal, 4)} gNorm:{formatFloat(m.gradNorm.float, ffDecimal, 3)}"
# Emit training-health JSON line
let hp = hyperparmSnapshot()
let ts = int(epochTime())
let jline = &"""{{\"type\":\"train\",\"round\":{roundCounter-1},\"actorLoss\":{m.actorLoss},\"valueLoss\":{m.valueLoss},\"gradNorm\":{m.gradNorm},\"ts\":{ts},{hp}}}"""
appendJsonLine(logFile, jline)
if bot.buffer.len == 0:
bot.hasLastTrans = false
return
# If last thread still running, drop this pass — start fresh with newer data
# ponytail: simple drop; queue if every round must train
if threadLaunched:
# Emit per-round game-stats JSON line
let ts = int(epochTime())
let jline = &"""{{"type":"round","round":{roundCounter},"ticks":{ticks},"avgReward":{avgR},"score":{e.results.totalScore},"ts":{ts}}}"""
appendJsonLine(logFile, jline)
# PPOB_EVAL_ONLY=1 → freeze training (pure evaluation): skip ppoUpdate and
# checkpoint save, but keep advancing/writing round_counter.txt so run.sh's
# remaining-rounds bookkeeping still works, and keep the game line above.
if evalOnly:
writeFile(weightsRoot / "round_counter.txt", $roundCounter)
bot.buffer.clear()
bot.hasLastTrans = false
return
# Emit per-round game-stats JSON line (training health will follow when thread finishes)
let ts = int(epochTime())
let jline = &"""{{\"type\":\"round\",\"round\":{roundCounter},\"ticks\":{ticks},\"avgReward\":{avgR},\"score\":{e.results.totalScore},\"ts\":{ts}}}"""
appendJsonLine(logFile, jline)
let args = TrainingArgs(
ac: ac,
adamStates: gAdamStates,
buffer: bot.buffer,
lastValue: 0.0'f32,
roundNum: roundCounter,
weightsRoot: weightsRoot,
lr: hpLr,
clipEpsilon: hpClipEpsilon,
entropyCoeff: hpEntropyCoeff,
valueLossCoeff: hpValueLossCoeff,
maxGradNorm: hpMaxGradNorm,
gamma: hpGamma,
lam: hpLam,
epochs: hpEpochs,
miniBatchSize: hpMiniBatchSize,
)
bot.buffer.clear()
bot.hasLastTrans = false
# ponytail: synchronous update. Arraymancer tensors can't cross threads under
# ORC — training-thread ppoUpdate frees/rebinds tensors owned by the bot
# thread's heap (SIGSEGV, reproduced with a lone trainer thread on a fixed
# buffer; save/channel/forward exonerated). The bot API runs events on one
# bot thread, so inline is single-threaded; ~0.5s per round, and every round
# trains (the old drop-loop trained ~1 in 60). Revert to a background thread
# only if tensors are rebuilt from plain data on that thread.
printToStdOut(&" train→ R:{roundCounter} ticks:{ticks}\n")
echo &" train→ R:{roundCounter} ticks:{ticks}"
createThread(trainingThread, trainingThreadProc, args)
threadLaunched = true
let m = ppoUpdate(ac, bot.buffer,
lastValue = 0.0'f32,
adamStates = gAdamStates,
epochs = hpEpochs,
miniBatchSize = hpMiniBatchSize,
clipEpsilon = hpClipEpsilon,
entropyCoeff = hpEntropyCoeff,
valueLossCoeff = hpValueLossCoeff,
lr = hpLr,
maxGradNorm = hpMaxGradNorm,
gamma = hpGamma,
lam = hpLam)
saveCheckpoint(ac, gAdamStates, weightsRoot, roundCounter)
printToStdOut(&" trained R:{roundCounter} aLoss:{formatFloat(m.actorLoss.float, ffDecimal, 4)} vLoss:{formatFloat(m.valueLoss.float, ffDecimal, 4)} gNorm:{formatFloat(m.gradNorm.float, ffDecimal, 3)}\n")
echo &" trained R:{roundCounter} aLoss:{formatFloat(m.actorLoss.float, ffDecimal, 4)} vLoss:{formatFloat(m.valueLoss.float, ffDecimal, 4)} gNorm:{formatFloat(m.gradNorm.float, ffDecimal, 3)}"
# Emit training-health JSON line
let hp = hyperparmSnapshot()
let ts2 = int(epochTime())
let jline2 = &"""{{"type":"train","round":{roundCounter},"actorLoss":{jsonFloat(m.actorLoss)},"valueLoss":{jsonFloat(m.valueLoss)},"gradNorm":{jsonFloat(m.gradNorm)},"ts":{ts2},{hp}}}"""
appendJsonLine(logFile, jline2)
bot.buffer.clear()
bot.hasLastTrans = false
debugLog("[PO-EXIT] round=" & $roundCounter & " tid=" & $getThreadId())
method run(bot: PPOBot) =
debugLog("[RUN-ENTER] tid=" & $getThreadId())
# Seed energy and goto/aimTo targets on first tick (remainingDistance = 0 initially)
bot.prevEnergy = getEnergy().float32
bot.prevEnemyE = if bot.tracker.hasContact: bot.tracker.current.energy.float32 else: 0.0'f32
@@ -299,9 +252,12 @@ method run(bot: PPOBot) =
)
bot.buffer.add(tr)
# Store current for next tick
bot.lastState = state
bot.lastAction = rawActs
# Store current for next tick — plain arrays only. `state`/`rawActs` tensors
# live and die on this thread; a NEW bot thread runs each round, so storing
# tensors in the shared bot object would free round-N's heap memory from
# round N+1's thread (SIGSEGV; confirmed empirically).
bot.lastState = stateToArr(state)
bot.lastAction = actionToArr(rawActs)
bot.lastLogP = logP
bot.lastValue = value
bot.prevEnergy = curEnergy
@@ -324,7 +280,6 @@ method run(bot: PPOBot) =
go()
when isMainModule:
resultChan.open()
createDir(weightsRoot)
cleanStaleTempDirs(weightsRoot)
let loadResult = loadBestAvailable(ac, gAdamStates, weightsRoot)
+2 -1
View File
@@ -7,5 +7,6 @@ bin = @["PPO_Bot"]
# Dependencies
requires "nim >= 2.0.0"
requires "tankroyale_botapi >= 1.0.0"
# tankroyale_botapi is vendored in-tree (libs/tankroyale_botapi) and wired via
# config.nims --path; no nimble dependency so builds never touch ~/.nimble/pkgs2.
requires "arraymancer >= 0.7.0"
+5
View File
@@ -6,3 +6,8 @@ switch("threads", "on")
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
# Use the repo-vendored Tank Royale bot API (libs/) instead of the nimble pkg.
# The pkg copy lived in ~/.nimble/pkgs2 and was patched ad-hoc; vendoring makes
# the build self-contained and keeps the cross-thread Channel fix in-tree.
# Must come AFTER the nimble.paths include: later --path wins the import search.
switch("path", thisDir() & "/../libs/tankroyale_botapi")
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50 -4
View File
@@ -14,14 +14,15 @@ template check(cond: bool, msg: string) =
block testTickReward:
# I lost 2, enemy lost 10 → reward = -2 - (-10) = 8
# + default closeness shaping 0.01*(1-0/maxDist) = 0.01 (gunBearingAbs=180 → 0)
let r = computeTickReward(-2.0'f32, -10.0'f32)
check abs(r - 8.0'f32) < 1e-6'f32, "computeTickReward(-2, -10) == 8, got " & $r
check abs(r - 8.01'f32) < 1e-6'f32, "computeTickReward(-2, -10) == 8.01, got " & $r
# ── computeRoundReward ────────────────────────────────────────────────────────
block testRoundReward:
let r = computeRoundReward(350.0'f32)
check abs(r - 3.5'f32) < 1e-6'f32, "computeRoundReward(350) == 3.5, got " & $r
check abs(r - 7.0'f32) < 1e-6'f32, "computeRoundReward(350) == 7.0, got " & $r
# ── TrajectoryBuffer ──────────────────────────────────────────────────────────
@@ -29,7 +30,8 @@ block testBuffer:
var buf = initTrajectoryBuffer()
check buf.len == 0, "empty buffer len == 0"
let t1 = Transition(state: zeros[float32](STATE_DIM), action: zeros[float32](ACTION_DIM),
let t1 = Transition(state: zeros[float32](STATE_DIM).stateToArr,
action: zeros[float32](ACTION_DIM).actionToArr,
logProb: -1.0'f32, reward: 0.5'f32, value: 0.3'f32)
buf.add(t1)
buf.add(t1)
@@ -84,7 +86,7 @@ block testPpoUpdate:
let a = randomNormalTensor[float32](ACTION_DIM)
let lp = ac.computeLogProb(s, a)
let v = ac.criticForward(s)
buf.add(Transition(state: s, action: a, logProb: lp, reward: 0.1'f32, value: v))
buf.add(Transition(state: s.stateToArr, action: a.actionToArr, logProb: lp, reward: 0.1'f32, value: v))
var adam: ACAdamStates
discard ppoUpdate(ac, buf, lastValue = 0.0'f32, adamStates = adam, epochs = 2, miniBatchSize = 5)
@@ -100,4 +102,48 @@ block testPpoUpdate:
break
check changed, "actor w1 should change after ppoUpdate"
# ── ppoUpdate on constant-reward trajectory: zero-variance guard ─────────────
# A passive round has near-constant per-tick rewards; with constant values the
# GAE advantages are identical → zero variance. The normalization must not
# amplify/NaN on this — update must complete with finite losses.
block testPpoUpdateConstantReward:
randomize(43)
var ac = initActorCritic()
var buf = initTrajectoryBuffer()
for _ in 0..<64:
let s = randomNormalTensor[float32](STATE_DIM)
let a = randomNormalTensor[float32](ACTION_DIM)
let lp = ac.computeLogProb(s, a)
buf.add(Transition(state: s.stateToArr, action: a.actionToArr, logProb: lp,
reward: 0.05'f32, value: 0.5'f32)) # constant reward+value
var adam: ACAdamStates
let m = ppoUpdate(ac, buf, lastValue = 0.5'f32, adamStates = adam,
epochs = 2, miniBatchSize = 16)
check m.actorLoss == m.actorLoss, "actorLoss NaN on constant-reward round"
check m.valueLoss == m.valueLoss, "valueLoss NaN on constant-reward round"
check m.gradNorm == m.gradNorm, "gradNorm NaN on constant-reward round"
# ── ppoUpdate on normal-reward trajectory: finite losses ─────────────────────
block testPpoUpdateNormalReward:
randomize(44)
var ac = initActorCritic()
var buf = initTrajectoryBuffer()
for i in 0..<64:
let s = randomNormalTensor[float32](STATE_DIM)
let a = randomNormalTensor[float32](ACTION_DIM)
let lp = ac.computeLogProb(s, a)
let v = ac.criticForward(s)
let r = 0.05'f32 + 0.5'f32 * sin(float32(i))
buf.add(Transition(state: s.stateToArr, action: a.actionToArr, logProb: lp, reward: r, value: v))
var adam: ACAdamStates
let m = ppoUpdate(ac, buf, lastValue = 0.0'f32, adamStates = adam,
epochs = 2, miniBatchSize = 16)
check m.actorLoss == m.actorLoss, "actorLoss NaN on normal-reward round"
check m.valueLoss == m.valueLoss, "valueLoss NaN on normal-reward round"
check m.gradNorm == m.gradNorm, "gradNorm NaN on normal-reward round"
echo "All tests passed"
Binary file not shown.
+56 -17
View File
@@ -7,31 +7,50 @@ import std/[math, random, sequtils]
import ./network
# ── Types ─────────────────────────────────────────────────────────────────────
# ponytail: transitions hold PLAIN fixed-size arrays, never Arraymancer tensors.
# Tensors crossing the bot-thread → main-thread boundary get freed on the wrong
# thread's heap under ORC (bot thread SIGSEGVs mid-round in addEvent — 5 matching
# coredumps). Plain arrays are value types: no heap, no GC, safe to move across
# threads. Tensors are rebuilt from the arrays on the consuming (training) thread.
const
MAX_TRANSITIONS* = 4096 # server rounds are 2000 ticks; headroom for config drift
type
Transition* = object
state*: Tensor[float32] # [STATE_DIM]
action*: Tensor[float32] # [ACTION_DIM]
state*: array[STATE_DIM, float32] # plain copy, rebuilt as tensor in ppoUpdate
action*: array[ACTION_DIM, float32]
logProb*: float32
reward*: float32
value*: float32 # critic estimate at collection time
TrajectoryBuffer* = object
transitions*: seq[Transition]
transitions*: array[MAX_TRANSITIONS, Transition]
len*: int
# ── Buffer ─────────────────────────────────────────────────────────────────────
proc initTrajectoryBuffer*(): TrajectoryBuffer =
result.transitions = @[]
result = TrajectoryBuffer()
proc add*(buf: var TrajectoryBuffer, t: Transition) =
buf.transitions.add(t)
## ponytail: fixed 4096 cap — server rounds run 2000 ticks; if a round ever
## exceeds the cap new transitions are dropped (oldest kept). Raise the cap
## if arena rounds get longer.
if buf.len < MAX_TRANSITIONS:
buf.transitions[buf.len] = t
inc buf.len
proc clear*(buf: var TrajectoryBuffer) =
buf.transitions.setLen(0)
buf.len = 0
proc len*(buf: TrajectoryBuffer): int =
buf.transitions.len
# ── Tensor → plain array (same-thread use; tensors never cross threads) ─────
proc stateToArr*(t: Tensor[float32]): array[STATE_DIM, float32] =
for i in 0..<STATE_DIM: result[i] = t[i]
proc actionToArr*(t: Tensor[float32]): array[ACTION_DIM, float32] =
for i in 0..<ACTION_DIM: result[i] = t[i]
# ── Reward helpers ─────────────────────────────────────────────────────────────
@@ -202,8 +221,8 @@ proc ppoUpdate*(ac: var ActorCritic;
adamStates = initACAdamStates(ac)
# 1. GAE
let rewards = buffer.transitions.mapIt(it.reward)
let values = buffer.transitions.mapIt(it.value)
let rewards = buffer.transitions[0 ..< buffer.len].mapIt(it.reward)
let values = buffer.transitions[0 ..< buffer.len].mapIt(it.value)
let (advantages, returns) = computeGAE(rewards, values, lastValue, gamma = gamma, lam = lam)
# 2. Normalise advantages
@@ -214,10 +233,23 @@ proc ppoUpdate*(ac: var ActorCritic;
var advVar = 0.0'f32
for a in advantages: advVar += (a - advMean) * (a - advMean)
advVar /= n
# ponytail: float32 adv noise ~1e-12; advVar < 1e-8 = constant-reward
# (passive) round — dividing by that amplifies noise ~1e4+ and drifts the
# policy into exp() overflow. Center-only, skip the divide.
var normAdv: seq[float32]
if advantages.allIt(it == it and abs(it) < 1e30'f32):
if advVar < 1e-8'f32:
normAdv = advantages.mapIt(it - advMean)
else:
let advStd = sqrt(advVar + 1e-8'f32)
let normAdv = advantages.mapIt((it - advMean) / advStd)
normAdv = advantages.mapIt((it - advMean) / advStd)
else:
normAdv = newSeq[float32](advantages.len) # poisoned input → zero advantages, no-op update
let bufLen = buffer.len
# ponytail: minibatch size <= 0 would make mbEnd == mbStart forever and spin.
# Treat as full-batch; breaks the loop unconditionally.
let mbSizeCap = if miniBatchSize > 0: miniBatchSize else: bufLen
for _ in 1..epochs:
# Shuffle indices
@@ -226,7 +258,8 @@ proc ppoUpdate*(ac: var ActorCritic;
var mbStart = 0
while mbStart < bufLen:
let mbEnd = min(mbStart + miniBatchSize, bufLen)
let mbEnd = min(mbStart + mbSizeCap, bufLen)
if mbEnd <= mbStart: break
let mbSize = mbEnd - mbStart
# Accumulators for gradients (zero-init)
@@ -251,8 +284,12 @@ proc ppoUpdate*(ac: var ActorCritic;
let adv = normAdv[idx]
let ret = returns[idx].float32
# Rebuild the state tensor on this (training) thread — transitions hold
# plain arrays so no tensor ever crosses a thread boundary.
let x = tr.state.toTensor()
# ── Actor forward ──
let actorFwd = mlpForwardCached(ac.actor, tr.state)
let actorFwd = mlpForwardCached(ac.actor, x)
let newMean = actorFwd.y # [ACTION_DIM]
let logStdClamped = ac.logStd.map(proc(v: float32): float32 = max(v, logStdFloor))
@@ -266,7 +303,9 @@ proc ppoUpdate*(ac: var ActorCritic;
let diff = (tr.action[i] - mu) / s
newLogP += -0.5'f32 * diff * diff - ln(s) - 0.5'f32 * ln(2.0'f32 * PI.float32)
let ratio = exp(newLogP - tr.logProb)
# ponytail: float32 exp overflows at ±88; ±20 is deep in clipped-ratio
# territory, so loss/grad are identical to the true ratio
let ratio = exp(clamp(newLogP - tr.logProb, -20.0'f32, 20.0'f32))
# Clipped surrogate
let ratioClipped = clamp(ratio, 1.0'f32 - clipEpsilon, 1.0'f32 + clipEpsilon)
@@ -306,7 +345,7 @@ proc ppoUpdate*(ac: var ActorCritic;
# Backprop actor gradients
let gradActorOut = dLoss_dNewLogP *. dLogP_dMean # [5]
let actorGrads = mlpBackward(ac.actor, actorFwd, tr.state, gradActorOut)
let actorGrads = mlpBackward(ac.actor, actorFwd, x, gradActorOut)
dActorW1 += actorGrads.dw1
dActorB1 += actorGrads.db1
@@ -316,13 +355,13 @@ proc ppoUpdate*(ac: var ActorCritic;
dActorB3 += actorGrads.db3
# ── Critic forward + loss ──
let criticFwd = mlpForwardCached(ac.critic, tr.state)
let criticFwd = mlpForwardCached(ac.critic, x)
let newVal = criticFwd.y[0]
# Value loss = (newVal - ret)^2; d/d(newVal) = 2*(newVal-ret)
totalValueLoss += (newVal - ret) * (newVal - ret)
let dVLoss_dVal = valueLossCoeff * 2.0'f32 * (newVal - ret) / mbSize.float32
let gradCriticOut = [dVLoss_dVal].toTensor() # [1]
let criticGrads = mlpBackward(ac.critic, criticFwd, tr.state, gradCriticOut)
let criticGrads = mlpBackward(ac.critic, criticFwd, x, gradCriticOut)
dCriticW1 += criticGrads.dw1
dCriticB1 += criticGrads.db1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
134308
134308
134308
134308
134308
134308
134308
134308
134308
134308
134308
134308
134308
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
3159
@@ -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()
+184
View File
@@ -0,0 +1,184 @@
import dev.robocode.tankroyale.runner.*;
import dev.robocode.tankroyale.client.model.*;
import java.io.*;
import java.nio.file.*;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Training runner: PPO_Bot vs opponent for N rounds, all in one battle.
*
* All rounds run in a single battle so PPO_Bot's process (and its training
* thread) survives across rounds — that is how checkpoints + round_counter
* advance. Per-round battles killed the bot's training thread at round end.
*
* Writes one JSON line per round to the log file for game-level outcomes.
* PPO_Bot itself writes training health fields (actorLoss, valueLoss, etc.)
* to the same log file via PPOB_LOG_FILE — the shell wrapper stitches them.
*
* Usage (env vars):
* PPO_BOT_DIR — path to PPO_Bot dir
* SAMPLE_BOTS_DIR — path to sample bots archive
* PPOB_LOG_FILE — path to training_log.jsonl (appended)
* TRAINING_OPPONENT — opponent bot name (default: Target)
* TRAINING_ROUNDS — number of rounds to run (CLI arg or env var)
*
* CLI: java RunTraining [opponent] [rounds]
*/
public class RunTraining {
public static void main(String[] args) throws Exception {
Logger.getLogger("dev.robocode.tankroyale").setLevel(Level.WARNING);
String ppoBotDir = requireEnv("PPO_BOT_DIR");
String sampleBots = requireEnv("SAMPLE_BOTS_DIR");
String logFile = requireEnv("PPOB_LOG_FILE");
String opponent = args.length > 0 ? args[0]
: System.getenv().getOrDefault("TRAINING_OPPONENT", "Target");
int totalRounds = args.length > 1 ? Integer.parseInt(args[1])
: Integer.parseInt(System.getenv().getOrDefault("TRAINING_ROUNDS", "100"));
System.out.printf("Training: PPO_Bot vs %s for %d rounds%n", opponent, totalRounds);
System.out.printf("Log: %s%n", logFile);
// Dead-bot guard: the runner keeps listing a crashed PPO_Bot in the
// round results (name stays present, rank collapses), so `!found`
// never fires. Instead watch the counter PPO_Bot writes at round
// end (weights/round_counter.txt, BEFORE the runner's round event —
// verified empirically). Aware that the counter write lags the
// harness: sync training + checkpoint save take up to ~7s while
// server rounds complete in 1-4s, so requiring only "no advance for
// 2 harness rounds" false-positives on healthy bots (observed). A
// truly dead process stays frozen for minutes, so abort only when
// the counter has not advanced for BOTH >=10 harness rounds AND
// >=10s wall time: exit(1) so run.sh's crash-restart loop resumes
// from the counter. (Hoisted above the try so the end-of-battle
// completeness check below can reuse them.)
Path counterPath = Paths.get(ppoBotDir, "weights", "round_counter.txt");
long[] lastCounter = { readCounter(counterPath) };
long startCounter = lastCounter[0];
int[] frozenRounds = { 0 };
long[] lastAdvanceMs = { System.currentTimeMillis() };
try (var runner = BattleRunner.create(b -> b.embeddedServer().suppressServerOutput())) {
var setup = BattleSetup.classic(s -> s.setNumberOfRounds(totalRounds));
var bots = List.of(
BotEntry.of(ppoBotDir),
BotEntry.of(sampleBots + "/" + opponent)
);
var owner = new Object();
try (var handle = runner.startBattleAsync(setup, bots)) {
handle.getOnRoundEnded().on(owner, event -> {
int round = event.getRoundNumber();
int ticks = event.getTurnNumber();
int score = 0;
boolean win = false;
boolean found = false;
for (var r : event.getResults()) {
if (r.getName().equals("PPO_Bot")) {
found = true;
score = r.getTotalScore();
win = r.getRank() == 1;
}
}
// PPO_Bot's process died mid-battle: abort so run.sh's crash-restart
// loop resumes from round_counter instead of grinding dummy rounds.
// Frozen for a few harness rounds can be a healthy-but-lagging counter
// (sync training + checkpoint save vs fast server rounds), so also
// require 10s of wall time before declaring death.
long ctr = readCounter(counterPath);
if (ctr == lastCounter[0]) {
frozenRounds[0]++;
long frozenMs = System.currentTimeMillis() - lastAdvanceMs[0];
if (frozenRounds[0] >= 10 && frozenMs >= 10_000) {
System.err.printf("PPO_Bot round_counter frozen at %d for %d "
+ "harness rounds / %.0fs — process dead, aborting for restart%n",
ctr, frozenRounds[0], frozenMs / 1000.0);
System.exit(1);
}
} else {
lastCounter[0] = ctr;
frozenRounds[0] = 0;
lastAdvanceMs[0] = System.currentTimeMillis();
}
if (!found) {
System.err.println("PPO_Bot missing from round " + round
+ " results — process died, aborting battle for restart");
System.exit(1);
}
// Append game-outcome JSON line
String line = String.format(
"{\"type\":\"game\",\"round\":%d,\"ticks\":%d,\"score\":%d,\"win\":%b,\"opponent\":\"%s\"}",
round, ticks, score, win, opponent
);
try {
appendLine(logFile, line);
} catch (IOException e) {
System.err.println("Failed to append log line: " + e);
}
System.out.printf("Round %d/%d — ticks:%d score:%d win:%b%n",
round, totalRounds, ticks, score, win);
});
handle.awaitResults();
// The final round's train lags the battle end: PPO_Bot's
// onRoundEnded runs ppoUpdate + checkpoint save synchronously
// on its own main thread, which can take longer than the
// runner takes to deliver the last RoundEnded/GameEnded
// events. So the counter right after awaitResults() is often
// the stale pre-train value of the LAST round. Poll for it to
// catch up before declaring the battle incomplete (the
// mid-battle guard already catches truly frozen processes,
// and a timeout still aborts for restart).
long endCounter = readCounter(counterPath);
long expectedEnd = startCounter + totalRounds; // every round must have trained
long waitUntil = System.currentTimeMillis() + 60_000;
while (endCounter < expectedEnd && System.currentTimeMillis() < waitUntil) {
Thread.sleep(500);
endCounter = readCounter(counterPath);
}
if (endCounter < expectedEnd) {
System.err.printf("PPO_Bot round_counter %d < expected %d (start+%d) at battle "
+ "end (waited 60s) — %d rounds never trained (corpse?), aborting for "
+ "restart%n",
endCounter, expectedEnd, totalRounds, expectedEnd - endCounter);
System.exit(1);
}
System.out.printf("Counter check passed: %d == expected %d%n", endCounter, expectedEnd);
}
}
}
static void appendLine(String path, String line) throws IOException {
try (var w = Files.newBufferedWriter(Paths.get(path),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND)) {
w.write(line);
w.newLine();
}
}
/** Read PPO_Bot's persisted round counter; -1 when absent/unreadable. */
static long readCounter(Path p) {
try {
return Long.parseLong(Files.readString(p).trim());
} catch (Exception e) {
return -1;
}
}
static String requireEnv(String name) {
var v = System.getenv(name);
if (v == null || v.isBlank()) {
System.err.println("Error: " + name + " env var not set");
System.exit(1);
}
return v;
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Training runner: compile PPO_Bot, then loop the Java battle runner with crash recovery.
#
# Usage:
# ./run.sh [opponent] [rounds]
#
# Arguments (also settable via env vars):
# opponent — bot name under SAMPLE_BOTS_DIR (default: Target)
# rounds — total rounds to run (default: 100)
#
# Env vars:
# TANK_ROYALE_JAR — path to robocode-tankroyale-runner.jar
# SAMPLE_BOTS_DIR — path to sample bots archive
# PPOB_LOG_FILE — JSON lines log file (default: training_log.jsonl in repo root)
# PPOB_LR, PPOB_CLIP_EPSILON, PPOB_ENTROPY_COEFF, PPOB_VALUE_LOSS_COEFF,
# PPOB_MAX_GRAD_NORM, PPOB_GAMMA, PPOB_LAM, PPOB_EPOCHS, PPOB_MINI_BATCH_SIZE
# — hyperparameters (all optional, PPO_Bot uses sensible defaults)
set -uo pipefail
ENV_FILE="$(cd "$(dirname "$0")" && pwd)/training.env"
[ -f "$ENV_FILE" ] && set -a && . "$ENV_FILE" && set +a
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
OPPONENT="${1:-${TRAINING_OPPONENT:-Target}}"
TOTAL_ROUNDS="${2:-${TRAINING_ROUNDS:-100}}"
JAR="${TANK_ROYALE_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}"
PPO_BOT_SRC="$REPO_ROOT/PPO_Bot"
export PPO_BOT_DIR="$PPO_BOT_SRC"
export SAMPLE_BOTS_DIR="${SAMPLE_BOTS_DIR:-/home/davide/Projects/tank-royale/sample-bots/java/build/archive}"
export PPOB_LOG_FILE="${PPOB_LOG_FILE:-$REPO_ROOT/training_log.jsonl}"
echo "=== Training runner ==="
echo "Opponent: $OPPONENT"
echo "Total rounds: $TOTAL_ROUNDS"
echo "Log file: $PPOB_LOG_FILE"
echo ""
# ── 1. Compile PPO_Bot ────────────────────────────────────────────────────────
echo ">>> Compiling PPO_Bot..."
(cd "$PPO_BOT_SRC" && nimble build -d:release)
echo ">>> PPO_Bot compiled."
# ── 2. Compile RunTraining.java ───────────────────────────────────────────────
cd "$SCRIPT_DIR"
echo ">>> Compiling RunTraining.java..."
javac -cp "$JAR" RunTraining.java
echo ">>> RunTraining compiled."
# ── 3. Run with crash-restart loop ───────────────────────────────────────────
# We pass total rounds to the Java runner. On crash, restart from where we left
# off by reading the round counter PPO_Bot persists to weights/round_counter.txt.
# ponytail: simple restart from persisted counter; no partial-round recovery needed
ROUND_COUNTER_FILE="$PPO_BOT_SRC/weights/round_counter.txt"
remaining_rounds() {
local done=0
if [ -f "$ROUND_COUNTER_FILE" ]; then
done=$(cat "$ROUND_COUNTER_FILE" 2>/dev/null || echo 0)
fi
echo $(( TOTAL_ROUNDS - done ))
}
attempts=0
while true; do
remaining=$(remaining_rounds)
if [ "$remaining" -le 0 ]; then
echo ">>> All $TOTAL_ROUNDS rounds complete."
break
fi
attempts=$(( attempts + 1 ))
if [ "$attempts" -gt 1 ]; then
echo ">>> Restart #$attempts — $remaining rounds remaining..."
sleep 2
fi
echo ">>> Running $remaining rounds (opponent: $OPPONENT)..."
java -cp ".:$JAR" RunTraining "$OPPONENT" "$remaining" && break || {
exit_code=$?
echo ">>> Java runner exited with code $exit_code — checking if done..."
remaining=$(remaining_rounds)
if [ "$remaining" -le 0 ]; then
echo ">>> All rounds complete despite non-zero exit."
break
fi
echo ">>> Crash detected ($remaining rounds remain). Restarting..."
}
done
echo ">>> Training done. Log at $PPOB_LOG_FILE"
+16
View File
@@ -0,0 +1,16 @@
TRAINING_OPPONENT=Fire
TRAINING_ROUNDS=3000
PPOB_LOG_FILE=/home/davide/Projects/SirRoboGarage/tools/training_runner/logs/fire_training.jsonl
PPOB_LR=0.0003
PPOB_CLIP_EPSILON=0.2
PPOB_ENTROPY_COEFF=0.01
PPOB_VALUE_LOSS_COEFF=0.5
PPOB_MAX_GRAD_NORM=0.5
PPOB_GAMMA=0.99
PPOB_LAM=0.95
PPOB_EPOCHS=4
PPOB_MINI_BATCH_SIZE=64
PPOB_LOG_STD_FLOOR=-3.0
PPOB_INITIAL_LOG_STD=0.0
# PPOB_EVAL_ONLY=1 → freeze training (pure evaluation)