feat(tools): the real DrussGT now plays and wins Tank Royale battles

The unmodified DrussGT.jar connects, wave-surfs, fires and beats every
adversary we have. 5 rounds each, all rounds won:
  SpinBot 542-16, Corners 823-4, Crazy 604-0, RamFire 900-0,
  ModularBot 506-72.

It is genuinely surfing, not drifting or stalling. Movement statistics
against the classic captures, same metrics, same analyzer:

  opp        perp TR/classic   reversing TR/cl   median range TR/cl
  SpinBot    0.967 / 0.962     0.466 / 0.464     462 / 402
  Corners    0.894 / 0.920     0.462 / 0.419     520 / 504
  Crazy      0.841 / 0.829     0.527 / 0.439     370 / 378
  RamFire    0.762 / 0.651     0.533 / 0.417     326 / 283
  ModularBot 0.956 / -         0.466 / -         453 / -

Every round starts moving within 3-11 ticks and runs at 75-82% full speed.

Implemented: the classic remaining-quantity motion model (delegated to the
TR Bot's own Nat-Pavasant model - identical constants: accel 1, decel -2,
max 8, body turn 10-0.75|v|, gun 20, radar 45 - so getDistanceRemaining and
getTurnRemaining are exactly self-consistent with what is emulated); event
synthesis with classic ordering; bullet identity via object identity; gun
heat; rounds; radar cadence; firing translation; and the ThreadManager
landmine is killed by installing a no-op IThreadManagerBase in
ContainerBase.instance (verified: without it 'RobotException: ThreadManager
cannot be null!' kills the bot thread; with it the write succeeds).

Also adds TrBattleCapture, an observer that dumps per-tick state in the SAME
JSONL fixture format as the classic capture, so legacy bots can be captured
from Tank Royale battles too.

HONEST DIVERGENCES (README section 5.9): the TR server moves along the
PRE-turn heading and then turns, while classic aligns displacement with the
POST-turn heading, so the analyzer's conversion error is ~1.5 deg rather than
0.000 deg; distanceRemaining decrements by target speed rather than actual
distance; collision clamping differs; BulletMissedEvent can fire less often
because age-expiry has no TR event; bulletId is a local temp id;
StatusEvent/onPaint/SkippedTurnEvent are never delivered. Physics fidelity
diverges by construction - expect to retune.

EnergyDomeWorker (the bullet shield) is off by default: its precise
bullet-detection warm-up makes round 1 up to 79% stationary vs 35% in
classic. Rounds 2+ match classic closely with it on, but the pure surfer is
the consistent path. DRUSSGT_SHIELD=1 re-enables it.

Jars remain out of git.
This commit is contained in:
2026-09-21 00:29:37 +02:00
parent d5061ee215
commit 8e2be6a4c6
11 changed files with 1221 additions and 99 deletions
+210 -32
View File
@@ -1,4 +1,4 @@
# Robocode shim spike — running the real DrussGT as a Tank Royale bot # Robocode shim — running the real DrussGT as a Tank Royale bot
**Question:** can the *unmodified* legacy DrussGT jar be driven from a shim, and **Question:** can the *unmodified* legacy DrussGT jar be driven from a shim, and
which is cheaper — (A) re-implement `robocode.*` or (B) reuse the genuine which is cheaper — (A) re-implement `robocode.*` or (B) reuse the genuine
@@ -10,8 +10,14 @@ delegates to a `peer` interface, and that seam is public. DrussGT compiles with
its own event handlers and emits 200 ticks of movement intents + 169 fire its own event handlers and emits 200 ticks of movement intents + 169 fire
requests through our peer. requests through our peer.
This is an **API-coverage / architecture spike**, not a working bot. No physics This was an **API-coverage / architecture spike** and is now a **working
is implemented; the peer's world model is stubbed. bridge**: the real, unmodified DrussGT jar connects to a Tank Royale server,
plays real battles, wave-surfs and fires. The physics/engine semantics are
supplied by the shim (`ClassicPeer` + `DrussGTBridge`) and documented in §5;
the measured movement statistics against classic-Robocode captures are in §5.8.
> **Status:** COMPLETE (Java-only). See §5 for what was implemented, §5.8 for the
> verification evidence, and §5.9 for the honest list of physics divergences.
--- ---
@@ -330,30 +336,190 @@ class jk.mega.dMove.Scan implements java.lang.Cloneable {
--- ---
## 5. Remaining work to go from "compiles" to "runs a battle" ## 5. The bridge is implemented (Java-only, working)
Everything below is now the *inverse* problem: the API is complete, but the The five remaining-work items are done. The bridge is `DrussGTBridge` (a Tank
**engine semantics** behind it are not implemented. Royale `Bot`) + `ClassicPeer` (the genuine-jar peer) + `BotHost` (child
classloader) + `ThreadManagerFix`.
| # | Item | Status | Notes | | # | Item | Status | What was done |
|---|---|---|---| |---|---|---|---|
| 1 | Coordinate rotation at the boundary | **[MEASURED]** | Pure function; conversion `trRad = PI/2 - classicRad` already validated to 0.000–0.001° against recorded DrussGT motion (`tools/fixtures/DRUSSGT_FIXTURES.md`). Implemented in `TankRoyaleBridge`. Cheap. | | 1 | Coordinate rotation | **DONE** | Every TR angle is converted on read (`tankRadToClassicRad`) and every classic intent on write (`toDegrees` of the classic signed value). See `TankRoyaleBridge`. |
| 2 | Async tick ↔ synchronous `execute()` bridge | **[MEASURED]** | Implemented in `ClassicPeer.provideTurn/awaitExecutedTurns`; proven by the 200-tick smoke run. Real integration still needs a Tank Royale Java websocket/JSON client to *produce* those ticks. | | 2 | `RobocodeFileOutputStream` / ThreadManager | **DONE** | `ThreadManagerFix.install()` registers a no-op `IThreadManagerBase` in `ContainerBase.instance`, so `DrussGT.contain()` writes a normal file instead of throwing `RobotException: ThreadManager cannot be null!` and killing the thread. Proven by `ThreadManagerFixTest`. |
| 3 | `RobocodeFileOutputStream` engine dependency | **[MEASURED]** | `javap -c` shows it resolves `IThreadManagerBase` via `ContainerBase`. Outside the engine it throws `RobotException` and kills the bot thread. Reached only from `DrussGT.contain()`. Fix: register a no-op `IThreadManagerBase` in `ContainerBase`, or replace `contain`'s writer (can't — bot is unmodified), or accept that any bot exception is fatal. This bit us in testing. | | 3 | Classic motion model | **DONE** | The peer's captured `setAhead`/`setTurn*` are forwarded to the TR `Bot`'s own classic motion model (`setForward`/`setTurnRight`/`setTurnGunRight`/`setTurnRadarRight`), which is Nat Pavasant's optimal-velocity model using the same constants (`ACCELERATION=1`, `DECELERATION=-2`, `MAX_SPEED=8`, body turn `10-0.75·|v|`, gun `20`, radar `45`). `getDistanceRemaining()`/`getTurnRemaining()` delegate to the same model, so they are exactly consistent with what is emulated. |
| 4 | Classic motion model (`setAhead` distance vs TR speed) | **[MEASURED] + [ESTIMATE]** | DrussGT calls `getDistanceRemaining()` (ours) and `setAhead(d)`; Tank Royale wants a *target speed* + *turn rate*. The bridge must integrate a classic motion model (`ACCELERATION=1`, `DECELERATION=2`, `MAX_VELOCITY=8`, turn rate `10-0.75·|v|`) in front of TR, or approximate. DrussGT *also* has its own `MovePredictor` using classic accel/decel, so divergence is baked in regardless. [ESTIMATE: 1–3 days] | | 4 | Event synthesis & ordering | **DONE** | TR events are mapped to classic events and delivered synchronously; TR's event queue priorities are the classic priorities, so ordering matches. `ScannedRobotEvent`, `HitByBulletEvent`, `BulletHitEvent`, `BulletHitBulletEvent`, `BulletMissedEvent`, `HitRobotEvent`, `HitWallEvent`, `RobotDeathEvent`, `WinEvent`, `DeathEvent`. `SkippedTurnEvent` is deliberately never delivered. |
| 5 | Event synthesis & order | **[MEASURED]** | All classic event constructors are public and confirmed by javap. `ClassicPeer.dispatch` already routes ScannedRobot/HitByBullet/BulletHit/BulletHitBullet/BulletMissed/HitRobot/HitWall/RobotDeath/Win/Death/Status/SkippedTurn/Custom. Still to do: emit them from TR events and honour classic priority ordering. | | 5 | Physics / state / bullets / rounds / radar | **DONE** | Energy, position, headings, speed, gun heat and cooling rate are read live from the TR bot; `getRoundNum()=roundNumber-1`, `getNumRounds`, `getOthers`, `getTime()=turnNumber`. Bullet identity is tracked: the `Bullet` returned by `setFireBullet` is the *same instance* later handed back in `BulletHit*` events, so DrussGT's `Bullet.equals` (which compares class + `bulletId`) and identity checks both work. Radar/gun adjust flags are forwarded. |
| 6 | `getGunHeat()` / gun cooling | **[MEASURED]** | `DrussMoveGT` tracks enemy gun heat itself from `getGunCoolingRate()` (classic value 0.1). `ClassicPeer` returns a configurable value; the bridge must model *our* heat with the same decrement so `getGunHeat`/`getGunTurnRemaining` are consistent. |
| 7 | `Bullet` identity / own-bullet tracking | **[ESTIMATE]** | DrussGT keeps `Bullet` objects from `setFireBullet` and matches them in `onBulletHit`/`HitByBulletEvent.getBullet()` (uses `equals`). The bridge must map TR bullet ids to stable classic `Bullet` instances. | ### 5.1 How the tick contract works
| 8 | Firing translation | **[ESTIMATE]** | `setFire(power)`/`setFireBullet` → TR fire command; `setFireBullet` must return a `Bullet` synchronously. |
| 9 | Rounds / counts | **[ESTIMATE]** | `getRoundNum`, `getNumRounds`, `getOthers`, `getNumSentries` map to TR round info; `DrussMoveGT` gates flattener learning on `getRoundNum() > 1 / > 4 / < 15`, so these must be real, not constants. | DrussGT's own `run()` is executed **on the Tank Royale bot thread** (this is
| 10 | Radar / scan cadence | **[ESTIMATE]** | Classic `ScannedRobotEvent` cadence is a swept radar; TR provides its own scan events. Must be synthesised with correct `bearing`/`distance` so `DrussMoveGT.onScannedRobot` sees true geometry. | exactly where the official `robocode-api-bridge` runs a legacy robot). Its
| 11 | `onPaint` / AWT | **[MEASURED]** | `ClassicPeer.getGraphics()` returns `null`; DrussGT's `onPaint(Graphics2D)` (157 lines, debug only) can be skipped. `setColors`/`Color` are accepted and ignored. | `execute()` calls back into `ClassicPeer.TickHost.onExecute()`, which:
| 12 | `getAllEvents()` on death | **[ESTIMATE]** | `DrussGT.contain`/`onDeath` replays events; peer already returns the turn's accumulated events. |
| 13 | Class-loading topology | **[MEASURED]** | Must load DrussGT with a child `URLClassLoader` whose parent holds `robocode.jar`, so `IBasicRobot` is the same class. Implemented in `BotHost`. | 1. applies the captured intents (`setAhead` → `setForward`, …) to the TR bot,
| 14 | Physics fidelity | **[ESTIMATE]** | Even with a perfect bridge, TR's `maxTurn = 10 - 0.75·|speed|`, ±1 ramp and bullet model differ from classic. Exact trajectory match is impossible; aim for "same decisions, diverging trajectories". | 2. calls the TR `Bot.go()`.
`go()` sends the intent, waits for the next tick and then dispatches that tick's
events. Our `DrussGTBridge` overrides the TR event handlers and forwards each
one to the classic listeners via `ClassicPeer.deliver`. Because the TR `Bot`
model dispatches the *new* turn's events before `run()`'s next iteration, the
classic contract "events for turn N are delivered before the turn-N run() body"
holds, as does the classic "new robot instance per round, static state survives"
lifetime (a fresh DrussGT is instantiated each round).
### 5.2 Classic motion model mapping (exact)
| classic (peer) | Tank Royale |
|---|---|
| `setAhead(d)` / `setMove(d)` | `setForward(d)` |
| `setTurnRightRadians(r)` | `setTurnRight(toDegrees(r))` |
| `setTurnGunRightRadians(r)` | `setTurnGunRight(toDegrees(r))` |
| `setTurnRadarRightRadians(r)` | `setTurnRadarRight(toDegrees(r))` |
| `setMaxVelocity(v)` / `setMaxTurnRate(r)` | `setMaxSpeed(v)` / `setMaxTurnRate(r)` |
| `setAdjustGunForRobotTurn` | `setAdjustGunForBodyTurn` |
| `setAdjustRadarForRobotTurn` / `setAdjustRadarForGunTurn` | `setAdjustRadarForBodyTurn` / `setAdjustRadarForGunTurn` |
| `setFireBullet(p)` | `setFire(p)` + create/return a `robocode.Bullet` |
| `execute()` | `go()` |
| `getDistanceRemaining()` | `getDistanceRemaining()` |
| `getTurnRemaining()` / gun / radar | `-toRadians(getTurnRemaining())` … (TR positive = left) |
A command is only forwarded when DrussGT actually issued it that turn (dirty
flags), because classic remaining-quantities persist until overwritten.
### 5.3 Bullet identity
At `setFireBullet(p)` the bridge calls the TR `setFire(p)`; if accepted it
creates a plain `robocode.Bullet` (never a subclass — `Bullet.equals` compares
`getClass()`), stores it in `unmatchedFired`, and returns it. When the TR
`BulletFiredEvent` arrives the nearest unmatched bullet is bound to the TR
`bulletId`. Later `BulletHitBotEvent` / `BulletHitBulletEvent` /
`BulletHitWallEvent` look the id up and pass **the same instance** to DrussGT, so
`e.getBullet().equals(w.bullet)` in `DrussGunDC.onBulletHitBullet` is true.
Position/`isActive` are updated by reflection (best effort).
### 5.4 Shield (EnergyDome) handling
DrussGT's `EnergyDomeWorker` ("shield") is a separate *precise* subsystem that
detects enemy bullets from per-scan energy drops. Its first-round warm-up is
sensitive to exact classic timing. The proven path (also used by `SmokeTest`) is
to run the **pure wave surfer** (`DrussMoveGT`): the bridge sets
`DrussGT.shieldEnabled = false` via `BotHost.disableShield()` unless
`DRUSSGT_SHIELD=1`. This is the configuration the statistics in §5.8 use.
### 5.5 Event mapping details / gotchas honoured
* `ScannedRobotEvent(name, energy, bearing, distance, heading, velocity)` — the
documented, non-intuitive argument order; `bearing` is the classic body-relative
bearing and `heading` the enemy's classic absolute heading.
* `BulletHitWallEvent` (TR) → `BulletMissedEvent` (classic).
* `BotDeathEvent` → `RobotDeathEvent`; `WonRoundEvent` → `WinEvent`; TR
`DeathEvent` → classic `DeathEvent`.
* `HitWallEvent` bearing is computed from the nearest wall normal.
* `onPaint`/AWT, `StatusEvent`, `SkippedTurnEvent`, `onCustomEvent` are not
delivered (DrussGT does not use them; `getGraphics()` returns `null`).
### 5.6 Class-loading topology
`BotHost` loads `jk.mega.DrussGT` in a child `URLClassLoader` whose parent is the
shim's loader (which holds the genuine `robocode.jar`), so `IBasicRobot` is the
same type the peer implements. One DrussGT class is loaded per JVM, so the static
KD-trees persist across rounds exactly like classic.
### 5.7 How to run it
```bash
export ROBOCODE_JAR=/tmp/robocode/install/libs/robocode.jar
export TR_BOT_API_JAR=$HOME/Downloads/sample-bots-java-1.0.2/lib/robocode-tankroyale-bot-api-1.0.2.jar
export DRUSSGT_JAR=/tmp/drussgt/DrussGT.jar
./build.sh
./run_smoke.sh # standalone 200-tick proof
java -cp "out:$ROBOCODE_JAR" robocode_shim.ThreadManagerFixTest # item 3 proof
# real battle + movement fixture (embedded server, boots the bot dir):
./run_bridge_battle.sh /home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot 5
```
`make_botdir.sh [DIR]` generates the Tank Royale bot directory (a `<DIR>.json`
identity file and a `<DIR>.sh` launcher that runs `robocode_shim.DrussGTBridge`).
`TrBattleCapture` is the observer: it runs the battle through the Tank Royale
battle-runner API and dumps every bot's per-turn state in the *same* JSONL
fixture format as `tools/robocode_fixture_capture/Capture.java`, so
`analyze.py` runs on both unchanged.
### 5.8 Verification — it is really DrussGT, not a stub
All runs: embedded Tank Royale server, `maxSpeed`, shield disabled (pure surfer),
5 rounds each, against the same sample bots whose classic captures are in
`tools/fixtures/`. Stats computed by `tools/robocode_fixture_capture/analyze.py`
on the TR capture (5 rounds) and the 20-round classic fixture.
**Battle outcomes** (DrussGT first, total score over 5 rounds):
| opponent | DrussGT | opponent | rounds won | bullets fired / hits / misses |
|---|---:|---:|---:|---:|
| SpinBot | **542** | 16 | 5/5 | 327 / 39 / 277 |
| Corners | **823** | 4 | 5/5 | 79 / 36 / 31 |
| Crazy | **604** | 0 | 5/5 | 332 / 45 / 274 |
| RamFire | **900** | 0 | 5/5 | 54 / 38 / 12 |
| ModularBot (Nim, real) | **506** | 72 | 5/5 | 491 / 56 / 418 (+5 bullet-bullet; took 18 hits) |
**Movement statistics** (TR vs the matching classic capture):
| opponent | metric | TR (bridge) | classic fixture |
|---|---|---:|---:|
| SpinBot | perp frac / radial frac | **0.967 / 0.000** | 0.962 / 0.001 |
| | full speed ≥7.5 / rest <0.5 / reversing | 0.781 / 0.017 / 0.466 | 0.701 / 0.067 / 0.464 |
| | median range (px) | 462 | 402 |
| Corners | perp / radial | **0.894 / 0.002** | 0.920 / 0.004 |
| | full / rest / reversing | 0.754 / 0.028 / 0.462 | 0.586 / 0.092 / 0.419 |
| | median range | 520 | 504 |
| Crazy | perp / radial | **0.841 / 0.003** | 0.829 / 0.010 |
| | full / rest / reversing | 0.811 / 0.017 / 0.527 | 0.668 / 0.137 / 0.439 |
| | median range | 370 | 378 |
| RamFire | perp / radial | **0.762 / 0.002** | 0.651 / 0.013 |
| | full / rest / reversing | 0.747 / 0.043 / 0.533 | 0.723 / 0.046 / 0.417 |
| | median range | 326 | 283 |
| ModularBot | perp / radial | **0.956 / 0.000** | — |
The perpendicular-to-opponent fraction, the near-zero radial fraction and the
~42–53% reversal rate are the signature of DrussGT's wave surfer and match the
classic captures closely. The TR run is slightly faster / shorter-ranged because
the shield is off and the rounds are shorter (5 vs 20). Per-round, every round
now starts moving within 3–11 ticks and runs at 75–82% full speed. **This is not
a straight line, not a standstill, and not a crash.**
### 5.9 Known physics divergences (honest list)
1. **Move/turn ordering.** The TR server calls `moveToNewPosition()` using the
*pre-turn* heading and only then applies the turn, whereas the classic capture
aligns each tick's displacement with the *post-turn* heading
(`analyze.py` conversion error 0.000° classic vs ~1.5° TR). Trajectories
therefore diverge even with identical intents.
2. **Distance bookkeeping.** The TR `Bot` motion model decrements
`distanceRemaining` by the *target* speed (Nat Pavasant's over-drive method);
classic decrements by the distance actually travelled, and collision/wall
clamping differs.
3. **No exact trajectory equivalence.** Bullet speeds/damage match
(`20-3p`, `4p (+2(p-1) if p>1)`), gun heat matches (`1+p/5`, cool 0.1/turn),
turn rates match — but the integration order above means "same decisions,
diverging trajectories" is the ceiling.
4. **First-round shield warm-up.** With the shield *enabled*
(`DRUSSGT_SHIELD=1`) round 1 can be 79% stationary vs 35% in classic, because
the shield's precise bullet detection is timing-sensitive. Disabled by
default.
5. **`BulletMissedEvent`.** TR emits `BulletHitWallEvent` on wall hits; bullets
that expire by age may not produce an event, so `onBulletMissed` can fire less
often than classic. DrussGT does not use it.
6. **`bulletId`.** The classic `Bullet`'s `bulletId` is a local temporary id, not
the TR server id; identity is preserved by object identity, not by id.
7. **`onPaint`/AWT, `StatusEvent`, `SkippedTurnEvent`, custom events** are not
delivered. DrussGT does not rely on them (`onPaint` is debug-only).
8. **Tick phase.** TR `getTurnNumber()` is 1-based like classic `getTime()`, but
the observer snapshot phase differs by the move/turn ordering above; per-tick
state read by the bot is always the current turn's.
There are **no remaining API unknowns**: the compiler resolved everything, and There are **no remaining API unknowns**: the compiler resolved everything, and
the smoke test exercised the full event → decision → intent path. the bridge now runs full battles, surf and fire.
--- ---
@@ -362,34 +528,46 @@ the smoke test exercised the full event → decision → intent path.
```text ```text
tools/robocode_shim/ tools/robocode_shim/
README.md this report README.md this report
build.sh compile the shim (ClassicPeer/BotHost/Bridge/SmokeTest) build.sh compile the shim + capture
compile_drussgt.sh ACCEPTANCE TEST: javac the real DrussGT sources compile_drussgt.sh ACCEPTANCE TEST: javac the real DrussGT sources
run_smoke.sh load + drive the real jar, assert intents run_smoke.sh load + drive the real jar, assert intents (standalone)
make_botdir.sh generate a Tank Royale bot dir that boots the bridge
run_bridge_battle.sh run a real TR battle + movement statistics
.gitignore out/ and *.jar never committed .gitignore out/ and *.jar never committed
src/robocode_shim/ src/robocode_shim/
ClassicPeer.java IAdvancedRobotPeer impl + execute() tick bridge ClassicPeer.java IAdvancedRobotPeer impl + world/tick seams
BotHost.java URLClassLoader + setPeer + run() thread BotHost.java URLClassLoader + setPeer + per-round instances
TankRoyaleBridge.java angle conversion + documented tick contract (skeleton) TankRoyaleBridge.java exact angle conversion
SmokeTest.java the runtime proof ThreadManagerFix.java no-op IThreadManagerBase in ContainerBase
ThreadManagerFixTest.java proof of the above
DrussGTBridge.java the Tank Royale Bot that hosts DrussGT
SmokeTest.java the standalone runtime proof
TrBattleCapture.java observer: battle -> classic JSONL fixture format
out/ build output (gitignored) out/ build output (gitignored)
``` ```
```bash ```bash
export ROBOCODE_JAR=/tmp/robocode/install/libs/robocode.jar export ROBOCODE_JAR=/tmp/robocode/install/libs/robocode.jar
export TR_BOT_API_JAR=$HOME/Downloads/sample-bots-java-1.0.2/lib/robocode-tankroyale-bot-api-1.0.2.jar
export DRUSSGT_JAR=/tmp/drussgt/DrussGT.jar export DRUSSGT_JAR=/tmp/drussgt/DrussGT.jar
./build.sh ./build.sh
./compile_drussgt.sh ./compile_drussgt.sh
./run_smoke.sh ./run_smoke.sh
./run_bridge_battle.sh /home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot 5
``` ```
## 7. Third-party jars ## 7. Third-party jars
Per the task constraints, **no third-party jar is copied into the repo.** Per the task constraints, **no third-party jar is copied into the repo.**
`robocode.jar` (Robocode 1.9.5.5) stays at `robocode.jar` (Robocode 1.9.5.5) stays at
`/tmp/robocode/install/libs/robocode.jar` and `DrussGT.jar` at `/tmp/robocode/install/libs/robocode.jar`, `DrussGT.jar` at
`/tmp/drussgt/DrussGT.jar`; the scripts reference them via `ROBOCODE_JAR` / `/tmp/drussgt/DrussGT.jar`, the Tank Royale Java bot API at
`DRUSSGT_JAR`. `tools/robocode_shim/.gitignore` additionally blocks `*.jar` and `~/Downloads/sample-bots-java-1.0.2/lib/robocode-tankroyale-bot-api-1.0.2.jar`
`out/` in case a jar is ever dropped here by mistake. and the Tank Royale battle runner at
`/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar`;
the scripts reference them via `ROBOCODE_JAR`, `DRUSSGT_JAR`, `TR_BOT_API_JAR`
and `TR_RUNNER_JAR`. `tools/robocode_shim/.gitignore` additionally blocks
`*.jar` and `out/` in case a jar is ever dropped here by mistake.
## 8. Gotchas discovered (for whoever wires the bridge) ## 8. Gotchas discovered (for whoever wires the bridge)
+27 -4
View File
@@ -1,24 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the Approach-(B) shim: the ClassicPeer driver + host + bridge. # Build the Approach-(B) shim.
# Requires JAVA_HOME/javac on PATH and the genuine Robocode 1.9.5.5 jar.
# #
# ROBOCODE_JAR=/path/to/robocode.jar ./build.sh # Requires JAVA_HOME/javac on PATH, the genuine Robocode 1.9.5.5 jar (for the
# robocode.* API the peer implements) and the Tank Royale Java bot API jar (for
# DrussGTBridge / TrBattleCapture).
#
# ROBOCODE_JAR=/path/to/robocode.jar TR_BOT_API_JAR=/path/to/bot-api.jar ./build.sh
set -euo pipefail set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROBOCODE_JAR="${ROBOCODE_JAR:-/tmp/robocode/install/libs/robocode.jar}" ROBOCODE_JAR="${ROBOCODE_JAR:-/tmp/robocode/install/libs/robocode.jar}"
TR_BOT_API_JAR="${TR_BOT_API_JAR:-$HOME/Downloads/sample-bots-java-1.0.2/lib/robocode-tankroyale-bot-api-1.0.2.jar}"
OUT="${OUT:-$HERE/out}" OUT="${OUT:-$HERE/out}"
RUNNER_JAR="${TR_RUNNER_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}"
if [[ ! -f "$ROBOCODE_JAR" ]]; then if [[ ! -f "$ROBOCODE_JAR" ]]; then
echo "robocode.jar not found: $ROBOCODE_JAR" >&2 echo "robocode.jar not found: $ROBOCODE_JAR" >&2
echo "Set ROBOCODE_JAR to a Robocode 1.9.5.5 install's libs/robocode.jar" >&2 echo "Set ROBOCODE_JAR to a Robocode 1.9.5.5 install's libs/robocode.jar" >&2
exit 1 exit 1
fi fi
if [[ ! -f "$TR_BOT_API_JAR" ]]; then
echo "Tank Royale bot API jar not found: $TR_BOT_API_JAR" >&2
echo "Set TR_BOT_API_JAR to robocode-tankroyale-bot-api-*.jar" >&2
exit 1
fi
mkdir -p "$OUT" mkdir -p "$OUT"
javac -nowarn -cp "$ROBOCODE_JAR" -d "$OUT" \ javac -nowarn -cp "$ROBOCODE_JAR:$TR_BOT_API_JAR" -d "$OUT" \
"$HERE/src/robocode_shim/ClassicPeer.java" \ "$HERE/src/robocode_shim/ClassicPeer.java" \
"$HERE/src/robocode_shim/BotHost.java" \ "$HERE/src/robocode_shim/BotHost.java" \
"$HERE/src/robocode_shim/TankRoyaleBridge.java" \ "$HERE/src/robocode_shim/TankRoyaleBridge.java" \
"$HERE/src/robocode_shim/ThreadManagerFix.java" \
"$HERE/src/robocode_shim/ThreadManagerFixTest.java" \
"$HERE/src/robocode_shim/DrussGTBridge.java" \
"$HERE/src/robocode_shim/SmokeTest.java" "$HERE/src/robocode_shim/SmokeTest.java"
echo "shim built -> $OUT" echo "shim built -> $OUT"
# The observer/capture program needs the battle-runner jar (which bundles the
# Tank Royale client model + embedded server) rather than the bot API.
if [[ -f "$RUNNER_JAR" ]]; then
javac -nowarn -cp "$RUNNER_JAR" -d "$OUT" \
"$HERE/src/robocode_shim/TrBattleCapture.java"
echo "capture built -> $OUT"
else
echo "runner jar not found ($RUNNER_JAR); skipped TrBattleCapture" >&2
fi
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Generate a Tank Royale bot directory that boots the DrussGT bridge.
#
# tools/robocode_shim/make_botdir.sh [TARGET_DIR]
#
# The generated directory contains only text files (<dir>.json + <dir>.sh); the
# third-party jars stay outside the repo and are referenced by absolute path.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="${1:-/tmp/tr_bots/DrussGT}"
NAME="$(basename "$TARGET")"
ROBOCODE_JAR="${ROBOCODE_JAR:-/tmp/robocode/install/libs/robocode.jar}"
TR_BOT_API_JAR="${TR_BOT_API_JAR:-$HOME/Downloads/sample-bots-java-1.0.2/lib/robocode-tankroyale-bot-api-1.0.2.jar}"
OUT="${OUT:-$HERE/out}"
mkdir -p "$TARGET"
cat > "$TARGET/$NAME.json" <<JSON
{
"name": "DrussGT",
"version": "3.1.4159",
"authors": ["Jacob"],
"description": "Real classic DrussGT running on Tank Royale via the robocode_shim bridge",
"homepage": "",
"countryCodes": ["se"],
"gameTypes": ["classic", "1v1"],
"platform": "JVM",
"programmingLang": "Java 8"
}
JSON
cat > "$TARGET/$NAME.sh" <<SH
#!/bin/sh
cd "\$(dirname "\$0")"
exec java -cp "$OUT:$ROBOCODE_JAR:$TR_BOT_API_JAR" robocode_shim.DrussGTBridge
SH
chmod +x "$TARGET/$NAME.sh"
echo "bot dir ready: $TARGET"
echo " $TARGET/$NAME.json"
echo " $TARGET/$NAME.sh"
echo " (classpath: $OUT:$ROBOCODE_JAR:$TR_BOT_API_JAR)"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run a real Tank Royale battle with the DrussGT bridge against an adversary and
# dump the movement fixture + statistics.
#
# tools/robocode_shim/run_bridge_battle.sh <adversaryBotDir> [rounds] [outJsonl]
#
# Example:
# tools/robocode_shim/run_bridge_battle.sh \
# /home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot 5
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
ADVERSARY="${1:?usage: run_bridge_battle.sh <adversaryBotDir> [rounds] [outJsonl]}"
ROUNDS="${2:-5}"
OUT_JSONL="${3:-/tmp/tr_drussgt_$(basename "$ADVERSARY" | tr 'A-Z' 'a-z').jsonl}"
RUNNER="${TR_RUNNER_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}"
export DRUSSGT_JAR="${DRUSSGT_JAR:-/tmp/drussgt/DrussGT.jar}"
export DRUSSGT_DATA="${DRUSSGT_DATA:-/tmp/drussgt_bridge_data}"
BOTDIR="${DRUSSGT_BOTDIR:-/tmp/tr_bots/DrussGT}"
"$HERE/make_botdir.sh" "$BOTDIR" >/dev/null
java -cp "$HERE/out:$RUNNER" robocode_shim.TrBattleCapture \
--rounds "$ROUNDS" --out "$OUT_JSONL" \
--bot "$BOTDIR" --bot "$ADVERSARY" \
--subject DrussGT --adversary "$(basename "$ADVERSARY")"
echo
echo "=== movement statistics (TR) ==="
python3 "$REPO/tools/robocode_fixture_capture/analyze.py" "$OUT_JSONL"
@@ -32,12 +32,14 @@ import java.util.List;
public class BotHost { public class BotHost {
public final ClassicPeer peer; public final ClassicPeer peer;
public final IBasicRobot robot; public volatile IBasicRobot robot;
public final URLClassLoader loader; public final URLClassLoader loader;
private final File dataDir;
private final Thread thread; private final Thread thread;
private volatile Throwable botError; private volatile Throwable botError;
public BotHost(File drussgtJar, File dataDir) throws Exception { public BotHost(File drussgtJar, File dataDir) throws Exception {
this.dataDir = dataDir;
this.peer = new ClassicPeer(dataDir); this.peer = new ClassicPeer(dataDir);
this.loader = new URLClassLoader( this.loader = new URLClassLoader(
new URL[]{drussgtJar.toURI().toURL()}, new URL[]{drussgtJar.toURI().toURL()},
@@ -59,6 +61,19 @@ public class BotHost {
this.thread.setDaemon(true); this.thread.setDaemon(true);
} }
/**
* Classic Robocode gives every round a fresh robot instance (only static
* fields survive). DrussGT sets up per-round state in run(), but mirroring
* the engine's instance lifetime removes a whole class of cross-round bugs.
*/
public IBasicRobot newRobot() throws Exception {
Class<?> cls = loader.loadClass("jk.mega.DrussGT");
IBasicRobot r = (IBasicRobot) cls.getDeclaredConstructor().newInstance();
r.setPeer(peer);
r.setOut(new PrintStream(new File(dataDir, "bot.out")));
return r;
}
/** Optional: force the shield subsystem off so movement runs its pure surfer path. */ /** Optional: force the shield subsystem off so movement runs its pure surfer path. */
public void disableShield() throws Exception { public void disableShield() throws Exception {
java.lang.reflect.Field f = loader.loadClass("jk.mega.DrussGT").getDeclaredField("shieldEnabled"); java.lang.reflect.Field f = loader.loadClass("jk.mega.DrussGT").getDeclaredField("shieldEnabled");
@@ -5,7 +5,6 @@ import robocode.Condition;
import robocode.Event; import robocode.Event;
import robocode.robotinterfaces.IBasicEvents; import robocode.robotinterfaces.IBasicEvents;
import robocode.robotinterfaces.IBasicRobot; import robocode.robotinterfaces.IBasicRobot;
import robocode.robotinterfaces.IAdvancedEvents;
import robocode.robotinterfaces.peer.IAdvancedRobotPeer; import robocode.robotinterfaces.peer.IAdvancedRobotPeer;
import java.awt.Color; import java.awt.Color;
@@ -19,24 +18,31 @@ import java.util.List;
* APPROACH (B) SEAM. * APPROACH (B) SEAM.
* *
* The genuine robocode.jar {@code AdvancedRobot}/{@code Robot} classes are pure * The genuine robocode.jar {@code AdvancedRobot}/{@code Robot} classes are pure
* delegates: every movement/fire setter forwards to * delegates: every movement/fire setter forwards to {@code _RobotBase.peer}, an
* {@code _RobotBase.peer}, an interface field of type * interface field of type {@link robocode.robotinterfaces.peer.IBasicRobotPeer}
* {@link robocode.robotinterfaces.peer.IBasicRobotPeer} (narrowed to * (narrowed to {@link IAdvancedRobotPeer} for advanced setters).
* {@link IAdvancedRobotPeer} for advanced setters). {@code _RobotBase.setPeer()} * {@code _RobotBase.setPeer()} is public+final, and
* is public+final, and {@code Robot.getRobotRunnable()} simply returns * {@code Robot.getRobotRunnable()} simply returns {@code this}.
* {@code this}.
* *
* Therefore we do NOT need to re-implement the robocode.* API (Approach A). * <p>Two modes:
* We only implement this peer interface, feed it world state, let the bot's own * <ul>
* {@code run()} thread block in {@link #execute()}, and read back the intents. * <li><b>standalone / synthetic</b> (SmokeTest): the public volatile world
* * fields are the world model and {@link #execute()} blocks on a lock fed by
* The getters here are a stubbed world model: this is an API/architecture * {@link #provideTurn(List)}. No physics.</li>
* spike, NOT physics. See TankRoyaleBridge for the translation layer that would * <li><b>battle</b>: a {@link WorldModel} is installed (the live Tank Royale
* fill these fields from a Tank Royale websocket tick. * bot) and a {@link TickHost} is installed. Getters then read the real TR
* state (converted), {@link #execute()} calls back into the host (which drives
* the TR tick), and fire is forwarded to the host so bullet identity can be
* tracked.</li>
* </ul>
*/ */
public class ClassicPeer implements IAdvancedRobotPeer { public class ClassicPeer implements IAdvancedRobotPeer {
// ---- world state (would be pushed from Tank Royale each tick) ---- // ---- optional live world model (battle mode) ----
public volatile WorldModel world;
public volatile TickHost tickHost;
// ---- static world state (standalone mode / captured intents) ----
public volatile String name = "DrussGT"; public volatile String name = "DrussGT";
public volatile long time = 0; public volatile long time = 0;
public volatile double energy = 100.0; public volatile double energy = 100.0;
@@ -59,15 +65,29 @@ public class ClassicPeer implements IAdvancedRobotPeer {
public volatile double gunTurnRemaining = 0.0; public volatile double gunTurnRemaining = 0.0;
public volatile double radarTurnRemaining = 0.0; public volatile double radarTurnRemaining = 0.0;
// ---- captured intents (the engine would normally read/execute these) ---- // ---- captured intents (the engine would normally read these) ----
public volatile double lastMove = 0.0; public volatile double lastMove = 0.0;
public volatile double lastTurnBody = 0.0; public volatile double lastTurnBody = 0.0;
public volatile double lastTurnGun = 0.0; public volatile double lastTurnGun = 0.0;
public volatile double lastTurnRadar = 0.0; public volatile double lastTurnRadar = 0.0;
/**
* "The robot issued this command this turn" flags. Classic
* {@code setAhead(d)} stores a remaining quantity that persists until it is
* overwritten, so the bridge must not re-apply a stale value every tick.
*/
public volatile boolean moveSet = false;
public volatile boolean turnBodySet = false;
public volatile boolean turnGunSet = false;
public volatile boolean turnRadarSet = false;
public volatile double lastFirePower = Double.NaN; public volatile double lastFirePower = Double.NaN;
public volatile double maxVelocity = Double.POSITIVE_INFINITY; public volatile double maxVelocity = Double.POSITIVE_INFINITY;
public volatile double maxTurnRate = Double.POSITIVE_INFINITY; public volatile double maxTurnRate = Double.POSITIVE_INFINITY;
public volatile boolean maxVelocitySet = false;
public volatile boolean maxTurnRateSet = false;
public volatile boolean stopRequested = false; public volatile boolean stopRequested = false;
public volatile boolean stopSet = false;
public volatile boolean adjustGunForBodyTurn = false; public volatile boolean adjustGunForBodyTurn = false;
public volatile boolean adjustRadarForGunTurn = false; public volatile boolean adjustRadarForGunTurn = false;
@@ -77,7 +97,7 @@ public class ClassicPeer implements IAdvancedRobotPeer {
public volatile Color bodyColor, gunColor, radarColor, bulletColor, scanColor; public volatile Color bodyColor, gunColor, radarColor, bulletColor, scanColor;
public final List<String> debugProperties = new ArrayList<>(); public final List<String> debugProperties = new ArrayList<>();
// ---- tick bridge ---- // ---- tick bridge (standalone mode) ----
private final Object lock = new Object(); private final Object lock = new Object();
private volatile boolean turnReady = false; private volatile boolean turnReady = false;
private volatile boolean shutdown = false; private volatile boolean shutdown = false;
@@ -100,54 +120,68 @@ public class ClassicPeer implements IAdvancedRobotPeer {
} }
// ================= world getters ================= // ================= world getters =================
@Override public String getName() { return name; } @Override public String getName() { WorldModel w = world; return w != null ? w.name() : name; }
@Override public long getTime() { return time; } @Override public long getTime() { WorldModel w = world; return w != null ? w.time() : time; }
@Override public double getEnergy() { return energy; } @Override public double getEnergy() { WorldModel w = world; return w != null ? w.energy() : energy; }
@Override public double getX() { return x; } @Override public double getX() { WorldModel w = world; return w != null ? w.x() : x; }
@Override public double getY() { return y; } @Override public double getY() { WorldModel w = world; return w != null ? w.y() : y; }
@Override public double getVelocity() { return velocity; } @Override public double getVelocity() { WorldModel w = world; return w != null ? w.velocity() : velocity; }
@Override public double getBodyHeading() { return bodyHeading; } @Override public double getBodyHeading() { WorldModel w = world; return w != null ? w.bodyHeading() : bodyHeading; }
@Override public double getGunHeading() { return gunHeading; } @Override public double getGunHeading() { WorldModel w = world; return w != null ? w.gunHeading() : gunHeading; }
@Override public double getRadarHeading() { return radarHeading; } @Override public double getRadarHeading() { WorldModel w = world; return w != null ? w.radarHeading() : radarHeading; }
@Override public double getGunHeat() { return gunHeat; } @Override public double getGunHeat() { WorldModel w = world; return w != null ? w.gunHeat() : gunHeat; }
@Override public double getBattleFieldWidth() { return battleFieldWidth; } @Override public double getBattleFieldWidth() { WorldModel w = world; return w != null ? w.battleFieldWidth() : battleFieldWidth; }
@Override public double getBattleFieldHeight() { return battleFieldHeight; } @Override public double getBattleFieldHeight() { WorldModel w = world; return w != null ? w.battleFieldHeight() : battleFieldHeight; }
@Override public int getOthers() { return others; } @Override public int getOthers() { WorldModel w = world; return w != null ? w.others() : others; }
@Override public int getNumSentries() { return numSentries; } @Override public int getNumSentries() { WorldModel w = world; return w != null ? w.numSentries() : numSentries; }
@Override public int getNumRounds() { return numRounds; } @Override public int getNumRounds() { WorldModel w = world; return w != null ? w.numRounds() : numRounds; }
@Override public int getRoundNum() { return roundNum; } @Override public int getRoundNum() { WorldModel w = world; return w != null ? w.roundNum() : roundNum; }
@Override public int getSentryBorderSize() { return sentryBorderSize; } @Override public int getSentryBorderSize() { WorldModel w = world; return w != null ? w.sentryBorderSize() : sentryBorderSize; }
@Override public double getGunCoolingRate() { return gunCoolingRate; } @Override public double getGunCoolingRate() { WorldModel w = world; return w != null ? w.gunCoolingRate() : gunCoolingRate; }
@Override public double getDistanceRemaining() { return distanceRemaining; } @Override public double getDistanceRemaining() { WorldModel w = world; return w != null ? w.distanceRemaining() : distanceRemaining; }
@Override public double getBodyTurnRemaining() { return bodyTurnRemaining; } @Override public double getBodyTurnRemaining() { WorldModel w = world; return w != null ? w.bodyTurnRemaining() : bodyTurnRemaining; }
@Override public double getGunTurnRemaining() { return gunTurnRemaining; } @Override public double getGunTurnRemaining() { WorldModel w = world; return w != null ? w.gunTurnRemaining() : gunTurnRemaining; }
@Override public double getRadarTurnRemaining() { return radarTurnRemaining; } @Override public double getRadarTurnRemaining() { WorldModel w = world; return w != null ? w.radarTurnRemaining() : radarTurnRemaining; }
// ================= movement / action intents ================= // ================= movement / action intents =================
@Override public void move(double distance) { lastMove = distance; } @Override public void move(double distance) { setMove(distance); }
@Override public void turnBody(double radians) { lastTurnBody = radians; } @Override public void turnBody(double radians) { setTurnBody(radians); }
@Override public void turnGun(double radians) { lastTurnGun = radians; } @Override public void turnGun(double radians) { setTurnGun(radians); }
@Override public void turnRadar(double radians) { lastTurnRadar = radians; } @Override public void turnRadar(double radians) { setTurnRadar(radians); }
@Override public void setMove(double distance) { lastMove = distance; }
@Override public void setTurnBody(double radians) { lastTurnBody = radians; }
@Override public void setTurnGun(double radians) { lastTurnGun = radians; }
@Override public void setTurnRadar(double radians) { lastTurnRadar = radians; }
@Override public void setMaxTurnRate(double rate) { maxTurnRate = rate; }
@Override public void setMaxVelocity(double velocity) { maxVelocity = velocity; }
@Override public Bullet fire(double power) { return makeBullet(power); } @Override public void setMove(double distance) {
@Override public Bullet setFire(double power) { return makeBullet(power); } lastMove = distance; moveSet = true;
}
@Override public void setTurnBody(double radians) { lastTurnBody = radians; turnBodySet = true; }
@Override public void setTurnGun(double radians) { lastTurnGun = radians; turnGunSet = true; }
@Override public void setTurnRadar(double radians) { lastTurnRadar = radians; turnRadarSet = true; }
private Bullet makeBullet(double power) { @Override public void setMaxTurnRate(double rate) { maxTurnRate = rate; maxTurnRateSet = true; }
@Override public void setMaxVelocity(double velocity) { maxVelocity = velocity; maxVelocitySet = true; }
@Override public Bullet fire(double power) {
TickHost h = tickHost;
if (h != null) {
return h.onFire(power);
}
lastFirePower = power; lastFirePower = power;
// Bullet(x, y, headingRadians, power, ownerName, victimName, isActive, bulletId) return makeBullet(power);
return new Bullet(x, y, gunHeading, power, name, null, true, executedTurns);
} }
@Override public void stop(boolean overwrite) { stopRequested = true; } @Override public Bullet setFire(double power) { return fire(power); }
@Override public void resume() { stopRequested = false; }
@Override public void setStop(boolean overwrite) { stopRequested = true; } /**
@Override public void setResume() { stopRequested = false; } * Standalone-mode bullet (battle mode creates its own via {@link TickHost#onFire}).
* NB: the genuine {@code Bullet} constructor is {@code (headingRadians, x, y, power, ...)}.
*/
private Bullet makeBullet(double power) {
return new Bullet(getGunHeading(), getX(), getY(), power, getName(), null, true, executedTurns);
}
@Override public void stop(boolean overwrite) { stopRequested = true; stopSet = true; }
@Override public void resume() { stopRequested = false; stopSet = true; }
@Override public void setStop(boolean overwrite) { stopRequested = true; stopSet = true; }
@Override public void setResume() { stopRequested = false; stopSet = true; }
@Override public void setAdjustGunForBodyTurn(boolean v) { adjustGunForBodyTurn = v; } @Override public void setAdjustGunForBodyTurn(boolean v) { adjustGunForBodyTurn = v; }
@Override public void setAdjustRadarForGunTurn(boolean v) { adjustRadarForGunTurn = v; } @Override public void setAdjustRadarForGunTurn(boolean v) { adjustRadarForGunTurn = v; }
@@ -157,6 +191,11 @@ public class ClassicPeer implements IAdvancedRobotPeer {
@Override public boolean isAdjustRadarForBodyTurn() { return adjustRadarForBodyTurn; } @Override public boolean isAdjustRadarForBodyTurn() { return adjustRadarForBodyTurn; }
@Override public void execute() { @Override public void execute() {
TickHost h = tickHost;
if (h != null) {
h.onExecute();
return;
}
synchronized (lock) { synchronized (lock) {
while (!turnReady && !shutdown) { while (!turnReady && !shutdown) {
try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
@@ -178,7 +217,7 @@ public class ClassicPeer implements IAdvancedRobotPeer {
private void dispatch(List<Event> events) { private void dispatch(List<Event> events) {
if (robot == null) return; if (robot == null) return;
IBasicEvents basic = robot.getBasicEventListener(); IBasicEvents basic = robot.getBasicEventListener();
IAdvancedEvents adv = null; robocode.robotinterfaces.IAdvancedEvents adv = null;
if (robot instanceof robocode.robotinterfaces.IAdvancedRobot) { if (robot instanceof robocode.robotinterfaces.IAdvancedRobot) {
adv = ((robocode.robotinterfaces.IAdvancedRobot) robot).getAdvancedEventListener(); adv = ((robocode.robotinterfaces.IAdvancedRobot) robot).getAdvancedEventListener();
} }
@@ -199,8 +238,13 @@ public class ClassicPeer implements IAdvancedRobotPeer {
} }
} }
/** Battle mode: forward one already-built classic event to the robot's listeners. */
public void deliver(Event e) {
dispatch(Collections.singletonList(e));
}
// ================= tick bridge control (driver side) ================= // ================= tick bridge control (driver side) =================
/** Driver: publish world state + this turn's events, then unblock the bot's execute(). */ /** Standalone driver: publish this turn's events, then unblock the bot's execute(). */
public void provideTurn(List<Event> events) { public void provideTurn(List<Event> events) {
synchronized (lock) { synchronized (lock) {
pendingEvents.addAll(events); pendingEvents.addAll(events);
@@ -210,7 +254,7 @@ public class ClassicPeer implements IAdvancedRobotPeer {
} }
} }
/** Driver: wait until the bot has finished consuming turn {@code n}. */ /** Standalone driver: wait until the bot has finished consuming turn {@code n}. */
public void awaitExecutedTurns(int n) { public void awaitExecutedTurns(int n) {
synchronized (lock) { synchronized (lock) {
while (executedTurns < n && !shutdown && !dead) { while (executedTurns < n && !shutdown && !dead) {
@@ -286,5 +330,41 @@ public class ClassicPeer implements IAdvancedRobotPeer {
@Override public long getDataQuotaAvailable() { return 1024L * 1024L; } @Override public long getDataQuotaAvailable() { return 1024L * 1024L; }
public List<Event> peekAllEvents() { return Collections.unmodifiableList(allEvents); } // ======================================================================
// Battle-mode seams implemented by the Tank Royale bridge.
// ======================================================================
/** Live, already-classic-converted world state read by the peer getters. */
public interface WorldModel {
String name();
long time();
double energy();
double x();
double y();
double velocity();
double bodyHeading();
double gunHeading();
double radarHeading();
double gunHeat();
double battleFieldWidth();
double battleFieldHeight();
int others();
int numSentries();
int numRounds();
int roundNum();
int sentryBorderSize();
double gunCoolingRate();
double distanceRemaining();
double bodyTurnRemaining();
double gunTurnRemaining();
double radarTurnRemaining();
}
/** Battle-mode tick + fire callbacks. */
public interface TickHost {
/** The robot asked to fire; return the classic Bullet if accepted, else null. */
Bullet onFire(double power);
/** The robot ended its turn: apply intents to the TR bot, then advance the TR tick. */
void onExecute();
}
} }
@@ -0,0 +1,456 @@
package robocode_shim;
import dev.robocode.tankroyale.botapi.BaseBot;
import dev.robocode.tankroyale.botapi.Bot;
import dev.robocode.tankroyale.botapi.BulletState;
import dev.robocode.tankroyale.botapi.events.BotDeathEvent;
import dev.robocode.tankroyale.botapi.events.BulletFiredEvent;
import dev.robocode.tankroyale.botapi.events.BulletHitBotEvent;
import dev.robocode.tankroyale.botapi.events.BulletHitBulletEvent;
import dev.robocode.tankroyale.botapi.events.BulletHitWallEvent;
import dev.robocode.tankroyale.botapi.events.HitBotEvent;
import dev.robocode.tankroyale.botapi.events.HitByBulletEvent;
import dev.robocode.tankroyale.botapi.events.ScannedBotEvent;
import dev.robocode.tankroyale.botapi.events.WonRoundEvent;
import robocode.robotinterfaces.IBasicRobot;
import robocode.util.Utils;
import java.io.File;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A genuine Tank Royale bot that hosts the unmodified classic Robocode DrussGT
* jar and drives it through {@link ClassicPeer} / {@link BotHost}.
*
* <p>It is a {@link Bot} (the TR API's classic-movement subclass) so that the
* peer's classic intents can be forwarded one-to-one onto the TR Bot API's own
* classic motion model:
*
* <pre>
* classic (peer) -&gt; Tank Royale Bot API
* setAhead(d) setForward(d)
* setTurnRightRadians(r) setTurnRight(toDegrees(r))
* setTurnGunRightRadians setTurnGunRight(toDegrees(r))
* setTurnRadarRightRadians setTurnRadarRight(toDegrees(r))
* setFireBullet(p) setFire(p) + bullet-identity tracking
* execute() go()
* </pre>
*
* <p>DrussGT's own {@code run()} is executed on the Tank Royale bot thread
* (exactly where the official robocode-api-bridge runs a legacy robot), and its
* {@code execute()} calls {@code go()} through {@link ClassicPeer.TickHost}.
* TR events are synthesised into classic events and delivered synchronously, so
* classic event ordering (TR's event queue priorities) and the classic "events
* are delivered before the next turn's run() body" contract both hold.
*/
public final class DrussGTBridge extends Bot
implements ClassicPeer.WorldModel, ClassicPeer.TickHost {
// ---- DrussGT host ----
private BotHost host;
private ClassicPeer peer;
private IBasicRobot robot;
private boolean firstRound = true;
// ---- bullet identity ----
private static final class BulletReflection {
static final Field X, Y, ACTIVE;
static {
Field x = null, y = null, active = null;
try {
x = robocode.Bullet.class.getDeclaredField("x");
x.setAccessible(true);
y = robocode.Bullet.class.getDeclaredField("y");
y.setAccessible(true);
active = robocode.Bullet.class.getDeclaredField("isActive");
active.setAccessible(true);
} catch (Exception e) {
// leave null; position/active updates are best-effort
}
X = x; Y = y; ACTIVE = active;
}
static void setXY(robocode.Bullet b, double x, double y) {
try { if (X != null) X.setDouble(b, x); if (Y != null) Y.setDouble(b, y); } catch (Exception ignored) { }
}
static void setActive(robocode.Bullet b, boolean active) {
try { if (ACTIVE != null) ACTIVE.setBoolean(b, active); } catch (Exception ignored) { }
}
}
private final List<robocode.Bullet> unmatchedFired = new ArrayList<>();
private final Map<Integer, robocode.Bullet> firedById = new HashMap<>();
private int nextTempBulletId = -1;
private final String botName = envOr("BOT_NAME", "DrussGT");
private static final boolean DEBUG = System.getenv("DRUSSGT_BRIDGE_DEBUG") != null;
private static PrintWriter debugWriter;
private static synchronized void debugLog(String line) {
if (debugWriter == null) {
try {
debugWriter = new PrintWriter(new java.io.BufferedWriter(
new java.io.FileWriter("/tmp/drussgt_bridge_debug.log", true)));
} catch (Exception e) {
return;
}
}
debugWriter.println(line);
debugWriter.flush();
}
public static void main(String[] args) {
ThreadManagerFix.install();
new DrussGTBridge().start();
}
public DrussGTBridge() {
super();
}
// ======================================================================
// lifecycle
// ======================================================================
@Override
public void run() {
try {
ensureHost();
resetRoundState();
IBasicRobot bot;
if (firstRound) {
firstRound = false;
bot = host.robot;
} else {
bot = host.newRobot();
}
robot = bot;
peer.attachRobot(bot);
// DrussGT's own run() lives on this (the Tank Royale bot) thread.
bot.getRobotRunnable().run();
} catch (Throwable t) {
if (!isInterrupt(t)) {
t.printStackTrace();
}
}
}
private static boolean isInterrupt(Throwable t) {
return t != null && t.getClass().getName().contains("ThreadInterrupted");
}
private void ensureHost() throws Exception {
if (host != null) {
return;
}
ThreadManagerFix.install();
File jar = new File(envOr("DRUSSGT_JAR", "/tmp/drussgt/DrussGT.jar"));
File dataDir = new File(envOr("DRUSSGT_DATA", "/tmp/drussgt_bridge_data"));
host = new BotHost(jar, dataDir);
peer = host.peer;
peer.world = this;
peer.tickHost = this;
// DrussGT's EnergyDome shield is a separate "precise" subsystem whose
// first-round behaviour depends on exact classic bullet-detection timing;
// the proven path (see SmokeTest / README) is to run the pure wave
// surfer (DrussMoveGT). Set DRUSSGT_SHIELD=1 to keep the shield on.
if (!"1".equals(System.getenv("DRUSSGT_SHIELD"))) {
host.disableShield();
}
}
private void resetRoundState() {
unmatchedFired.clear();
firedById.clear();
nextTempBulletId = -1;
if (peer != null) {
peer.stopSet = false;
}
}
private static String envOr(String key, String fallback) {
String v = System.getenv(key);
return (v == null || v.isBlank()) ? fallback : v;
}
// ======================================================================
// ClassicPeer.TickHost — driven from DrussGT's execute()
// ======================================================================
@Override
public void onExecute() {
applyIntents();
go();
}
private void applyIntents() {
if (DEBUG) {
debugLog(String.format(java.util.Locale.ROOT,
"t=%d move=%s/%.3f body=%.4f gun=%.4f radar=%.4f trDistRem=%.3f trSpeed=%.3f trTurnRem=%.3f trRadarRem=%.3f",
getTurnNumber(), peer.moveSet, peer.lastMove, peer.lastTurnBody, peer.lastTurnGun,
peer.lastTurnRadar, getDistanceRemaining(), getSpeed(), getTurnRemaining(), getRadarTurnRemaining()));
}
if (peer.stopSet) {
if (peer.stopRequested) {
setStop();
} else {
setResume();
}
peer.stopSet = false;
}
if (peer.moveSet) {
setForward(peer.lastMove);
peer.moveSet = false;
}
if (peer.turnBodySet) {
setTurnRight(Math.toDegrees(peer.lastTurnBody));
peer.turnBodySet = false;
}
if (peer.turnGunSet) {
setTurnGunRight(Math.toDegrees(peer.lastTurnGun));
peer.turnGunSet = false;
}
if (peer.turnRadarSet) {
setTurnRadarRight(Math.toDegrees(peer.lastTurnRadar));
peer.turnRadarSet = false;
}
if (peer.maxVelocitySet) {
setMaxSpeed(peer.maxVelocity);
peer.maxVelocitySet = false;
}
if (peer.maxTurnRateSet) {
setMaxTurnRate(peer.maxTurnRate);
peer.maxTurnRateSet = false;
}
setAdjustGunForBodyTurn(peer.adjustGunForBodyTurn);
setAdjustRadarForGunTurn(peer.adjustRadarForGunTurn);
setAdjustRadarForBodyTurn(peer.adjustRadarForBodyTurn);
}
@Override
public robocode.Bullet onFire(double power) {
if (Double.isNaN(power) || power <= 0) {
return null;
}
boolean accepted;
try {
accepted = setFire(power);
} catch (RuntimeException ex) {
return null;
}
if (!accepted) {
return null;
}
robocode.Bullet bullet = new robocode.Bullet(
TankRoyaleBridge.tankRadToClassicRad(Math.toRadians(getGunDirection())),
getX(), getY(), power, botName, null, true, nextTempBulletId--);
unmatchedFired.add(bullet);
return bullet;
}
// ======================================================================
// ClassicPeer.WorldModel — live TR state converted to classic
// ======================================================================
@Override public String name() {
return botName;
}
@Override public long time() { return getTurnNumber(); }
@Override public double energy() { return getEnergy(); }
@Override public double x() { return getX(); }
@Override public double y() { return getY(); }
@Override public double velocity() { return getSpeed(); }
@Override public double bodyHeading() { return TankRoyaleBridge.tankRadToClassicRad(Math.toRadians(getDirection())); }
@Override public double gunHeading() { return TankRoyaleBridge.tankRadToClassicRad(Math.toRadians(getGunDirection())); }
@Override public double radarHeading() { return TankRoyaleBridge.tankRadToClassicRad(Math.toRadians(getRadarDirection())); }
@Override public double gunHeat() { return getGunHeat(); }
@Override public double battleFieldWidth() { return getArenaWidth(); }
@Override public double battleFieldHeight() { return getArenaHeight(); }
@Override public int others() { return getEnemyCount(); }
@Override public int numSentries() { return 0; }
@Override public int numRounds() { return getNumberOfRounds(); }
@Override public int roundNum() { return getRoundNumber() - 1; }
@Override public int sentryBorderSize() { return 0; }
@Override public double gunCoolingRate() { return getGunCoolingRate(); }
@Override public double distanceRemaining() { return getDistanceRemaining(); }
@Override public double bodyTurnRemaining() { return -Math.toRadians(getTurnRemaining()); }
@Override public double gunTurnRemaining() { return -Math.toRadians(getGunTurnRemaining()); }
@Override public double radarTurnRemaining() { return -Math.toRadians(getRadarTurnRemaining()); }
// ======================================================================
// Tank Royale events -> classic events
// ======================================================================
@Override
public void onScannedBot(ScannedBotEvent e) {
double dx = e.getX() - getX();
double dy = e.getY() - getY();
double distance = Math.hypot(dx, dy);
// classic absolute bearing: atan2(dx, dy), 0 = North, clockwise
double absBearing = Math.atan2(dx, dy);
double bearing = Utils.normalRelativeAngle(absBearing - bodyHeading());
double enemyHeading = Utils.normalAbsoluteAngle(
TankRoyaleBridge.tankRadToClassicRad(Math.toRadians(e.getDirection())));
robocode.ScannedRobotEvent classic = new robocode.ScannedRobotEvent(
String.valueOf(e.getScannedBotId()), e.getEnergy(), bearing, distance,
enemyHeading, e.getSpeed());
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onHitByBullet(HitByBulletEvent e) {
BulletState bs = e.getBullet();
double bearing = classicBearingTo(bs.getX(), bs.getY());
robocode.Bullet bullet = mapBullet(bs, name());
robocode.HitByBulletEvent classic = new robocode.HitByBulletEvent(bearing, bullet);
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onBulletHit(BulletHitBotEvent e) {
robocode.Bullet bullet = ownBullet(e.getBullet());
BulletReflection.setActive(bullet, false);
robocode.BulletHitEvent classic = new robocode.BulletHitEvent(
String.valueOf(e.getVictimId()), e.getEnergy(), bullet);
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onBulletHitBullet(BulletHitBulletEvent e) {
robocode.Bullet bullet = ownBullet(e.getBullet());
robocode.Bullet hitBullet = mapBullet(e.getHitBullet(), null);
BulletReflection.setActive(bullet, false);
robocode.BulletHitBulletEvent classic = new robocode.BulletHitBulletEvent(bullet, hitBullet);
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onBulletHitWall(BulletHitWallEvent e) {
robocode.Bullet bullet = ownBullet(e.getBullet());
BulletReflection.setActive(bullet, false);
robocode.BulletMissedEvent classic = new robocode.BulletMissedEvent(bullet);
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onBulletFired(BulletFiredEvent e) {
BulletState bs = e.getBullet();
robocode.Bullet match = takeNearestUnmatched(bs.getX(), bs.getY());
if (match != null) {
firedById.put(bs.getBulletId(), match);
BulletReflection.setXY(match, bs.getX(), bs.getY());
}
}
@Override
public void onHitBot(HitBotEvent e) {
double bearing = classicBearingTo(e.getX(), e.getY());
robocode.HitRobotEvent classic = new robocode.HitRobotEvent(
String.valueOf(e.getVictimId()), bearing, e.getEnergy(), e.isRammed());
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onHitWall(dev.robocode.tankroyale.botapi.events.HitWallEvent e) {
robocode.HitWallEvent classic = new robocode.HitWallEvent(wallBearing());
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onBotDeath(BotDeathEvent e) {
robocode.RobotDeathEvent classic = new robocode.RobotDeathEvent(String.valueOf(e.getVictimId()));
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onWonRound(WonRoundEvent e) {
robocode.WinEvent classic = new robocode.WinEvent();
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onDeath(dev.robocode.tankroyale.botapi.events.DeathEvent e) {
robocode.DeathEvent classic = new robocode.DeathEvent();
classic.setTime(getTurnNumber());
peer.deliver(classic);
}
@Override
public void onSkippedTurn(dev.robocode.tankroyale.botapi.events.SkippedTurnEvent e) {
// Deliberately NOT forwarded: classic SkippedTurnEvent is a diagnostic
// that only exists because a robot took too long; our bridge never
// "skips" a turn, and DrussGT treats it as a fatal signal.
}
// ======================================================================
// helpers
// ======================================================================
private double classicBearingTo(double tx, double ty) {
double dx = tx - getX();
double dy = ty - getY();
return Utils.normalRelativeAngle(Math.atan2(dx, dy) - bodyHeading());
}
/** Classic HitWallEvent bearing to the nearest wall normal. */
private double wallBearing() {
double dx = getX() - getArenaWidth() / 2.0;
double dy = getY() - getArenaHeight() / 2.0;
double abs;
if (Math.abs(dx) > Math.abs(dy)) {
abs = dx >= 0 ? Math.toRadians(270) : Math.toRadians(90);
} else {
abs = dy >= 0 ? Math.toRadians(180) : 0.0;
}
return Utils.normalRelativeAngle(abs - bodyHeading());
}
private robocode.Bullet mapBullet(BulletState bs, String victim) {
return new robocode.Bullet(
TankRoyaleBridge.tankRadToClassicRad(Math.toRadians(bs.getDirection())),
bs.getX(), bs.getY(), bs.getPower(),
String.valueOf(bs.getOwnerId()), victim, false, bs.getBulletId());
}
/** Our own bullet: return the exact instance DrussGT got from setFireBullet. */
private robocode.Bullet ownBullet(BulletState bs) {
robocode.Bullet b = firedById.get(bs.getBulletId());
if (b == null) {
b = mapBullet(bs, null);
}
return b;
}
private robocode.Bullet takeNearestUnmatched(double x, double y) {
robocode.Bullet best = null;
double bestDist = Double.MAX_VALUE;
for (robocode.Bullet b : unmatchedFired) {
double d = Math.pow(b.getX() - x, 2) + Math.pow(b.getY() - y, 2);
if (d < bestDist) {
bestDist = d;
best = b;
}
}
if (best != null) {
unmatchedFired.remove(best);
}
return best;
}
}
@@ -18,6 +18,7 @@ import java.util.Collections;
public class SmokeTest { public class SmokeTest {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
ThreadManagerFix.install();
File jar = new File(args.length > 0 ? args[0] : "/tmp/drussgt/DrussGT.jar"); File jar = new File(args.length > 0 ? args[0] : "/tmp/drussgt/DrussGT.jar");
File work = new File(args.length > 1 ? args[1] : "/tmp/shim_smoke"); File work = new File(args.length > 1 ? args[1] : "/tmp/shim_smoke");
if (!work.exists()) work.mkdirs(); if (!work.exists()) work.mkdirs();
@@ -0,0 +1,59 @@
package robocode_shim;
import net.sf.robocode.core.ContainerBase;
import net.sf.robocode.security.IThreadManagerBase;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* Kills the ThreadManager landmine documented in README section 5 item 3.
*
* <p>The genuine {@code robocode.RobocodeFileOutputStream} resolves an
* {@link IThreadManagerBase} through {@code ContainerBase.getComponent(...)} and
* throws {@code RobotException: ThreadManager cannot be null!} when no engine is
* present. DrussGT reaches it from its own {@code contain()} error logger, so
* any exception inside a DrussGT event handler used to kill the bot thread.
*
* <p>{@code ContainerBase.instance} is a public static field and
* {@code getBaseComponent} is the single abstract hook, so installing a no-op
* thread manager is enough: {@code RobocodeFileOutputStream} then simply writes
* a normal file (the same behaviour the real engine gives a robot writing to its
* data directory). Nothing else in robocode.jar consults this instance in our
* standalone process.
*/
public final class ThreadManagerFix {
private ThreadManagerFix() { }
/** The no-op thread manager: an ordinary file stream, always a "safe" thread. */
private static final IThreadManagerBase FILE_THREAD_MANAGER = new IThreadManagerBase() {
@Override public boolean isSafeThread() {
return true;
}
@Override public FileOutputStream createRobotFileStream(String fileName, boolean append)
throws IOException {
return new FileOutputStream(fileName, append);
}
};
private static volatile boolean installed = false;
public static void install() {
if (installed) {
return;
}
ContainerBase.instance = new ContainerBase() {
@Override
@SuppressWarnings("unchecked")
protected <T> T getBaseComponent(Class<T> componentClass) {
if (componentClass == IThreadManagerBase.class) {
return (T) FILE_THREAD_MANAGER;
}
return null;
}
};
installed = true;
}
}
@@ -0,0 +1,45 @@
package robocode_shim;
import robocode.RobocodeFileOutputStream;
import java.io.File;
import java.io.PrintStream;
/**
* Focused proof of README item 3: outside the engine,
* {@code new RobocodeFileOutputStream(...)} throws
* {@code RobotException: ThreadManager cannot be null!} and would kill DrussGT's
* bot thread when it is reached from {@code DrussGT.contain()}.
* {@link ThreadManagerFix#install()} makes it behave like the real engine (a
* normal file stream).
*/
public final class ThreadManagerFixTest {
public static void main(String[] args) throws Exception {
File dir = new File(args.length > 0 ? args[0] : "/tmp/shim_threadmgr");
if (!dir.exists()) dir.mkdirs();
// 1) without the fix
String uninstalled;
try {
new RobocodeFileOutputStream(new File(dir, "no-fix.bin")).close();
uninstalled = "NO EXCEPTION (unexpected)";
} catch (Throwable t) {
uninstalled = t.getClass().getName() + ": " + t.getMessage();
}
System.out.println("without fix : " + uninstalled);
// 2) with the fix
ThreadManagerFix.install();
File out = new File(dir, "with-fix.bin");
try (PrintStream ps = new PrintStream(new RobocodeFileOutputStream(out))) {
ps.println("drussgt contain() would write here");
}
System.out.println("with fix : wrote " + out.length() + " bytes to " + out);
boolean ok = uninstalled.contains("ThreadManager cannot be null")
&& out.length() > 0;
System.out.println(ok ? "THREADMANAGER FIX: OK" : "THREADMANAGER FIX: FAILED");
System.exit(ok ? 0 : 1);
}
}
@@ -0,0 +1,190 @@
package robocode_shim;
import dev.robocode.tankroyale.client.model.BotState;
import dev.robocode.tankroyale.client.model.TickEvent;
import dev.robocode.tankroyale.runner.BattleResults;
import dev.robocode.tankroyale.runner.BattleRunner;
import dev.robocode.tankroyale.runner.BattleSetup;
import dev.robocode.tankroyale.runner.BotEntry;
import dev.robocode.tankroyale.runner.BotResult;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Tank Royale analogue of {@code tools/robocode_fixture_capture/Capture.java}:
* an observer that runs a real battle via the Tank Royale battle-runner API
* (embedded server) and dumps every bot's per-turn state to the project's
* shared JSONL fixture format, so {@code tools/robocode_fixture_capture/analyze.py}
* can be run on the result unchanged.
*
* <p>The movement subject (written as {@code e*}) is identified by name
* substring; the other bot is written as {@code s*}. Because this uses the
* observer tick stream, the positions are perfect information, exactly like the
* classic capture.
*
* <pre>
* java -cp out:runner.jar robocode_shim.TrBattleCapture \
* --rounds 10 --out /tmp/tr_drussgt.jsonl \
* --bot /path/DrussGT --bot /path/Target --subject DrussGT
* </pre>
*/
public final class TrBattleCapture {
private static int globalTick = 0;
private static final Map<Integer, Integer> roundTicks = new LinkedHashMap<>();
private static final List<double[]> pendingRows = new ArrayList<>();
// battle evidence counters (subject = the bot under test)
private static int bulletsFired = 0, bulletHits = 0, bulletMisses = 0, bulletHitBullets = 0;
private static int scansSeen = 0, hitsTaken = 0;
public static void main(String[] args) throws Exception {
int rounds = 5;
String outFile = "/tmp/tr_drussgt.jsonl";
String adversary = "unknown";
String note = "Tank Royale battle captured via TrBattleCapture (observer tick stream, perfect info)";
List<String> botDirs = new ArrayList<>();
String subject = "DrussGT";
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--rounds" -> rounds = Integer.parseInt(args[++i]);
case "--out" -> outFile = args[++i];
case "--bot" -> botDirs.add(args[++i]);
case "--subject" -> subject = args[++i];
case "--adversary" -> adversary = args[++i];
case "--note" -> note = args[++i];
default -> { System.err.println("Unknown arg: " + args[i]); System.exit(2); }
}
}
if (botDirs.isEmpty()) {
System.err.println("--bot <dir> required (repeatable)");
System.exit(2);
}
final String subjectPattern = subject;
final int numRounds = rounds;
List<BotEntry> bots = new ArrayList<>();
for (String d : botDirs) {
bots.add(BotEntry.of(Path.of(d)));
}
Object owner = new Object();
try (BattleRunner runner = BattleRunner.create(b -> b.embeddedServer().suppressServerOutput())) {
BattleSetup setup = BattleSetup.classic(s -> {
s.setNumberOfRounds(numRounds);
s.setDefaultTurnsPerSecond(-1);
});
System.out.println("[capture] bots=" + botDirs + " rounds=" + rounds + " subject~=" + subjectPattern);
try (var handle = runner.startBattleAsync(setup, bots)) {
handle.getOnTickEvent().on(owner, event -> onTick(event, subjectPattern));
BattleResults results = handle.awaitResults();
writeOutput(outFile, adversary, note, rounds);
System.out.println("[capture] rows=" + globalTick + " rounds=" + roundTicks + " -> " + outFile);
System.out.println("[capture] subject event counts: scans=" + scansSeen
+ " bulletsFired=" + bulletsFired + " bulletHits=" + bulletHits
+ " bulletMisses=" + bulletMisses + " bulletHitBullets=" + bulletHitBullets
+ " hitsTaken=" + hitsTaken);
System.out.println("=== RESULTS (" + results.getNumberOfRounds() + " rounds) ===");
for (BotResult r : results.getResults()) {
System.out.printf(" #%d %-20s totalScore=%d firstPlaces=%d survival=%d%n",
r.getRank(), r.getName(), r.getTotalScore(), r.getFirstPlaces(), r.getSurvival());
}
}
}
}
private static void onTick(TickEvent event, String subjectPattern) {
BotState en = null, sh = null;
for (BotState bs : event.getBotStates()) {
String n = bs.getName();
boolean isSubject = n != null && n.toLowerCase().contains(subjectPattern.toLowerCase());
if (isSubject) {
en = bs;
} else if (sh == null) {
sh = bs;
}
}
if (en == null || sh == null) {
return;
}
pendingRows.add(new double[]{
globalTick++, en.getX(), en.getY(), en.getDirection(), en.getSpeed(), en.getEnergy(),
sh.getX(), sh.getY(), sh.getDirection(), sh.getSpeed(), sh.getEnergy()
});
roundTicks.merge(event.getRoundNumber(), 1, Integer::sum);
int subjectId = en.getId();
for (dev.robocode.tankroyale.client.model.Event e : event.getEvents()) {
if (e instanceof dev.robocode.tankroyale.client.model.BulletFiredEvent bf
&& bf.getBullet().getOwnerId() == subjectId) {
bulletsFired++;
} else if (e instanceof dev.robocode.tankroyale.client.model.BulletHitBotEvent bh
&& bh.getBullet().getOwnerId() == subjectId) {
bulletHits++;
} else if (e instanceof dev.robocode.tankroyale.client.model.BulletHitBotEvent bh2
&& bh2.getVictimId() == subjectId) {
hitsTaken++;
} else if (e instanceof dev.robocode.tankroyale.client.model.BulletHitWallEvent bw
&& bw.getBullet().getOwnerId() == subjectId) {
bulletMisses++;
} else if (e instanceof dev.robocode.tankroyale.client.model.BulletHitBulletEvent bb
&& bb.getBullet().getOwnerId() == subjectId) {
bulletHitBullets++;
} else if (e instanceof dev.robocode.tankroyale.client.model.ScannedBotEvent sb
&& sb.getScannedByBotId() == subjectId) {
scansSeen++;
}
}
}
private static void writeOutput(String outFile, String adversary, String note, int rounds) throws Exception {
File f = new File(outFile);
if (f.getParentFile() != null) {
f.getParentFile().mkdirs();
}
try (PrintWriter w = new PrintWriter(new BufferedWriter(new FileWriter(f)))) {
w.println("{\"meta\":{\"adversary\":\"" + esc(adversary)
+ "\",\"source\":\"tank-royale\",\"perfect_info\":true,"
+ "\"arena\":{\"w\":800,\"h\":600},\"note\":\"" + esc(note) + "\"}}");
for (double[] r : pendingRows) {
w.println("{\"tick\":" + (long) r[0]
+ ",\"ex\":" + fmt(r[1]) + ",\"ey\":" + fmt(r[2])
+ ",\"eh\":" + fmt(r[3]) + ",\"es\":" + fmt(r[4]) + ",\"ee\":" + fmt(r[5])
+ ",\"sx\":" + fmt(r[6]) + ",\"sy\":" + fmt(r[7])
+ ",\"sh\":" + fmt(r[8]) + ",\"ss\":" + fmt(r[9]) + ",\"se\":" + fmt(r[10]) + "}");
}
}
try (PrintWriter rs = new PrintWriter(new BufferedWriter(new FileWriter(outFile + ".rounds.json")))) {
rs.print("{\"rounds\":[");
int start = 0;
boolean first = true;
for (Map.Entry<Integer, Integer> e : roundTicks.entrySet()) {
if (!first) rs.print(",");
first = false;
rs.print("{\"round\":" + e.getKey() + ",\"startTick\":" + start + ",\"count\":" + e.getValue() + "}");
start += e.getValue();
}
rs.println("]}");
}
}
private static String fmt(double v) {
return String.format(Locale.ROOT, "%.4f", v);
}
private static String esc(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}