Files
SirRoboGarage/common_libs/test_framework
SirStone c214abcfa8 fix(adversaries): repair four of the five sparring bots
The user suspected these were bugged. They were, and the verdicts are not
uniform - three genuinely broken, one merely sloppy, one fine:

- WaveSurfer: GENUINELY BUGGED, worst of the five. (a) The enemy velocity
  decomposition was sin/cos SWAPPED - enemyVx used sin and enemyVy used cos,
  while Tank Royale is 0 deg = East, CCW+, so it must be cos for X and sin for
  Y. Its linear-prediction gun was aiming at a reflected position. (b) The wall
  escape flipped strafeDir on EVERY tick the bot was inside the wall margin,
  so instead of turning away it flip-flopped in place: measured standing still
  (speed < 0.5) for 96.2% of ticks with a longest continuous standstill of 1398
  ticks. Fixed with a hysteretic wall-escape selection plus a corner escape,
  dead enemyLastDir removed, and per-round state reset.
  AFTER, measured through the booter: rest 12.3%, mean speed 6.69, full speed
  79.7%, longest zero run 18, perpendicular 0.845 / radial 0.012 - it now
  actually strafes. Gun sanity: lead error 1.0 px vs 106 px for head-on on a
  constant-velocity target; lead gun 45.8% hits vs 29.3% for head-on.
- PatternMover: GENUINELY BUGGED. Real deadlock - it decremented its step
  counter by the REQUESTED amount while issuing setTargetSpeed(8), so against a
  wall the counter never reached 0, advanceStep never ran and it was stuck
  forever (309-tick standstill). Now counts down by ACTUAL distance/turn with a
  STALL_LIMIT watchdog and steers toward the arena centre. Standstill 309 -> 19
  ticks; full-speed ticks 10.0% -> 28.4%.
- OscillatorBot: GENUINELY BUGGED, milder. No wall handling at all, so it
  ground along walls 53.4% of ticks and could pin in a corner. Added wall
  steering that preserves the fixed 25-tick reversal cadence. Wall-band 53.4%
  -> 18.6%, mean wall distance 72 -> 119.
- RandomMover: merely sloppy, not broken. Its turn intent saturated against the
  speed-dependent limit (18.4% of moving ticks clamped) and the fire gate was a
  very loose 10 deg. Now clamps to calcMaxTurnRate and fires within 3 deg.
  Saturation 18.4% -> 3.9%.
- SittingDuck: FINE. Speed 0 for 100% of ticks, zero shots. Left untouched -
  it is a duck by design.

Adds test_wavesurfer_velocity.nim, a direct assertion that the decomposition is
cos/sin and explicitly NOT the swapped form (7 cases).

KNOWN ISSUE, not fixed: RandomMover/PatternMover/WaveSurfer import
tankroyale_botapi 1.0.1 and intermittently SIGSEGV in
tankroyale_botapi/event_queue.nim:89 addEvent, freezing the bot for the rest of
the battle. It reproduces on old and new code and never occurs for SittingDuck/
OscillatorBot, which import robocode_tankroyale_botapi 1.0.7. Migrating the
three to 1.0.7 would likely fix it and is worth doing - it is a real
reliability risk for these as sparring partners.
2026-09-21 08:21:47 +02:00
..

test_framework

Integration test framework for Tank Royale bots. Spins up a TR server JAR on a random port, compiles your Nim bots, runs a battle via a Java TestBattleRunner subprocess, and returns structured results you can assert on. Local development tool only — no CI support.


Prerequisites

  • Java JRE (11+) — java must be on PATH
  • Tank Royale server JAR — robocode-tankroyale-server-*-all.jar
  • Tank Royale runner JAR — robocode-tankroyale-runner.jar

Environment variables

Variable Purpose Default
TR_SERVER_JAR Path to the TR server JAR hardcoded dev path in server_manager.nim
TR_BATTLE_RUNNER Path to the TR runner JAR hardcoded dev path in runner_process.nim
TR_BATTLE_RUNNER_DIR Directory containing compiled TestBattleRunner.class tools/battle_runner/

Set at minimum TR_SERVER_JAR and TR_BATTLE_RUNNER to point at your local JAR builds.

One-time Java compilation

cd tools/battle_runner
TANK_ROYALE_JAR=/path/to/robocode-tankroyale-runner.jar ./run_test_battle.sh --help

run_test_battle.sh auto-compiles TestBattleRunner.java when .class is missing or stale. After this, TR_BATTLE_RUNNER_DIR defaults to tools/battle_runner/ and the class file is found automatically.


Setup for your garage

tests/config.nims — add the common_libs path so the import resolves:

--path:"../../common_libs"

Adjust the relative depth to match your garage's position in the repo.

YourBot.nimble — add a test task:

task test, "Compile and run integration tests":
  exec "nim c -r --path:../common_libs tests/test_basic_battle.nim"

Run with:

nimble test

Writing tests

import std/[os, unittest]
import test_framework/test_framework

const
  myBotDir    = currentSourcePath().parentDir.parentDir   # garage root
  sittingDuck = currentSourcePath().parentDir / "bots" / "SittingDuck"

suite "MyBot basic battle":
  test "beats SittingDuck in 3 rounds":
    let r = runBattle(@[myBotDir, sittingDuck], rounds = 3)

    check r.rounds.len == 3
    check r.results.len == 2

    let me = block:
      var found: BotResult
      for b in r.results:
        if b.name == "MyBot": found = b
      found

    check me.totalScore > 0
    check "MyBot" in r.winners

runBattle blocks until the battle finishes (or timeout ms elapses). The server process is started once per test binary run and killed on exit.


API reference

runBattle

proc runBattle*(botDirs: seq[string], rounds: int = 10,
                timeout: int = 120000): BattleResult
Parameter Description
botDirs Absolute or relative paths to bot directories (one per participating bot)
rounds Number of rounds to play (default: 10)
timeout Milliseconds to wait for the battle runner to finish (default: 120000)

Each bot directory must have src/<BotName>.nim and the compiler writes the binary to out/<BotName>.

BattleResult

BattleResult = object
  bots*:    seq[string]      # bot names in participation order
  rounds*:  seq[RoundResult] # one entry per round
  results*: seq[BotResult]   # final standings, sorted by rank
  winners*: seq[string]      # names of bots with rank == 1

BotResult

BotResult = object
  name*:          string
  totalScore*:    int
  rank*:          int
  firstPlaces*:   int
  survivalCount*: int

RoundResult

RoundResult = object
  round*:   int
  results*: seq[BotRoundResult]

BotRoundResult

BotRoundResult = object
  name*:     string
  score*:    int
  rank*:     int
  survived*: bool

Compilation behavior

compileBots runs on every runBattle() call — no caching. Each test that calls runBattle() recompiles all bots. Compile timeout is 30s per bot, hardcoded, independent of runBattle()'s timeout parameter.

Exceptions

runBattle() raises:

Exception Cause
OSError Bot compilation failed — compiler output included in message
IOError Battle runner exited non-zero
TimeoutError Any timeout: server startup (15s), compilation (30s/bot), battle runner (per timeout param)
try:
  let r = runBattle(@[myBotDir, sittingDuck], rounds = 3)
  check "MyBot" in r.winners
except OSError as e:
  echo "Compile failed: ", e.msg
except IOError as e:
  echo "Battle runner failed: ", e.msg
except TimeoutError as e:
  echo "Timed out: ", e.msg

Zero-value BotResult trap

Searching r.results by name returns a zero-initialized BotResult if the name doesn't match — no error raised. Guard against it:

var myBot: BotResult
for b in r.results:
  if b.name == "MyBot": myBot = b
check myBot.name == "MyBot"  # catches name mismatch / missing bot

Writing adversary bots

Shared adversary bots

Pre-built adversaries (SittingDuck, OscillatorBot) live at common_libs/test_framework/adversaries/ — import them directly, no need to copy per garage:

const sittingDuck = currentSourcePath().parentDir.parentDir.parentDir /
                    "common_libs" / "test_framework" / "adversaries" / "SittingDuck"

Minimal bot — does nothing, useful as a baseline target:

tests/bots/SittingDuck/src/SittingDuck.nim

import std/os
import tankroyale_botapi

const botJsonPath = currentSourcePath().parentDir / "SittingDuck.json"

type SittingDuck = ref object of Bot

method run*(bot: SittingDuck) =
  while isRunning():
    go()

when isMainModule:
  var bot = SittingDuck()
  start(bot, botJsonPath)

tests/bots/SittingDuck/src/SittingDuck.json — required metadata:

{
  "name": "SittingDuck",
  "version": "0.1.0",
  "authors": ["Test"],
  "description": "Does nothing — test adversary",
  "gameTypes": ["classic", "1v1"],
  "platform": "Nim",
  "programmingLang": "Nim"
}

Place adversary bots under tests/bots/<BotName>/src/. The framework infers the binary name from the directory name, so the directory name must match the .nim filename.


Testing parsing offline

parseServerOutput is exported from battle_result.nim and can be imported directly. Use it for fast offline tests — no Java server needed:

import test_framework/battle_result

const fixture = """
{"event":"game_started","bots":["MyBot","Enemy"]}
{"event":"round_ended","round":1,"results":[{"name":"MyBot","score":200,"rank":1,"survived":true},{"name":"Enemy","score":0,"rank":2,"survived":false}]}
{"event":"battle_ended","results":[{"name":"MyBot","totalScore":200,"rank":1,"firstPlaces":1,"survivalCount":1},{"name":"Enemy","totalScore":0,"rank":2,"firstPlaces":0,"survivalCount":0}]}
"""
let r = parseServerOutput(fixture)
assert r.winners == @["MyBot"]

Skipping when JARs are unavailable

import std/os
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
  echo "Skipping integration tests: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
  quit(0)

Put this at the top of your test file, before any suite blocks.


Troubleshooting

TR server JAR not found Set TR_SERVER_JAR to the full path of your robocode-tankroyale-server-*-all.jar.

TR server did not start within 15s The server took too long — check that the JAR is valid and the port was free. Also check for leftover java processes from a previous crashed run.

BattleRunner timed out after Nms The timeout parameter (default 120 000 ms) was exceeded. Increase it: runBattle(..., timeout = 300_000). Also check that bots are connecting to the server (look for compilation errors in test output).

BattleRunner exited with code N TestBattleRunner.class is missing or TR_BATTLE_RUNNER points at the wrong JAR. Re-run run_test_battle.sh once to recompile.

Zombie java processes after a crash The server process is registered with addExitProc and killed on normal exit. A hard kill (SIGKILL) of the test binary will leave the server process behind. Kill manually: pkill -f robocode-tankroyale-server.

No .nim source found in .../src/ The bot directory's src/ folder is empty or the .nim file is missing. The compiler expects src/<DirName>.nim (stripping a _garage suffix if present).