diff --git a/PPO_Bot/PPO_Bot b/PPO_Bot/PPO_Bot index 47aa1be..996e4b9 100755 Binary files a/PPO_Bot/PPO_Bot and b/PPO_Bot/PPO_Bot differ diff --git a/PPO_Bot/PPO_Bot.nim b/PPO_Bot/PPO_Bot.nim index 3aa3fad..09a0a6c 100644 --- a/PPO_Bot/PPO_Bot.nim +++ b/PPO_Bot/PPO_Bot.nim @@ -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) diff --git a/PPO_Bot/PPO_Bot.nimble b/PPO_Bot/PPO_Bot.nimble index ef8bc53..49f065e 100644 --- a/PPO_Bot/PPO_Bot.nimble +++ b/PPO_Bot/PPO_Bot.nimble @@ -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" diff --git a/PPO_Bot/config.nims b/PPO_Bot/config.nims index 3324b6d..c95fd68 100644 --- a/PPO_Bot/config.nims +++ b/PPO_Bot/config.nims @@ -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") diff --git a/PPO_Bot/tests/test_network b/PPO_Bot/tests/test_network index 5a70dc6..93b4b89 100755 Binary files a/PPO_Bot/tests/test_network and b/PPO_Bot/tests/test_network differ diff --git a/PPO_Bot/tests/test_state b/PPO_Bot/tests/test_state index 04c93eb..3171ca6 100755 Binary files a/PPO_Bot/tests/test_state and b/PPO_Bot/tests/test_state differ diff --git a/PPO_Bot/tests/test_training b/PPO_Bot/tests/test_training index c62ab93..964f826 100755 Binary files a/PPO_Bot/tests/test_training and b/PPO_Bot/tests/test_training differ diff --git a/PPO_Bot/tests/test_training.nim b/PPO_Bot/tests/test_training.nim index a3b70fc..4a9d4df 100644 --- a/PPO_Bot/tests/test_training.nim +++ b/PPO_Bot/tests/test_training.nim @@ -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" diff --git a/PPO_Bot/tests/test_weights b/PPO_Bot/tests/test_weights index e4c3214..ef5feb5 100755 Binary files a/PPO_Bot/tests/test_weights and b/PPO_Bot/tests/test_weights differ diff --git a/PPO_Bot/training.nim b/PPO_Bot/training.nim index 0b3de93..6cc4cc0 100644 --- a/PPO_Bot/training.nim +++ b/PPO_Bot/training.nim @@ -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.. 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 diff --git a/PPO_Bot/weights/latest/actor_b1.npy b/PPO_Bot/weights/latest/actor_b1.npy index 8409018..67bb044 100644 Binary files a/PPO_Bot/weights/latest/actor_b1.npy and b/PPO_Bot/weights/latest/actor_b1.npy differ diff --git a/PPO_Bot/weights/latest/actor_b2.npy b/PPO_Bot/weights/latest/actor_b2.npy index 185c4f8..3d15539 100644 Binary files a/PPO_Bot/weights/latest/actor_b2.npy and b/PPO_Bot/weights/latest/actor_b2.npy differ diff --git a/PPO_Bot/weights/latest/actor_b3.npy b/PPO_Bot/weights/latest/actor_b3.npy index 918d39d..29067aa 100644 Binary files a/PPO_Bot/weights/latest/actor_b3.npy and b/PPO_Bot/weights/latest/actor_b3.npy differ diff --git a/PPO_Bot/weights/latest/actor_w1.npy b/PPO_Bot/weights/latest/actor_w1.npy index 0959b5c..f4899ca 100644 Binary files a/PPO_Bot/weights/latest/actor_w1.npy and b/PPO_Bot/weights/latest/actor_w1.npy differ diff --git a/PPO_Bot/weights/latest/actor_w2.npy b/PPO_Bot/weights/latest/actor_w2.npy index b16aadc..b932603 100644 Binary files a/PPO_Bot/weights/latest/actor_w2.npy and b/PPO_Bot/weights/latest/actor_w2.npy differ diff --git a/PPO_Bot/weights/latest/actor_w3.npy b/PPO_Bot/weights/latest/actor_w3.npy index 86eec7f..25b4c06 100644 Binary files a/PPO_Bot/weights/latest/actor_w3.npy and b/PPO_Bot/weights/latest/actor_w3.npy differ diff --git a/PPO_Bot/weights/latest/adam_ab1_m.npy b/PPO_Bot/weights/latest/adam_ab1_m.npy new file mode 100644 index 0000000..23b1ce2 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_ab1_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_ab1_v.npy b/PPO_Bot/weights/latest/adam_ab1_v.npy new file mode 100644 index 0000000..23ca074 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_ab1_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_ab2_m.npy b/PPO_Bot/weights/latest/adam_ab2_m.npy new file mode 100644 index 0000000..90afeed Binary files /dev/null and b/PPO_Bot/weights/latest/adam_ab2_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_ab2_v.npy b/PPO_Bot/weights/latest/adam_ab2_v.npy new file mode 100644 index 0000000..93f80fd Binary files /dev/null and b/PPO_Bot/weights/latest/adam_ab2_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_ab3_m.npy b/PPO_Bot/weights/latest/adam_ab3_m.npy new file mode 100644 index 0000000..8880282 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_ab3_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_ab3_v.npy b/PPO_Bot/weights/latest/adam_ab3_v.npy new file mode 100644 index 0000000..ef6d26e Binary files /dev/null and b/PPO_Bot/weights/latest/adam_ab3_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_aw1_m.npy b/PPO_Bot/weights/latest/adam_aw1_m.npy new file mode 100644 index 0000000..76de757 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_aw1_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_aw1_v.npy b/PPO_Bot/weights/latest/adam_aw1_v.npy new file mode 100644 index 0000000..2234da8 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_aw1_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_aw2_m.npy b/PPO_Bot/weights/latest/adam_aw2_m.npy new file mode 100644 index 0000000..8a618b1 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_aw2_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_aw2_v.npy b/PPO_Bot/weights/latest/adam_aw2_v.npy new file mode 100644 index 0000000..08c6f63 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_aw2_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_aw3_m.npy b/PPO_Bot/weights/latest/adam_aw3_m.npy new file mode 100644 index 0000000..0016bfb Binary files /dev/null and b/PPO_Bot/weights/latest/adam_aw3_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_aw3_v.npy b/PPO_Bot/weights/latest/adam_aw3_v.npy new file mode 100644 index 0000000..0b398ce Binary files /dev/null and b/PPO_Bot/weights/latest/adam_aw3_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_cb1_m.npy b/PPO_Bot/weights/latest/adam_cb1_m.npy new file mode 100644 index 0000000..dd35d5e Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cb1_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_cb1_v.npy b/PPO_Bot/weights/latest/adam_cb1_v.npy new file mode 100644 index 0000000..c8b1753 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cb1_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_cb2_m.npy b/PPO_Bot/weights/latest/adam_cb2_m.npy new file mode 100644 index 0000000..c44f57d Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cb2_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_cb2_v.npy b/PPO_Bot/weights/latest/adam_cb2_v.npy new file mode 100644 index 0000000..0cd1745 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cb2_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_cb3_m.npy b/PPO_Bot/weights/latest/adam_cb3_m.npy new file mode 100644 index 0000000..32db93c Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cb3_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_cb3_v.npy b/PPO_Bot/weights/latest/adam_cb3_v.npy new file mode 100644 index 0000000..b6f6a7e Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cb3_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_cw1_m.npy b/PPO_Bot/weights/latest/adam_cw1_m.npy new file mode 100644 index 0000000..6021369 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cw1_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_cw1_v.npy b/PPO_Bot/weights/latest/adam_cw1_v.npy new file mode 100644 index 0000000..90bc8b5 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cw1_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_cw2_m.npy b/PPO_Bot/weights/latest/adam_cw2_m.npy new file mode 100644 index 0000000..0c311be Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cw2_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_cw2_v.npy b/PPO_Bot/weights/latest/adam_cw2_v.npy new file mode 100644 index 0000000..af91421 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cw2_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_cw3_m.npy b/PPO_Bot/weights/latest/adam_cw3_m.npy new file mode 100644 index 0000000..6b2b488 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cw3_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_cw3_v.npy b/PPO_Bot/weights/latest/adam_cw3_v.npy new file mode 100644 index 0000000..9f39ba2 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_cw3_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_logstd_m.npy b/PPO_Bot/weights/latest/adam_logstd_m.npy new file mode 100644 index 0000000..0cb7a83 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_logstd_m.npy differ diff --git a/PPO_Bot/weights/latest/adam_logstd_v.npy b/PPO_Bot/weights/latest/adam_logstd_v.npy new file mode 100644 index 0000000..f5aaec2 Binary files /dev/null and b/PPO_Bot/weights/latest/adam_logstd_v.npy differ diff --git a/PPO_Bot/weights/latest/adam_t.txt b/PPO_Bot/weights/latest/adam_t.txt new file mode 100644 index 0000000..a0fda86 --- /dev/null +++ b/PPO_Bot/weights/latest/adam_t.txt @@ -0,0 +1,13 @@ +134308 +134308 +134308 +134308 +134308 +134308 +134308 +134308 +134308 +134308 +134308 +134308 +134308 \ No newline at end of file diff --git a/PPO_Bot/weights/latest/critic_b1.npy b/PPO_Bot/weights/latest/critic_b1.npy index e950f18..40dbb47 100644 Binary files a/PPO_Bot/weights/latest/critic_b1.npy and b/PPO_Bot/weights/latest/critic_b1.npy differ diff --git a/PPO_Bot/weights/latest/critic_b2.npy b/PPO_Bot/weights/latest/critic_b2.npy index 8a51c4d..0167325 100644 Binary files a/PPO_Bot/weights/latest/critic_b2.npy and b/PPO_Bot/weights/latest/critic_b2.npy differ diff --git a/PPO_Bot/weights/latest/critic_b3.npy b/PPO_Bot/weights/latest/critic_b3.npy index b2716b2..d50abab 100644 Binary files a/PPO_Bot/weights/latest/critic_b3.npy and b/PPO_Bot/weights/latest/critic_b3.npy differ diff --git a/PPO_Bot/weights/latest/critic_w1.npy b/PPO_Bot/weights/latest/critic_w1.npy index dc64755..7cd3cff 100644 Binary files a/PPO_Bot/weights/latest/critic_w1.npy and b/PPO_Bot/weights/latest/critic_w1.npy differ diff --git a/PPO_Bot/weights/latest/critic_w2.npy b/PPO_Bot/weights/latest/critic_w2.npy index 890e3e2..2f79f78 100644 Binary files a/PPO_Bot/weights/latest/critic_w2.npy and b/PPO_Bot/weights/latest/critic_w2.npy differ diff --git a/PPO_Bot/weights/latest/critic_w3.npy b/PPO_Bot/weights/latest/critic_w3.npy index 0bfff90..0756f12 100644 Binary files a/PPO_Bot/weights/latest/critic_w3.npy and b/PPO_Bot/weights/latest/critic_w3.npy differ diff --git a/PPO_Bot/weights/latest/log_std.npy b/PPO_Bot/weights/latest/log_std.npy index 61d2a07..b98b483 100644 Binary files a/PPO_Bot/weights/latest/log_std.npy and b/PPO_Bot/weights/latest/log_std.npy differ diff --git a/PPO_Bot/weights/round_counter.txt b/PPO_Bot/weights/round_counter.txt new file mode 100644 index 0000000..db8eb44 --- /dev/null +++ b/PPO_Bot/weights/round_counter.txt @@ -0,0 +1 @@ +3159 \ No newline at end of file diff --git a/libs/tankroyale_botapi/tankroyale_botapi.nim b/libs/tankroyale_botapi/tankroyale_botapi.nim new file mode 100644 index 0000000..81ab133 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi.nim @@ -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() diff --git a/libs/tankroyale_botapi/tankroyale_botapi.nimble b/libs/tankroyale_botapi/tankroyale_botapi.nimble new file mode 100644 index 0000000..76b6ef7 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi.nimble @@ -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" diff --git a/libs/tankroyale_botapi/tankroyale_botapi/bot.nim b/libs/tankroyale_botapi/tankroyale_botapi/bot.nim new file mode 100644 index 0000000..6bcbc81 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/bot.nim @@ -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) diff --git a/libs/tankroyale_botapi/tankroyale_botapi/bot_info.nim b/libs/tankroyale_botapi/tankroyale_botapi/bot_info.nim new file mode 100644 index 0000000..21b9349 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/bot_info.nim @@ -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" diff --git a/libs/tankroyale_botapi/tankroyale_botapi/color.nim b/libs/tankroyale_botapi/tankroyale_botapi/color.nim new file mode 100644 index 0000000..02afcfe --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/color.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) diff --git a/libs/tankroyale_botapi/tankroyale_botapi/constants.nim b/libs/tankroyale_botapi/tankroyale_botapi/constants.nim new file mode 100644 index 0000000..28c4c16 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/constants.nim @@ -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 diff --git a/libs/tankroyale_botapi/tankroyale_botapi/event_queue.nim b/libs/tankroyale_botapi/tankroyale_botapi/event_queue.nim new file mode 100644 index 0000000..112dfcc --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/event_queue.nim @@ -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] diff --git a/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim b/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim new file mode 100644 index 0000000..503c68b --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim @@ -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 "" + "" & gSvgBuffer & "" + +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 &"" + +proc drawRectangle*(x, y, w, h: float) = + let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\"" + gSvgBuffer.add &"" + +proc fillRectangle*(x, y, w, h: float) = + let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\"" + gSvgBuffer.add &"" + +proc drawCircle*(x, y, r: float) = + let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\"" + gSvgBuffer.add &"" + +proc fillCircle*(x, y, r: float) = + let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\"" + gSvgBuffer.add &"" + +proc drawText*(text: string; x, y: float) = + gSvgBuffer.add &"{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 &"" + +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 &"" diff --git a/libs/tankroyale_botapi/tankroyale_botapi/json_parse.nim b/libs/tankroyale_botapi/tankroyale_botapi/json_parse.nim new file mode 100644 index 0000000..9dc896f --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/json_parse.nim @@ -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 diff --git a/libs/tankroyale_botapi/tankroyale_botapi/schemas.nim b/libs/tankroyale_botapi/tankroyale_botapi/schemas.nim new file mode 100644 index 0000000..c2ad48b --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/schemas.nim @@ -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 diff --git a/libs/tankroyale_botapi/tankroyale_botapi/utils.nim b/libs/tankroyale_botapi/tankroyale_botapi/utils.nim new file mode 100644 index 0000000..93dbdf8 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/utils.nim @@ -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 diff --git a/libs/tankroyale_botapi/tankroyale_botapi/ws_client.nim b/libs/tankroyale_botapi/tankroyale_botapi/ws_client.nim new file mode 100644 index 0000000..02e9476 --- /dev/null +++ b/libs/tankroyale_botapi/tankroyale_botapi/ws_client.nim @@ -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() diff --git a/tools/training_runner/RunTraining.java b/tools/training_runner/RunTraining.java new file mode 100644 index 0000000..7b5a68f --- /dev/null +++ b/tools/training_runner/RunTraining.java @@ -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; + } +} \ No newline at end of file diff --git a/tools/training_runner/run.sh b/tools/training_runner/run.sh new file mode 100755 index 0000000..c4c611a --- /dev/null +++ b/tools/training_runner/run.sh @@ -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" diff --git a/tools/training_runner/training.env b/tools/training_runner/training.env new file mode 100644 index 0000000..2f1f76c --- /dev/null +++ b/tools/training_runner/training.env @@ -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)