feat(SAC_LSTM_Bot): training harness (#49)

sac_train.sh orchestrates chunked self-play via tools/training_runner/
RunTraining.java: weighted opponent sampling per chunk, deterministic
eval (SACLSTM_EVAL_MODE=1) every N chunks with win-rate tracking, best
checkpoint (weights/sac_best.zip) by eval score, crash-restart loop on
the runner's liveness detection.

Supporting changes:
- integration.nim: opponentKey() keys the NewBattle buffer-clear rule on
  getBotName(id) with numeric-id fallback (#49 Q14 follow-up);
  bumpRoundCounter() emits the per-round liveness signal.
- SAC_LSTM_Bot.nim: onRoundEnded -> bumpRoundCounter().
- RunTraining.java: BOT_NAME env parameterizes result matching
  (default PPO_Bot, unchanged behavior for PPO).
- Launch packaging: root SAC_LSTM_Bot.json + .sh for the booter;
  src json name aligned to 'SAC_LSTM_Bot' so self-reported identity
  matches the booted identity (mismatch = runner connect timeout).
This commit is contained in:
2026-08-21 21:51:27 +02:00
parent 7104645f5d
commit df256b4d3e
8 changed files with 222 additions and 21 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"name": "SAC_LSTM_Bot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "SAC+LSTM-trained Tank Royale bot (#37) — training/eval launch config",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "melee", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Launch config for tools/training_runner/RunTraining.java (#49): the runner
# executes <json-basename>.sh inside the bot dir (sample-bots convention).
exec "$(dirname "$0")/SAC_LSTM_Bot"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# sac_train.sh — #49 training orchestration for SAC_LSTM_Bot.
#
# Drives chunked self-play via tools/training_runner/RunTraining.java (which
# owns server lifecycle, opponent connection and dead-bot liveness detection
# through weights/round_counter.txt), samples opponents by weight per chunk,
# runs deterministic evaluation (SACLSTM_EVAL_MODE=1) every N chunks, and keeps
# the best checkpoint (weights/sac_best.zip) by eval win rate.
#
# Config (env vars):
# SAC_OPPONENTS "Name:weight,Name:weight,..." (default below)
# SAC_TOTAL_ROUNDS total training-round budget (default 100)
# SAC_CHUNK_SIZE rounds per RunTraining battle (default 10)
# SAC_EVAL_INTERVAL eval every N chunks (default 2)
# SAC_EVAL_ROUNDS rounds per evaluation battle (default 10)
# SAC_EVAL_OPPONENT fixed eval opponent (default first opponent)
# SAC_MAX_CRASHES consecutive crashes before abort (default 5)
# SAC_LOG_FILE / SAC_EVAL_LOG_FILE (JSON-lines logs)
# SACLSTM_* passed through to the bot (UTD_RATIO, BATCH_SIZE, ...)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUNNER_DIR="$REPO_ROOT/tools/training_runner"
JAR="${TANK_ROYALE_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}"
export PPO_BOT_DIR="$SCRIPT_DIR" # runner launches THIS bot dir
export BOT_NAME="${BOT_NAME:-SAC_LSTM_Bot}" # RunTraining result matching
export SAMPLE_BOTS_DIR="${SAMPLE_BOTS_DIR:-/home/davide/Projects/tank-royale/sample-bots/java/build/archive}"
# Liveness contract (#49): RunTraining.java watches $BOT_DIR/weights/round_counter.txt
# and integration.bumpRoundCounter() writes it next to the weights — so the bot's
# weights path is pinned here, NOT env-overridable.
export SACLSTM_WEIGHTS_PATH="$SCRIPT_DIR/weights/sac_latest.zip"
WEIGHTS_DIR="$(dirname "$SACLSTM_WEIGHTS_PATH")"
OPPONENTS="${SAC_OPPONENTS:-Corners:3,Crazy:2,RamFire:1,Target:1}"
TOTAL_ROUNDS="${SAC_TOTAL_ROUNDS:-100}"
CHUNK_SIZE="${SAC_CHUNK_SIZE:-10}"
EVAL_INTERVAL="${SAC_EVAL_INTERVAL:-2}"
EVAL_ROUNDS="${SAC_EVAL_ROUNDS:-10}"
EVAL_OPPONENT="${SAC_EVAL_OPPONENT:-${OPPONENTS%%:*}}"
MAX_CRASHES="${SAC_MAX_CRASHES:-5}"
LOG_FILE="${SAC_LOG_FILE:-$SCRIPT_DIR/training_log.jsonl}"
EVAL_LOG_FILE="${SAC_EVAL_LOG_FILE:-$SCRIPT_DIR/eval_log.jsonl}"
CLASSES_DIR="/tmp/opencode/sac_train_classes"
echo "=== SAC_LSTM_Bot training harness ==="
echo "Opponents: $OPPONENTS | budget: $TOTAL_ROUNDS rounds in chunks of $CHUNK_SIZE"
echo "Eval: every $EVAL_INTERVAL chunks, $EVAL_ROUNDS rounds vs $EVAL_OPPONENT"
echo "Weights: $SACLSTM_WEIGHTS_PATH"
# ── compile bot + java runner ─────────────────────────────────────────────────
(cd "$SCRIPT_DIR" && nimble build -d:release) || { echo ">>> bot build failed"; exit 1; }
mkdir -p "$WEIGHTS_DIR" "$CLASSES_DIR"
javac -cp "$JAR" -d "$CLASSES_DIR" "$RUNNER_DIR/RunTraining.java" || { echo ">>> javac failed"; exit 1; }
# ── weighted opponent pick over "Name:w,Name:w" ───────────────────────────────
pick_opponent() {
local total=0 p name w r
local pairs
IFS=',' read -ra pairs <<< "$OPPONENTS"
for p in "${pairs[@]}"; do total=$(( total + ${p##*:} )); done
r=$(( RANDOM % total ))
for p in "${pairs[@]}"; do
name="${p%%:*}"; w="${p##*:}"
if (( r < w )); then echo "$name"; return; fi
r=$(( r - w ))
done
echo "${pairs[0]%%:*}"
}
run_battle() { # $1=opponent $2=rounds $3=log file
PPOB_LOG_FILE="$3" java -cp "$CLASSES_DIR:$JAR" RunTraining "$1" "$2"
}
eval_checkpoint() {
local tmp="$EVAL_LOG_FILE.tmp" wins rounds wr best
: > "$tmp"
echo ">>> [eval] $EVAL_ROUNDS deterministic rounds vs $EVAL_OPPONENT"
if ! SACLSTM_EVAL_MODE=1 run_battle "$EVAL_OPPONENT" "$EVAL_ROUNDS" "$tmp"; then
rm -f "$tmp"
echo ">>> [eval] crashed — keeping previous best"
return 0
fi
mv "$tmp" "$EVAL_LOG_FILE"
wins=$(grep -c '"win":true' "$EVAL_LOG_FILE" || true)
rounds=$(grep -c '"type":"game"' "$EVAL_LOG_FILE" || true)
(( rounds == 0 )) && { echo ">>> [eval] no results"; return 0; }
wr=$(( 100 * wins / rounds ))
echo ">>> [eval] win rate: $wins/$rounds ($wr%) vs $EVAL_OPPONENT"
# ponytail: best-score state is a plain file next to the checkpoint; survives
# harness restarts, no lock needed (single harness instance assumed).
best=-1
[ -f "$WEIGHTS_DIR/best_score.txt" ] && best=$(cat "$WEIGHTS_DIR/best_score.txt")
if (( wr > best )) && [ -f "$SACLSTM_WEIGHTS_PATH" ]; then
echo "$wr" > "$WEIGHTS_DIR/best_score.txt"
cp "$SACLSTM_WEIGHTS_PATH" "$WEIGHTS_DIR/sac_best.zip"
echo ">>> [eval] new best ($wr%) -> sac_best.zip"
fi
}
NUM_CHUNKS=$(( (TOTAL_ROUNDS + CHUNK_SIZE - 1) / CHUNK_SIZE ))
fails=0
for chunk in $(seq 1 "$NUM_CHUNKS"); do
ROUNDS=$CHUNK_SIZE
(( TOTAL_ROUNDS - (chunk - 1) * CHUNK_SIZE < CHUNK_SIZE )) && \
ROUNDS=$(( TOTAL_ROUNDS - (chunk - 1) * CHUNK_SIZE ))
OPP=$(pick_opponent)
echo "=== Chunk $chunk/$NUM_CHUNKS: $ROUNDS rounds vs $OPP ==="
if ! run_battle "$OPP" "$ROUNDS" "$LOG_FILE"; then
fails=$(( fails + 1 ))
if (( fails >= MAX_CRASHES )); then
echo ">>> aborted: $fails consecutive crashes (bot process dying?)"
exit 1
fi
# Crash recovery: RunTraining's liveness detection exited; the bot reloads
# its latest checkpoint on restart, so just rerun this chunk.
echo ">>> crash #$fails — restarting chunk from latest checkpoint"
(( chunk-- )); continue
fi
fails=0
(( chunk % EVAL_INTERVAL == 0 )) && eval_checkpoint
done
echo ">>> training complete: $NUM_CHUNKS chunks. Logs:"
echo " training: $LOG_FILE"
echo " eval: $EVAL_LOG_FILE"
[ -f "$WEIGHTS_DIR/sac_best.zip" ] && \
echo " best: $WEIGHTS_DIR/sac_best.zip ($(cat "$WEIGHTS_DIR/best_score.txt")%)"
exit 0
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "Recurrent Royalty",
"name": "SAC_LSTM_Bot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "SAC+LSTM Tank Royale bot — skeleton with radar lock",
"description": "SAC+LSTM Tank Royale bot — self-reported identity; MUST match the name in ../SAC_LSTM_Bot.json (booter identity) or the training runner never sees this bot join",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "melee", "1v1"],
+8 -2
View File
@@ -144,8 +144,9 @@ method onScannedBot*(bot: SacBot, e: ScannedBotEvent) =
bot.enemy.energy = e.energy
bot.hasContact = true
bot.ticksSinceScan = 0
# Q12b/Q14: one NewBattle per battle, numeric scannedBotId
# ponytail: name-based opponent identity deferred to #49 (protocol has no names).
# Q12b/Q14+#49: one NewBattle per battle, numeric scannedBotId. Identity is
# keyed on getBotName(id) training-side (integration.opponentKey), numeric
# fallback in the pre-BotListUpdate window.
if not bot.newBattleSent:
bot.newBattleSent = true
discard sendTrainingMsg(TrainingMsg(kind: tmkNewBattle, enemyId: e.scannedBotId))
@@ -169,6 +170,11 @@ method onGameAborted*(bot: SacBot) =
bot.hasLastTrans = false
bot.dmgDealt = 0; bot.dmgTaken = 0; bot.wastedPower = 0; bot.wallHits = 0
method onRoundEnded*(bot: SacBot, e: RoundEndedEventForBot) =
## Harness liveness signal (#49): RunTraining.java watches round_counter.txt
## and aborts the battle if it freezes (dead bot process). Main thread.
bumpRoundCounter()
method onGameEnded*(bot: SacBot, e: GameEndedEventForBot) =
## Battle end -> terminal transition with done=true. Main thread; the API has
## joined the bot thread before this fires, so these plain fields are quiescent.
+32 -4
View File
@@ -13,6 +13,7 @@
import arraymancer except Linear
import std/[locks, os, math, random, strutils]
import tankroyale_botapi # getBotName (#49 name-based opponent identity)
import SAC_LSTM_Bot/network
import SAC_LSTM_Bot/state # STATE_DIM
import SAC_LSTM_Bot/actions # ACTION_DIM
@@ -38,6 +39,31 @@ proc getWeightsPath*(): string =
getEnv("SACLSTM_WEIGHTS_PATH",
currentSourcePath().parentDir / "weights" / "sac_latest.zip")
proc opponentKey*(enemyId: int): string =
## Q14 follow-up (#49): name-based opponent identity from the v1.0.1
## BotListUpdate table; numeric-id fallback for the window before the first
## update arrives (getBotName still returns "" then). Called on the training
## thread — the lookup is lock-guarded in the API, no cross-thread refs.
let name = getBotName(enemyId)
if name.len > 0: name else: $enemyId
proc bumpRoundCounter*() =
## Liveness signal for tools/training_runner/RunTraining.java (#49): one
## increment per round end; the runner aborts when it freezes (dead bot).
# ponytail: non-atomic read-modify-write; single writer (main thread) and the
# runner re-polls every 500ms with multi-round tolerance, torn reads self-heal.
let p = getWeightsPath().parentDir / "round_counter.txt"
var n = 0
try:
n = parseInt(readFile(p).strip())
except CatchableError:
discard # absent/garbage -> start at 1
try:
createDir(p.parentDir)
writeFile(p, $(n + 1))
except CatchableError:
discard # counter is best-effort liveness; never kill an event handler
# ── Channel message (plain data only) ────────────────────────────────────────
type
@@ -200,7 +226,7 @@ type
TrainState* = object
trainer*: SACTrainer
buf*: ReplayBuffer
lastEnemyId*: int
lastEnemyKey*: string # opponent identity key (#49 name-based, Q14)
stepCount*: int
nextSave*: int
@@ -227,7 +253,7 @@ proc trainerFromFull*(initial: FullSnap): SACTrainer =
proc initTrainState*(initial: FullSnap): TrainState =
result.trainer = trainerFromFull(initial)
result.buf = newReplayBuffer(getBufferCapacity(), STATE_DIM, ACTION_DIM)
result.lastEnemyId = -1
result.lastEnemyKey = ""
result.nextSave = getSaveInterval()
proc handleTrainingMsg*(st: var TrainState; msg: TrainingMsg): bool =
@@ -243,9 +269,11 @@ proc handleTrainingMsg*(st: var TrainState; msg: TrainingMsg): bool =
done: msg.done))
of tmkNewBattle:
# Q12a: keep the buffer if the opponent is unchanged, clear otherwise.
if msg.enemyId != st.lastEnemyId:
# Identity keyed on NAME (#49); numeric-id fallback pre-BotListUpdate.
let key = opponentKey(msg.enemyId)
if key != st.lastEnemyKey:
st.buf.clear()
st.lastEnemyId = msg.enemyId
st.lastEnemyKey = key
of tmkShutdown:
return false
true
+25 -5
View File
@@ -3,7 +3,8 @@
## drain-then-train NewBattle/Shutdown handling, trainPass safety below canSample.
import arraymancer except Linear
import std/locks
import std/[locks, json, os]
import tankroyale_botapi # updateBotNames: seed the vendored id->name table
import SAC_LSTM_Bot/integration
import SAC_LSTM_Bot/state # STATE_DIM
import SAC_LSTM_Bot/actions # ACTION_DIM
@@ -46,14 +47,20 @@ block:
assert not ok4, "closed channel must report dataAvailable=false"
echo "PASS TrainingMsg channel round-trip"
# ── 2. NewBattle clears only when the opponent changes; Shutdown stops ────────
# ── 2. NewBattle clears only when the opponent NAME changes; Shutdown stops ───
block:
# Seed the API's id->name table (v1.0.1) for name-based identity (#49).
updateBotNames(parseJson(
"""{"bots":[{"id":7,"name":"Corners"},{"id":8,"name":"Crazy"}]}"""))
assert opponentKey(7) == "Corners", "known id resolves to name"
assert opponentKey(99) == "99", "unknown id falls back to numeric string"
var st: TrainState
st.trainer = initSACTrainer(STATE_DIM, ACTION_DIM)
st.buf = newReplayBuffer(64, STATE_DIM, ACTION_DIM, burnIn = 2, trainWindow = 3)
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 7))
assert st.lastEnemyId == 7
assert st.lastEnemyKey == "Corners"
for i in 0 ..< 5:
var m = TrainingMsg(kind: tmkTransition)
@@ -68,11 +75,24 @@ block:
# Opponent changed -> clear.
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 8))
assert st.buf.len == 0, "opponent change must clear"
assert st.lastEnemyId == 8
assert st.lastEnemyKey == "Crazy"
# Nameless window (pre-BotListUpdate) is a distinct key.
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 99))
assert st.lastEnemyKey == "99" and st.buf.len == 0
# Shutdown stops the caller's loop.
assert not handleTrainingMsg(st, TrainingMsg(kind: tmkShutdown))
echo "PASS drain-then-train NewBattle/Shutdown"
echo "PASS name-keyed NewBattle clear / Shutdown"
# ── 2b. bumpRoundCounter increments per call, cold-starts at 1 ────────────────
block:
let tmp = getTempDir() / "sac_test_weights_" & $getCurrentProcessId()
putEnv("SACLSTM_WEIGHTS_PATH", tmp / "sac_latest.zip")
bumpRoundCounter()
bumpRoundCounter()
assert readFile(tmp / "round_counter.txt") == "2", "counter increments per round"
echo "PASS bumpRoundCounter"
# ── 3. trainPass is a safe no-op below canSample (no steps, no publish) ───────
block: