From 8e2be6a4c6dec595f03db5c40b516b8d480e2aa6 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Mon, 21 Sep 2026 00:29:37 +0200 Subject: [PATCH] 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. --- tools/robocode_shim/README.md | 242 ++++++++-- tools/robocode_shim/build.sh | 31 +- tools/robocode_shim/make_botdir.sh | 43 ++ tools/robocode_shim/run_bridge_battle.sh | 32 ++ .../src/robocode_shim/BotHost.java | 17 +- .../src/robocode_shim/ClassicPeer.java | 204 +++++--- .../src/robocode_shim/DrussGTBridge.java | 456 ++++++++++++++++++ .../src/robocode_shim/SmokeTest.java | 1 + .../src/robocode_shim/ThreadManagerFix.java | 59 +++ .../robocode_shim/ThreadManagerFixTest.java | 45 ++ .../src/robocode_shim/TrBattleCapture.java | 190 ++++++++ 11 files changed, 1221 insertions(+), 99 deletions(-) create mode 100755 tools/robocode_shim/make_botdir.sh create mode 100755 tools/robocode_shim/run_bridge_battle.sh create mode 100644 tools/robocode_shim/src/robocode_shim/DrussGTBridge.java create mode 100644 tools/robocode_shim/src/robocode_shim/ThreadManagerFix.java create mode 100644 tools/robocode_shim/src/robocode_shim/ThreadManagerFixTest.java create mode 100644 tools/robocode_shim/src/robocode_shim/TrBattleCapture.java diff --git a/tools/robocode_shim/README.md b/tools/robocode_shim/README.md index e6ec8f2..f3663bc 100644 --- a/tools/robocode_shim/README.md +++ b/tools/robocode_shim/README.md @@ -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 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 requests through our peer. -This is an **API-coverage / architecture spike**, not a working bot. No physics -is implemented; the peer's world model is stubbed. +This was an **API-coverage / architecture spike** and is now a **working +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 -**engine semantics** behind it are not implemented. +The five remaining-work items are done. The bridge is `DrussGTBridge` (a Tank +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. | -| 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. | -| 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. | -| 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] | -| 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. | -| 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. | -| 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. | -| 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. | -| 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. | -| 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`. | -| 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". | +| 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 | `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 | 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 | 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 | 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. | + +### 5.1 How the tick contract works + +DrussGT's own `run()` is executed **on the Tank Royale bot thread** (this is +exactly where the official `robocode-api-bridge` runs a legacy robot). Its +`execute()` calls back into `ClassicPeer.TickHost.onExecute()`, which: + +1. applies the captured intents (`setAhead` → `setForward`, …) to the TR bot, +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 `.json` +identity file and a `.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 -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 tools/robocode_shim/ 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 - 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 src/robocode_shim/ - ClassicPeer.java IAdvancedRobotPeer impl + execute() tick bridge - BotHost.java URLClassLoader + setPeer + run() thread - TankRoyaleBridge.java angle conversion + documented tick contract (skeleton) - SmokeTest.java the runtime proof + ClassicPeer.java IAdvancedRobotPeer impl + world/tick seams + BotHost.java URLClassLoader + setPeer + per-round instances + TankRoyaleBridge.java exact angle conversion + 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) ``` ```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 ./compile_drussgt.sh ./run_smoke.sh +./run_bridge_battle.sh /home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot 5 ``` ## 7. Third-party jars Per the task constraints, **no third-party jar is copied into the repo.** `robocode.jar` (Robocode 1.9.5.5) stays at -`/tmp/robocode/install/libs/robocode.jar` and `DrussGT.jar` at -`/tmp/drussgt/DrussGT.jar`; the scripts reference them via `ROBOCODE_JAR` / -`DRUSSGT_JAR`. `tools/robocode_shim/.gitignore` additionally blocks `*.jar` and -`out/` in case a jar is ever dropped here by mistake. +`/tmp/robocode/install/libs/robocode.jar`, `DrussGT.jar` at +`/tmp/drussgt/DrussGT.jar`, the Tank Royale Java bot API at +`~/Downloads/sample-bots-java-1.0.2/lib/robocode-tankroyale-bot-api-1.0.2.jar` +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) diff --git a/tools/robocode_shim/build.sh b/tools/robocode_shim/build.sh index 214e411..c1807e5 100755 --- a/tools/robocode_shim/build.sh +++ b/tools/robocode_shim/build.sh @@ -1,24 +1,47 @@ #!/usr/bin/env bash -# Build the Approach-(B) shim: the ClassicPeer driver + host + bridge. -# Requires JAVA_HOME/javac on PATH and the genuine Robocode 1.9.5.5 jar. +# Build the Approach-(B) shim. # -# 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 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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}" +RUNNER_JAR="${TR_RUNNER_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}" if [[ ! -f "$ROBOCODE_JAR" ]]; then 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 exit 1 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" -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/BotHost.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" 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 diff --git a/tools/robocode_shim/make_botdir.sh b/tools/robocode_shim/make_botdir.sh new file mode 100755 index 0000000..c0498cc --- /dev/null +++ b/tools/robocode_shim/make_botdir.sh @@ -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 (.json + .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" < "$TARGET/$NAME.sh" < [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 [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" diff --git a/tools/robocode_shim/src/robocode_shim/BotHost.java b/tools/robocode_shim/src/robocode_shim/BotHost.java index 0cd0c3d..9d0ea99 100644 --- a/tools/robocode_shim/src/robocode_shim/BotHost.java +++ b/tools/robocode_shim/src/robocode_shim/BotHost.java @@ -32,12 +32,14 @@ import java.util.List; public class BotHost { public final ClassicPeer peer; - public final IBasicRobot robot; + public volatile IBasicRobot robot; public final URLClassLoader loader; + private final File dataDir; private final Thread thread; private volatile Throwable botError; public BotHost(File drussgtJar, File dataDir) throws Exception { + this.dataDir = dataDir; this.peer = new ClassicPeer(dataDir); this.loader = new URLClassLoader( new URL[]{drussgtJar.toURI().toURL()}, @@ -59,6 +61,19 @@ public class BotHost { 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. */ public void disableShield() throws Exception { java.lang.reflect.Field f = loader.loadClass("jk.mega.DrussGT").getDeclaredField("shieldEnabled"); diff --git a/tools/robocode_shim/src/robocode_shim/ClassicPeer.java b/tools/robocode_shim/src/robocode_shim/ClassicPeer.java index 9d925f1..caa1570 100644 --- a/tools/robocode_shim/src/robocode_shim/ClassicPeer.java +++ b/tools/robocode_shim/src/robocode_shim/ClassicPeer.java @@ -5,7 +5,6 @@ import robocode.Condition; import robocode.Event; import robocode.robotinterfaces.IBasicEvents; import robocode.robotinterfaces.IBasicRobot; -import robocode.robotinterfaces.IAdvancedEvents; import robocode.robotinterfaces.peer.IAdvancedRobotPeer; import java.awt.Color; @@ -19,24 +18,31 @@ import java.util.List; * APPROACH (B) SEAM. * * The genuine robocode.jar {@code AdvancedRobot}/{@code Robot} classes are pure - * delegates: every movement/fire setter forwards to - * {@code _RobotBase.peer}, an interface field of type - * {@link robocode.robotinterfaces.peer.IBasicRobotPeer} (narrowed to - * {@link IAdvancedRobotPeer} for advanced setters). {@code _RobotBase.setPeer()} - * is public+final, and {@code Robot.getRobotRunnable()} simply returns - * {@code this}. + * delegates: every movement/fire setter forwards to {@code _RobotBase.peer}, an + * interface field of type {@link robocode.robotinterfaces.peer.IBasicRobotPeer} + * (narrowed to {@link IAdvancedRobotPeer} for advanced setters). + * {@code _RobotBase.setPeer()} is public+final, and + * {@code Robot.getRobotRunnable()} simply returns {@code this}. * - * Therefore we do NOT need to re-implement the robocode.* API (Approach A). - * We only implement this peer interface, feed it world state, let the bot's own - * {@code run()} thread block in {@link #execute()}, and read back the intents. - * - * The getters here are a stubbed world model: this is an API/architecture - * spike, NOT physics. See TankRoyaleBridge for the translation layer that would - * fill these fields from a Tank Royale websocket tick. + *

Two modes: + *

    + *
  • standalone / synthetic (SmokeTest): the public volatile world + * fields are the world model and {@link #execute()} blocks on a lock fed by + * {@link #provideTurn(List)}. No physics.
  • + *
  • battle: a {@link WorldModel} is installed (the live Tank Royale + * 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.
  • + *
*/ 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 long time = 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 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 lastTurnBody = 0.0; public volatile double lastTurnGun = 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 maxVelocity = 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 stopSet = false; public volatile boolean adjustGunForBodyTurn = false; public volatile boolean adjustRadarForGunTurn = false; @@ -77,7 +97,7 @@ public class ClassicPeer implements IAdvancedRobotPeer { public volatile Color bodyColor, gunColor, radarColor, bulletColor, scanColor; public final List debugProperties = new ArrayList<>(); - // ---- tick bridge ---- + // ---- tick bridge (standalone mode) ---- private final Object lock = new Object(); private volatile boolean turnReady = false; private volatile boolean shutdown = false; @@ -100,54 +120,68 @@ public class ClassicPeer implements IAdvancedRobotPeer { } // ================= world getters ================= - @Override public String getName() { return name; } - @Override public long getTime() { return time; } - @Override public double getEnergy() { return energy; } - @Override public double getX() { return x; } - @Override public double getY() { return y; } - @Override public double getVelocity() { return velocity; } - @Override public double getBodyHeading() { return bodyHeading; } - @Override public double getGunHeading() { return gunHeading; } - @Override public double getRadarHeading() { return radarHeading; } - @Override public double getGunHeat() { return gunHeat; } - @Override public double getBattleFieldWidth() { return battleFieldWidth; } - @Override public double getBattleFieldHeight() { return battleFieldHeight; } - @Override public int getOthers() { return others; } - @Override public int getNumSentries() { return numSentries; } - @Override public int getNumRounds() { return numRounds; } - @Override public int getRoundNum() { return roundNum; } - @Override public int getSentryBorderSize() { return sentryBorderSize; } - @Override public double getGunCoolingRate() { return gunCoolingRate; } - @Override public double getDistanceRemaining() { return distanceRemaining; } - @Override public double getBodyTurnRemaining() { return bodyTurnRemaining; } - @Override public double getGunTurnRemaining() { return gunTurnRemaining; } - @Override public double getRadarTurnRemaining() { return radarTurnRemaining; } + @Override public String getName() { WorldModel w = world; return w != null ? w.name() : name; } + @Override public long getTime() { WorldModel w = world; return w != null ? w.time() : time; } + @Override public double getEnergy() { WorldModel w = world; return w != null ? w.energy() : energy; } + @Override public double getX() { WorldModel w = world; return w != null ? w.x() : x; } + @Override public double getY() { WorldModel w = world; return w != null ? w.y() : y; } + @Override public double getVelocity() { WorldModel w = world; return w != null ? w.velocity() : velocity; } + @Override public double getBodyHeading() { WorldModel w = world; return w != null ? w.bodyHeading() : bodyHeading; } + @Override public double getGunHeading() { WorldModel w = world; return w != null ? w.gunHeading() : gunHeading; } + @Override public double getRadarHeading() { WorldModel w = world; return w != null ? w.radarHeading() : radarHeading; } + @Override public double getGunHeat() { WorldModel w = world; return w != null ? w.gunHeat() : gunHeat; } + @Override public double getBattleFieldWidth() { WorldModel w = world; return w != null ? w.battleFieldWidth() : battleFieldWidth; } + @Override public double getBattleFieldHeight() { WorldModel w = world; return w != null ? w.battleFieldHeight() : battleFieldHeight; } + @Override public int getOthers() { WorldModel w = world; return w != null ? w.others() : others; } + @Override public int getNumSentries() { WorldModel w = world; return w != null ? w.numSentries() : numSentries; } + @Override public int getNumRounds() { WorldModel w = world; return w != null ? w.numRounds() : numRounds; } + @Override public int getRoundNum() { WorldModel w = world; return w != null ? w.roundNum() : roundNum; } + @Override public int getSentryBorderSize() { WorldModel w = world; return w != null ? w.sentryBorderSize() : sentryBorderSize; } + @Override public double getGunCoolingRate() { WorldModel w = world; return w != null ? w.gunCoolingRate() : gunCoolingRate; } + @Override public double getDistanceRemaining() { WorldModel w = world; return w != null ? w.distanceRemaining() : distanceRemaining; } + @Override public double getBodyTurnRemaining() { WorldModel w = world; return w != null ? w.bodyTurnRemaining() : bodyTurnRemaining; } + @Override public double getGunTurnRemaining() { WorldModel w = world; return w != null ? w.gunTurnRemaining() : gunTurnRemaining; } + @Override public double getRadarTurnRemaining() { WorldModel w = world; return w != null ? w.radarTurnRemaining() : radarTurnRemaining; } // ================= movement / action intents ================= - @Override public void move(double distance) { lastMove = distance; } - @Override public void turnBody(double radians) { lastTurnBody = radians; } - @Override public void turnGun(double radians) { lastTurnGun = radians; } - @Override public void turnRadar(double radians) { lastTurnRadar = 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 void move(double distance) { setMove(distance); } + @Override public void turnBody(double radians) { setTurnBody(radians); } + @Override public void turnGun(double radians) { setTurnGun(radians); } + @Override public void turnRadar(double radians) { setTurnRadar(radians); } - @Override public Bullet fire(double power) { return makeBullet(power); } - @Override public Bullet setFire(double power) { return makeBullet(power); } + @Override public void setMove(double distance) { + 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; - // Bullet(x, y, headingRadians, power, ownerName, victimName, isActive, bulletId) - return new Bullet(x, y, gunHeading, power, name, null, true, executedTurns); + return makeBullet(power); } - @Override public void stop(boolean overwrite) { stopRequested = true; } - @Override public void resume() { stopRequested = false; } - @Override public void setStop(boolean overwrite) { stopRequested = true; } - @Override public void setResume() { stopRequested = false; } + @Override public Bullet setFire(double power) { return fire(power); } + + /** + * 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 setAdjustRadarForGunTurn(boolean v) { adjustRadarForGunTurn = v; } @@ -157,6 +191,11 @@ public class ClassicPeer implements IAdvancedRobotPeer { @Override public boolean isAdjustRadarForBodyTurn() { return adjustRadarForBodyTurn; } @Override public void execute() { + TickHost h = tickHost; + if (h != null) { + h.onExecute(); + return; + } synchronized (lock) { while (!turnReady && !shutdown) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } @@ -178,7 +217,7 @@ public class ClassicPeer implements IAdvancedRobotPeer { private void dispatch(List events) { if (robot == null) return; IBasicEvents basic = robot.getBasicEventListener(); - IAdvancedEvents adv = null; + robocode.robotinterfaces.IAdvancedEvents adv = null; if (robot instanceof robocode.robotinterfaces.IAdvancedRobot) { 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) ================= - /** 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 events) { synchronized (lock) { 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) { synchronized (lock) { while (executedTurns < n && !shutdown && !dead) { @@ -286,5 +330,41 @@ public class ClassicPeer implements IAdvancedRobotPeer { @Override public long getDataQuotaAvailable() { return 1024L * 1024L; } - public List 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(); + } } diff --git a/tools/robocode_shim/src/robocode_shim/DrussGTBridge.java b/tools/robocode_shim/src/robocode_shim/DrussGTBridge.java new file mode 100644 index 0000000..c006b7e --- /dev/null +++ b/tools/robocode_shim/src/robocode_shim/DrussGTBridge.java @@ -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}. + * + *

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: + * + *

+ *   classic (peer)        ->  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()
+ * 
+ * + *

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 unmatchedFired = new ArrayList<>(); + private final Map 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; + } +} diff --git a/tools/robocode_shim/src/robocode_shim/SmokeTest.java b/tools/robocode_shim/src/robocode_shim/SmokeTest.java index 8a9530b..caafc29 100644 --- a/tools/robocode_shim/src/robocode_shim/SmokeTest.java +++ b/tools/robocode_shim/src/robocode_shim/SmokeTest.java @@ -18,6 +18,7 @@ import java.util.Collections; public class SmokeTest { public static void main(String[] args) throws Exception { + ThreadManagerFix.install(); 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"); if (!work.exists()) work.mkdirs(); diff --git a/tools/robocode_shim/src/robocode_shim/ThreadManagerFix.java b/tools/robocode_shim/src/robocode_shim/ThreadManagerFix.java new file mode 100644 index 0000000..2584ffb --- /dev/null +++ b/tools/robocode_shim/src/robocode_shim/ThreadManagerFix.java @@ -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. + * + *

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. + * + *

{@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 getBaseComponent(Class componentClass) { + if (componentClass == IThreadManagerBase.class) { + return (T) FILE_THREAD_MANAGER; + } + return null; + } + }; + installed = true; + } +} diff --git a/tools/robocode_shim/src/robocode_shim/ThreadManagerFixTest.java b/tools/robocode_shim/src/robocode_shim/ThreadManagerFixTest.java new file mode 100644 index 0000000..171fda3 --- /dev/null +++ b/tools/robocode_shim/src/robocode_shim/ThreadManagerFixTest.java @@ -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); + } +} diff --git a/tools/robocode_shim/src/robocode_shim/TrBattleCapture.java b/tools/robocode_shim/src/robocode_shim/TrBattleCapture.java new file mode 100644 index 0000000..ab67034 --- /dev/null +++ b/tools/robocode_shim/src/robocode_shim/TrBattleCapture.java @@ -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. + * + *

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. + * + *

+ * java -cp out:runner.jar robocode_shim.TrBattleCapture \
+ *     --rounds 10 --out /tmp/tr_drussgt.jsonl \
+ *     --bot /path/DrussGT --bot /path/Target --subject DrussGT
+ * 
+ */ +public final class TrBattleCapture { + + private static int globalTick = 0; + private static final Map roundTicks = new LinkedHashMap<>(); + private static final List 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 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 required (repeatable)"); + System.exit(2); + } + final String subjectPattern = subject; + final int numRounds = rounds; + + List 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 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("\"", "\\\""); + } +}