diff --git a/AGENTS.md b/AGENTS.md index 513db7c..e105b91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,3 +49,74 @@ Single-context layout. See `docs/agents/domain.md`. ### NIM 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 | diff --git a/OscillatorBot_garage/tests/test_basic_battle.nim b/OscillatorBot_garage/tests/test_basic_battle.nim index e6b445a..6ab8119 100644 --- a/OscillatorBot_garage/tests/test_basic_battle.nim +++ b/OscillatorBot_garage/tests/test_basic_battle.nim @@ -6,7 +6,7 @@ import test_framework/test_framework const 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": test "beats SittingDuck in 3 rounds": diff --git a/QBot_garage/QBot.nimble b/QBot_garage/QBot.nimble index 0c1bd0e..22c6505 100644 --- a/QBot_garage/QBot.nimble +++ b/QBot_garage/QBot.nimble @@ -10,3 +10,6 @@ bin = @["QBot"] 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. + +task test, "Run integration tests": + exec "nim c -r --path:../common_libs tests/test_basic_battle.nim" diff --git a/common_libs/test_framework/README.md b/common_libs/test_framework/README.md index 71279bd..f241977 100644 --- a/common_libs/test_framework/README.md +++ b/common_libs/test_framework/README.md @@ -143,10 +143,56 @@ BotRoundResult = object 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 +### 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: **`tests/bots/SittingDuck/src/SittingDuck.nim`** @@ -184,6 +230,37 @@ Place adversary bots under `tests/bots//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 **`TR server JAR not found`** diff --git a/common_libs/test_framework/adversaries/OscillatorBot/OscillatorBot.json b/common_libs/test_framework/adversaries/OscillatorBot/OscillatorBot.json new file mode 100644 index 0000000..4d64a9f --- /dev/null +++ b/common_libs/test_framework/adversaries/OscillatorBot/OscillatorBot.json @@ -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" +} diff --git a/common_libs/test_framework/adversaries/OscillatorBot/OscillatorBot.sh b/common_libs/test_framework/adversaries/OscillatorBot/OscillatorBot.sh new file mode 100755 index 0000000..a94efab --- /dev/null +++ b/common_libs/test_framework/adversaries/OscillatorBot/OscillatorBot.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" +exec ./out/OscillatorBot 2>> /tmp/oscillatorbot_stderr.log diff --git a/common_libs/test_framework/adversaries/OscillatorBot/config.nims b/common_libs/test_framework/adversaries/OscillatorBot/config.nims new file mode 100644 index 0000000..594b66e --- /dev/null +++ b/common_libs/test_framework/adversaries/OscillatorBot/config.nims @@ -0,0 +1,2 @@ +--path:"../../.." +switch("outdir", "out") diff --git a/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot b/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot new file mode 100755 index 0000000..2970a80 Binary files /dev/null and b/common_libs/test_framework/adversaries/OscillatorBot/out/OscillatorBot differ diff --git a/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.json b/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.json new file mode 100644 index 0000000..4d64a9f --- /dev/null +++ b/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.json @@ -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" +} diff --git a/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.nim b/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.nim new file mode 100644 index 0000000..f071468 --- /dev/null +++ b/common_libs/test_framework/adversaries/OscillatorBot/src/OscillatorBot.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) diff --git a/common_libs/test_framework/adversaries/SittingDuck/SittingDuck.json b/common_libs/test_framework/adversaries/SittingDuck/SittingDuck.json new file mode 100644 index 0000000..70f585d --- /dev/null +++ b/common_libs/test_framework/adversaries/SittingDuck/SittingDuck.json @@ -0,0 +1,9 @@ +{ + "name": "SittingDuck", + "version": "0.1.0", + "authors": ["Test"], + "description": "Does nothing — test adversary", + "gameTypes": ["classic", "1v1"], + "platform": "Nim", + "programmingLang": "Nim" +} diff --git a/common_libs/test_framework/adversaries/SittingDuck/SittingDuck.sh b/common_libs/test_framework/adversaries/SittingDuck/SittingDuck.sh new file mode 100755 index 0000000..18b906e --- /dev/null +++ b/common_libs/test_framework/adversaries/SittingDuck/SittingDuck.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" +exec ./out/SittingDuck 2>> /tmp/sittingduck_stderr.log diff --git a/common_libs/test_framework/adversaries/SittingDuck/config.nims b/common_libs/test_framework/adversaries/SittingDuck/config.nims new file mode 100644 index 0000000..594b66e --- /dev/null +++ b/common_libs/test_framework/adversaries/SittingDuck/config.nims @@ -0,0 +1,2 @@ +--path:"../../.." +switch("outdir", "out") diff --git a/common_libs/test_framework/adversaries/SittingDuck/out/SittingDuck b/common_libs/test_framework/adversaries/SittingDuck/out/SittingDuck new file mode 100755 index 0000000..09c16e8 Binary files /dev/null and b/common_libs/test_framework/adversaries/SittingDuck/out/SittingDuck differ diff --git a/common_libs/test_framework/adversaries/SittingDuck/src/SittingDuck.json b/common_libs/test_framework/adversaries/SittingDuck/src/SittingDuck.json new file mode 100644 index 0000000..70f585d --- /dev/null +++ b/common_libs/test_framework/adversaries/SittingDuck/src/SittingDuck.json @@ -0,0 +1,9 @@ +{ + "name": "SittingDuck", + "version": "0.1.0", + "authors": ["Test"], + "description": "Does nothing — test adversary", + "gameTypes": ["classic", "1v1"], + "platform": "Nim", + "programmingLang": "Nim" +} diff --git a/common_libs/test_framework/adversaries/SittingDuck/src/SittingDuck.nim b/common_libs/test_framework/adversaries/SittingDuck/src/SittingDuck.nim new file mode 100644 index 0000000..86356c5 --- /dev/null +++ b/common_libs/test_framework/adversaries/SittingDuck/src/SittingDuck.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) diff --git a/common_libs/test_framework/bot_compiler b/common_libs/test_framework/bot_compiler new file mode 100755 index 0000000..fd7f37f Binary files /dev/null and b/common_libs/test_framework/bot_compiler differ diff --git a/common_libs/test_framework/runner_process.nim b/common_libs/test_framework/runner_process.nim index 84d785a..87b7c0c 100644 --- a/common_libs/test_framework/runner_process.nim +++ b/common_libs/test_framework/runner_process.nim @@ -29,14 +29,15 @@ proc runBattleRunner*(serverUrl: string, botDirs: seq[string], rounds: int, let deadline = epochTime() + timeout.float / 1000.0 var stdout = "" var line: string + # ponytail: poll instead of blocking readLine; 50ms sleep is fine for battle timescales while epochTime() < deadline: if p.peekExitCode() != -1: - # Process finished; drain remaining output - while p.outputStream.readLine(line): + # Process finished; drain remaining output without blocking + while p.outputStream.atEnd == false: + discard p.outputStream.readLine(line) stdout.add(line & "\n") break - if p.outputStream.readLine(line): - stdout.add(line & "\n") + sleep(50) if epochTime() >= deadline and p.peekExitCode() == -1: p.terminate() diff --git a/tools/battle_runner/TestBattleRunner.java b/tools/battle_runner/TestBattleRunner.java index 994bc48..1d719b9 100644 --- a/tools/battle_runner/TestBattleRunner.java +++ b/tools/battle_runner/TestBattleRunner.java @@ -15,7 +15,7 @@ import java.util.stream.Collectors; public class TestBattleRunner { 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; int rounds = 1; @@ -38,11 +38,20 @@ public class TestBattleRunner { final int numRounds = rounds; 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())) { var owner = new Object(); + System.err.println("[DEBUG] bots: " + bots); try (var handle = runner.startBattleAsync(setup, bots)) { 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() .map(p -> "\"" + escape(p.getName()) + "\"") .collect(Collectors.joining(",")); @@ -51,6 +60,7 @@ public class TestBattleRunner { }); handle.getOnRoundEnded().on(owner, event -> { + System.err.println("[DEBUG] round_ended results count: " + event.getResults().size()); var results = event.getResults().stream().map(r -> "{\"name\":\"" + escape(r.getName()) + "\"" + ",\"score\":" + r.getTotalScore() +