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