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
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 `<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
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)