103 Commits

Author SHA1 Message Date
SirStone d3e4540e7f chore: add missing OscillatorBot.nimble 2026-08-27 18:42:35 +02:00
SirStone 259e67d7ff chore: standardize internal bot dir structure (src/, tests/, out/)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 18:42:03 +02:00
SirStone c5115fdf61 chore: standardize Nim builds to out/ subdir, simplify .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 18:35:54 +02:00
SirStone e276548223 chore: remove exported/ from AGENTS.md, generalize binary ignores in .gitignore
- Removed non-existent exported/ directory from layout docs
- Replaced hardcoded bot binary entries with *_garage/out/ pattern for build directories
- Added all known bot binaries (GotoTest, OscillatorBot, PPO_Bot, QBot, SAC_LSTM_Bot)
  to .gitignore with clarifying comment about Nim's compilation target structure

Nim places compiled binaries at bot root (no extension) and in out/ subdirs.
The out/ pattern catches all build outputs; specific bot binaries listed for
root-level executables since gitignore lacks a reliable "no-extension files" glob.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 18:32:09 +02:00
SirStone 2097c2e6fa chore: gitignore binaries/artifacts, remove drafts and stale files 2026-08-27 18:28:21 +02:00
SirStone 06ed905033 docs(AGENTS): generalize layout rules, remove hardcoded bot list 2026-08-27 18:22:38 +02:00
SirStone eab85be8b3 docs(AGENTS): update directory layout to match current structure 2026-08-27 18:19:37 +02:00
SirStone b509195ee9 chore: rename libs→common_libs, all bot dirs to _garage suffix, fix all path refs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 18:18:41 +02:00
SirStone f8c0c871c6 chore: remove unused prototypes/ and spike/ dirs 2026-08-27 18:16:34 +02:00
SirStone b9877bfff5 chore: add .gitignore 2026-08-27 18:12:08 +02:00
SirStone 8ba4bae21a chore: rename CLAUDE.md → AGENTS.md, move research doc to docs/ 2026-08-27 18:11:56 +02:00
SirStone f5b1ade48c fix(dashboard): harden generation against common failures 2026-08-24 11:10:39 +02:00
SirStone 12a0eca44a docs(dashboard): add health-check reference section 2026-08-24 10:18:43 +02:00
SirStone 08d1c44ca8 docs(dashboard): add how-to-read guide per panel 2026-08-24 09:25:14 +02:00
SirStone f47925eeb0 fix(dashboard): remove redundant standalone alpha panel 2026-08-24 09:22:54 +02:00
SirStone 55d69a9352 fix(dashboard): dual y-axis for losses+alpha panel 2026-08-24 09:21:43 +02:00
SirStone 4a6e3347c6 feat(dashboard): max-score-per-eval-cycle panel 2026-08-24 09:15:24 +02:00
SirStone 8872be3ff6 fix(dashboard): add alpha temperature to training losses panel 2026-08-24 09:10:46 +02:00
SirStone 8eb7c53dd0 fix(dashboard): remove broken unused win%-per-opponent panel 2026-08-24 09:05:41 +02:00
SirStone 51e7f33ecb fix(dashboard): plot live campaign-4 logs; derive ROOT from script location 2026-08-24 08:30:04 +02:00
SirStone d2d205e6f6 Merge branch 'research/ga-parameters' into research/goto-controller 2026-08-24 08:23:25 +02:00
SirStone 3851f28cc5 Merge branch 'worktree-agent-a556517b' into research/goto-controller 2026-08-24 08:23:22 +02:00
SirStone 6241c41e52 Merge branch 'worktree-agent-a4c06ae3' into research/goto-controller 2026-08-24 08:23:19 +02:00
SirStone 156b4ae8db Merge branch 'worktree-agent-af671e69' into research/goto-controller 2026-08-24 08:23:12 +02:00
SirStone eee48dec38 prototype: GA evolution spike -- sin(x) prediction validates pipeline (#66)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-24 08:14:36 +02:00
SirStone eb8faa7c99 prototype: GA evolution spike -- sin(x) prediction validates pipeline (#66)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-24 08:14:32 +02:00
SirStone 96065597c0 fix(divergence): guard reward-normalizer cold start; drop arctanh recovery
Welford variance-collapse divided by 1e-8 producing bit-exact +/−5e8 /
+−1.25e8 poisoned rewards into TD targets; guard skips normalization
until stats meaningful; tanh-inversion removal bounds log-prob path.

Fixes #60.
2026-08-24 01:03:04 +02:00
SirStone cb1bbc35dc docs(adr): Evo_Bot neuroevolution gun architecture (#63)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-24 00:02:47 +02:00
SirStone b7b10af811 feat: OscillatorBot sparring partner for GA gun testing (#64)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-24 00:01:38 +02:00
SirStone e455566c8e docs: CONTEXT.md — Evo_Bot domain vocabulary (#62)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-24 00:00:21 +02:00
SirStone eae6fc15a2 research(Evo_Bot): GA/ES parameter recommendations for ~750-weight neuroevolution (#65)
Extracted concrete numbers from 13 papers in docs/papers/neuroevolution/.
Key findings: use CMA-ES or mutation-only truncation GA, mutate ALL weights
(not 5%), sigma=0.005-0.01, pop=64-200, no crossover, single elite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-23 23:32:10 +02:00
SirStone 81718e3a4c fix(SAC_LSTM_Bot): un-invert dashboard axes — orientation selftest added 2026-08-23 10:47:48 +02:00
SirStone 04c149ea28 refactor(SAC_LSTM_Bot): reconcile graph tooling — dashboard-only output 2026-08-23 10:30:16 +02:00
SirStone 533a146342 feat(SAC_LSTM_Bot): live campaign dashboard — five panels, auto-reload, run-1 comparison dropped 2026-08-23 10:25:51 +02:00
SirStone 03a1853e57 feat(SAC_LSTM_Bot): embedded plain-English reading guides in graphs 2026-08-23 10:11:44 +02:00
SirStone 002a568c7e fix(SAC_LSTM_Bot): atomic+validated SVG output for progress graphs 2026-08-23 10:06:05 +02:00
SirStone f3ac0888bb feat(SAC_LSTM_Bot): readable eval chart — trends primary, raw dots secondary, v1 comparison separated 2026-08-23 09:59:30 +02:00
SirStone 0b0933294a feat(SAC_LSTM_Bot): progress graph tooling + first graphs 2026-08-23 09:08:53 +02:00
SirStone 781e41595e docs(SAC_LSTM_Bot): simple-words story + dictionary for readability 2026-08-23 08:48:37 +02:00
SirStone 40e074e5a3 docs(SAC_LSTM_Bot): lever-5 trigger + attempt-3 chapter — MA-wipe root cause, LR_CRITIC=1e-4 decision, launch health (#59 #60) 2026-08-23 07:11:09 +02:00
SirStone 167bcc4ce5 fix(SAC_LSTM_Bot): MA history self-truncation — read before > redirect; campaign v2 attempt-3 prep (#60)
$(cat f) inside a command redirected to f saw the already-truncated file,
so every eval cycle wiped ma_history_*.txt back to one leading-space value
and degraded the composite best-gate to last-cycle mean. Read is hoisted
into its own statement; unquoted expansion + tail -n keeps exactly the last
MA_WINDOW values. Verified: fresh/5+/6+ cycle edges reproduce sliding window.
2026-08-23 07:02:36 +02:00
SirStone 1619b86f25 docs(SAC_LSTM_Bot): v2 restart saga, twin-freeze correction, pacing decisions 2026-08-22 21:26:24 +02:00
SirStone 19f34abf0c fix(SAC_LSTM_Bot): freeze mirror-twin via eval-mode gate in twin launcher
SACLSTM_EVAL_MODE=1 in SacTwin.sh suppresses all sendTrainingMsg traffic
(lever-4 gate), so the twin never trains — not even in-RAM within a battle.
Required now that the main bot's SACLSTM_SAVE_INTERVAL drops to 1 (v2 relaunch
after checkpoint-cadence diagnosis): without the gate the twin would persist
per-battle drift and stop being the frozen reproducible opponent #54 specifies.
2026-08-22 21:09:07 +02:00
SirStone bd58794b4c docs(SAC_LSTM_Bot): campaign v2 chapter — locked config, fresh-start archive, twin reseed, ceiling net, launch health evidence (#59) 2026-08-22 20:31:33 +02:00
SirStone 6fc01eb4e5 feat(SAC_LSTM_Bot): campaign v2 levers — aggression/anti-ram reward shaping + stability knob overrides (part 2)
Lever 2 (#59): x1.25 aggression mult on damage dealt, flat +0.5 hit bonus,
-3.0 per bot-bot collision (server deals RAM_DAMAGE=0.6 to both parties but
only notifies the hitter), escalating proximity deterrent below 12% arena
diagonal suppressed while dealing damage. Win/loss terminals unchanged and
dominant. All weights TUNABLE consts marked ponytail. SACLSTM_REWARD_DEBUG=1
env-gated reward_debug.log for calibration greps.

Lever 5 (#59): no code needed — SACLSTM_LR_ACTOR/LR_CRITIC/LR_ALPHA (3e-4)
and SACLSTM_TARGET_ENTROPY (-4.0) were already env-overridable in training.nim.

Smoke vs RamFire+Crazy (hidden=32, random init, isolated weights): 75 ram
penalties, 381 charge events, hit bonuses firing, 0 crashes, metrics JSONL
flowing. Tests: 8/8 suites green incl. new assert-level term math.
2026-08-22 19:44:58 +02:00
SirStone a07e5305f5 feat(SAC_LSTM_Bot): campaign v2 levers — loss metrics, eval-mode gate, eval rotation + MA gating (part 1)
Levers 3, 4, 1 of the #57 sign-off (execution order 3->4->1), tracked in #59.

- Lever 3 (#59): one JSONL line per trainPass in training_metrics.jsonl with
  exactly the scalars sacUpdate already exposes (SACMetrics: critic/actor/alpha
  losses + alpha, averaged per pass) plus epoch, buffer size (replay_buffer.len),
  cumulative steps and drained count. No trainer change needed.
- Lever 4 (#59): sendTrainingMsg drops all training input while SACLSTM_EVAL_MODE=1
  (existing #49 harness mechanism) — eval battles can neither pollute the replay
  buffer nor trigger gradient updates; one-time stderr notice at bot init.
- Lever 1 (#59): sac_train.sh evaluates every SAC_EVAL_OPPONENTS entry per cycle
  (results carry opponent name in eval_log.jsonl); best-gating now uses a
  composite = mean over opponents of the last-5-evals moving average per
  opponent. best_score.txt format change: float composite replaces the
  single-opponent integer win rate semantics (retired).
- Tests: metricsLine JSONL scalars + eval-mode suppression asserts.

Refs: #59, #57
2026-08-22 18:58:47 +02:00
SirStone 2619ba06fc docs(SAC_LSTM_Bot): campaign ending, results table, verdict, hygiene (#57) 2026-08-22 14:40:57 +02:00
SirStone f45e8f2717 fix(SAC_LSTM_Bot): startup sweep of stale .part checkpoint corpses (#57) 2026-08-22 14:40:57 +02:00
SirStone b7492f1080 docs(SAC_LSTM_Bot): notebook — score:60 anatomy, .part forensics+sweep, ceiling defused 2026-08-22 08:08:31 +02:00
SirStone f1962c7506 docs(SAC_LSTM_Bot): backfill shift-1 milestone entry + morning audit 2026-08-22 07:27:37 +02:00
SirStone 05929d2dbd docs(SAC_LSTM_Bot): notebook — shift 1, deferred-intervention milestone 2026-08-22 07:14:35 +02:00
SirStone 4b64bf18ac docs(SAC_LSTM_Bot): campaign-v1 launch record — save-check incident, health check, check-in procedure (#56) 2026-08-22 00:31:47 +02:00
SirStone 26536713ba fix(SAC_LSTM_Bot): checkpoint save check inside gradient-step loop (#56)
Launch finding during campaign-v1 verification: at production sizes
(hidden 256, ~1s/step, ~13s trainer CPU per ~40s chunk process) the
save check ran only between drain-burst passes, so stepCount never
crossed nextSave before the process died — zero checkpoints persisted
across entire runs (masked at #49/#54 smoke sizes where steps were
sub-millisecond). Check now fires mid-loop; with SAVE_INTERVAL<=5
(within the per-process step budget) every chunk persists its chain.
2026-08-22 00:21:43 +02:00
SirStone 2f49cb243f docs(SAC_LSTM_Bot): campaign-v1 notebook — locked config + story so far (#56) 2026-08-21 23:26:31 +02:00
SirStone 6a294ad7ad feat(SAC_LSTM_Bot): mirror-twin sparring partner + readiness check (#54)
- make_twin.sh: generates self-contained SacTwin dir in the sample-bots
  archive (own json/sh identity, own weights dir seeded from a frozen
  sac_best.zip copy, own round_counter) so RunTraining.java resolves it
  like any sample bot; re-running resets the twin to the frozen baseline.
- src/SAC_LSTM_Bot.nim: SACLSTM_BOT_JSON env overrides the baked-in bot
  json (loadBotInfo gives json total precedence, #49) so the same binary
  boots under the twin's name.
- sac_train.sh: chunk loop is a while, not for-over-seq — a crash on the
  FINAL chunk previously fell through ((chunk--);continue on an exhausted
  seq list) and exited 0 with budget incomplete; observed live vs SacTwin.

Readiness dry-run (#54): weighted pool Corners:1,SacTwin:3 picked the twin
in 3/4 chunks; all battles counter-checked; deterministic eval parsed;
main sac_best.zip/counter untouched by twin (twin counter advanced
independently); crash-restart proven end-to-end incl. final-chunk retry.
2026-08-21 23:19:27 +02:00
SirStone edf26aa45d fix(SAC_LSTM_Bot): enforce MaxHidden cap on SACLSTM_HIDDEN_SIZE (review of #48/#49) 2026-08-21 22:17:55 +02:00
SirStone df256b4d3e 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).
2026-08-21 21:51:27 +02:00
SirStone 7104645f5d chore(deps): update vendored tankroyale botapi to v1.0.1
Syncs libs/tankroyale_botapi with SirStone/robocode_tankroyale_botapi
v1.0.1 (extracted from tank-royale nim branch @ 03195a814). The local
SIGSEGV fixes (static event queue/SVG/intent buffers) were already
ported upstream in issue #24 — content is otherwise identical.

New capability (upstream #23): opponent name exposure for ticket #49.
- bot.nim: gBotNames id→name table + getBotName(id) / updateBotNames()
- umbrella module: dispatch BotListUpdate messages to updateBotNames()

Vendored layout and wiring unchanged (--path via config.nims, module
name stays tankroyale_botapi).
2026-08-21 21:03:17 +02:00
SirStone 32b71d9fc8 feat(SAC_LSTM_Bot): main bot integration (#48) 2026-08-21 20:19:05 +02:00
SirStone 62a6cc8ccf fix(SAC_LSTM_Bot): training review fixes — hidden state ordering, redundant forwards, actor grad clip (#47)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-21 00:16:47 +02:00
SirStone 415d4e3738 feat(SAC_LSTM_Bot): SAC training module (#47)
Implements sacUpdate with burn-in LSTM warm-up, twin-critic TD update,
actor reparameterization gradient, auto-alpha, and soft target update.
Manual backprop (linear + LSTM single-step, truncated BPTT). 11 new tests
all green; full regression suite (57+ tests) unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-21 00:11:35 +02:00
SirStone 717ef3ead8 Merge branch 'worktree-agent-a8622248' (ticket #46 weight persistence) 2026-08-20 23:57:53 +02:00
SirStone 54b8139b11 feat(SAC_LSTM_Bot): weight persistence module (#46)
Save/load all SAC-LSTM tensors (actor, 2 critics, 2 target critics,
alpha, Adam states) into a single .zip of .npy files. Atomic write
via temp path + rename. Adam types (AdamVar, SACAdamStates) defined
here for training.nim to use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:57:13 +02:00
SirStone 55ef22ff8b Merge branch 'worktree-agent-a3ca3066' (ticket #45 replay buffer) 2026-08-20 23:41:23 +02:00
SirStone 5ea57bcae3 Merge branch 'worktree-agent-a393a8b9' (ticket #44 reward module) 2026-08-20 23:41:23 +02:00
SirStone cb33551621 feat(SAC_LSTM_Bot): replay buffer module (#45)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:40:17 +02:00
SirStone 23c65c9ac6 feat(SAC_LSTM_Bot): reward module (#44)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:39:45 +02:00
SirStone 4ee0d8272c feat(SAC_LSTM_Bot): action mapping module (#43)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:38:45 +02:00
SirStone add3e34926 feat(SAC_LSTM_Bot): LSTM network module (#41)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:38:17 +02:00
SirStone f130bf1254 feat(SAC_LSTM_Bot): state vector module (#42)
35-dim normalized tensor (GameState → buildState). No history window —
LSTM handles temporal context. Covers own-bot (7), enemy (7), derived (4),
walls (4), bullets (12), scan staleness (1). All tests pass.
2026-08-20 23:37:56 +02:00
SirStone a0a3840980 feat(SAC_LSTM_Bot): skeleton bot with radar lock and colors (#40)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:37:50 +02:00
SirStone 7d73d32c85 Merge branch 'worktree-agent-ac811b59' (ticket #39 radar lock) 2026-08-20 23:34:24 +02:00
SirStone df3bbbd14e Merge branch 'worktree-agent-a1c1f549' (ticket #38 SAC_LSTM_Bot scaffold) 2026-08-20 23:34:21 +02:00
SirStone 4258d364b9 feat(SAC_LSTM_Bot): project scaffold (#38)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:33:43 +02:00
SirStone f88580b157 feat(radar_lock): standalone reusable module (#39)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 23:33:40 +02:00
SirStone ca3e3d2272 tune(PPO_Bot): logStd=-2.0 (std≈0.135), entropy=0, ceiling=-1.0
Stochastic eval at std≈0.37 was 0/10 vs Corners (deterministic: 10/10).
Warm-start policy is correct but brittle — any noise breaks it.
- log_std initialized to -2.0 (std≈0.135) for moderate exploration
- entropy_coeff=0.0 (no push toward exploration during fine-tuning)
- logStd ceiling=-1.0 (cap at std≈0.37)
2026-08-20 15:28:15 +02:00
SirStone 0d35646dc9 feat(PPO_Bot): deterministic eval + fix logStd warm-start
- actorForward: deterministic param, uses mean-only when PPOB_EVAL_ONLY=1
  (eval was adding unit Gaussian noise to every action — unreliable scores)
- warm_start.py: log_std initialized to -1.0 (std≈0.37) instead of copying
  snapshot values (were 2.27-4.68 → std 9-108, completely drowning signal)
- training.env: LOG_STD_CEILING 0.0→-0.5 (cap exploration at std≈0.6)
2026-08-20 15:18:07 +02:00
SirStone c834d2cbee fix(PPO_Bot): round_counter always written after increment
Early-return guard on empty buffer was skipping round_counter.txt write,
causing training script to think bot crashed (counter stuck at 0).
2026-08-20 15:11:12 +02:00
SirStone fedab54bc0 feat(PPO_Bot): multi-round transition accumulation (UPDATE_INTERVAL=10)
- Accumulate transitions across 10 rounds (~3000) before PPO update
  (was per-round ~300 — gradient estimates were far too noisy)
- training.nim: MAX_TRANSITIONS 4096→8192, done flag on transitions,
  GAE handles episode boundaries correctly
- PPO_Bot.nim: buffer persists across rounds, update every N rounds
- training.env: lr 5e-5→1e-4, entropy 0.001, UPDATE_INTERVAL=10
2026-08-20 15:06:00 +02:00
SirStone 82eeb53e5c tune(training): 20-round chunks, 50 eval rounds, 30k total rounds
- generalist_train.sh: CHUNK_SIZE 60→20 for faster opponent cycling
- generalist_train.sh: EVAL_ROUNDS 30→50 for more reliable eval
- training.env: TRAINING_ROUNDS→30000, removed hardcoded opponent
- warm_start.py: TARGET_DIM=57 (already committed, ensure latest)
2026-08-20 14:38:09 +02:00
SirStone 12624d3069 feat(PPO_Bot): bot-relative bullets + scan staleness (STATE_DIM=57)
- Bullet state (indices 44-55): enemy-relative → bot-relative frame
  (bot needs threat vectors to itself for dodging, not to enemy)
- New index 56: scan staleness = min(ticksSinceLastScan / 30, 1.0)
  (gives policy a confidence signal for enemy data freshness)
- warm_start.py updated: 44→57 dim expansion, TARGET_DIM variable
- Tests updated for new state layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 14:27:28 +02:00
SirStone 6ad51148f4 fix(PPO_Bot): SIGSEGV crash fixes + static buffers for thread safety
- bullets: seq[InFlightBullet] → array[4, InFlightBullet] + bulletCount
  (eliminates cross-thread heap realloc under ORC)
- hasFired: edge-triggered (cleared after state build, not level-triggered)
- round_counter parseInt: wrapped for empty/torn file → 0
- Static SVG + intent buffers to kill cross-thread heap realloc
- Tick-local alive/bulletData also fixed arrays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 14:23:08 +02:00
SirStone 75e32e3315 PPO_Bot Fire campaign: 3787/3789 wins (99.95%); frozen eval 500/500
Single 3789-round battle (5212-9000), only rounds 1-2 lost (cold start);
last 100 rounds 100%. Frozen-policy eval (PPOB_EVAL_ONLY=1, 500 rounds vs
Fire, counter 9000->9500): 500/500 = 100%, zero train lines, weights mtime
and content untouched. vLoss avg 39.7/max 361 stable throughout — bounded
terminal reward (db99153) holds at cumulative score ~462k.
2026-08-19 04:00:47 +02:00
SirStone db99153f65 cap terminal reward scale (bounded score bonus) and restore single-battle campaigns
computeRoundReward used cumulative totalScore/50 — unbounded in long battles
(vLoss 353 at round 3160 → 25745 by 3871 in the 5841-round attempt). Cap the
score term at 400 before /50: bonus ∈ [0,8], so the critic's value scale stays
stable regardless of battle length and across battle boundaries.

Reverts the 60-round battle chunking (186e005/da2f825): one battle per
campaign for the whole remaining budget; keeps the crash-restart loop, the
mid-battle freeze guard and the end-of-battle counter completeness check.

Cert (5211, single 60-round battle): 59/60 wins (sole loss = cold-start round
1, score 61), vLoss avg 36.1 / max 148.5, gNorm max 596, zero NaN, zero
restarts, counter check passed. Weights persist to round 5211.
2026-08-19 03:50:14 +02:00
SirStone da2f825ad8 fix(training): keep run.sh looping across 60-round battle chunks
RunTraining exits 0 after each chunk; && break ended the whole run after
the first battle (counter 3219, not 9000). Loop now falls through the
success path and re-checks the persisted counter each iteration.
2026-08-19 03:36:20 +02:00
SirStone 186e005a96 fix(training): cap battles at 60 rounds to bound round-end reward scale
Round-end reward = cumulative totalScore/50 grows unboundedly with battle
length; long battles (5841 rounds) blew the critic's value scale: vLoss
10-30 during the 60-round cert, 353 at battle-1 round 1, 25745 by round 3871,
policy drift to 0/6 wins. 60-round battles reproduce the certified regime:
bounded value targets, fresh bot process per battle (clears thread state).
2026-08-19 03:32:31 +02:00
SirStone a4e830531b fix(botapi): static SVG + intent buffers to kill cross-thread heap realloc
Round N+1's fresh bot thread realloc'd module-level strings/seqs (SVG buffer,
intent stdout/stderr, team messages) left behind by dead round N's thread —
same rawDealloc SIGSEGV class as the event queue, seen at graphics.nim:274
(drawText->prepareAdd, core 2490478 @ 03:13:40, battle round 257).

- graphics.nim: gSvgBuffer -> array[16384, char] + gSvgLen, appendSvg
- bot.nim: intent stdout/stderr -> static char arrays; team messages ->
  array[16, TeamMessage] + len; buildIntentJson/printToStdOut/Err/
  broadcastTeamMessage bounded appends
- botThreadEntry: reset graphics+intent buffers on the owning thread

Also fixes stale mapActions call sites in tests/ (missing enemyX/enemyY).
2026-08-19 03:32:28 +02:00
SirStone 64697f917e 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.
2026-08-19 03:12:22 +02:00
SirStone 766b9e03ee feat(PPO_Bot): enemy-centered action space + reward shaping for 100% vs Target
- actions.nim: goto/aimTo coordinates now offset from enemy position
  (enemyX + tanh(raw) * scale) instead of absolute arena coords
  (sigmoid(raw) * arenaSize). Initial random policy defaults to
  approaching and aiming at enemy.
- training.nim: added dense reward shaping (distance closeness +
  gun bearing) to computeTickReward, doubled round reward scaling.
- PPO_Bot.nim: passes enemy position to mapActions, computes
  gun-to-enemy bearing for reward shaping.

Result: 100/100 win rate vs Target with frozen weights.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-18 17:40:59 +02:00
SirStone 0b17430735 feat(PPO_Bot): persist Adam optimizer state and round counter across restarts (#35)
Save ACAdamStates (m/v tensors + t counters) as .npy files alongside
network weights in latest/ and checkpoint dirs; save round counter to
round_counter.txt. loadBestAvailable restores both on startup; fresh
start works unchanged when files are absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-18 15:40:54 +02:00
SirStone cdde60d79f feat(PPO_Bot): command abstraction layer — goto/aimTo controllers (#24)
- Add gotoTick/aimToTick controller functions (#25)
- Update network dims: actor 5→6, state 42→44 (#26)
- Rewrite mapActions for 6-dim command space (#27)
- Delete stale weight files (shape mismatch)
- Fix existing tests for new signatures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 19:20:40 +02:00
SirStone 56e0b306c9 docs(research): goto controller algorithm for issue #20
Covers forward/reverse decision, proportional steering with speed-dependent
turn rate clamping, and deceleration using the existing getNewTargetSpeed util.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-17 17:04:57 +02:00
SirStone e5609a7d9b fix(PPO_Bot): radar lock — use enemy_tracker width-lock, fix arctan2 arg order
Three bugs caused the radar to sweep continuously instead of locking:

1. run() loop set radar to Inf every tick, overwriting any lock
   → replaced with enemy_tracker.getRadarTurnRate()

2. onScannedBot used radarBearingTo() (math convention, east=0 CCW)
   → removed; run loop now handles radar via enemy_tracker

3. enemy_tracker.getRadarTurnRate() had arctan2(dx,dy) instead of
   arctan2(dy,dx) — introduced by fd22535; bearing was off by ~90°

Also relaxed stale-lock threshold from 2 to 8 ticks to survive
brief scan gaps without falling back to full sweep.

Added tools/battle_runner for automated 1v1 testing.

Result: 1303/1308 ticks with successful scan (was ~1 in 4).
2026-08-17 11:52:36 +02:00
SirStone a8ee2a86e3 feat(PPO_Bot): show training progress in game UI
Per-tick SVG drawText overlay above the bot showing round number and
running average reward (e.g. "R:42 avg:3.50"). Per-round summary also
printed to the UI console via printToStdOut with tick count and score.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 16:48:08 +02:00
SirStone bbc9e51166 fix(PPO_Bot): state vector bearing uses game coords (north=0° CW)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 16:40:28 +02:00
SirStone fd22535f5b fix(PPO_Bot): radar lock oscillation bug — arctan2 arg order wrong for Tank Royale coords
arctan2(dy, dx) gives east-based math bearing; Tank Royale uses north=0°, CW+.
Swapping to arctan2(dx, dy) gives the correct game-space bearing.
Symptom: radar commanded 45°/tick away from a target directly ahead.
Adds test_radar_lock.nim as regression test (20-tick lock, ±15° tolerance).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 16:38:15 +02:00
SirStone f27b0238f0 feat(PPO_Bot): full PPO RL implementation (#14, #15, #16, #17) 2026-08-16 16:34:37 +02:00
SirStone aea0724d3a fix(PPO_Bot): radar oscillation, Adam persistence, checkpoint order, channel race
- enemy_tracker: toggle lastOvershootDir each tick; make getRadarTurnRate take var tracker
- training: remove threadvar Adam globals; pass adamStates as var param to ppoUpdate; export ACAdamStates
- PPO_Bot: carry ACAdamStates through TrainingArgs/TrainingResult; drop trainingDone bool and Lock — use resultChan.tryRecv() directly as synchronisation
- weights: sort checkpoint dirs newest-first by mtime instead of hardcoded order
- tests/test_training: pass explicit ACAdamStates to ppoUpdate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 15:42:06 +02:00
SirStone 473d67f644 feat(PPO_Bot): weight persistence + background training (#17)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 15:35:42 +02:00
SirStone eadd177d3b feat(PPO_Bot): reward + trajectory + GAE + PPO training (#16)
Manual-backprop PPO with Adam: TrajectoryBuffer, computeGAE, ppoUpdate
(4 epochs, minibatch 64, clip 0.2, grad norm 0.5). Reward helpers
computeTickReward/computeRoundReward. Bot wired: tick transitions
collected in run loop, ppoUpdate called on onRoundEnded. Fix: add
arraymancer import to PPO_Bot.nim so Tensor resolves at top level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 15:27:15 +02:00
SirStone 588c9ebc2f feat(PPO_Bot): enemy tracker + 42-float state vector (#15)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 15:12:13 +02:00
SirStone aa4bc77068 feat(PPO_Bot): network forward pass + action mapping (#14)
Two-hidden-layer MLP actor-critic (42→64→64→5/1) with stochastic
actorForward, logStd floor at -3, and BotAction mapper wired into
the run() loop. Assert-based test suite covers shapes, finiteness,
logStd collapse, and all action range bounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 15:10:46 +02:00
SirStone 30cda871cc research: RL algorithm choice — recommend PPO for Tank Royale bot
Evaluates A2C, PPO, TD3, SAC, DDPG against the constraints: short
on-policy episodes, no RL library, few-hundred-ms training window.
PPO wins on implementation simplicity and stability at this scale.

Closes #3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-15 22:07:47 +02:00
850 changed files with 16243 additions and 13 deletions
+36
View File
@@ -0,0 +1,36 @@
# Compiled output
*.out
# Python cache
__pycache__/
*.pyc
*.pyo
# Log files
*.log
# Training logs
*.jsonl
# Test binaries (but keep .nim source files)
**/tests/test_*
!**/tests/test_*.nim
!**/tests/test_*.nims
# User-specific dev environment
.envrc
devbox.json
devbox.lock
# Compiled bot binaries — all builds go to *_garage/out/
*_garage/out/
# Java compiled classes
*.class
# Log and snapshot directories
**/logs/
**/snapshots/
# Git worktrees
worktrees/
+47
View File
@@ -0,0 +1,47 @@
## Agent skills
### Issue tracker
Gitea Issues at git.fossellini.top:SirStone/SirRoboGarage. See `docs/agents/issue-tracker.md`.
### Triage labels
Default label vocabulary. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context layout. See `docs/agents/domain.md`.
### Folders
## SirGarage Directory Layout
**SirGarage** (root directory)
- `botname_garage/` — each bot gets its own directory following this pattern
- src/
- botname.nim
- botname.json
- botname.sh
- config.nim
- botname/ — bot-specific modules
- *.nim files
- tests/
- test*.nim files
- out/
- compiled binaries
- prototypes or spikes for this bot (not at root)
- docs/ — generic project documentation
- tools/ — generic tools valid for all bots (battle runners, scripts, etc.)
- common_libs/ — shared libraries valid for all bots
- worktrees/ — git worktrees (gitignored)
- AGENTS.md
- CONTEXT.md
- README.md
- .gitignore
## How to use
- These indications are for files that are always present, for files not mentioned here should be claer from context where belongs too.
- Each bot development must be done in each folder in isolation.
- If the bot requires extra folders during runtime, these must be created by the bot itself if not present already.
-13
View File
@@ -1,13 +0,0 @@
## Agent skills
### Issue tracker
Gitea Issues at git.fossellini.top:SirStone/SirRoboGarage. See `docs/agents/issue-tracker.md`.
### Triage labels
Default label vocabulary. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context layout. See `docs/agents/domain.md`.
+59
View File
@@ -0,0 +1,59 @@
# Evo_Bot
Robocode Tank Royale bot with a modular gun system where evolved neural networks learn to predict enemy dodge behavior.
## Language
**Evo_Bot**:
The bot itself — handles movement and firing discipline. Guns are pluggable modules.
_Avoid_: robot, tank
**Guess Factor (GF)**:
A value from -1 to +1 representing where on the maximum escape angle arc the enemy is. 0 = directly ahead, -1 = full left dodge, +1 = full right dodge. The gun's prediction target.
_Avoid_: aim offset, dodge index
**Max Escape Angle (MEA)**:
The widest angle the enemy can reach before a bullet arrives, computed from distance and bullet speed. Guess factor is multiplied by MEA to get the aim offset.
**Lateral Velocity**:
Enemy speed projected perpendicular to the line between you and them. The primary signal for guess factor prediction.
_Avoid_: tangential speed, sideways velocity
**Sliding Window**:
The last N ticks (default 30) of enemy state fed as input to the network. Each tick contains lateral velocity, heading delta, and wall distance ahead.
_Avoid_: observation buffer, input history
**Replay Tape**:
Rolling buffer of recorded enemy states (~2000 ticks). The evolution thread evaluates gun fitness against this tape.
_Avoid_: experience buffer, replay buffer
**Virtual Gun**:
A gun that runs in parallel without actually firing. It tracks where it would have aimed and whether a simulated bullet would have hit. Used to compare gun variants.
**Virtual Bullet**:
A simulated bullet fired by a virtual gun. Never actually sent to the game engine.
**TOPO_Gun**:
Fixed-topology ANN gun evolved by GA. Network shape is predetermined (e.g., 91-8-1), only weights are evolved.
_Avoid_: static gun, fixed gun
**NEAT_Gun**:
Variable-topology ANN gun where evolution can add/remove neurons and connections (NEAT algorithm). Deferred — only built if TOPO_Gun hits a ceiling.
**Population**:
The set of candidate networks (default 64-200) being evolved. Each member is a complete set of ANN weights.
**Champion**:
The best-performing network in the current GA population. The champion's weights are pushed to the inference side when it beats the current best.
_Avoid_: best, winner, elite
**Fitness**:
Hit count when a network's aim predictions are evaluated as virtual bullets against sampled ticks from the replay tape.
_Avoid_: score, reward
**Cold Start**:
The first-ever battle with no saved weights. The gun does not fire until the evolution thread produces its first champion. Subsequent battles load persisted weights.
**Weight Persistence**:
Saving evolved weights to disk. Load order: per-opponent file, then global fallback, then random initialization.
_Avoid_: model saving, checkpointing
+3
View File
@@ -0,0 +1,3 @@
nimble.develop
nimble.paths
nimbledeps
+11
View File
@@ -0,0 +1,11 @@
# Package
version = "0.1.0"
author = "Davide Cappellini"
description = "GotoTest — throwaway goto(x,y) controller prototype"
license = "MIT"
srcDir = "src"
bin = @["GotoTest"]
# Dependencies
requires "nim >= 2.0.0"
requires "tankroyale_botapi >= 1.0.0"
+5
View File
@@ -0,0 +1,5 @@
switch("outdir", "out")
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
View File
+11
View File
@@ -0,0 +1,11 @@
{
"name": "GotoTest",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Throwaway goto(x,y) controller prototype",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "melee", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+95
View File
@@ -0,0 +1,95 @@
## GotoTest — throwaway prototype to validate goto(x,y) + aimTo(x,y) controllers.
## Diamond pattern with wall-smash north waypoint to test stuck recovery.
## Tank Royale: Y=0 is south, Y increases northward. Arena 800x600.
import std/[strformat, os]
import tankroyale_botapi
const botJsonPath = currentSourcePath().parentDir / "GotoTest.json"
# Diamond waypoints. (400,600) is AT the north wall — physically impossible, tests wall recovery.
const waypoints = [
(400.0, 300.0), # center
(400.0, 600.0), # north wall — AT wall, unreachable, tests wall-stuck recovery
( 36.0, 300.0), # west wall (left)
(400.0, 36.0), # south wall (bottom)
(764.0, 300.0), # east wall (right)
(400.0, 300.0), # center
]
type GotoBot = ref object of Bot
waypointIdx: int
oldX, oldY: float # position 3 ticks ago for stuck detection
tickCount: int # ticks since oldX/oldY was last updated
stuckTicks: int # remaining ticks of reverse override
method onRoundStarted*(bot: GotoBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
bot.waypointIdx = 0
bot.oldX = 0.0; bot.oldY = 0.0
bot.tickCount = 0; bot.stuckTicks = 0
method run(bot: GotoBot) =
while isRunning():
let bx = getX()
let by = getY()
let (tx, ty) = waypoints[bot.waypointIdx]
let dist = distanceTo(bx, by, tx, ty)
if dist < 40.0:
bot.waypointIdx = (bot.waypointIdx + 1) mod waypoints.len
# goto controller — explicit forward/reverse proportional steering
# ponytail: no speed-taper on heading error; add when overshooting observed
let rawBearing = normalizeRelativeAngle(directionTo(tx, ty) - getDirection())
let (dirSign, effBearing) =
if abs(rawBearing) > 90.0:
(-1.0, normalizeRelativeAngle(rawBearing + 180.0))
else:
(1.0, rawBearing)
let maxTurn = calcMaxTurnRate(getSpeed())
setTurnRate(effBearing.clamp(-maxTurn, maxTurn))
# stuck detector — ponytail: 3-tick sample, upgrade to wall-nav if needed
inc bot.tickCount
if bot.tickCount >= 3:
let moved = distanceTo(bot.oldX, bot.oldY, bx, by)
if moved < 1.0 and dist >= 30.0:
bot.stuckTicks = 6
echo &"STUCK at ({bx:.1f},{by:.1f}) moved={moved:.2f} — reversing"
bot.oldX = bx; bot.oldY = by
bot.tickCount = 0
if bot.stuckTicks > 0:
# reverse current speed direction to unstick
let targetSpd = if getSpeed() >= 0.0: -8.0 else: 8.0
setTargetSpeed(targetSpd)
dec bot.stuckTicks
else:
let rawSpeed = getNewTargetSpeed(MAX_SPEED, abs(getSpeed()), dist)
setTargetSpeed(dirSign * rawSpeed)
# aimTo arena center — radarBearingTo reuses getX/getY/getGunDirection internally
let cx = getArenaWidth().float / 2.0
let cy = getArenaHeight().float / 2.0
let gunTurnNeeded = normalizeRelativeAngle(directionTo(cx, cy) - getGunDirection())
setGunTurnRate(gunTurnNeeded.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE))
echo &"pos=({bx:.1f},{by:.1f}) target=({tx:.1f},{ty:.1f}) dist={dist:.1f} spd={getSpeed():.2f} stuck={bot.stuckTicks}"
# Debug graphics: waypoint circles + line to current target
for i, (wx, wy) in waypoints:
if i == bot.waypointIdx:
setStrokeColor(RED)
else:
setStrokeColor(GRAY)
drawCircle(wx, wy, 10.0)
setStrokeColor(YELLOW)
drawLine(bx, by, tx, ty)
go()
when isMainModule:
var bot = GotoBot()
start(bot, botJsonPath)
View File
+12
View File
@@ -0,0 +1,12 @@
# Package
version = "0.1.0"
author = "Davide Cappellini"
description = "Oscillator pattern Tank Royale bot"
license = "MIT"
srcDir = "src"
bin = @["OscillatorBot"]
# Dependencies
requires "nim >= 2.0.0"
# tankroyale_botapi and radar_lock are vendored in-tree (common_libs/) and wired via
# config.nims --path; no nimble dependency so builds never touch ~/.nimble/pkgs2.
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./out/OscillatorBot 2>> /tmp/oscillatorbot_stderr.log
+2
View File
@@ -0,0 +1,2 @@
--path:"../common_libs"
switch("outdir", "out")
View File
@@ -0,0 +1,11 @@
{
"name": "OscillatorBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Predictable zigzag sparring partner for gun testing",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,48 @@
# OscillatorBot — predictable zigzag sparring partner for GA gun testing.
# Reverses direction + turn every PERIOD ticks. Head-on targeting only.
import std/[math, os]
import tankroyale_botapi
const botJsonPath = currentSourcePath().parentDir / "OscillatorBot.json"
const
SPEED = 7.0 # forward/backward speed
PERIOD = 25 # ticks between direction reversals
type OscillatorBot = ref object of Bot
tickCount: int
moveSign: float # +1 forward, -1 backward
turnSign: float # +1 right, -1 left
method onRoundStarted*(bot: OscillatorBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
bot.tickCount = 0
bot.moveSign = 1.0
bot.turnSign = 1.0
method onScannedBot*(bot: OscillatorBot, e: ScannedBotEvent) =
# Head-on targeting: aim gun directly at enemy, fire medium power
let bearing = directionTo(getX(), getY(), e.x, e.y)
let gunDelta = normalizeRelativeAngle(bearing - getGunDirection())
setGunTurnRate(gunDelta.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE))
if abs(gunDelta) < 10.0 and getGunHeat() <= 0.0:
discard setFire(2.0)
method run*(bot: OscillatorBot) =
while isRunning():
inc bot.tickCount
if bot.tickCount mod PERIOD == 0:
bot.moveSign *= -1.0
bot.turnSign *= -1.0
setTargetSpeed(bot.moveSign * SPEED)
setTurnRate(bot.turnSign * 4.0)
setRadarTurnRate(45.0) # spin radar to keep scanning
go()
when isMainModule:
var bot = OscillatorBot(moveSign: 1.0, turnSign: 1.0)
start(bot, botJsonPath)
View File
+3
View File
@@ -0,0 +1,3 @@
nimble.develop
nimble.paths
nimbledeps
+13
View File
@@ -0,0 +1,13 @@
# Package
version = "0.1.0"
author = "Davide Cappellini"
description = "PPO-trained Tank Royale bot"
license = "MIT"
srcDir = "src"
bin = @["PPO_Bot"]
# Dependencies
requires "nim >= 2.0.0"
# tankroyale_botapi is vendored in-tree (common_libs/tankroyale_botapi) and wired via
# config.nims --path; no nimble dependency so builds never touch ~/.nimble/pkgs2.
requires "arraymancer >= 0.7.0"
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# PPO_Bot — PPO-trained RL bot (compiled native binary)
# OPENBLAS_NUM_THREADS=1: prevent OpenBLAS from spawning worker threads,
# which deadlock when called from within a multi-threaded Nim bot process.
export OPENBLAS_NUM_THREADS=1
cd -- "$(dirname -- "$0")"
exec "./out/PPO_Bot"
+15
View File
@@ -0,0 +1,15 @@
# Static-link OpenBLAS for portable deployment
# ponytail: adjust path per machine, or use pkg-config
switch("passL", "-L/nix/store/v07svn2y92bvzjl51aj7c9ca1cwg7rw7-openblas-0.3.32/lib -lopenblas")
switch("threads", "on")
switch("outdir", "out")
switch("path", thisDir() & "/src")
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
# Use the repo-vendored Tank Royale bot API (common_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() & "/../common_libs/tankroyale_botapi")
View File
+7
View File
@@ -0,0 +1,7 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
openblas
];
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "PPO_Bot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "PPO-trained RL bot",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "melee", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
+371
View File
@@ -0,0 +1,371 @@
## PPO_Bot — enemy tracker + state vector wired into the game loop.
## Training: trajectory collected per tick, PPO update in background thread.
import std/[os, strformat, strutils, math, times, algorithm]
import arraymancer
import tankroyale_botapi
import PPO_Bot/network
import PPO_Bot/actions
import PPO_Bot/training
import PPO_Bot/weights
import PPO_Bot/enemy_tracker
import PPO_Bot/state_vector
# ── Hyperparameters from env vars (PPOB_ prefix) ─────────────────────────────
# All optional; defaults match ppoUpdate signature in training.nim.
proc getEnvFloat(name: string, default: float32): float32 =
let v = getEnv(name)
if v.len == 0: default else: parseFloat(v).float32
proc getEnvInt(name: string, default: int): int =
let v = getEnv(name)
if v.len == 0: default else: parseInt(v)
var
hpLr: float32 = getEnvFloat("PPOB_LR", 3e-4'f32)
hpClipEpsilon: float32 = getEnvFloat("PPOB_CLIP_EPSILON", 0.2'f32)
hpEntropyCoeff: float32 = getEnvFloat("PPOB_ENTROPY_COEFF", 0.01'f32)
hpValueLossCoeff: float32 = getEnvFloat("PPOB_VALUE_LOSS_COEFF", 0.5'f32)
hpMaxGradNorm: float32 = getEnvFloat("PPOB_MAX_GRAD_NORM", 0.5'f32)
hpGamma: float32 = getEnvFloat("PPOB_GAMMA", 0.99'f32)
hpLam: float32 = getEnvFloat("PPOB_LAM", 0.95'f32)
hpEpochs: int = getEnvInt("PPOB_EPOCHS", 4)
hpMiniBatchSize: int = getEnvInt("PPOB_MINI_BATCH_SIZE", 64)
hpUpdateInterval: int = getEnvInt("PPOB_UPDATE_INTERVAL", 10)
# Wire logStd tunable params into network module vars (read before initActorCritic)
logStdFloor = getEnvFloat("PPOB_LOG_STD_FLOOR", -3.0'f32)
logStdCeiling = getEnvFloat("PPOB_LOG_STD_CEILING", 0.5'f32)
initialLogStd = getEnvFloat("PPOB_INITIAL_LOG_STD", 0.0'f32)
# ── Structured log output ─────────────────────────────────────────────────────
let logFile = getEnv("PPOB_LOG_FILE") # empty → no JSON logging
let evalOnly = getEnv("PPOB_EVAL_ONLY") == "1" # freeze training (pure evaluation)
proc appendJsonLine(path, line: string) =
## Append a JSON line to path; no-op if path is empty.
if path.len == 0: return
let f = open(path, fmAppend)
f.writeLine(line)
f.close()
proc jsonFloat(v: float32): string =
## Serialize a float for JSON; non-finite → null (keeps JSONL parseable).
if v == v and v > -1e30'f32 and v < 1e30'f32: $v else: "null"
proc hyperparmSnapshot(): string =
## Compact JSON object of current hyperparams (no outer braces).
&"\"lr\":{hpLr},\"clipEpsilon\":{hpClipEpsilon}," &
&"\"entropyCoeff\":{hpEntropyCoeff},\"valueLossCoeff\":{hpValueLossCoeff}," &
&"\"maxGradNorm\":{hpMaxGradNorm},\"gamma\":{hpGamma},\"lam\":{hpLam}," &
&"\"epochs\":{hpEpochs},\"miniBatchSize\":{hpMiniBatchSize}," &
&"\"logStdFloor\":{logStdFloor},\"logStdCeiling\":{logStdCeiling},\"initialLogStd\":{initialLogStd}"
const botJsonPath = currentSourcePath().parentDir / "PPO_Bot.json"
const weightsRoot = currentSourcePath().parentDir / "../weights"
type
InFlightBullet = object
x, y: float64 # current position
vx, vy: float64 # velocity (pixels/tick)
power: float64
type PPOBot = ref object of Bot
tracker: EnemyTracker
buffer: TrajectoryBuffer
prevEnergy: float32 # own energy last tick
prevEnemyE: float32 # enemy energy last tick (from tracker)
lastState: array[STATE_DIM, float32] # plain arrays — tensors NEVER cross threads
lastAction: array[ACTION_DIM, float32]
lastLogP: float32
lastValue: float32
hasLastTrans: bool
lastActions: BotActions # previous tick's decoded actions (for state vector)
roundRewardSum: float32 # cumulative reward this round (for live display)
roundTicks: int # ticks this round
# Fixed-size bullet buffer — NO heap on the shared bot object. A seq here is
# allocated by the per-round bot thread and freed by the next round's thread
# (bot.bullets = @[] on round start) → foreign-heap free under --threads:on +
# ORC → SIGSEGV. N=4: the state vector only consumes the closest 3 slots.
bullets: array[4, InFlightBullet]
bulletCount: int
var ac = initActorCritic()
var gAdamStates: ACAdamStates # persists across rounds
var roundCounter = 0
var roundsSinceUpdate = 0
# ── Bot methods ───────────────────────────────────────────────────────────────
method onScannedBot*(bot: PPOBot, e: ScannedBotEvent) =
bot.tracker.update(e.x, e.y, e.direction, e.speed, e.energy)
method onRoundStarted*(bot: PPOBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
bot.tracker = initEnemyTracker()
# Do NOT clear bot.buffer here — transitions accumulate across rounds
# until hpUpdateInterval rounds have passed (cleared in onRoundEnded).
bot.prevEnergy = 0.0'f32
bot.prevEnemyE = 0.0'f32
bot.hasLastTrans = false
bot.roundRewardSum = 0.0'f32
bot.roundTicks = 0
bot.bulletCount = 0
method onRoundEnded*(bot: PPOBot, e: RoundEndedEventForBot) =
inc roundCounter
inc roundsSinceUpdate
debugLog("[PO-ENTER] round=" & $roundCounter & " tid=" & $getThreadId())
# Add round-end score bonus to last transition and mark it as episode boundary
let roundReward = computeRoundReward(e.results.totalScore.float32)
if bot.hasLastTrans and bot.buffer.len > 0:
let lastIdx = bot.buffer.len - 1
bot.buffer.transitions[lastIdx].reward += roundReward
bot.buffer.transitions[lastIdx].done = true
# Training progress display — one line per round in the UI console
let ticks = bot.buffer.len
var avgR = 0.0'f32
if ticks > 0:
var rewardSum = 0.0'f32
for i in 0 ..< bot.buffer.len: rewardSum += bot.buffer.transitions[i].reward
avgR = rewardSum / ticks.float32
let avgRStr = formatFloat(avgR.float, ffDecimal, 3)
printToStdOut(&"R:{roundCounter} ticks:{ticks} buf:{bot.buffer.len} sinceUpd:{roundsSinceUpdate}/{hpUpdateInterval} avgR:{avgRStr} score:{e.results.totalScore}\n")
echo &"R:{roundCounter} ticks:{ticks} buf:{bot.buffer.len} sinceUpd:{roundsSinceUpdate}/{hpUpdateInterval} avgR:{avgRStr} score:{e.results.totalScore}"
writeFile(weightsRoot / "round_counter.txt", $roundCounter)
if bot.buffer.len == 0:
bot.hasLastTrans = false
return
# Emit per-round game-stats JSON line
let ts = int(epochTime())
let jline = &"""{{"type":"round","round":{roundCounter},"ticks":{ticks},"bufLen":{bot.buffer.len},"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:
bot.buffer.clear()
bot.hasLastTrans = false
roundsSinceUpdate = 0
return
# Always save weights every round so round_counter.txt stays current.
saveCheckpoint(ac, gAdamStates, weightsRoot, roundCounter)
# Only update policy every hpUpdateInterval rounds (~3000 transitions).
if roundsSinceUpdate >= hpUpdateInterval:
# ponytail: synchronous update. Arraymancer tensors can't cross threads under
# ORC — training-thread ppoUpdate frees/rebinds tensors owned by the bot
# thread's heap (SIGSEGV, reproduced with a lone trainer thread on a fixed
# buffer; save/channel/forward exonerated). The bot API runs events on one
# bot thread, so inline is single-threaded. Revert to a background thread
# only if tensors are rebuilt from plain data on that thread.
printToStdOut(&" train→ R:{roundCounter} bufLen:{bot.buffer.len}\n")
echo &" train→ R:{roundCounter} bufLen:{bot.buffer.len}"
let m = ppoUpdate(ac, bot.buffer,
lastValue = 0.0'f32,
adamStates = gAdamStates,
epochs = hpEpochs,
miniBatchSize = hpMiniBatchSize,
clipEpsilon = hpClipEpsilon,
entropyCoeff = hpEntropyCoeff,
valueLossCoeff = hpValueLossCoeff,
lr = hpLr,
maxGradNorm = hpMaxGradNorm,
gamma = hpGamma,
lam = hpLam)
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()
roundsSinceUpdate = 0
bot.hasLastTrans = false
debugLog("[PO-EXIT] round=" & $roundCounter & " tid=" & $getThreadId())
method run(bot: PPOBot) =
debugLog("[RUN-ENTER] tid=" & $getThreadId())
# Seed energy and goto/aimTo targets on first tick (remainingDistance = 0 initially)
bot.prevEnergy = getEnergy().float32
bot.prevEnemyE = if bot.tracker.hasContact: bot.tracker.current.energy.float32 else: 0.0'f32
bot.lastActions.gotoX = getX()
bot.lastActions.gotoY = getY()
bot.lastActions.aimToX = getX()
bot.lastActions.aimToY = getY()
while isRunning():
bot.tracker.deadReckon()
# Spawn bullet when enemy fired last scan tick. Edge-triggered: the flag is
# consumed later (after the state build) so a radar gap (deadReckon ticks)
# can't spawn k phantom bullets from one shot — exactly one bullet, and
# state index 12 still pulses 1 on the detection tick.
if bot.tracker.hasContact and bot.tracker.current.hasFired:
if bot.bulletCount < 4:
let power = bot.tracker.current.lastFirePower
let speed = 20.0 - 3.0 * power
# Approximate gun direction: bearing from enemy toward our position
let myX = getX(); let myY = getY()
let ang = arctan2(myY - bot.tracker.current.y, myX - bot.tracker.current.x)
bot.bullets[bot.bulletCount] = InFlightBullet(
x: bot.tracker.current.x,
y: bot.tracker.current.y,
vx: speed * cos(ang),
vy: speed * sin(ang),
power: power,
)
inc bot.bulletCount
# Advance in-flight bullets and prune those off-arena (in-place compaction
# into the fixed buffer — no per-tick heap churn).
let aW = float64(getArenaWidth()); let aH = float64(getArenaHeight())
var n = 0
for i in 0 ..< bot.bulletCount:
let b = bot.bullets[i]
let nx = b.x + b.vx; let ny = b.y + b.vy
if nx >= 0.0 and nx <= aW and ny >= 0.0 and ny <= aH:
bot.bullets[n] = InFlightBullet(x: nx, y: ny, vx: b.vx, vy: b.vy, power: b.power)
inc n
bot.bulletCount = n
# Convert to BulletData for state vector (closest 3 by distance to us)
let myX2 = getX(); let myY2 = getY()
var bulletData: array[4, BulletData]
var bulletDataCount = 0
for i in 0 ..< bot.bulletCount:
let b = bot.bullets[i]
bulletData[bulletDataCount] = BulletData(x: b.x, y: b.y, power: b.power)
inc bulletDataCount
# sort ascending by distance so the nearest threats fill slots 0-2
if bulletDataCount > 1:
bulletData.toOpenArray(0, bulletDataCount - 1).sort(proc(a, b: BulletData): int =
let da = hypot(a.x - myX2, a.y - myY2)
let db = hypot(b.x - myX2, b.y - myY2)
cmp(da, db))
setRadarTurnRate(bot.tracker.getRadarTurnRate(getX(), getY(), getDirection(), getRadarDirection()))
let botData = BotStateData(
x: getX(),
y: getY(),
direction: getDirection(),
speed: getSpeed(),
energy: getEnergy(),
gunDirection: getGunDirection(),
gunHeat: getGunHeat(),
arenaWidth: float64(getArenaWidth()),
arenaHeight: float64(getArenaHeight()),
)
let remainingGotoDistance = hypot(bot.lastActions.gotoX - botData.x,
bot.lastActions.gotoY - botData.y)
let remainingGunAngle = abs(normalizeRelativeAngle(
directionTo(botData.x, botData.y, bot.lastActions.aimToX, bot.lastActions.aimToY) -
botData.gunDirection))
let state = buildStateVector(botData, bot.tracker, remainingGotoDistance, remainingGunAngle, bulletData, bulletDataCount)
# Consume the fired flag AFTER the state build: state index 12 saw the
# detection-tick pulse, and the next iteration's spawn check sees false —
# one shot → exactly one bullet, even across deadReckon gaps.
bot.tracker.current.hasFired = false
let (rawActs, logP) = ac.actorForward(state, deterministic = evalOnly)
let value = ac.criticForward(state)
let ex = if bot.tracker.hasContact: bot.tracker.current.x else: botData.arenaWidth / 2.0
let ey = if bot.tracker.hasContact: bot.tracker.current.y else: botData.arenaHeight / 2.0
let acts = mapActions(rawActs,
getGunHeat().float,
botData.arenaWidth, botData.arenaHeight,
botData.x, botData.y,
botData.direction, botData.speed, botData.gunDirection,
ex, ey)
# Compute tick reward from energy deltas + dense shaping
let curEnergy = getEnergy().float32
let curEnemyE = if bot.tracker.hasContact: bot.tracker.current.energy.float32 else: bot.prevEnemyE
let myDelta = curEnergy - bot.prevEnergy
let enemyDelta = curEnemyE - bot.prevEnemyE
let arenaDiag = float32(sqrt(botData.arenaWidth * botData.arenaWidth +
botData.arenaHeight * botData.arenaHeight))
let distEnemy = if bot.tracker.hasContact:
float32(hypot(bot.tracker.current.x - botData.x,
bot.tracker.current.y - botData.y))
else: arenaDiag
let gunToEnemy = if bot.tracker.hasContact:
abs(normalizeRelativeAngle(
arctan2(bot.tracker.current.y - botData.y,
bot.tracker.current.x - botData.x) * 180.0 / PI -
botData.gunDirection)).float32
else: 180.0'f32
let tickReward = computeTickReward(myDelta, enemyDelta,
distToEnemy = distEnemy,
maxDist = arenaDiag,
gunBearingAbs = gunToEnemy)
# Track running reward for in-game display
bot.roundRewardSum += tickReward
inc bot.roundTicks
# Finalise previous transition with the reward from this tick's state change
if bot.hasLastTrans:
let tr = Transition(
state: bot.lastState,
action: bot.lastAction,
logProb: bot.lastLogP,
reward: tickReward,
value: bot.lastValue,
done: false, # episode boundary set in onRoundEnded
)
bot.buffer.add(tr)
# Store current for next tick — plain arrays only. `state`/`rawActs` tensors
# live and die on this thread; a NEW bot thread runs each round, so storing
# tensors in the shared bot object would free round-N's heap memory from
# round N+1's thread (SIGSEGV; confirmed empirically).
bot.lastState = stateToArr(state)
bot.lastAction = actionToArr(rawActs)
bot.lastLogP = logP
bot.lastValue = value
bot.prevEnergy = curEnergy
bot.prevEnemyE = curEnemyE
bot.hasLastTrans = true
bot.lastActions = acts
setTargetSpeed(acts.targetSpeed.float)
setTurnRate(acts.turnRate.float)
setGunTurnRate(acts.gunTurnRate.float)
if acts.shouldFire:
discard setFire(acts.firePower.float)
# In-game training progress overlay
let avgR = if bot.roundTicks > 0: bot.roundRewardSum / bot.roundTicks.float32
else: 0.0'f32
let avgRStr = formatFloat(avgR.float, ffDecimal, 2)
drawText(&"R:{roundCounter} avg:{avgRStr}", getX(), getY() - 40.0)
go()
when isMainModule:
createDir(weightsRoot)
cleanStaleTempDirs(weightsRoot)
let loadResult = loadBestAvailable(ac, gAdamStates, weightsRoot)
if loadResult.loaded:
roundCounter = loadResult.roundNum
var bot = PPOBot(
tracker: initEnemyTracker(),
buffer: initTrajectoryBuffer(),
)
start(bot, botJsonPath)
+49
View File
@@ -0,0 +1,49 @@
## actions.nim — map raw network output to Tank Royale bot commands.
import arraymancer
import std/math
import ./controllers
func sigmoid(x: float): float = 1.0 / (1.0 + exp(-x))
type
BotActions* = object
targetSpeed*: float
turnRate*: float
gunTurnRate*: float
shouldFire*: bool
firePower*: float
gotoX*: float
gotoY*: float
aimToX*: float
aimToY*: float
proc mapActions*(rawActions: Tensor[float32],
gunHeat: float,
arenaWidth, arenaHeight: float,
botX, botY, direction, speed, gunDirection: float,
enemyX, enemyY: float): BotActions =
## rawActions: [6] tensor from actorForward.
## Dims 0–1: goto x/y offset from enemy, 2–3: aimTo x/y offset from enemy,
## 4: fire decision, 5: fire power.
## Enemy-centred mapping: tanh gives [-1,1]; scale by arena/4 (goto) and
## arena/8 (aimTo) so zero-init defaults the bot toward the enemy.
let gotoX = clamp(enemyX + tanh(rawActions[0].float) * arenaWidth * 0.25, 0.0, arenaWidth)
let gotoY = clamp(enemyY + tanh(rawActions[1].float) * arenaHeight * 0.25, 0.0, arenaHeight)
let aimToX = clamp(enemyX + tanh(rawActions[2].float) * arenaWidth * 0.125, 0.0, arenaWidth)
let aimToY = clamp(enemyY + tanh(rawActions[3].float) * arenaHeight * 0.125, 0.0, arenaHeight)
let fireDec = tanh(rawActions[4].float)
let fp = sigmoid(rawActions[5].float) * 2.9 + 0.1
let (ts, tr) = gotoTick(gotoX, gotoY, botX, botY, direction, speed)
let gtr = aimToTick(aimToX, aimToY, botX, botY, gunDirection)
result.gotoX = gotoX
result.gotoY = gotoY
result.aimToX = aimToX
result.aimToY = aimToY
result.targetSpeed = ts
result.turnRate = tr
result.gunTurnRate = gtr
result.shouldFire = fireDec >= 0.0 and gunHeat <= 0.0
result.firePower = fp
@@ -0,0 +1,24 @@
## Pure tick-level controllers for goto(x,y) and aimTo(x,y).
## No bot object needed — all inputs are explicit parameters.
import tankroyale_botapi
proc gotoTick*(targetX, targetY, botX, botY, direction, speed: float): (float, float) =
## Returns (targetSpeed, turnRate) to drive toward (targetX, targetY).
## Selects forward or reverse automatically based on bearing.
let bearing = normalizeRelativeAngle(directionTo(botX, botY, targetX, targetY) - direction)
let (dirSign, effBearing) =
if abs(bearing) > 90.0:
(-1.0, normalizeRelativeAngle(bearing + 180.0))
else:
(1.0, bearing)
let dist = distanceTo(botX, botY, targetX, targetY)
let maxTurn = calcMaxTurnRate(speed)
let turnRate = effBearing.clamp(-maxTurn, maxTurn)
let rawSpeed = getNewTargetSpeed(MAX_SPEED, abs(speed), dist)
(dirSign * rawSpeed, turnRate)
proc aimToTick*(targetX, targetY, botX, botY, gunDirection: float): float =
## Returns gunTurnRate (clamped to ±MAX_GUN_TURN_RATE) to rotate gun toward target.
let bearing = normalizeRelativeAngle(directionTo(botX, botY, targetX, targetY) - gunDirection)
bearing.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE)
@@ -0,0 +1,100 @@
## Enemy tracker — deterministic radar lock + dead reckoning for PPO_Bot.
## No bot API imports; takes plain floats.
import std/math
type
EnemyState* = object
x*, y*: float64
direction*: float64
speed*: float64
energy*: float64
ticksSinceLastScan*: int
hasFired*: bool
lastFirePower*: float64
EnemyTracker* = object
current*: EnemyState
history*: array[5, tuple[x, y, direction, speed: float64]] # sliding window
historyCount*: int # valid entries 0-5
prevEnergy*: float64
hasContact*: bool
proc initEnemyTracker*(): EnemyTracker = discard
proc update*(tracker: var EnemyTracker;
scanX, scanY, scanDir, scanSpeed, scanEnergy: float64) =
## Call on ScannedBotEvent. Detects enemy fire from energy delta.
# Shift history window
if tracker.historyCount == 0:
# Cold-start: pre-fill all slots with the incoming scan so indices 22-41
# are never zero-padded on tick 1. Accel/turn-rate correctly stay 0 (no delta yet).
for i in 0 ..< 5:
tracker.history[i] = (scanX, scanY, scanDir, scanSpeed)
tracker.historyCount = 5
else:
for i in countdown(min(tracker.historyCount, 4), 1):
tracker.history[i] = tracker.history[i - 1]
tracker.history[0] = (tracker.current.x, tracker.current.y,
tracker.current.direction, tracker.current.speed)
if tracker.historyCount < 5:
inc tracker.historyCount
# Detect firing: energy drop in [0.1, 3.0] means enemy fired
let delta = tracker.prevEnergy - scanEnergy
if tracker.hasContact and delta >= 0.1 and delta <= 3.0:
tracker.current.hasFired = true
tracker.current.lastFirePower = delta
else:
tracker.current.hasFired = false
tracker.prevEnergy = scanEnergy
tracker.current.x = scanX
tracker.current.y = scanY
tracker.current.direction = scanDir
tracker.current.speed = scanSpeed
tracker.current.energy = scanEnergy
tracker.current.ticksSinceLastScan = 0
tracker.hasContact = true
proc deadReckon*(tracker: var EnemyTracker) =
## Call on missed ticks. Predict position from last known velocity.
if not tracker.hasContact:
return
let rad = tracker.current.direction * PI / 180.0
tracker.current.x += tracker.current.speed * sin(rad)
tracker.current.y += tracker.current.speed * cos(rad)
inc tracker.current.ticksSinceLastScan
proc normalizeRelative(angle: float64): float64 {.inline.} =
result = angle mod 360.0
if result >= 180.0: result -= 360.0
elif result < -180.0: result += 360.0
proc getRadarTurnRate*(tracker: var EnemyTracker;
botX, botY, botDirection, radarDirection: float64): float64 =
## Returns radar turn rate (degrees/tick, positive = right).
## Before contact: full 45° sweep.
## After contact: lock with overshoot; widen if stale.
if not tracker.hasContact:
return 45.0
if tracker.current.ticksSinceLastScan >= 8:
# Lost lock — widen sweep
return 45.0
# Bearing from radar to enemy.
# Tank Royale radar directions use standard math convention (0=east, CCW+).
# arctan2(dy, dx) gives the standard math angle matching radarDirection units.
let dx = tracker.current.x - botX
let dy = tracker.current.y - botY
let absoluteDir = (180.0 * arctan2(dy, dx) / PI + 360.0) mod 360.0
var radarTurn = normalizeRelative(absoluteDir - radarDirection)
# Width Lock: overshoot proportional to arctan(36 / distance)
let distance = sqrt(dx * dx + dy * dy)
let extraTurn = min(arctan(36.0 / distance) * 180.0 / PI, 45.0)
if radarTurn < 0.0: radarTurn -= extraTurn
else: radarTurn += extraTurn
result = radarTurn.clamp(-45.0, 45.0)
+86
View File
@@ -0,0 +1,86 @@
## network.nim — MLP and ActorCritic forward pass (inference only, no autograd).
import arraymancer
import std/[math, random]
const
STATE_DIM* = 57
ACTION_DIM* = 6
var
logStdFloor*: float32 = -3.0'f32 # overridden by PPOB_LOG_STD_FLOOR
logStdCeiling*: float32 = 0.5'f32 # overridden by PPOB_LOG_STD_CEILING
initialLogStd*: float32 = 0.0'f32 # overridden by PPOB_INITIAL_LOG_STD
type
MLP* = object
w1*, b1*: Tensor[float32] # [hidden, input], [hidden]
w2*, b2*: Tensor[float32] # [hidden, hidden], [hidden]
w3*, b3*: Tensor[float32] # [output, hidden], [output]
ActorCritic* = object
actor*: MLP
critic*: MLP
logStd*: Tensor[float32] # [ACTION_DIM] — one per action dim
proc initMLP*(inputDim, hiddenDim, outputDim: int): MLP =
# Xavier/He-style init: scale weights by sqrt(2/fan_in)
result.w1 = randomNormalTensor[float32]([hiddenDim, inputDim]) *. sqrt(2.0'f32 / inputDim.float32)
result.b1 = zeros[float32](hiddenDim)
result.w2 = randomNormalTensor[float32]([hiddenDim, hiddenDim]) *. sqrt(2.0'f32 / hiddenDim.float32)
result.b2 = zeros[float32](hiddenDim)
result.w3 = randomNormalTensor[float32]([outputDim, hiddenDim]) *. sqrt(1.0'f32 / hiddenDim.float32)
result.b3 = zeros[float32](outputDim)
proc initActorCritic*(): ActorCritic =
result.actor = initMLP(STATE_DIM, 64, ACTION_DIM)
result.critic = initMLP(STATE_DIM, 64, 1)
result.logStd = newTensor[float32](ACTION_DIM).map(proc(v: float32): float32 = initialLogStd)
proc forward*(mlp: MLP, x: Tensor[float32]): Tensor[float32] =
## x shape: [inputDim] (1D vector)
let h1 = tanh(mlp.w1 * x + mlp.b1)
let h2 = tanh(mlp.w2 * h1 + mlp.b2)
result = mlp.w3 * h2 + mlp.b3
proc actorForward*(ac: ActorCritic, state: Tensor[float32], deterministic = false): tuple[actions: Tensor[float32], logProb: float32] =
## state: [STATE_DIM]. Returns actions [ACTION_DIM] and sum log-prob.
## deterministic=true: return mean only (no noise), logProb=0.
let mean = ac.actor.forward(state)
if deterministic:
return (actions: mean, logProb: 0.0'f32)
# Floor logStd at logStdFloor before exp → min std ≈ exp(logStdFloor)
var clampedLogStd = ac.logStd.map(proc(v: float32): float32 = clamp(v, logStdFloor, logStdCeiling))
let std = clampedLogStd.map(proc(v: float32): float32 = exp(v))
var actions = newTensor[float32](ACTION_DIM)
var logP = 0.0'f32
for i in 0..<ACTION_DIM:
let mu = mean[i]
let s = std[i]
let z = gauss(0.0'f64, 1.0'f64).float32
actions[i] = mu + s * z
# log N(a; mu, s) = -0.5*((a-mu)/s)^2 - log(s) - 0.5*log(2π)
let diff = (actions[i] - mu) / s
logP += -0.5'f32 * diff * diff - ln(s) - 0.5'f32 * ln(2.0'f32 * PI.float32)
result = (actions: actions, logProb: logP)
proc criticForward*(ac: ActorCritic, state: Tensor[float32]): float32 =
## state: [STATE_DIM]. Returns scalar value estimate.
let val = ac.critic.forward(state)
result = val[0]
proc computeLogProb*(ac: ActorCritic, state, action: Tensor[float32]): float32 =
## Log-probability of action under current policy (no sampling).
let mean = ac.actor.forward(state)
let logStdClamped = ac.logStd.map(proc(v: float32): float32 = clamp(v, logStdFloor, logStdCeiling))
let std = logStdClamped.map(proc(v: float32): float32 = exp(v))
var logP = 0.0'f32
for i in 0..<ACTION_DIM:
let mu = mean[i]
let s = std[i]
let diff = (action[i] - mu) / s
logP += -0.5'f32 * diff * diff - ln(s) - 0.5'f32 * ln(2.0'f32 * PI.float32)
result = logP
+121
View File
@@ -0,0 +1,121 @@
## State vector builder — produces 57-float normalized tensor for PPO policy.
## No bot API imports; takes plain BotState + EnemyTracker structs.
import std/math
import arraymancer
import ./enemy_tracker
type
BotStateData* = object
## Plain data mirror of the bot's observable state.
x*, y*: float64
direction*: float64
speed*: float64
energy*: float64
gunDirection*: float64
gunHeat*: float64
arenaWidth*, arenaHeight*: float64
BulletData* = object
## Enemy bullet in flight (absolute arena coords + fire power).
x*, y*: float64
power*: float64 # fire power in [0.1, 3.0]; speed = 20 - 3*power
proc buildStateVector*(bot: BotStateData; enemy: EnemyTracker;
remainingGotoDistance: float64 = 0.0;
remainingGunAngle: float64 = 0.0;
bullets: openArray[BulletData] = [];
bulletCount: int = 0): Tensor[float32] =
## Build the 57-float normalized state tensor.
## Indices 0-43: existing features. Indices 44-55: up to 3 bullet slots (4 floats each).
## Index 56: scan staleness (ticksSinceLastScan / 30, clamped to 1).
## All values clipped to roughly [-1, 1] via division by physical maxima.
result = zeros[float32](57)
let aW = bot.arenaWidth
let aH = bot.arenaHeight
let diag = sqrt(aW * aW + aH * aH) # ≈ 1700 for 1200×800
let wallMax = max(aW, aH)
# --- Current tick: own bot (indices 0-6) ---
result[0] = float32(bot.x / aW)
result[1] = float32(bot.y / aH)
result[2] = float32(bot.direction / 360.0)
result[3] = float32(bot.speed / 8.0)
result[4] = float32(bot.energy / 100.0)
result[5] = float32(bot.gunDirection / 360.0)
result[6] = float32(bot.gunHeat / 1.8)
# --- Current tick: enemy (indices 7-13) ---
if enemy.hasContact:
result[7] = float32(enemy.current.x / aW)
result[8] = float32(enemy.current.y / aH)
result[9] = float32(enemy.current.direction / 360.0)
result[10] = float32(enemy.current.speed / 8.0)
result[11] = float32(enemy.current.energy / 100.0)
result[12] = float32(if enemy.current.hasFired: 1.0 else: 0.0)
result[13] = float32(enemy.current.lastFirePower / 3.0)
# else: remain 0.0
# --- Derived features (indices 14-21) ---
if enemy.hasContact:
# Enemy acceleration (speed delta from last history entry)
# Max delta is ±8 (stopped ↔ full speed); divide by 8 to normalize
if enemy.historyCount >= 1:
result[14] = float32((enemy.current.speed - enemy.history[0].speed) / 8.0)
# Enemy turn rate (direction delta from last history entry, normalized to [-1,1])
# Divide by 180 (max possible relative rotation) rather than 10 (max body turn rate)
# ponytail: /180 covers all cases; use /10 if you want sensitivity to small turns
if enemy.historyCount >= 1:
let dirDelta = ((enemy.current.direction - enemy.history[0].direction) + 540.0) mod 360.0 - 180.0
result[15] = float32(dirDelta / 180.0)
# Relative bearing to enemy (signed, from bot perspective)
let dx = enemy.current.x - bot.x
let dy = enemy.current.y - bot.y
let absDir = (180.0 * arctan2(dx, dy) / PI + 360.0) mod 360.0
let relBearing = ((absDir - bot.direction) + 540.0) mod 360.0 - 180.0
result[16] = float32(relBearing / 180.0)
# Distance to enemy
let dist = sqrt(dx * dx + dy * dy)
result[17] = float32(dist / diag)
# Wall distances (indices 18-21): top, bottom, left, right
# top = distance from bot to top wall (y=aH), bottom = distance to bottom (y=0)
# left = distance to left (x=0), right = distance to right (x=aW)
result[18] = float32((aH - bot.y) / wallMax) # top
result[19] = float32(bot.y / wallMax) # bottom
result[20] = float32(bot.x / wallMax) # left
result[21] = float32((aW - bot.x) / wallMax) # right
# --- History: 5 ticks × 4 floats = 20 floats (indices 22-41) ---
for i in 0 ..< 5:
let base = 22 + i * 4
if i < enemy.historyCount:
result[base + 0] = float32(enemy.history[i].x / aW)
result[base + 1] = float32(enemy.history[i].y / aH)
result[base + 2] = float32(enemy.history[i].direction / 360.0)
result[base + 3] = float32(enemy.history[i].speed / 8.0)
# else: remain 0.0 (pad)
# --- Goto controller inputs (indices 42-43) ---
result[42] = float32(remainingGotoDistance / diag)
result[43] = float32(remainingGunAngle / 180.0)
# --- Bullet tracking (indices 44-55): up to 3 enemy bullets, 4 floats each ---
# Per bullet: relX/aW, relY/aH, speed/20, ticksToImpact/diag
# Positions are relative to bot (useful for dodging). Slots beyond bulletCount stay 0.
for i in 0 ..< min(bulletCount, 3):
let b = bullets[i]
let bSpeed = 20.0 - 3.0 * b.power # Tank Royale bullet speed formula
let bdx = b.x - bot.x
let bdy = b.y - bot.y
let bdist = sqrt(bdx * bdx + bdy * bdy)
let ticks = if bSpeed > 0.0: bdist / bSpeed else: 0.0
let base = 44 + i * 4
result[base + 0] = float32(bdx / bot.arenaWidth)
result[base + 1] = float32(bdy / bot.arenaHeight)
result[base + 2] = float32(bSpeed / 20.0)
result[base + 3] = float32(ticks / diag)
# --- Scan staleness (index 56) ---
result[56] = float32(min(enemy.current.ticksSinceLastScan.float64 / 30.0, 1.0))
+449
View File
@@ -0,0 +1,449 @@
## training.nim — Trajectory buffer, GAE, and PPO training loop.
## Uses manual backprop through the 3-layer tanh MLP + manual Adam.
## No external autograd dependencies — pure Arraymancer Tensor math.
import arraymancer
import std/[math, random, sequtils]
import ./network
# ── Types ─────────────────────────────────────────────────────────────────────
# ponytail: transitions hold PLAIN fixed-size arrays, never Arraymancer tensors.
# Tensors crossing the bot-thread → main-thread boundary get freed on the wrong
# thread's heap under ORC (bot thread SIGSEGVs mid-round in addEvent — 5 matching
# coredumps). Plain arrays are value types: no heap, no GC, safe to move across
# threads. Tensors are rebuilt from the arrays on the consuming (training) thread.
const
MAX_TRANSITIONS* = 8192 # 10 rounds × ~300 ticks + headroom
type
Transition* = object
state*: array[STATE_DIM, float32] # plain copy, rebuilt as tensor in ppoUpdate
action*: array[ACTION_DIM, float32]
logProb*: float32
reward*: float32
value*: float32 # critic estimate at collection time
done*: bool # true at episode (round) boundary
TrajectoryBuffer* = object
transitions*: array[MAX_TRANSITIONS, Transition]
len*: int
# ── Buffer ─────────────────────────────────────────────────────────────────────
proc initTrajectoryBuffer*(): TrajectoryBuffer =
result = TrajectoryBuffer()
proc add*(buf: var TrajectoryBuffer, t: Transition) =
## ponytail: fixed 8192 cap — 10 rounds × ~300 ticks with headroom. Drops
## new transitions when full. Raise cap if accumulation window grows.
if buf.len < MAX_TRANSITIONS:
buf.transitions[buf.len] = t
inc buf.len
proc clear*(buf: var TrajectoryBuffer) =
buf.len = 0
# ── Tensor → plain array (same-thread use; tensors never cross threads) ─────
proc stateToArr*(t: Tensor[float32]): array[STATE_DIM, float32] =
for i in 0..<STATE_DIM: result[i] = t[i]
proc actionToArr*(t: Tensor[float32]): array[ACTION_DIM, float32] =
for i in 0..<ACTION_DIM: result[i] = t[i]
# ── Reward helpers ─────────────────────────────────────────────────────────────
proc computeTickReward*(myEnergyDelta, enemyEnergyDelta: float32;
distToEnemy: float32 = 0.0'f32;
maxDist: float32 = 1.0'f32;
gunBearingAbs: float32 = 180.0'f32): float32 =
## Positive when we deal more damage than we receive.
## Dense shaping: closeness (0-0.01/tick) + aim quality (0-0.02/tick).
## ponytail: magnitudes 10x smaller than original to keep shaping as a nudge,
## not the dominant signal. Increase if bot ignores positioning entirely.
let sparseReward = myEnergyDelta - enemyEnergyDelta
let distReward = 0.01'f32 * (1.0'f32 - distToEnemy / maxDist)
let aimReward = 0.02'f32 * (1.0'f32 - gunBearingAbs / 180.0'f32)
result = sparseReward + distReward + aimReward
proc computeRoundReward*(roundScore: float32): float32 =
## Normalise round-end score to a rough ±6 range (doubled win signal).
## ponytail: cap cumulative score at 400 before /50 — bounded terminal bonus
## keeps critic value scale stable across battle boundaries and long battles.
result = min(roundScore, 400.0'f32) / 50.0'f32
# ── GAE ───────────────────────────────────────────────────────────────────────
proc computeGAE*(rewards, values: seq[float32];
dones: seq[bool];
lastValue: float32;
gamma: float32 = 0.99'f32;
lam: float32 = 0.95'f32):
tuple[advantages: seq[float32], returns: seq[float32]] =
## Generalised Advantage Estimation — reverse sweep with episode boundaries.
## When done=true on transition t, bootstrap value and accumulated GAE are
## reset to 0 at that boundary (terminal state has no future value).
let n = rewards.len
var advantages = newSeq[float32](n)
var lastGae = 0.0'f32
for t in countdown(n - 1, 0):
let nextVal: float32 =
if t == n - 1 or dones[t]: 0.0'f32
else: values[t + 1]
if t == n - 1 or dones[t]:
lastGae = 0.0'f32
let delta = rewards[t] + gamma * nextVal - values[t]
lastGae = delta + gamma * lam * lastGae
advantages[t] = lastGae
var returns = newSeq[float32](n)
for t in 0..<n:
returns[t] = advantages[t] + values[t]
result = (advantages: advantages, returns: returns)
# ── Manual Adam state ─────────────────────────────────────────────────────────
type
AdamState* = object
m*, v*: Tensor[float32]
t*: int
proc initAdamState(like: Tensor[float32]): AdamState =
result.m = zeros_like(like)
result.v = zeros_like(like)
result.t = 0
proc adamStep(param: var Tensor[float32];
grad: Tensor[float32];
state: var AdamState;
lr: float32 = 3e-4'f32;
beta1: float32 = 0.9'f32;
beta2: float32 = 0.999'f32;
eps: float32 = 1e-8'f32) =
inc state.t
state.m = beta1 *. state.m + (1.0'f32 - beta1) *. grad
state.v = beta2 *. state.v + (1.0'f32 - beta2) *. (grad *. grad)
let mHat = state.m /. (1.0'f32 - beta1 ^ state.t.float32)
let vHat = state.v /. (1.0'f32 - beta2 ^ state.t.float32)
param -= lr *. mHat /. (vHat.map(proc(x: float32): float32 = sqrt(x) + eps))
# ── MLP forward with cached activations (for backprop) ────────────────────────
type MLPFwd = object
h1, h2, y: Tensor[float32] # activations (h1=layer1, h2=layer2, y=output)
proc mlpForwardCached(mlp: MLP; x: Tensor[float32]): MLPFwd =
## Forward pass saving intermediate activations needed for backprop.
result.h1 = tanh(mlp.w1 * x + mlp.b1)
result.h2 = tanh(mlp.w2 * result.h1 + mlp.b2)
result.y = mlp.w3 * result.h2 + mlp.b3
proc mlpBackward(mlp: MLP; fwd: MLPFwd; x: Tensor[float32];
gradOut: Tensor[float32]):
tuple[dw1, db1, dw2, db2, dw3, db3: Tensor[float32]] =
## Chain-rule through 3-layer tanh MLP.
## gradOut: [outputDim] — d_loss / d_out
# Layer 3
let dw3 = gradOut.unsqueeze(1) * fwd.h2.unsqueeze(0) # [out, hidden]
let db3 = gradOut
let dh2 = mlp.w3.transpose * gradOut # [hidden]
# tanh backward: d/dx tanh(x) = 1 - tanh²(x)
let dpre2 = dh2 *. (ones[float32](fwd.h2.shape) - fwd.h2 *. fwd.h2)
# Layer 2
let dw2 = dpre2.unsqueeze(1) * fwd.h1.unsqueeze(0) # [hidden, hidden]
let db2 = dpre2
let dh1 = mlp.w2.transpose * dpre2 # [hidden]
let dpre1 = dh1 *. (ones[float32](fwd.h1.shape) - fwd.h1 *. fwd.h1)
# Layer 1
let dw1 = dpre1.unsqueeze(1) * x.unsqueeze(0) # [hidden, input]
let db1 = dpre1
result = (dw1: dw1, db1: db1, dw2: dw2, db2: db2, dw3: dw3, db3: db3)
# ── Adam states for ActorCritic parameters ───────────────────────────────────
type ACAdamStates* = object
## One AdamState per learnable tensor in ActorCritic.
aw1*, ab1*, aw2*, ab2*, aw3*, ab3*: AdamState # actor MLP
cw1*, cb1*, cw2*, cb2*, cw3*, cb3*: AdamState # critic MLP
logStd*: AdamState
initialized*: bool
proc initACAdamStates*(ac: ActorCritic): ACAdamStates =
result.aw1 = initAdamState(ac.actor.w1)
result.ab1 = initAdamState(ac.actor.b1)
result.aw2 = initAdamState(ac.actor.w2)
result.ab2 = initAdamState(ac.actor.b2)
result.aw3 = initAdamState(ac.actor.w3)
result.ab3 = initAdamState(ac.actor.b3)
result.cw1 = initAdamState(ac.critic.w1)
result.cb1 = initAdamState(ac.critic.b1)
result.cw2 = initAdamState(ac.critic.w2)
result.cb2 = initAdamState(ac.critic.b2)
result.cw3 = initAdamState(ac.critic.w3)
result.cb3 = initAdamState(ac.critic.b3)
result.logStd = initAdamState(ac.logStd)
result.initialized = true
# ── Training metrics ──────────────────────────────────────────────────────────
type PPOMetrics* = object
actorLoss*: float32
valueLoss*: float32
gradNorm*: float32
# ── Gradient clipping ─────────────────────────────────────────────────────────
proc globalNorm(grads: varargs[Tensor[float32]]): float32 =
var sumSq = 0.0'f32
for g in grads:
for v in g: sumSq += v * v
result = sqrt(sumSq)
# ── PPO update ────────────────────────────────────────────────────────────────
proc ppoUpdate*(ac: var ActorCritic;
buffer: TrajectoryBuffer;
lastValue: float32;
adamStates: var ACAdamStates;
epochs: int = 4;
miniBatchSize: int = 64;
clipEpsilon: float32 = 0.2'f32;
entropyCoeff: float32 = 0.01'f32;
valueLossCoeff: float32 = 0.5'f32;
lr: float32 = 3e-4'f32;
maxGradNorm: float32 = 0.5'f32;
gamma: float32 = 0.99'f32;
lam: float32 = 0.95'f32): PPOMetrics {.gcsafe.} =
if buffer.len == 0: return
var totalActorLoss = 0.0'f32
var totalValueLoss = 0.0'f32
var totalGradNorm = 0.0'f32
var totalMiniBatches = 0
# Initialise Adam states once; caller persists them across rounds.
# Also reinit if aw1.m has wrong shape (e.g. loaded from old checkpoint with
# different STATE_DIM, leaving a (0,) placeholder after shape-mismatch skip).
if not adamStates.initialized or
adamStates.aw1.m.shape.len == 0 or
adamStates.aw1.m.shape != ac.actor.w1.shape:
adamStates = initACAdamStates(ac)
# 1. GAE
let rewards = buffer.transitions[0 ..< buffer.len].mapIt(it.reward)
let values = buffer.transitions[0 ..< buffer.len].mapIt(it.value)
let dones = buffer.transitions[0 ..< buffer.len].mapIt(it.done)
let (advantages, returns) = computeGAE(rewards, values, dones, lastValue, gamma = gamma, lam = lam)
# 2. Normalise advantages
let n = advantages.len.float32
var advMean = 0.0'f32
for a in advantages: advMean += a
advMean /= n
var advVar = 0.0'f32
for a in advantages: advVar += (a - advMean) * (a - advMean)
advVar /= n
# ponytail: float32 adv noise ~1e-12; advVar < 1e-8 = constant-reward
# (passive) round — dividing by that amplifies noise ~1e4+ and drifts the
# policy into exp() overflow. Center-only, skip the divide.
var normAdv: seq[float32]
if advantages.allIt(it == it and abs(it) < 1e30'f32):
if advVar < 1e-8'f32:
normAdv = advantages.mapIt(it - advMean)
else:
let advStd = sqrt(advVar + 1e-8'f32)
normAdv = advantages.mapIt((it - advMean) / advStd)
else:
normAdv = newSeq[float32](advantages.len) # poisoned input → zero advantages, no-op update
let bufLen = buffer.len
# ponytail: minibatch size <= 0 would make mbEnd == mbStart forever and spin.
# Treat as full-batch; breaks the loop unconditionally.
let mbSizeCap = if miniBatchSize > 0: miniBatchSize else: bufLen
for epochNum in 1..epochs:
# Shuffle indices
var indices = toSeq(0..<bufLen)
shuffle(indices)
var mbStart = 0
while mbStart < bufLen:
let mbEnd = min(mbStart + mbSizeCap, bufLen)
if mbEnd <= mbStart: break
let mbSize = mbEnd - mbStart
# Accumulators for gradients (zero-init)
var dActorW1 = zeros[float32](ac.actor.w1.shape)
var dActorB1 = zeros[float32](ac.actor.b1.shape)
var dActorW2 = zeros[float32](ac.actor.w2.shape)
var dActorB2 = zeros[float32](ac.actor.b2.shape)
var dActorW3 = zeros[float32](ac.actor.w3.shape)
var dActorB3 = zeros[float32](ac.actor.b3.shape)
var dLogStd = zeros[float32](ac.logStd.shape)
var dCriticW1 = zeros[float32](ac.critic.w1.shape)
var dCriticB1 = zeros[float32](ac.critic.b1.shape)
var dCriticW2 = zeros[float32](ac.critic.w2.shape)
var dCriticB2 = zeros[float32](ac.critic.b2.shape)
var dCriticW3 = zeros[float32](ac.critic.w3.shape)
var dCriticB3 = zeros[float32](ac.critic.b3.shape)
for j in mbStart..<mbEnd:
let idx = indices[j]
let tr = buffer.transitions[idx]
let adv = normAdv[idx]
let ret = returns[idx].float32
# Rebuild the state tensor on this (training) thread — transitions hold
# plain arrays so no tensor ever crosses a thread boundary.
let x = tr.state.toTensor()
# ── Actor forward ──
let actorFwd = mlpForwardCached(ac.actor, x)
# Critic forward
let newMean = actorFwd.y # [ACTION_DIM]
# Same clamp as collection (network.nim actorForward): train-time std must
# exactly match the std the acting policy used, or ratios are distorted.
let logStdClamped = ac.logStd.map(proc(v: float32): float32 = clamp(v, logStdFloor, logStdCeiling))
let std = logStdClamped.map(proc(v: float32): float32 = exp(v))
# New log prob
var newLogP = 0.0'f32
for i in 0..<ACTION_DIM:
let mu = newMean[i]
let s = std[i]
let diff = (tr.action[i] - mu) / s
newLogP += -0.5'f32 * diff * diff - ln(s) - 0.5'f32 * ln(2.0'f32 * PI.float32)
# ponytail: float32 exp overflows at ±88; ±20 is deep in clipped-ratio
# territory, so loss/grad are identical to the true ratio
let ratio = exp(clamp(newLogP - tr.logProb, -20.0'f32, 20.0'f32))
# Clipped surrogate
let ratioClipped = clamp(ratio, 1.0'f32 - clipEpsilon, 1.0'f32 + clipEpsilon)
let surr1 = ratio * adv
let surr2 = ratioClipped * adv
# Actor loss per sample = -min(surr1, surr2)
totalActorLoss += -min(surr1, surr2)
# Which branch is active?
let useClipped = (surr2 < surr1)
let dLoss_dSurr = -1.0'f32 / mbSize.float32 # d(-mean(min))/d(min) = -1/N
# d(actor_loss)/d(ratio): only the non-clipped branch passes gradient
let dLoss_dRatio = if useClipped: 0.0'f32 else: dLoss_dSurr * adv
# d(ratio)/d(newLogP) = ratio
let dLoss_dNewLogP = dLoss_dRatio * ratio
# d(newLogP)/d(mean[i]) = (action[i] - mean[i]) / std[i]^2
var dLogP_dMean = newTensor[float32](ACTION_DIM)
for i in 0..<ACTION_DIM:
let s = std[i]
dLogP_dMean[i] = (tr.action[i] - newMean[i]) / (s * s)
# Entropy gradient for logStd:
# entropy = sum_i [ logStd_i + 0.5*(1+ln(2π)) ]
# d(entropy)/d(logStd_i) = 1 (for clamped logStd_i > -3, else 0)
# total loss gradient w.r.t. logStd: -entropyCoeff * d(entropy)/d(logStd)
# Also: d(newLogP)/d(logStd_i) when logStd_i > -3:
# = (action_i - mean_i)^2/std_i^2 - 1
for i in 0..<ACTION_DIM:
let isFloorClamped = (ac.logStd[i] <= logStdFloor)
let isCeilingClamped = (ac.logStd[i] >= logStdCeiling)
if not isFloorClamped:
let s = std[i]
let diff = (tr.action[i] - newMean[i]) / s
let dLogP_dLogStdI = diff * diff - 1.0'f32
let ppoGrad = dLoss_dNewLogP * dLogP_dLogStdI
# Entropy term pushes logStd up (update = param - lr*grad, grad is -entropyCoeff < 0).
# Gate it off at the ceiling to prevent runaway logStd.
let entropyGrad = if isCeilingClamped: 0.0'f32
else: -entropyCoeff / mbSize.float32
dLogStd[i] += ppoGrad + entropyGrad
# Backprop actor gradients
let gradActorOut = dLoss_dNewLogP *. dLogP_dMean # [5]
let actorGrads = mlpBackward(ac.actor, actorFwd, x, gradActorOut)
dActorW1 += actorGrads.dw1
dActorB1 += actorGrads.db1
dActorW2 += actorGrads.dw2
dActorB2 += actorGrads.db2
dActorW3 += actorGrads.dw3
dActorB3 += actorGrads.db3
# ── Critic forward + loss ──
let criticFwd = mlpForwardCached(ac.critic, x)
let newVal = criticFwd.y[0]
# Value loss = (newVal - ret)^2; d/d(newVal) = 2*(newVal-ret)
totalValueLoss += (newVal - ret) * (newVal - ret)
let dVLoss_dVal = valueLossCoeff * 2.0'f32 * (newVal - ret) / mbSize.float32
let gradCriticOut = [dVLoss_dVal].toTensor() # [1]
let criticGrads = mlpBackward(ac.critic, criticFwd, x, gradCriticOut)
dCriticW1 += criticGrads.dw1
dCriticB1 += criticGrads.db1
dCriticW2 += criticGrads.dw2
dCriticB2 += criticGrads.db2
dCriticW3 += criticGrads.dw3
dCriticB3 += criticGrads.db3
# ── Gradient clipping ──
# Collect all grads into a seq for norm computation
var allGrads: seq[Tensor[float32]] = @[
dActorW1, dActorB1, dActorW2, dActorB2, dActorW3, dActorB3,
dLogStd,
dCriticW1, dCriticB1, dCriticW2, dCriticB2, dCriticW3, dCriticB3
]
let norm = globalNorm(allGrads)
# ponytail: NaN/Inf grad norm means something exploded this minibatch
# (extreme logprob ratios, poisoned initial weights, etc.). Skip the
# Adam update entirely — no-op is safer than writing NaN into weights,
# which corrupts all future inference and hangs the bot.
if norm != norm or norm > 1e15'f32:
mbStart = mbEnd
continue
totalGradNorm += norm
inc totalMiniBatches
if norm > maxGradNorm:
let scale = maxGradNorm / norm
for g in allGrads.mitems: g = g *. scale
# Unpack clipped grads
dActorW1 = allGrads[0]; dActorB1 = allGrads[1]
dActorW2 = allGrads[2]; dActorB2 = allGrads[3]
dActorW3 = allGrads[4]; dActorB3 = allGrads[5]
dLogStd = allGrads[6]
dCriticW1 = allGrads[7]; dCriticB1 = allGrads[8]
dCriticW2 = allGrads[9]; dCriticB2 = allGrads[10]
dCriticW3 = allGrads[11]; dCriticB3 = allGrads[12]
# ── Adam updates ──
adamStep(ac.actor.w1, dActorW1, adamStates.aw1, lr)
adamStep(ac.actor.b1, dActorB1, adamStates.ab1, lr)
adamStep(ac.actor.w2, dActorW2, adamStates.aw2, lr)
adamStep(ac.actor.b2, dActorB2, adamStates.ab2, lr)
adamStep(ac.actor.w3, dActorW3, adamStates.aw3, lr)
adamStep(ac.actor.b3, dActorB3, adamStates.ab3, lr)
adamStep(ac.logStd, dLogStd, adamStates.logStd, lr)
# Collection clamps logStd to [floor, ceiling] at inference; clamp the raw
# param after the step so it can't drift above the ceiling (the old code
# only clamped at collection → train-time recompute used a bigger std than
# the policy that actually acted → distorted importance ratios).
ac.logStd = ac.logStd.map(proc(v: float32): float32 = clamp(v, logStdFloor, logStdCeiling))
adamStep(ac.critic.w1, dCriticW1, adamStates.cw1, lr)
adamStep(ac.critic.b1, dCriticB1, adamStates.cb1, lr)
adamStep(ac.critic.w2, dCriticW2, adamStates.cw2, lr)
adamStep(ac.critic.b2, dCriticB2, adamStates.cb2, lr)
adamStep(ac.critic.w3, dCriticW3, adamStates.cw3, lr)
adamStep(ac.critic.b3, dCriticB3, adamStates.cb3, lr)
mbStart = mbEnd
let totalSamples = (epochs * bufLen).float32
result.actorLoss = totalActorLoss / totalSamples
result.valueLoss = totalValueLoss / totalSamples
result.gradNorm = if totalMiniBatches > 0: totalGradNorm / totalMiniBatches.float32
else: 0.0'f32
+211
View File
@@ -0,0 +1,211 @@
## weights.nim — save/load ActorCritic weights as .npy files.
import std/[os, times, strutils, algorithm, sequtils]
import arraymancer
import ./network
import ./training
# ── Tensor names — order must match save/load ─────────────────────────────────
const weightFiles = [
"actor_w1.npy", "actor_b1.npy", "actor_w2.npy", "actor_b2.npy",
"actor_w3.npy", "actor_b3.npy",
"critic_w1.npy", "critic_b1.npy", "critic_w2.npy", "critic_b2.npy",
"critic_w3.npy", "critic_b3.npy",
"log_std.npy",
]
proc saveWeights*(ac: ActorCritic, dir: string) =
## Write all weight tensors to dir/ as .npy files.
createDir(dir)
ac.actor.w1.write_npy(dir / "actor_w1.npy")
ac.actor.b1.write_npy(dir / "actor_b1.npy")
ac.actor.w2.write_npy(dir / "actor_w2.npy")
ac.actor.b2.write_npy(dir / "actor_b2.npy")
ac.actor.w3.write_npy(dir / "actor_w3.npy")
ac.actor.b3.write_npy(dir / "actor_b3.npy")
ac.critic.w1.write_npy(dir / "critic_w1.npy")
ac.critic.b1.write_npy(dir / "critic_b1.npy")
ac.critic.w2.write_npy(dir / "critic_w2.npy")
ac.critic.b2.write_npy(dir / "critic_b2.npy")
ac.critic.w3.write_npy(dir / "critic_w3.npy")
ac.critic.b3.write_npy(dir / "critic_b3.npy")
ac.logStd.write_npy(dir / "log_std.npy")
proc loadWeights*(ac: var ActorCritic, dir: string) =
## Load all weight tensors from dir/.
## If a tensor's shape doesn't match (e.g. STATE_DIM changed), keep the
## freshly-initialised value and print a warning — other tensors still load.
template loadOrSkip(dest: untyped, path: string) =
let loaded = read_npy[float32](path)
if loaded.shape == dest.shape:
dest = loaded
else:
echo "weights: shape mismatch for " & path &
" (got " & $loaded.shape & " want " & $dest.shape & ") — keeping fresh init"
loadOrSkip(ac.actor.w1, dir / "actor_w1.npy")
loadOrSkip(ac.actor.b1, dir / "actor_b1.npy")
loadOrSkip(ac.actor.w2, dir / "actor_w2.npy")
loadOrSkip(ac.actor.b2, dir / "actor_b2.npy")
loadOrSkip(ac.actor.w3, dir / "actor_w3.npy")
loadOrSkip(ac.actor.b3, dir / "actor_b3.npy")
loadOrSkip(ac.critic.w1, dir / "critic_w1.npy")
loadOrSkip(ac.critic.b1, dir / "critic_b1.npy")
loadOrSkip(ac.critic.w2, dir / "critic_w2.npy")
loadOrSkip(ac.critic.b2, dir / "critic_b2.npy")
loadOrSkip(ac.critic.w3, dir / "critic_w3.npy")
loadOrSkip(ac.critic.b3, dir / "critic_b3.npy")
loadOrSkip(ac.logStd, dir / "log_std.npy")
proc saveWeightsAtomic*(ac: ActorCritic, targetDir: string) =
## Write to a temp dir, then rename atomically over targetDir.
let tmpDir = targetDir & "_tmp_" & $int(epochTime())
saveWeights(ac, tmpDir)
if dirExists(targetDir):
removeDir(targetDir)
moveDir(tmpDir, targetDir)
# ── Adam state file names ──────────────────────────────────────────────────────
const adamMFiles = [
"adam_aw1_m.npy", "adam_ab1_m.npy", "adam_aw2_m.npy", "adam_ab2_m.npy",
"adam_aw3_m.npy", "adam_ab3_m.npy",
"adam_cw1_m.npy", "adam_cb1_m.npy", "adam_cw2_m.npy", "adam_cb2_m.npy",
"adam_cw3_m.npy", "adam_cb3_m.npy",
"adam_logstd_m.npy",
]
const adamVFiles = [
"adam_aw1_v.npy", "adam_ab1_v.npy", "adam_aw2_v.npy", "adam_ab2_v.npy",
"adam_aw3_v.npy", "adam_ab3_v.npy",
"adam_cw1_v.npy", "adam_cb1_v.npy", "adam_cw2_v.npy", "adam_cb2_v.npy",
"adam_cw3_v.npy", "adam_cb3_v.npy",
"adam_logstd_v.npy",
]
proc saveAdamStates*(adam: ACAdamStates, dir: string) =
## Write Adam m/v tensors and t counters to dir/.
adam.aw1.m.write_npy(dir / "adam_aw1_m.npy"); adam.aw1.v.write_npy(dir / "adam_aw1_v.npy")
adam.ab1.m.write_npy(dir / "adam_ab1_m.npy"); adam.ab1.v.write_npy(dir / "adam_ab1_v.npy")
adam.aw2.m.write_npy(dir / "adam_aw2_m.npy"); adam.aw2.v.write_npy(dir / "adam_aw2_v.npy")
adam.ab2.m.write_npy(dir / "adam_ab2_m.npy"); adam.ab2.v.write_npy(dir / "adam_ab2_v.npy")
adam.aw3.m.write_npy(dir / "adam_aw3_m.npy"); adam.aw3.v.write_npy(dir / "adam_aw3_v.npy")
adam.ab3.m.write_npy(dir / "adam_ab3_m.npy"); adam.ab3.v.write_npy(dir / "adam_ab3_v.npy")
adam.cw1.m.write_npy(dir / "adam_cw1_m.npy"); adam.cw1.v.write_npy(dir / "adam_cw1_v.npy")
adam.cb1.m.write_npy(dir / "adam_cb1_m.npy"); adam.cb1.v.write_npy(dir / "adam_cb1_v.npy")
adam.cw2.m.write_npy(dir / "adam_cw2_m.npy"); adam.cw2.v.write_npy(dir / "adam_cw2_v.npy")
adam.cb2.m.write_npy(dir / "adam_cb2_m.npy"); adam.cb2.v.write_npy(dir / "adam_cb2_v.npy")
adam.cw3.m.write_npy(dir / "adam_cw3_m.npy"); adam.cw3.v.write_npy(dir / "adam_cw3_v.npy")
adam.cb3.m.write_npy(dir / "adam_cb3_m.npy"); adam.cb3.v.write_npy(dir / "adam_cb3_v.npy")
adam.logStd.m.write_npy(dir / "adam_logstd_m.npy")
adam.logStd.v.write_npy(dir / "adam_logstd_v.npy")
# t counters (all stepped in lockstep; store each for safety)
writeFile(dir / "adam_t.txt",
[$adam.aw1.t, $adam.ab1.t, $adam.aw2.t, $adam.ab2.t,
$adam.aw3.t, $adam.ab3.t, $adam.cw1.t, $adam.cb1.t,
$adam.cw2.t, $adam.cb2.t, $adam.cw3.t, $adam.cb3.t,
$adam.logStd.t].join("\n"))
proc loadAdamStates*(adam: var ACAdamStates, dir: string) =
## Load Adam m/v tensors and t counters from dir/. Called only when files exist.
## Shape mismatch (e.g. STATE_DIM changed) → keep zero-initialised state (safe fresh start).
template lm(dest: untyped, path: string) =
let loaded = read_npy[float32](path)
if loaded.shape == dest.shape:
dest = loaded
else:
echo "weights: Adam shape mismatch for " & path &
" (got " & $loaded.shape & " want " & $dest.shape & ") — resetting Adam state"
lm(adam.aw1.m, dir / "adam_aw1_m.npy"); lm(adam.aw1.v, dir / "adam_aw1_v.npy")
lm(adam.ab1.m, dir / "adam_ab1_m.npy"); lm(adam.ab1.v, dir / "adam_ab1_v.npy")
lm(adam.aw2.m, dir / "adam_aw2_m.npy"); lm(adam.aw2.v, dir / "adam_aw2_v.npy")
lm(adam.ab2.m, dir / "adam_ab2_m.npy"); lm(adam.ab2.v, dir / "adam_ab2_v.npy")
lm(adam.aw3.m, dir / "adam_aw3_m.npy"); lm(adam.aw3.v, dir / "adam_aw3_v.npy")
lm(adam.ab3.m, dir / "adam_ab3_m.npy"); lm(adam.ab3.v, dir / "adam_ab3_v.npy")
lm(adam.cw1.m, dir / "adam_cw1_m.npy"); lm(adam.cw1.v, dir / "adam_cw1_v.npy")
lm(adam.cb1.m, dir / "adam_cb1_m.npy"); lm(adam.cb1.v, dir / "adam_cb1_v.npy")
lm(adam.cw2.m, dir / "adam_cw2_m.npy"); lm(adam.cw2.v, dir / "adam_cw2_v.npy")
lm(adam.cb2.m, dir / "adam_cb2_m.npy"); lm(adam.cb2.v, dir / "adam_cb2_v.npy")
lm(adam.cw3.m, dir / "adam_cw3_m.npy"); lm(adam.cw3.v, dir / "adam_cw3_v.npy")
lm(adam.cb3.m, dir / "adam_cb3_m.npy"); lm(adam.cb3.v, dir / "adam_cb3_v.npy")
lm(adam.logStd.m, dir / "adam_logstd_m.npy"); lm(adam.logStd.v, dir / "adam_logstd_v.npy")
let ts = readFile(dir / "adam_t.txt").strip().splitLines()
if ts.len >= 13:
adam.aw1.t = parseInt(ts[0]); adam.ab1.t = parseInt(ts[1])
adam.aw2.t = parseInt(ts[2]); adam.ab2.t = parseInt(ts[3])
adam.aw3.t = parseInt(ts[4]); adam.ab3.t = parseInt(ts[5])
adam.cw1.t = parseInt(ts[6]); adam.cb1.t = parseInt(ts[7])
adam.cw2.t = parseInt(ts[8]); adam.cb2.t = parseInt(ts[9])
adam.cw3.t = parseInt(ts[10]); adam.cb3.t = parseInt(ts[11])
adam.logStd.t = parseInt(ts[12])
adam.initialized = true
proc adamStateFilesExist(dir: string): bool =
## Check that the minimum set of Adam files is present.
for f in adamMFiles:
if not fileExists(dir / f): return false
for f in adamVFiles:
if not fileExists(dir / f): return false
fileExists(dir / "adam_t.txt")
proc saveCheckpoint*(ac: ActorCritic, adam: ACAdamStates,
weightsRoot: string, roundNum: int) =
## Always saves weights + Adam state to weightsRoot/latest/.
## Every 50 rounds also saves to checkpoint_{1,2,3} in round-robin.
## Round counter is saved to weightsRoot/round_counter.txt (outside checkpoint dirs).
let latestDir = weightsRoot / "latest"
saveWeightsAtomic(ac, latestDir)
if adam.initialized:
saveAdamStates(adam, latestDir)
writeFile(weightsRoot / "round_counter.txt", $roundNum)
if roundNum mod 50 == 0:
let slot = ((roundNum div 50 - 1) mod 3) + 1 # 50→1, 100→2, 150→3, 200→1, …
let ckDir = weightsRoot / ("checkpoint_" & $slot)
saveWeightsAtomic(ac, ckDir)
if adam.initialized:
saveAdamStates(adam, ckDir)
proc loadBestAvailable*(ac: var ActorCritic, adam: var ACAdamStates,
weightsRoot: string): tuple[loaded: bool, roundNum: int] =
## Try latest/ first, then checkpoints sorted newest-first by mtime.
## Returns (true, roundNum) if weights loaded, (false, 0) if all fail.
## Adam state is loaded if present alongside weights; otherwise left uninitialised.
## Round counter is read from weightsRoot/round_counter.txt if present.
let checkpoints = [weightsRoot / "checkpoint_1",
weightsRoot / "checkpoint_2",
weightsRoot / "checkpoint_3"]
# Sort checkpoints newest-first by modification time
var existing: seq[tuple[mtime: Time, path: string]]
for p in checkpoints:
if dirExists(p):
existing.add((getLastModificationTime(p), p))
existing.sort(proc(a, b: tuple[mtime: Time, path: string]): int =
cmp(b.mtime, a.mtime)) # descending
let candidates = @[weightsRoot / "latest"] & existing.mapIt(it.path)
for candidate in candidates:
if dirExists(candidate):
var ok = true
for f in weightFiles:
if not fileExists(candidate / f):
ok = false
break
if ok:
ac.loadWeights(candidate)
if adamStateFilesExist(candidate):
adam.loadAdamStates(candidate)
let rcPath = weightsRoot / "round_counter.txt"
# Torn/empty file (e.g. after a crash) must not abort startup → treat as 0
let roundNum = if fileExists(rcPath):
try: parseInt(readFile(rcPath).strip())
except ValueError: 0
else: 0
return (loaded: true, roundNum: roundNum)
result = (loaded: false, roundNum: 0)
proc cleanStaleTempDirs*(weightsRoot: string) =
## Delete any dirs inside weightsRoot whose name contains "_tmp_".
if not dirExists(weightsRoot): return
for kind, path in walkDir(weightsRoot):
if kind == pcDir and "_tmp_" in lastPathPart(path):
removeDir(path)
+2
View File
@@ -0,0 +1,2 @@
switch("path", "../src")
switch("path", "../../common_libs/tankroyale_botapi")
Binary file not shown.
@@ -0,0 +1,36 @@
## diag_ppo_fullbuffer.nim — run ppoUpdate on a FULL 8192-transition buffer.
## Verifies whether the round-10 death (first ppoUpdate at buffer capacity)
## is a real crash in ppoUpdate or purely the saveAdamStates empty-shape bug.
import std/[math, random]
import arraymancer
import PPO_Bot/network
import PPO_Bot/training
var ac = initActorCritic()
var adam: ACAdamStates # uninitialised → ppoUpdate must reinit (fresh-process path)
var buf = initTrajectoryBuffer()
randomize(1)
while buf.len < MAX_TRANSITIONS:
var t: Transition
for i in 0..<STATE_DIM: t.state[i] = rand(1.0'f32) - 0.5'f32
for i in 0..<ACTION_DIM: t.action[i] = rand(1.0'f32) - 0.5'f32
t.logProb = rand(1.0'f32) - 1.0'f32
t.reward = rand(0.02'f32) - 0.01'f32
t.value = rand(0.1'f32)
t.done = buf.len mod 300 == 299
buf.add(t)
echo "buffer.len = ", buf.len, " (MAX=", MAX_TRANSITIONS, ")"
var ac2 = ac
var adam2: ACAdamStates
try:
let m = ppoUpdate(ac2, buf, lastValue = 0.0'f32, adamStates = adam2)
echo "ppoUpdate OK: aLoss=", m.actorLoss, " vLoss=", m.valueLoss, " gNorm=", m.gradNorm
except CatchableError as e:
echo "CAUGHT CatchableError: ", e.msg
echo getStackTrace(e)
except Defect as e:
echo "CAUGHT Defect: ", e.msg
echo getStackTrace(e)
Binary file not shown.
@@ -0,0 +1,31 @@
## diag_savecheckpoint.nim — reproduce the startup+saveCheckpoint path that
## crashes with `io_npy.nim(143, 3) `0 < t.shape.len`` under the boot server.
## Run: nim c -d:release tests/diag_savecheckpoint.nim && ./tests/diag_savecheckpoint
import std/[os]
import arraymancer
import PPO_Bot/network
import PPO_Bot/training
import PPO_Bot/weights
const weightsRoot = currentSourcePath().parentDir.parentDir / "weights"
var ac = initActorCritic()
var adam: ACAdamStates
let loadResult = loadBestAvailable(ac, adam, weightsRoot)
echo "loaded=", loadResult.loaded, " roundNum=", loadResult.roundNum
echo "adam.initialized=", adam.initialized
echo "adam.aw1.m.shape=", adam.aw1.m.shape, " aw1.v.shape=", adam.aw1.v.shape
echo "adam.logStd.m.shape=", adam.logStd.m.shape
echo "adam.aw1.t=", adam.aw1.t
try:
saveCheckpoint(ac, adam, weightsRoot, 1)
echo "saveCheckpoint OK"
except CatchableError as e:
echo "CAUGHT CatchableError: ", e.msg
echo getStackTrace(e)
except Defect as e:
echo "CAUGHT Defect: ", e.msg
echo getStackTrace(e)
BIN
View File
Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
## Assert-based tests for actions.nim.
## Run: nim c -r tests/test_actions.nim
import arraymancer
import std/math
import PPO_Bot/actions
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
let arenaW = 1200.0
let arenaH = 800.0
# Build a 6-element zero tensor and a helper to set individual values
proc makeRaw(vals: array[6, float32]): Tensor[float32] =
result = zeros[float32](6)
for i in 0 ..< 6: result[i] = vals[i]
# --- 6-dim input produces a valid BotActions ---
block basicDecode:
let raw = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, 0.0])
let acts = mapActions(raw, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
# sigmoid(0)*arenaW = 0.5*1200 = 600, sigmoid(0)*arenaH = 0.5*800 = 400
check abs(acts.gotoX - 600.0) < 1e-6, "gotoX = sigmoid(0)*arenaW"
check abs(acts.gotoY - 400.0) < 1e-6, "gotoY = sigmoid(0)*arenaH"
check abs(acts.aimToX - 600.0) < 1e-6, "aimToX = sigmoid(0)*arenaW"
check abs(acts.aimToY - 400.0) < 1e-6, "aimToY = sigmoid(0)*arenaH"
# --- Coordinates bounded to arena size ---
block coordBounds:
# Large positive raw → sigmoid ≈ 1 → close to arenaW/arenaH
let rawHigh = makeRaw([100.0'f32, 100.0, 100.0, 100.0, 0.0, 0.0])
let actsHigh = mapActions(rawHigh, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsHigh.gotoX <= arenaW + 1e-9, "gotoX <= arenaWidth"
check actsHigh.gotoY <= arenaH + 1e-9, "gotoY <= arenaHeight"
check actsHigh.gotoX >= 0.0, "gotoX >= 0"
# Large negative raw → sigmoid ≈ 0 → close to 0
let rawLow = makeRaw([-100.0'f32, -100.0, -100.0, -100.0, 0.0, 0.0])
let actsLow = mapActions(rawLow, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsLow.gotoX >= -1e-9, "gotoX >= 0 (low raw)"
check actsLow.gotoY >= -1e-9, "gotoY >= 0 (low raw)"
# --- Fire triggers correctly ---
block fireTrigger:
# tanh(positive) >= 0 → fire when gunHeat = 0
let rawFire = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 1.0, 0.0])
let actsFire = mapActions(rawFire, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsFire.shouldFire, "positive tanh → should fire when gun cool"
# tanh(negative) < 0 → no fire
let rawNoFire = makeRaw([0.0'f32, 0.0, 0.0, 0.0, -1.0, 0.0])
let actsNoFire = mapActions(rawNoFire, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check not actsNoFire.shouldFire, "negative tanh → no fire"
# gunHeat > 0 → no fire even with positive decision
let actsHot = mapActions(rawFire, 0.5, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check not actsHot.shouldFire, "positive tanh but gun hot → no fire"
# --- Fire power in [0.1, 3.0] ---
block firePowerRange:
let rawMin = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, -100.0])
let rawMax = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, 100.0])
let actsMin = mapActions(rawMin, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
let actsMax = mapActions(rawMax, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsMin.firePower >= 0.1 - 1e-6, "firePower >= 0.1"
check actsMax.firePower <= 3.0 + 1e-6, "firePower <= 3.0"
echo "test_actions: all passed"
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
## Assert-based tests for controllers.nim.
## Run: nim c -r tests/test_controllers.nim
import std/math
import PPO_Bot/controllers
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
# ---------------------------------------------------------------------------
# gotoTick tests
# ---------------------------------------------------------------------------
block forwardMovement:
# Bot at (0,0) facing East (geometric 0°), target due east at (100,0) → bearing=0 → forward
let (spd2, turn2) = gotoTick(100.0, 0.0, 0.0, 0.0, 0.0, 0.0)
check spd2 > 0.0, "forward: targetSpeed should be positive"
check abs(turn2) < 1e-9, "forward: no turn needed when already aimed"
block reverseMovement:
# Bot at (100,0) facing East (direction=0), target at (0,0) — directly behind
# bearing = normalizeRelativeAngle(180 - 0) = 180 → |bearing|>90 → reverse
let (spd, _) = gotoTick(0.0, 0.0, 100.0, 0.0, 0.0, 0.0)
check spd < 0.0, "reverse: targetSpeed should be negative when target is behind"
block turnRateClamping:
# Bot at (0,0) facing North (game north = geometric 90°, so direction=90 in geometric)
# Target at (100,0) = East. bearing = normalizeRelativeAngle(0 - 90) = -90 → still ≤90
# Use large perpendicular target so bearing is 89°, and high speed → small maxTurn
# At speed=8, calcMaxTurnRate = 10 - 0.75*8 = 4°
# Bot facing East (0°), target at angle 89° bearing (just under 90)
let (_, turn) = gotoTick(100.0 * cos(89.0 * PI / 180.0), 100.0 * sin(89.0 * PI / 180.0), 0.0, 0.0, 0.0, 8.0)
check abs(turn) <= 4.0 + 1e-9, "turn rate clamped to calcMaxTurnRate at speed=8 (max 4°)"
# ---------------------------------------------------------------------------
# aimToTick tests
# ---------------------------------------------------------------------------
block gunShortestArc:
# Gun facing East (0°), target due north (geometric 90°) → turn left +90° but clamped to 20
let rate = aimToTick(0.0, 100.0, 0.0, 0.0, 0.0)
check rate > 0.0, "gun shortest arc: should turn toward target"
# Gun facing East (0°), target due south (geometric 270° → normalised -90°)
let rate2 = aimToTick(0.0, -100.0, 0.0, 0.0, 0.0)
check rate2 < 0.0, "gun shortest arc: should turn other way for target behind"
block gunTurnRateClamping:
# 180° away → clamped to ±20
let rate = aimToTick(-100.0, 0.0, 0.0, 0.0, 0.0) # target west, gun east
check abs(rate) <= 20.0 + 1e-9, "gun turn rate clamped to ±MAX_GUN_TURN_RATE"
check abs(abs(rate) - 20.0) < 1e-9, "gun turn rate at max when 180° away"
echo "test_controllers: all passed"
BIN
View File
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
## test_network.nim — assert-based tests for network.nim and actions.nim.
## Run: nim c --threads:on tests/test_network.nim && ./tests/test_network
import arraymancer
import std/[math, strformat]
import PPO_Bot/network
import PPO_Bot/actions
func isFiniteF(x: float32): bool = classify(x) notin {fcNan, fcInf, fcNegInf}
func isNaNF(x: float32): bool = classify(x) == fcNan
when isMainModule:
# ---- MLP forward shape ----
let mlp = initMLP(STATE_DIM, 64, ACTION_DIM)
let inp = zeros[float32](STATE_DIM)
let mlpOut = mlp.forward(inp)
assert mlpOut.shape[0] == ACTION_DIM, &"MLP output shape wrong: {mlpOut.shape}"
# ---- ActorCritic actorForward ----
let ac = initActorCritic()
let state = zeros[float32](STATE_DIM)
let (acts, logP) = ac.actorForward(state)
assert acts.shape[0] == ACTION_DIM, &"actorForward actions shape wrong: {acts.shape}"
assert not isNaNF(logP), "logProb is NaN"
assert isFiniteF(logP), &"logProb not finite: {logP}"
# ---- criticForward ----
let v = ac.criticForward(state)
assert not isNaNF(v), "critic value is NaN"
assert isFiniteF(v), &"critic value not finite: {v}"
# ---- logStd floor: collapsing logStd should not break actorForward ----
var ac2 = initActorCritic()
for i in 0..<ACTION_DIM: ac2.logStd[i] = -10.0'f32
let (acts2, logP2) = ac2.actorForward(state)
assert acts2.shape[0] == ACTION_DIM, "acts2 shape wrong after logStd=-10"
assert isFiniteF(logP2), &"logP2 not finite with floored logStd: {logP2}"
# ---- action mapping ranges ----
let raw = randomNormalTensor[float32](ACTION_DIM)
let speed = 4.0'f32
# arena 800×600, bot at centre, heading north, gun north
let botActs = mapActions(raw, 0.0'f32, 800.0, 600.0, 400.0, 300.0, 0.0, speed.float, 0.0, 400.0, 300.0) # gunHeat=0 → fire allowed
assert botActs.targetSpeed >= -8.0'f32 and botActs.targetSpeed <= 8.0'f32,
&"targetSpeed out of range: {botActs.targetSpeed}"
assert botActs.gunTurnRate >= -20.0'f32 and botActs.gunTurnRate <= 20.0'f32,
&"gunTurnRate out of range: {botActs.gunTurnRate}"
assert botActs.firePower >= 0.1'f32 and botActs.firePower <= 3.0'f32,
&"firePower out of range: {botActs.firePower}"
# shouldFire=false when gunHeat > 0
let noFire = mapActions(raw, 1.0'f32, 800.0, 600.0, 400.0, 300.0, 0.0, speed.float, 0.0, 400.0, 300.0)
assert not noFire.shouldFire, "shouldFire should be false when gunHeat > 0"
echo "All tests passed"
+42
View File
@@ -0,0 +1,42 @@
## Regression test: radar lock must hold on a stationary target.
## Run: nim c -r tests/test_radar_lock.nim
import std/[math, strformat]
import PPO_Bot/enemy_tracker
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
# Bot at arena center; stationary enemy due north (same x, higher y).
# Tank Royale: y increases northward.
# Radar uses math convention (0°=east, CCW+); north = 90° in that system.
let botX = 400.0
let botY = 300.0
let enemyX = 400.0 # same x → dx = 0
let enemyY = 500.0 # north of bot → dy > 0
let trueBearing = 90.0 # north in math convention (0=east, CCW+)
# Radar starts pointing at the enemy (radarDirection = 90°, due north in math convention).
var radarDir = 90.0
var tracker = initEnemyTracker()
# Prime with contact at the known position
tracker.update(enemyX, enemyY, 0.0, 0.0, 100.0)
echo "Tick | radarDir | trueBearing | bearingErr"
for tick in 1 .. 20:
let rate = tracker.getRadarTurnRate(botX, botY, 0.0, radarDir)
radarDir = (radarDir + rate + 360.0) mod 360.0
# Simulate a successful scan every tick (enemy is stationary)
tracker.update(enemyX, enemyY, 0.0, 0.0, 100.0)
# Bearing error: signed difference, wrapped to [-180, 180]
let err = ((radarDir - trueBearing) + 540.0) mod 360.0 - 180.0
echo &" {tick:2d} | {radarDir:8.3f}° | {trueBearing:8.3f}° | {err:+.3f}°"
check abs(err) <= 15.0, &"tick {tick}: radar {radarDir:.1f}° drifted > 15° from target {trueBearing:.1f}°"
echo "All radar lock tests passed"
BIN
View File
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
## Assert-based tests for EnemyTracker and StateVector.
## Run: nim c -r tests/test_state.nim
import std/[math, strformat]
import arraymancer
# Import from parent dir
import PPO_Bot/enemy_tracker
import PPO_Bot/state_vector
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
# ---------------------------------------------------------------------------
# EnemyTracker tests
# ---------------------------------------------------------------------------
block testBasicUpdate:
var t = initEnemyTracker()
t.update(200.0, 300.0, 90.0, 5.0, 80.0)
check t.hasContact, "hasContact after update"
check t.current.x == 200.0, "x after update"
check t.current.y == 300.0, "y after update"
check t.current.direction == 90.0, "direction after update"
check t.current.speed == 5.0, "speed after update"
check t.current.energy == 80.0, "energy after update"
check t.current.ticksSinceLastScan == 0, "ticksSinceLastScan reset"
block testFireDetection:
var t = initEnemyTracker()
# First update sets prevEnergy
t.update(100.0, 100.0, 0.0, 0.0, 100.0)
# Second update: energy drop of 3.0 → enemy fired power 3.0
t.update(100.0, 100.0, 0.0, 0.0, 97.0)
check t.current.hasFired, "hasFired when energy drops by 3.0"
check abs(t.current.lastFirePower - 3.0) < 0.001, "lastFirePower == 3.0"
block testNoFireOnSmallDrop:
var t = initEnemyTracker()
t.update(100.0, 100.0, 0.0, 0.0, 100.0)
# Drop of 0.05 — below MIN_FIRE_POWER threshold
t.update(100.0, 100.0, 0.0, 0.0, 99.95)
check not t.current.hasFired, "no fire on small energy drop"
block testDeadReckoning:
var t = initEnemyTracker()
# direction=0° in Tank Royale means north (y increases)
t.update(100.0, 100.0, 0.0, 5.0, 100.0)
t.deadReckon()
# x unchanged (sin 0° = 0), y increases by speed (cos 0° = 1)
check abs(t.current.x - 100.0) < 0.001, "dead reckon: x unchanged for dir=0"
check abs(t.current.y - 105.0) < 0.001, "dead reckon: y += speed for dir=0"
check t.current.ticksSinceLastScan == 1, "ticksSinceLastScan incremented"
block testDeadReckonEast:
var t = initEnemyTracker()
# direction=90° → east (sin 90° = 1, cos 90° = 0)
t.update(100.0, 100.0, 90.0, 5.0, 100.0)
t.deadReckon()
check abs(t.current.x - 105.0) < 0.001, "dead reckon east: x += speed"
check abs(t.current.y - 100.0) < 0.001, "dead reckon east: y unchanged"
block testHistoryWindow:
var t = initEnemyTracker()
# Feed 6 updates — history should hold last 5
for i in 1 .. 6:
t.update(float64(i) * 10.0, float64(i) * 20.0, 0.0, float64(i), 100.0)
check t.historyCount == 5, "historyCount capped at 5"
# history[0] should be the second-to-last scan (i=5)
check abs(t.history[0].x - 50.0) < 0.001, "history[0].x == 50 (i=5)"
check abs(t.history[4].x - 10.0) < 0.001, "history[4].x == 10 (i=1)"
# ---------------------------------------------------------------------------
# StateVector tests
# ---------------------------------------------------------------------------
block testStateVectorLength:
var t = initEnemyTracker()
t.update(400.0, 300.0, 45.0, 3.0, 80.0)
let bot = BotStateData(
x: 200.0, y: 200.0, direction: 90.0, speed: 4.0, energy: 50.0,
gunDirection: 180.0, gunHeat: 0.5,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check sv.shape == [57], "state vector has 57 elements"
block testStateVectorRange:
var t = initEnemyTracker()
t.update(400.0, 300.0, 180.0, 8.0, 100.0)
let bot = BotStateData(
x: 800.0, y: 600.0, direction: 360.0, speed: 8.0, energy: 100.0,
gunDirection: 360.0, gunHeat: 1.8,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
for i in 0 ..< 57:
check sv[i] >= -2.0f32 and sv[i] <= 2.0f32,
&"sv[{i}]={sv[i]} out of [-2,2] range"
block testWallDistances:
# Bot at (100, 200) in 800×600 arena
# wallMax = max(800, 600) = 800
# top = (600 - 200) / 800 = 400/800 = 0.5
# bottom = 200 / 800 = 0.25
# left = 100 / 800 = 0.125
# right = (800 - 100) / 800 = 700/800 = 0.875
var t = initEnemyTracker()
let bot = BotStateData(
x: 100.0, y: 200.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check abs(sv[18] - 0.5f32) < 0.001f32, "top wall = 0.5"
check abs(sv[19] - 0.25f32) < 0.001f32, "bottom wall = 0.25"
check abs(sv[20] - 0.125f32) < 0.001f32, "left wall = 0.125"
check abs(sv[21] - 0.875f32) < 0.001f32, "right wall = 0.875"
block testRelativeBearing:
# Game convention: north=0°, CW. arctan2(dx,dy) used.
# Bot at (0,0) dir=0°. Enemy at (0,100) → due north → absDir=0°.
# relBearing = (0 - 0 + 540) mod 360 - 180 = 0°. normalized = 0/180 = 0.0
var t = initEnemyTracker()
t.update(0.0, 100.0, 0.0, 0.0, 100.0)
let bot = BotStateData(
x: 0.0, y: 0.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check abs(sv[16] - 0.0f32) < 0.01f32, "relative bearing = 0.0 (due north), got " & $sv[16]
block testRelativeBearingEast:
# Enemy at (100,0) → due east → absDir=90°.
# relBearing = (90 - 0 + 540) mod 360 - 180 = 90°. normalized = 90/180 = 0.5
var t = initEnemyTracker()
t.update(100.0, 0.0, 0.0, 0.0, 100.0)
let bot = BotStateData(
x: 0.0, y: 0.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check abs(sv[16] - 0.5f32) < 0.01f32, "relative bearing east = 0.5, got " & $sv[16]
block testHistoryPaddedWhenEmpty:
var t = initEnemyTracker()
let bot = BotStateData(
x: 400.0, y: 300.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
for i in 22 ..< 42:
check sv[i] == 0.0f32, &"history slot {i} should be 0 when no contact"
# indices 42-43 (goto inputs) default to 0 when not provided
check sv[42] == 0.0f32, "sv[42] (remainingGotoDistance) should default to 0"
check sv[43] == 0.0f32, "sv[43] (remainingGunAngle) should default to 0"
# indices 44-55 (bullet slots) default to 0 when no bullets provided
for i in 44 ..< 56:
check sv[i] == 0.0f32, &"bullet slot {i} should be 0 when no bullets"
# index 56 (staleness): no contact so ticksSinceLastScan=0 → 0/30 = 0
check sv[56] == 0.0f32, "sv[56] staleness should be 0 when no contact"
block testBulletSlots:
var t = initEnemyTracker()
t.update(400.0, 300.0, 0.0, 0.0, 100.0) # enemy at (400,300)
let bot = BotStateData(
x: 200.0, y: 200.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
# Bullet at (300,250), power 1.0 → speed = 20-3 = 17
# relX = 300-200 = 100, relY = 250-200 = 50 (relative to bot, not enemy)
# dist to bot = sqrt(100^2+50^2) ≈ 111.8, ticks ≈ 111.8/17 ≈ 6.58
let b = BulletData(x: 300.0, y: 250.0, power: 1.0)
let sv = buildStateVector(bot, t, 0.0, 0.0, [b], 1)
check abs(sv[44] - (100.0/800.0).float32) < 0.001f32, "bullet relX"
check abs(sv[45] - (50.0/600.0).float32) < 0.001f32, "bullet relY"
check abs(sv[46] - (17.0/20.0).float32) < 0.001f32, "bullet speed norm"
# second slot should be zero-padded
for i in 48 ..< 57:
check sv[i] == 0.0f32, &"unused bullet slot {i} should be 0"
echo "All tests passed"
BIN
View File
Binary file not shown.
+175
View File
@@ -0,0 +1,175 @@
## test_training.nim — assert-based tests for training.nim
## Run: nim c tests/test_training.nim && ./tests/test_training
import std/[math, random]
import arraymancer
import PPO_Bot/network
import PPO_Bot/training
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
# ── computeTickReward ─────────────────────────────────────────────────────────
block testTickReward:
# I lost 2, enemy lost 10 → reward = -2 - (-10) = 8
# + default closeness shaping 0.01*(1-0/maxDist) = 0.01 (gunBearingAbs=180 → 0)
let r = computeTickReward(-2.0'f32, -10.0'f32)
check abs(r - 8.01'f32) < 1e-6'f32, "computeTickReward(-2, -10) == 8.01, got " & $r
# ── computeRoundReward ────────────────────────────────────────────────────────
block testRoundReward:
let r = computeRoundReward(350.0'f32)
check abs(r - 7.0'f32) < 1e-6'f32, "computeRoundReward(350) == 7.0, got " & $r
# bounded: long-battle cumulative scores must saturate, not blow the value scale
check abs(computeRoundReward(89299.0'f32) - 8.0'f32) < 1e-6'f32,
"computeRoundReward(89299) == 8.0 (capped), got " & $computeRoundReward(89299.0'f32)
# ── TrajectoryBuffer ──────────────────────────────────────────────────────────
block testBuffer:
var buf = initTrajectoryBuffer()
check buf.len == 0, "empty buffer len == 0"
let t1 = Transition(state: zeros[float32](STATE_DIM).stateToArr,
action: zeros[float32](ACTION_DIM).actionToArr,
logProb: -1.0'f32, reward: 0.5'f32, value: 0.3'f32)
buf.add(t1)
buf.add(t1)
buf.add(t1)
check buf.len == 3, "buffer len == 3 after 3 adds"
buf.clear()
check buf.len == 0, "buffer len == 0 after clear"
# ── computeGAE — hand-calculated 3-step ──────────────────────────────────────
block testGAE:
# rewards = [1.0, 0.0, 1.0], values = [0.5, 0.5, 0.5], lastValue = 0.0
# gamma = 0.99, lam = 0.95
# delta_2 = 1.0 + 0.99*0.0 - 0.5 = 0.5
# adv_2 = 0.5
# delta_1 = 0.0 + 0.99*0.5 - 0.5 = -0.005
# adv_1 = -0.005 + 0.99*0.95*0.5 ≈ -0.005 + 0.47025 = 0.46525
# delta_0 = 1.0 + 0.99*0.5 - 0.5 = 0.995
# adv_0 = 0.995 + 0.99*0.95*0.46525 ≈ 0.995 + 0.43744 = 1.43244
let (adv, ret) = computeGAE(
rewards = @[1.0'f32, 0.0'f32, 1.0'f32],
values = @[0.5'f32, 0.5'f32, 0.5'f32],
lastValue = 0.0'f32,
gamma = 0.99'f32,
lam = 0.95'f32
)
check abs(adv[2] - 0.5'f32) < 1e-4'f32,
"adv[2] should be ~0.5, got " & $adv[2]
check abs(adv[1] - 0.46525'f32) < 1e-3'f32,
"adv[1] should be ~0.46525, got " & $adv[1]
check abs(adv[0] - 1.43244'f32) < 1e-2'f32,
"adv[0] should be ~1.43244, got " & $adv[0]
# returns = adv + values
check abs(ret[2] - (0.5'f32 + 0.5'f32)) < 1e-4'f32, "ret[2] = adv[2] + 0.5"
check abs(ret[0] - (adv[0] + 0.5'f32)) < 1e-4'f32, "ret[0] = adv[0] + 0.5"
# ── ppoUpdate runs without crash; weights change ──────────────────────────────
block testPpoUpdate:
randomize(42)
var ac = initActorCritic()
# Save a copy of w1 before update
let w1Before = ac.actor.w1.clone()
var buf = initTrajectoryBuffer()
for _ in 0..<10:
let s = randomNormalTensor[float32](STATE_DIM)
let a = randomNormalTensor[float32](ACTION_DIM)
let lp = ac.computeLogProb(s, a)
let v = ac.criticForward(s)
buf.add(Transition(state: s.stateToArr, action: a.actionToArr, logProb: lp, reward: 0.1'f32, value: v))
var adam: ACAdamStates
discard ppoUpdate(ac, buf, lastValue = 0.0'f32, adamStates = adam, epochs = 2, miniBatchSize = 5)
# Weights should have changed — compare flattened
let n = ac.actor.w1.shape[0] * ac.actor.w1.shape[1]
let w1After = ac.actor.w1.reshape(n)
let w1Flat = w1Before.reshape(n)
var changed = false
for i in 0..<n:
if abs(w1After[i] - w1Flat[i]) > 1e-9'f32:
changed = true
break
check changed, "actor w1 should change after ppoUpdate"
# ── ppoUpdate on constant-reward trajectory: zero-variance guard ─────────────
# A passive round has near-constant per-tick rewards; with constant values the
# GAE advantages are identical → zero variance. The normalization must not
# amplify/NaN on this — update must complete with finite losses.
block testPpoUpdateConstantReward:
randomize(43)
var ac = initActorCritic()
var buf = initTrajectoryBuffer()
for _ in 0..<64:
let s = randomNormalTensor[float32](STATE_DIM)
let a = randomNormalTensor[float32](ACTION_DIM)
let lp = ac.computeLogProb(s, a)
buf.add(Transition(state: s.stateToArr, action: a.actionToArr, logProb: lp,
reward: 0.05'f32, value: 0.5'f32)) # constant reward+value
var adam: ACAdamStates
let m = ppoUpdate(ac, buf, lastValue = 0.5'f32, adamStates = adam,
epochs = 2, miniBatchSize = 16)
check m.actorLoss == m.actorLoss, "actorLoss NaN on constant-reward round"
check m.valueLoss == m.valueLoss, "valueLoss NaN on constant-reward round"
check m.gradNorm == m.gradNorm, "gradNorm NaN on constant-reward round"
# ── ppoUpdate on normal-reward trajectory: finite losses ─────────────────────
block testPpoUpdateNormalReward:
randomize(44)
var ac = initActorCritic()
var buf = initTrajectoryBuffer()
for i in 0..<64:
let s = randomNormalTensor[float32](STATE_DIM)
let a = randomNormalTensor[float32](ACTION_DIM)
let lp = ac.computeLogProb(s, a)
let v = ac.criticForward(s)
let r = 0.05'f32 + 0.5'f32 * sin(float32(i))
buf.add(Transition(state: s.stateToArr, action: a.actionToArr, logProb: lp, reward: r, value: v))
var adam: ACAdamStates
let m = ppoUpdate(ac, buf, lastValue = 0.0'f32, adamStates = adam,
epochs = 2, miniBatchSize = 16)
check m.actorLoss == m.actorLoss, "actorLoss NaN on normal-reward round"
check m.valueLoss == m.valueLoss, "valueLoss NaN on normal-reward round"
check m.gradNorm == m.gradNorm, "gradNorm NaN on normal-reward round"
# ── logStd ceiling: raw param must never drift above the collection clamp ─────
# Regression for the train/collection std mismatch: logStd starting above the
# ceiling (as in the warm-start snapshot) must be clamped back to [floor, ceiling]
# by the first Adam step, so recomputed logP matches the acting policy's std.
block testLogStdCeilingClamp:
randomize(45)
var ac = initActorCritic()
ac.logStd = newTensor[float32](ACTION_DIM).map(proc(v: float32): float32 = 3.0'f32)
var buf = initTrajectoryBuffer()
for _ in 0..<16:
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.1'f32, value: 0.5'f32))
var adam: ACAdamStates
discard ppoUpdate(ac, buf, lastValue = 0.0'f32, adamStates = adam,
epochs = 1, miniBatchSize = 16)
for v in ac.logStd:
check v <= logStdCeiling + 1e-6'f32, "logStd above ceiling after ppoUpdate"
check v >= logStdFloor - 1e-6'f32, "logStd below floor after ppoUpdate"
echo "All tests passed"
BIN
View File
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
## test_weights.nim — assert-based tests for weights.nim
## Run: nim c tests/test_weights.nim && ./tests/test_weights
import std/[os, math]
import arraymancer
import PPO_Bot/network
import PPO_Bot/training
import PPO_Bot/weights
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
const tmpBase = "/tmp/test_weights_nim"
# ── saveWeights / loadWeights roundtrip ───────────────────────────────────────
block testRoundtrip:
let dir = tmpBase & "_roundtrip"
removeDir(dir)
let ac1 = initActorCritic()
saveWeights(ac1, dir)
var ac2 = initActorCritic()
loadWeights(ac2, dir)
# Verify a sample of tensors
template tensorEq(a, b: Tensor[float32]) =
check a.shape == b.shape, "shape mismatch"
let diff = abs(a - b)
var maxDiff = 0.0'f32
for v in diff: maxDiff = max(maxDiff, v)
check maxDiff < 1e-6'f32, "tensor values differ by " & $maxDiff
tensorEq(ac1.actor.w1, ac2.actor.w1)
tensorEq(ac1.actor.b1, ac2.actor.b1)
tensorEq(ac1.actor.w3, ac2.actor.w3)
tensorEq(ac1.critic.w1, ac2.critic.w1)
tensorEq(ac1.critic.b3, ac2.critic.b3)
tensorEq(ac1.logStd, ac2.logStd)
removeDir(dir)
# ── saveWeightsAtomic ─────────────────────────────────────────────────────────
block testAtomic:
let dir = tmpBase & "_atomic"
removeDir(dir)
let ac = initActorCritic()
saveWeightsAtomic(ac, dir)
check dirExists(dir), "targetDir should exist after atomic save"
for f in ["actor_w1.npy", "critic_w1.npy", "log_std.npy"]:
check fileExists(dir / f), "missing file: " & f
removeDir(dir)
# ── loadBestAvailable ─────────────────────────────────────────────────────────
block testLoadBest:
let root = tmpBase & "_loadbest"
removeDir(root)
createDir(root)
let ac0 = initActorCritic()
# Try with no weights — should return false
var acEmpty = initActorCritic()
var adamEmpty: ACAdamStates
check not loadBestAvailable(acEmpty, adamEmpty, root).loaded, "should return false with no weights"
# Save to latest/; should load
saveWeights(ac0, root / "latest")
var ac1 = initActorCritic()
var adam1: ACAdamStates
check loadBestAvailable(ac1, adam1, root).loaded, "should load from latest/"
# Remove latest/, save to checkpoint_1/ — should fall back
removeDir(root / "latest")
saveWeights(ac0, root / "checkpoint_1")
var ac2 = initActorCritic()
var adam2: ACAdamStates
check loadBestAvailable(ac2, adam2, root).loaded, "should load from checkpoint_1/"
removeDir(root)
# ── cleanStaleTempDirs ────────────────────────────────────────────────────────
block testClean:
let root = tmpBase & "_clean"
removeDir(root)
createDir(root)
let stale = root / "latest_tmp_12345"
createDir(stale)
check dirExists(stale), "stale dir should exist before clean"
cleanStaleTempDirs(root)
check not dirExists(stale), "stale dir should be gone after clean"
removeDir(root)
echo "All tests passed"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
324564
324564
324564
324564
324564
324564
324564
324564
324564
324564
324564
324564
324564
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
0

Some files were not shown because too many files have changed in this diff Show More