docs(test_framework): add comprehensive integration test guide and fix runner blocking issue
- Document exception handling, zero-value BotResult trap, shared adversary bots - Add offline parsing example using parseServerOutput - Skip tests gracefully when JARs missing (guard before suite blocks) - Fix blocking readLine in runner_process.nim: poll with 50ms sleep + atEnd check (was preventing timeout enforcement, now blocks correctly during battle) - Add test task to QBot.nimble and config.nims setup docs to AGENTS.md - Add debug logging to TestBattleRunner for bot identity tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,3 +49,74 @@ Single-context layout. See `docs/agents/domain.md`.
|
|||||||
### NIM
|
### NIM
|
||||||
|
|
||||||
This repsoitory's main language is Nim, when you need to work with nim use the /nim mcp.
|
This repsoitory's main language is Nim, when you need to work with nim use the /nim mcp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writing tests for a bot
|
||||||
|
|
||||||
|
Framework lives at `common_libs/test_framework/`. Full docs: `common_libs/test_framework/README.md`.
|
||||||
|
|
||||||
|
### One-liner
|
||||||
|
|
||||||
|
```nim
|
||||||
|
import test_framework/test_framework
|
||||||
|
|
||||||
|
let r = runBattle(@[myBotDir, adversaryDir], rounds = 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
`runBattle` blocks, returns `BattleResult`, raises on failure.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```
|
||||||
|
BattleResult → bots, rounds: seq[RoundResult], results: seq[BotResult], winners: seq[string]
|
||||||
|
BotResult → name, totalScore, rank, firstPlaces, survivalCount
|
||||||
|
RoundResult → round, results: seq[BotRoundResult]
|
||||||
|
BotRoundResult→ name, score, rank, survived
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared adversaries
|
||||||
|
|
||||||
|
```nim
|
||||||
|
const tfAdversaries = currentSourcePath().parentDir.parentDir.parentDir /
|
||||||
|
"common_libs" / "test_framework" / "adversaries"
|
||||||
|
const sittingDuck = tfAdversaries / "SittingDuck" # passive — does nothing
|
||||||
|
const oscillator = tfAdversaries / "OscillatorBot" # fights back
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup (once per garage)
|
||||||
|
|
||||||
|
**`tests/config.nims`**
|
||||||
|
```nim
|
||||||
|
--path:"../../common_libs" # adjust depth to match garage location in repo
|
||||||
|
```
|
||||||
|
|
||||||
|
**`YourBot.nimble`** test task:
|
||||||
|
```nim
|
||||||
|
task test, "Run integration tests":
|
||||||
|
exec "nim c -r --path:../common_libs tests/test_basic_battle.nim"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skip when JARs are absent (put before any suite block)
|
||||||
|
|
||||||
|
```nim
|
||||||
|
import std/os
|
||||||
|
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
|
||||||
|
echo "Skipping: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
|
||||||
|
quit(0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline parsing (no Java needed)
|
||||||
|
|
||||||
|
```nim
|
||||||
|
import test_framework/battle_result
|
||||||
|
let r = parseServerOutput(rawJsonLines)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exceptions
|
||||||
|
|
||||||
|
| Exception | Cause |
|
||||||
|
|---|---|
|
||||||
|
| `OSError` | Bot compilation failed |
|
||||||
|
| `IOError` | Battle runner exited non-zero |
|
||||||
|
| `TimeoutError` | Server startup (15s), compile (30s/bot), or battle runner timeout |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import test_framework/test_framework
|
|||||||
|
|
||||||
const
|
const
|
||||||
oscillatorDir = currentSourcePath().parentDir.parentDir # OscillatorBot_garage/
|
oscillatorDir = currentSourcePath().parentDir.parentDir # OscillatorBot_garage/
|
||||||
sittingDuckDir = currentSourcePath().parentDir / "bots" / "SittingDuck"
|
sittingDuckDir = currentSourcePath().parentDir.parentDir.parentDir / "common_libs" / "test_framework" / "adversaries" / "SittingDuck"
|
||||||
|
|
||||||
suite "OscillatorBot basic battle":
|
suite "OscillatorBot basic battle":
|
||||||
test "beats SittingDuck in 3 rounds":
|
test "beats SittingDuck in 3 rounds":
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ bin = @["QBot"]
|
|||||||
requires "nim >= 2.0.0"
|
requires "nim >= 2.0.0"
|
||||||
# tankroyale_botapi and radar_lock are vendored in-tree (common_libs/) and wired via
|
# 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.
|
# config.nims --path; no nimble dependency so builds never touch ~/.nimble/pkgs2.
|
||||||
|
|
||||||
|
task test, "Run integration tests":
|
||||||
|
exec "nim c -r --path:../common_libs tests/test_basic_battle.nim"
|
||||||
|
|||||||
@@ -143,10 +143,56 @@ BotRoundResult = object
|
|||||||
survived*: bool
|
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) |
|
||||||
|
|
||||||
|
```nim
|
||||||
|
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:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
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
|
## 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:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
const sittingDuck = currentSourcePath().parentDir.parentDir.parentDir /
|
||||||
|
"common_libs" / "test_framework" / "adversaries" / "SittingDuck"
|
||||||
|
```
|
||||||
|
|
||||||
Minimal bot — does nothing, useful as a baseline target:
|
Minimal bot — does nothing, useful as a baseline target:
|
||||||
|
|
||||||
**`tests/bots/SittingDuck/src/SittingDuck.nim`**
|
**`tests/bots/SittingDuck/src/SittingDuck.nim`**
|
||||||
@@ -184,6 +230,37 @@ Place adversary bots under `tests/bots/<BotName>/src/`. The framework infers the
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
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
|
||||||
|
|
||||||
|
```nim
|
||||||
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
**`TR server JAR not found`**
|
**`TR server JAR not found`**
|
||||||
|
|||||||
@@ -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,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec ./out/OscillatorBot 2>> /tmp/oscillatorbot_stderr.log
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
--path:"../../.."
|
||||||
|
switch("outdir", "out")
|
||||||
Binary file not shown.
@@ -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)
|
||||||
@@ -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,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec ./out/SittingDuck 2>> /tmp/sittingduck_stderr.log
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
--path:"../../.."
|
||||||
|
switch("outdir", "out")
|
||||||
Binary file not shown.
@@ -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)
|
||||||
Executable
BIN
Binary file not shown.
@@ -29,14 +29,15 @@ proc runBattleRunner*(serverUrl: string, botDirs: seq[string], rounds: int,
|
|||||||
let deadline = epochTime() + timeout.float / 1000.0
|
let deadline = epochTime() + timeout.float / 1000.0
|
||||||
var stdout = ""
|
var stdout = ""
|
||||||
var line: string
|
var line: string
|
||||||
|
# ponytail: poll instead of blocking readLine; 50ms sleep is fine for battle timescales
|
||||||
while epochTime() < deadline:
|
while epochTime() < deadline:
|
||||||
if p.peekExitCode() != -1:
|
if p.peekExitCode() != -1:
|
||||||
# Process finished; drain remaining output
|
# Process finished; drain remaining output without blocking
|
||||||
while p.outputStream.readLine(line):
|
while p.outputStream.atEnd == false:
|
||||||
|
discard p.outputStream.readLine(line)
|
||||||
stdout.add(line & "\n")
|
stdout.add(line & "\n")
|
||||||
break
|
break
|
||||||
if p.outputStream.readLine(line):
|
sleep(50)
|
||||||
stdout.add(line & "\n")
|
|
||||||
|
|
||||||
if epochTime() >= deadline and p.peekExitCode() == -1:
|
if epochTime() >= deadline and p.peekExitCode() == -1:
|
||||||
p.terminate()
|
p.terminate()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import java.util.stream.Collectors;
|
|||||||
public class TestBattleRunner {
|
public class TestBattleRunner {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
Logger.getLogger("dev.robocode.tankroyale").setLevel(Level.WARNING);
|
Logger.getLogger("dev.robocode.tankroyale").setLevel(Level.INFO);
|
||||||
|
|
||||||
String serverUrl = null;
|
String serverUrl = null;
|
||||||
int rounds = 1;
|
int rounds = 1;
|
||||||
@@ -38,11 +38,20 @@ public class TestBattleRunner {
|
|||||||
final int numRounds = rounds;
|
final int numRounds = rounds;
|
||||||
final var setup = BattleSetup.classic(s -> s.setNumberOfRounds(numRounds));
|
final var setup = BattleSetup.classic(s -> s.setNumberOfRounds(numRounds));
|
||||||
|
|
||||||
|
// Debug: check what JSON files the runner will find for each bot dir
|
||||||
|
for (String dir : botDirs) {
|
||||||
|
var path = java.nio.file.Path.of(dir);
|
||||||
|
var jsonPath = path.resolve(path.getFileName() + ".json");
|
||||||
|
System.err.println("[DEBUG] identity JSON: " + jsonPath + " exists=" + java.nio.file.Files.exists(jsonPath));
|
||||||
|
}
|
||||||
|
|
||||||
try (var runner = BattleRunner.create(b -> b.externalServer(url).suppressServerOutput())) {
|
try (var runner = BattleRunner.create(b -> b.externalServer(url).suppressServerOutput())) {
|
||||||
var owner = new Object();
|
var owner = new Object();
|
||||||
|
System.err.println("[DEBUG] bots: " + bots);
|
||||||
try (var handle = runner.startBattleAsync(setup, bots)) {
|
try (var handle = runner.startBattleAsync(setup, bots)) {
|
||||||
|
|
||||||
handle.getOnGameStarted().on(owner, event -> {
|
handle.getOnGameStarted().on(owner, event -> {
|
||||||
|
System.err.println("[DEBUG] game_started participants: " + event.getParticipants().stream().map(p -> p.getName() + "/" + p.getVersion() + "/" + p.getAuthors()).collect(Collectors.joining(", ")));
|
||||||
var names = event.getParticipants().stream()
|
var names = event.getParticipants().stream()
|
||||||
.map(p -> "\"" + escape(p.getName()) + "\"")
|
.map(p -> "\"" + escape(p.getName()) + "\"")
|
||||||
.collect(Collectors.joining(","));
|
.collect(Collectors.joining(","));
|
||||||
@@ -51,6 +60,7 @@ public class TestBattleRunner {
|
|||||||
});
|
});
|
||||||
|
|
||||||
handle.getOnRoundEnded().on(owner, event -> {
|
handle.getOnRoundEnded().on(owner, event -> {
|
||||||
|
System.err.println("[DEBUG] round_ended results count: " + event.getResults().size());
|
||||||
var results = event.getResults().stream().map(r ->
|
var results = event.getResults().stream().map(r ->
|
||||||
"{\"name\":\"" + escape(r.getName()) + "\"" +
|
"{\"name\":\"" + escape(r.getName()) + "\"" +
|
||||||
",\"score\":" + r.getTotalScore() +
|
",\"score\":" + r.getTotalScore() +
|
||||||
|
|||||||
Reference in New Issue
Block a user