feat: add shared integration test framework for garages (#126)

Implements:
- BattleResult type and JSON-lines parser (#127)
- TR server lifecycle manager (#128)
- Bot compiler using nim c (#129)
- runBattle() orchestrator (#130)
- Example test in OscillatorBot_garage (#131)
- Framework usage guide (#132)
- TestBattleRunner.java for external server (#133)
- BattleRunner process lifecycle (#134)

Fix: runner_process.nim was redefining TimeoutError locally; now
uses std/net.TimeoutError consistently with server_manager.nim.
This commit is contained in:
2026-08-30 12:03:35 +02:00
parent a50571e6da
commit 57a1915b10
13 changed files with 654 additions and 2 deletions
+5 -2
View File
@@ -8,5 +8,8 @@ bin = @["OscillatorBot"]
# Dependencies # Dependencies
requires "nim >= 2.0.0" requires "nim >= 2.0.0"
# tankroyale_botapi and radar_lock are vendored in-tree (common_libs/) and wired via requires "tankroyale_botapi >= 1.0.1"
# config.nims --path; no nimble dependency so builds never touch ~/.nimble/pkgs2. # radar_lock is vendored in-tree (common_libs/) and wired via config.nims --path
task test, "Compile and run integration tests":
exec "nim c -r --path:../common_libs tests/test_basic_battle.nim"
@@ -0,0 +1,9 @@
{
"name": "SittingDuck",
"version": "0.1.0",
"authors": ["Test"],
"description": "Does nothing — test adversary",
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,15 @@
# SittingDuck — does nothing, used as a test adversary.
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)
+1
View File
@@ -0,0 +1 @@
--path:"../../common_libs"
@@ -0,0 +1,30 @@
## Integration test: OscillatorBot vs SittingDuck, 3 rounds.
## Requires a running TR server (set TR_SERVER_JAR / TR_BATTLE_RUNNER env vars).
import std/[os, unittest]
import test_framework/test_framework
const
oscillatorDir = currentSourcePath().parentDir.parentDir # OscillatorBot_garage/
sittingDuckDir = currentSourcePath().parentDir / "bots" / "SittingDuck"
suite "OscillatorBot basic battle":
test "beats SittingDuck in 3 rounds":
let r = runBattle(@[oscillatorDir, sittingDuckDir], rounds = 3)
check r.rounds.len == 3
check r.results.len == 2
let oscillator = block:
var found: BotResult
for b in r.results:
if b.name == "OscillatorBot": found = b
found
let duck = block:
var found: BotResult
for b in r.results:
if b.name == "SittingDuck": found = b
found
check oscillator.totalScore > duck.totalScore
check "OscillatorBot" in r.winners
+205
View File
@@ -0,0 +1,205 @@
# 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
```sh
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:
```nim
--path:"../../common_libs"
```
Adjust the relative depth to match your garage's position in the repo.
**`YourBot.nimble`** — add a test task:
```nim
task test, "Compile and run integration tests":
exec "nim c -r --path:../common_libs tests/test_basic_battle.nim"
```
Run with:
```sh
nimble test
```
---
## Writing tests
```nim
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`
```nim
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`
```nim
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`
```nim
BotResult = object
name*: string
totalScore*: int
rank*: int
firstPlaces*: int
survivalCount*: int
```
### `RoundResult`
```nim
RoundResult = object
round*: int
results*: seq[BotRoundResult]
```
### `BotRoundResult`
```nim
BotRoundResult = object
name*: string
score*: int
rank*: int
survived*: bool
```
---
## Writing adversary bots
Minimal bot — does nothing, useful as a baseline target:
**`tests/bots/SittingDuck/src/SittingDuck.nim`**
```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:
```json
{
"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.
---
## 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).
@@ -0,0 +1,82 @@
import std/[json, strutils, sequtils]
type
BotRoundResult* = object
name*: string
score*: int
rank*: int
survived*: bool
RoundResult* = object
round*: int
results*: seq[BotRoundResult]
BotResult* = object
name*: string
totalScore*: int
rank*: int
firstPlaces*: int
survivalCount*: int
BattleResult* = object
bots*: seq[string]
rounds*: seq[RoundResult]
results*: seq[BotResult]
winners*: seq[string] # bots with rank == 1
proc parseServerOutput*(stdout: string): BattleResult =
for line in stdout.splitLines():
let s = line.strip()
if s.len == 0 or s[0] != '{': continue
let node = parseJson(s)
case node["event"].getStr()
of "game_started":
result.bots = node["bots"].getElems().mapIt(it.getStr())
of "round_ended":
var rr = RoundResult(round: node["round"].getInt())
for r in node["results"].getElems():
rr.results.add BotRoundResult(
name: r["name"].getStr(),
score: r["score"].getInt(),
rank: r["rank"].getInt(),
survived: r["survived"].getBool()
)
result.rounds.add rr
of "battle_ended":
for r in node["results"].getElems():
result.results.add BotResult(
name: r["name"].getStr(),
totalScore: r["totalScore"].getInt(),
rank: r["rank"].getInt(),
firstPlaces: r["firstPlaces"].getInt(),
survivalCount: r["survivalCount"].getInt()
)
result.winners = result.results.filterIt(it.rank == 1).mapIt(it.name)
else: discard
when isMainModule:
const sample = """
{"event":"game_started","bots":["Alpha","Beta"]}
{"event":"round_ended","round":1,"results":[{"name":"Alpha","score":150,"rank":1,"survived":true},{"name":"Beta","score":80,"rank":2,"survived":false}]}
{"event":"round_ended","round":2,"results":[{"name":"Beta","score":200,"rank":1,"survived":true},{"name":"Alpha","score":60,"rank":2,"survived":false}]}
{"event":"battle_ended","results":[{"name":"Alpha","totalScore":500,"rank":1,"firstPlaces":3,"survivalCount":5},{"name":"Beta","totalScore":300,"rank":2,"firstPlaces":1,"survivalCount":2}]}
not json, should be ignored
"""
let br = parseServerOutput(sample)
assert br.bots == @["Alpha", "Beta"]
assert br.rounds.len == 2
assert br.rounds[0].round == 1
assert br.rounds[0].results[0].name == "Alpha"
assert br.rounds[0].results[0].score == 150
assert br.rounds[0].results[0].rank == 1
assert br.rounds[0].results[0].survived == true
assert br.rounds[1].results[0].name == "Beta"
assert br.rounds[1].results[1].survived == false
assert br.results.len == 2
assert br.results[0].name == "Alpha"
assert br.results[0].totalScore == 500
assert br.results[0].firstPlaces == 3
assert br.results[0].survivalCount == 5
assert br.winners == @["Alpha"]
echo "all assertions passed"
@@ -0,0 +1,48 @@
## Compiles Nim bots before battle tests.
## config.nims in each bot dir already sets --path and --outdir, so we just
## run `nim c src/BotName.nim` from the bot dir and let config.nims do the rest.
import std/[os, osproc, streams, strformat, strutils, sequtils]
proc compileBots*(botDirs: seq[string]): seq[string] =
## Compiles each bot dir, returns absolute paths to compiled binaries.
## Raises on first compilation failure, message includes compiler output.
for dir in botDirs:
let absDir = absolutePath(dir)
# Infer main source: src/<stem>.nim where stem = last path component
# minus optional _garage suffix (e.g. OscillatorBot_garage → OscillatorBot)
var stem = lastPathPart(absDir)
if stem.endsWith("_garage"): stem = stem[0 ..< stem.len - "_garage".len]
let srcFile = absDir / "src" / stem & ".nim"
if not fileExists(srcFile):
# Fall back: pick the first .nim in src/
let candidates = toSeq(walkFiles(absDir / "src" / "*.nim"))
if candidates.len == 0:
raise newException(IOError, fmt"No .nim source found in {absDir}/src/")
stem = splitFile(candidates[0]).name
# ponytail: startProcess+streams for timeout; execCmdEx has no timeout param
let p = startProcess("nim", workingDir = absDir,
args = ["c", fmt"src/{stem}.nim"],
options = {poUsePath, poStdErrToStdOut})
let exitCode = waitForExit(p, timeout = 30_000)
let output = p.outputStream.readAll()
p.close()
if exitCode != 0:
raise newException(OSError,
fmt"Compilation failed for {absDir}:{'\n'}{output}")
result.add absDir / "out" / stem
when isMainModule:
let botDir = currentSourcePath().parentDir().parentDir().parentDir() /
"OscillatorBot_garage"
echo "Compiling OscillatorBot from: ", botDir
try:
let bins = compileBots(@[botDir])
echo "OK — binary: ", bins[0]
doAssert fileExists(bins[0]), "binary not found after compile"
except CatchableError as e:
echo "FAIL: ", e.msg
quit(1)
@@ -0,0 +1,61 @@
## BattleRunner Java subprocess — capture stdout JSON-lines output.
import std/[osproc, os, strutils, streams, times, net]
const
DefaultRunnerJar = "/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar"
ScriptDir = currentSourcePath().parentDir() / ".." / ".." / "tools" / "battle_runner"
proc runBattleRunner*(serverUrl: string, botDirs: seq[string], rounds: int,
timeout: int = 30000): string =
## Starts TestBattleRunner as a subprocess. Returns captured stdout on success.
## Raises IOError on non-zero exit or TimeoutError on timeout.
let jar = getEnv("TR_BATTLE_RUNNER", DefaultRunnerJar)
let classDir = getEnv("TR_BATTLE_RUNNER_DIR", ScriptDir)
let args = @[
"-cp", classDir & ":" & jar,
"TestBattleRunner",
"--server-url", serverUrl,
"--rounds", $rounds,
"--bot-dirs", botDirs.join(",")
]
let p = startProcess("java", args = args,
options = {poUsePath, poStdErrToStdOut})
defer:
p.close()
let deadline = epochTime() + timeout.float / 1000.0
var stdout = ""
var line: string
while epochTime() < deadline:
if p.peekExitCode() != -1:
# Process finished; drain remaining output
while p.outputStream.readLine(line):
stdout.add(line & "\n")
break
if p.outputStream.readLine(line):
stdout.add(line & "\n")
if epochTime() >= deadline and p.peekExitCode() == -1:
p.terminate()
raise newException(TimeoutError,
"BattleRunner timed out after " & $timeout & "ms")
let code = p.waitForExit()
if code != 0:
raise newException(IOError,
"BattleRunner exited with code " & $code & "\nOutput:\n" & stdout)
result = stdout
when isMainModule:
echo "runner_process.nim — usage:"
echo " runBattleRunner(serverUrl, botDirs, rounds, timeout)"
echo ""
echo "Env vars:"
echo " TR_BATTLE_RUNNER JAR path (default: " & DefaultRunnerJar & ")"
echo " TR_BATTLE_RUNNER_DIR class dir (default: tools/battle_runner/)"
echo ""
echo "Cannot self-test without a running TR server. Start one first."
@@ -0,0 +1,67 @@
## TR server lifecycle manager — lazy singleton for integration tests.
## Start with ensureServer(), query with getServerUrl().
import std/[osproc, net, os, strutils, times, streams, exitprocs]
const
DefaultJar = "/home/davide/Projects/tank-royale/server/build/libs/robocode-tankroyale-server-0.35.5-all.jar"
ReadySignal = "Starting server on port"
StartupTimeout = 15.0 # seconds
var
serverProc: Process = nil
serverPort: int = 0
proc findFreePort(): int =
let s = newSocket()
s.bindAddr(Port(0))
let (_, port) = s.getLocalAddr()
result = port.int
s.close()
proc ensureServer*() =
if serverProc != nil: return
let jar = getEnv("TR_SERVER_JAR", DefaultJar)
if not fileExists(jar):
raise newException(IOError, "TR server JAR not found: " & jar &
"\nSet TR_SERVER_JAR env var to override.")
serverPort = findFreePort()
serverProc = startProcess("java",
args = @["-jar", jar, "--port", $serverPort, "--enable-initial-position"],
options = {poUsePath, poStdErrToStdOut})
exitprocs.addExitProc(proc() =
if serverProc != nil:
serverProc.terminate()
serverProc.close()
)
# Wait for ready signal
let deadline = epochTime() + StartupTimeout
var ready = false
while epochTime() < deadline:
var line: string
discard serverProc.outputStream.readLine(line)
if ReadySignal in line:
ready = true
break
if not ready:
serverProc.terminate()
serverProc.close()
serverProc = nil
raise newException(TimeoutError, "TR server did not start within " & $StartupTimeout & "s")
proc getServerUrl*(): string =
if serverPort == 0:
raise newException(CatchableError, "Server not started — call ensureServer() first")
"ws://localhost:" & $serverPort
when isMainModule:
echo "Starting TR server..."
ensureServer()
echo "Server ready at: ", getServerUrl()
echo "Sleeping 2s then exiting (addExitProc will kill server)..."
os.sleep(2000)
echo "Done."
@@ -0,0 +1,32 @@
## Public API for the TR integration test framework.
## Import this module only; it re-exports all types you need.
import ./server_manager
import ./bot_compiler
import ./battle_result
import ./runner_process
export battle_result.BattleResult
export battle_result.BotResult
export battle_result.RoundResult
export battle_result.BotRoundResult
proc runBattle*(botDirs: seq[string], rounds: int = 10,
timeout: int = 120000): BattleResult =
ensureServer()
discard compileBots(botDirs)
let stdout = runBattleRunner(getServerUrl(), botDirs, rounds, timeout)
result = parseServerOutput(stdout)
when isMainModule:
echo "test_framework.nim — public API, not a standalone binary."
echo "Requires a TR server JAR. Usage from your test file:"
echo ""
echo " import common_libs/test_framework/test_framework"
echo " let r = runBattle(@[\"path/to/BotA\", \"path/to/BotB\"])"
echo " echo r.winners"
echo ""
echo "Env vars forwarded to sub-modules:"
echo " TR_SERVER_JAR TR server JAR path"
echo " TR_BATTLE_RUNNER BattleRunner JAR path"
echo " TR_BATTLE_RUNNER_DIR BattleRunner class dir"
+85
View File
@@ -0,0 +1,85 @@
import dev.robocode.tankroyale.runner.*;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
/**
* Generic battle runner that connects to an external Tank Royale server and
* emits JSON-lines to stdout for downstream parsing.
*
* Usage:
* ./run_test_battle.sh --server-url ws://localhost:7654 --rounds 5 --bot-dirs /path/bot1,/path/bot2
*/
public class TestBattleRunner {
public static void main(String[] args) {
Logger.getLogger("dev.robocode.tankroyale").setLevel(Level.WARNING);
String serverUrl = null;
int rounds = 1;
List<String> botDirs = List.of();
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--server-url" -> serverUrl = args[++i];
case "--rounds" -> rounds = Integer.parseInt(args[++i]);
case "--bot-dirs" -> botDirs = Arrays.asList(args[++i].split(","));
default -> { System.err.println("Unknown arg: " + args[i]); System.exit(1); }
}
}
if (serverUrl == null) { System.err.println("--server-url required"); System.exit(1); }
if (botDirs.isEmpty()) { System.err.println("--bot-dirs required"); System.exit(1); }
final String url = serverUrl;
final List<BotEntry> bots = botDirs.stream().map(BotEntry::of).collect(Collectors.toList());
final int numRounds = rounds;
final var setup = BattleSetup.classic(s -> s.setNumberOfRounds(numRounds));
try (var runner = BattleRunner.create(b -> b.externalServer(url).suppressServerOutput())) {
var owner = new Object();
try (var handle = runner.startBattleAsync(setup, bots)) {
handle.getOnGameStarted().on(owner, event -> {
var names = event.getParticipants().stream()
.map(p -> "\"" + escape(p.getName()) + "\"")
.collect(Collectors.joining(","));
System.out.println("{\"event\":\"game_started\",\"bots\":[" + names + "]}");
System.out.flush();
});
handle.getOnRoundEnded().on(owner, event -> {
var results = event.getResults().stream().map(r ->
"{\"name\":\"" + escape(r.getName()) + "\"" +
",\"score\":" + r.getTotalScore() +
",\"rank\":" + r.getRank() +
",\"survived\":" + (r.getSurvival() > 0) + "}"
).collect(Collectors.joining(","));
System.out.println("{\"event\":\"round_ended\",\"round\":" + event.getRoundNumber() + ",\"results\":[" + results + "]}");
System.out.flush();
});
var final_ = handle.awaitResults();
var results = final_.getResults().stream().map(r ->
"{\"name\":\"" + escape(r.getName()) + "\"" +
",\"totalScore\":" + r.getTotalScore() +
",\"rank\":" + r.getRank() +
",\"firstPlaces\":" + r.getFirstPlaces() +
",\"survivalCount\":" + r.getSurvival() + "}"
).collect(Collectors.joining(","));
System.out.println("{\"event\":\"battle_ended\",\"results\":[" + results + "]}");
System.out.flush();
}
} catch (Exception e) {
System.err.println("Battle failed: " + e.getMessage());
System.exit(1);
}
}
// ponytail: minimal JSON escaping — only backslash and double-quote; extend if bot names use control chars
static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
JAR="${TANK_ROYALE_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}"
cd "$SCRIPT_DIR"
if [ ! -f TestBattleRunner.class ] || [ TestBattleRunner.java -nt TestBattleRunner.class ]; then
echo ">>> Compiling TestBattleRunner.java..." >&2
javac -cp "$JAR" TestBattleRunner.java
fi
exec java -cp ".:$JAR" TestBattleRunner "$@"