diff --git a/bots/java/Retcon/Retcon.cmd b/bots/java/Retcon/Retcon.cmd new file mode 100644 index 0000000..fc31c87 --- /dev/null +++ b/bots/java/Retcon/Retcon.cmd @@ -0,0 +1,19 @@ +@echo off +rem Boots Retcon from source (Java 22+ multi-file source launch), following the rumble-bots catalog convention: +rem only the official Bot API jar is fetched, into a cache outside the bot directory. +setlocal +rem Older launchers compile only the entry file and fail with misleading "package does not exist" errors. +set JAVA_SPEC=0 +for /f "tokens=2 delims==" %%v in ('java -XshowSettings:properties -version 2^>^&1 ^| findstr /c:"java.specification.version"') do set JAVA_SPEC=%%v +set JAVA_SPEC=%JAVA_SPEC: =% +for /f "tokens=1 delims=." %%m in ("%JAVA_SPEC%") do set JAVA_MAJOR=%%m +if %JAVA_MAJOR% LSS 22 ( + echo Retcon needs Java 22 or newer as 'java' on PATH ^(multi-file source launch^), found java.specification.version=%JAVA_SPEC% 1>&2 + exit /b 1 +) +set BOT_API_VERSION=1.3.1 +if "%RUMBLE_JAVA_CACHE%"=="" set RUMBLE_JAVA_CACHE=%USERPROFILE%\.cache\rumble-bots-java +if not exist "%RUMBLE_JAVA_CACHE%" mkdir "%RUMBLE_JAVA_CACHE%" +set JAR=%RUMBLE_JAVA_CACHE%\robocode-tankroyale-bot-api-%BOT_API_VERSION%.jar +if not exist "%JAR%" curl -fsSL -o "%JAR%" "https://repo1.maven.org/maven2/dev/robocode/tankroyale/robocode-tankroyale-bot-api/%BOT_API_VERSION%/robocode-tankroyale-bot-api-%BOT_API_VERSION%.jar" +java -cp "%JAR%" "%~dp0src\fnl\bots\retcon\Retcon.java" %* diff --git a/bots/java/Retcon/Retcon.json b/bots/java/Retcon/Retcon.json new file mode 100644 index 0000000..421a367 --- /dev/null +++ b/bots/java/Retcon/Retcon.json @@ -0,0 +1,12 @@ +{ + "name": "Retcon", + "version": "0.2.0", + "authors": ["Flemming N. Larsen"], + "description": "Research bot for Retroactive Hit Analysis. Milestone 2: wave surfing, orbiting, and a virtual gun choosing linear or head-on aim.", + "homepage": "", + "countryCodes": ["dk"], + "platform": "JVM", + "programmingLang": "Java 25", + "gameTypes": ["1v1", "melee", "twinduel"], + "license": "MIT" +} diff --git a/bots/java/Retcon/Retcon.sh b/bots/java/Retcon/Retcon.sh new file mode 100755 index 0000000..d191d1e --- /dev/null +++ b/bots/java/Retcon/Retcon.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +# Boots Retcon from source (Java 22+ multi-file source launch), following the rumble-bots catalog convention: +# only the official Bot API jar is fetched, into a cache outside the bot directory. +set -eu +# Older launchers compile only the entry file and fail with misleading "package does not exist" errors. +JAVA_SPEC=$(java -XshowSettings:properties -version 2>&1 | sed -n 's/^ *java\.specification\.version = //p') +JAVA_MAJOR=${JAVA_SPEC%%.*} +case "$JAVA_MAJOR" in ''|*[!0-9]*) JAVA_MAJOR=0 ;; esac +if [ "$JAVA_MAJOR" -lt 22 ]; then + echo "Retcon needs Java 22 or newer as 'java' on PATH (multi-file source launch), found java.specification.version=${JAVA_SPEC:-none}" >&2 + exit 1 +fi +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BOT_API_VERSION=1.3.1 +CACHE_DIR="${RUMBLE_JAVA_CACHE:-$HOME/.cache/rumble-bots-java}" +JAR="$CACHE_DIR/robocode-tankroyale-bot-api-$BOT_API_VERSION.jar" +if [ ! -f "$JAR" ]; then + mkdir -p "$CACHE_DIR" + TMP="$JAR.$$.tmp" + curl -fsSL -o "$TMP" "https://repo1.maven.org/maven2/dev/robocode/tankroyale/robocode-tankroyale-bot-api/$BOT_API_VERSION/robocode-tankroyale-bot-api-$BOT_API_VERSION.jar" + mv "$TMP" "$JAR" +fi +exec java -cp "$JAR" "$SCRIPT_DIR/src/fnl/bots/retcon/Retcon.java" "$@" diff --git a/bots/java/Retcon/src/fnl/bots/framework/adapter/tankroyale/TankRoyaleAngles.java b/bots/java/Retcon/src/fnl/bots/framework/adapter/tankroyale/TankRoyaleAngles.java new file mode 100644 index 0000000..82bcd78 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/adapter/tankroyale/TankRoyaleAngles.java @@ -0,0 +1,21 @@ +package fnl.bots.framework.adapter.tankroyale; + +import fnl.bots.framework.geom.Angles; + +/** + * Tank Royale uses degrees, counter-clockwise from east, with positive turn rates turning left (counter-clockwise). + * The core uses the same orientation in radians, so conversion is a unit change only. + */ +final class TankRoyaleAngles { + + private TankRoyaleAngles() { + } + + static double headingToCore(double degrees) { + return Angles.normalizeAbsolute(Math.toRadians(degrees)); + } + + static double turnRateToPlatform(double radiansPerTurn) { + return Math.toDegrees(radiansPerTurn); + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/adapter/tankroyale/TankRoyaleBot.java b/bots/java/Retcon/src/fnl/bots/framework/adapter/tankroyale/TankRoyaleBot.java new file mode 100644 index 0000000..058ef74 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/adapter/tankroyale/TankRoyaleBot.java @@ -0,0 +1,277 @@ +package fnl.bots.framework.adapter.tankroyale; + +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.BotException; +import dev.robocode.tankroyale.botapi.BotInfo; +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.GameEndedEvent; +import dev.robocode.tankroyale.botapi.events.GameStartedEvent; +import dev.robocode.tankroyale.botapi.events.HitBotEvent; +import dev.robocode.tankroyale.botapi.events.HitByBulletEvent; +import dev.robocode.tankroyale.botapi.events.RoundEndedEvent; +import dev.robocode.tankroyale.botapi.events.ScannedBotEvent; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import fnl.bots.framework.arbitration.Intent; +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.EnemyView; +import fnl.bots.framework.pipeline.BotProgram; +import fnl.bots.framework.pipeline.Pipeline; +import fnl.bots.framework.pipeline.Warmup; +import fnl.bots.framework.rules.Rules; +import fnl.bots.framework.sense.BulletRecord; +import fnl.bots.framework.sense.GameInfo; +import fnl.bots.framework.sense.ScanRecord; +import fnl.bots.framework.sense.TurnInput; +import fnl.bots.framework.stats.MovementProbe; +import fnl.bots.framework.stats.StatsWriter; +import fnl.bots.framework.stats.TimingProbe; +import fnl.bots.framework.stats.TurnStats; +import fnl.bots.framework.stats.WaveProbe; +import fnl.bots.framework.team.TeamOutbox; + +import java.nio.file.Path; +import java.util.Set; + +/** + * The Tank Royale adapter: the only code that touches the Tank Royale Bot API. Event handlers translate events into + * the preallocated {@link TurnInput} (degrees become radians); each turn the run loop steps the {@link Pipeline} and + * translates its {@link Intent} back into Bot API setters. + *
+ * The Bot API dispatches a turn's events on the bot thread inside {@code go()}, after waiting for the next tick, so
+ * when {@code go()} returns, the input holds exactly that turn's events.
+ */
+public final class TankRoyaleBot extends Bot {
+
+ /** Synthetic turns run before connecting, to compile and JIT-warm the pipeline (see {@link Warmup}). */
+ private static final int WARMUP_TURNS = 20_000;
+
+ private final String name;
+ private final BotProgram program;
+ private final TurnInput input = new TurnInput();
+ private final GameInfo game = new GameInfo();
+ private final TimingProbe probe = new TimingProbe();
+ private final WaveProbe waveProbe = new WaveProbe();
+ private final MovementProbe movementProbe = new MovementProbe();
+ private Pipeline pipeline;
+ private TurnStats stats;
+ private Path statsFile;
+ private int roundsRecorded;
+
+ private TankRoyaleBot(BotInfo info, BotProgram program) {
+ super(info);
+ this.name = info != null ? info.getName() : envOrDefault("BOT_NAME", "bot");
+ this.program = program;
+ }
+
+ /**
+ * Starts a bot on Tank Royale. The config file is read from the working directory (the booter starts bots in
+ * their own directory); without it, the Bot API falls back to the booter's environment variables.
+ */
+ public static void start(String configFile, BotProgram program) {
+ Warmup.run(program, WARMUP_TURNS);
+ new TurnStats(1).recordCompute(0);
+ new TimingProbe().observe(new TurnInput());
+ new TankRoyaleBot(loadBotInfo(configFile), program).start();
+ }
+
+ /** Builds the pipeline as soon as the game setup is known, before the first tick. */
+ @Override
+ public void onGameStarted(GameStartedEvent e) {
+ createPipeline();
+ }
+
+ /** Called from the event thread (game started) and the bot thread (round start); builds once. */
+ private synchronized void createPipeline() {
+ if (pipeline != null) {
+ return;
+ }
+ readGameInfo();
+ pipeline = program.createPipeline(game, input);
+ stats = new TurnStats(game.numberOfRounds);
+ stats.setBudget(game.turnTimeoutMicros, pipeline.blackboard().turn().computeBudgetMicros());
+ statsFile = StatsWriter.statsFile(name, game.myId);
+ movementProbe.setArena(game.arenaWidth, game.arenaHeight);
+ }
+
+ @Override
+ public void run() {
+ createPipeline();
+ // Decouple body, gun and radar: each turn rate is then independent (Rules.ACTUATORS_DECOUPLED).
+ setAdjustGunForBodyTurn(true);
+ setAdjustRadarForGunTurn(true);
+ setAdjustRadarForBodyTurn(true);
+ setFireAssist(false);
+
+ input.clearEvents();
+ pipeline.onRoundStart();
+
+ while (isRunning()) {
+ long start = System.nanoTime();
+ readState();
+ probe.observe(input);
+ movementProbe.observe(input);
+ Intent intent = pipeline.step(input);
+ waveProbe.observe(pipeline.blackboard().enemyWaves(), input.turn);
+ apply(intent);
+ movementProbe.onIntent(input, intent.targetSpeed, intent.bodyTurnRate);
+ stats.recordCompute((System.nanoTime() - start) / 1000);
+ input.clearEvents();
+ go();
+ }
+ }
+
+ private void readGameInfo() {
+ game.myId = getMyId();
+ game.arenaWidth = getArenaWidth();
+ game.arenaHeight = getArenaHeight();
+ game.numberOfRounds = getNumberOfRounds();
+ game.gunCoolingRate = getGunCoolingRate();
+ game.turnTimeoutMicros = getTurnTimeout();
+ Set
+ * Platform conventions (Tank Royale degrees, classic Robocode clockwise-from-north) are converted in the adapters
+ * only. See Coordinate Systems & Angles.
+ */
+public final class Angles {
+
+ public static final double TWO_PI = 2 * Math.PI;
+
+ private Angles() {
+ }
+
+ /** Normalizes an absolute heading into [0, 2π). */
+ public static double normalizeAbsolute(double angle) {
+ double a = angle % TWO_PI;
+ if (a < 0) {
+ a += TWO_PI;
+ }
+ return a >= TWO_PI ? 0 : a;
+ }
+
+ /** Normalizes a relative angle (a turn or a difference) into (−π, π]. */
+ public static double normalizeRelative(double angle) {
+ double a = angle % TWO_PI;
+ if (a <= -Math.PI) {
+ a += TWO_PI;
+ } else if (a > Math.PI) {
+ a -= TWO_PI;
+ }
+ return a;
+ }
+
+ /** Converts to degrees for logs and debug output only; never use degrees in strategy code. */
+ public static double toDegreesForDisplay(double radians) {
+ return Math.toDegrees(radians);
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/geom/Geom.java b/bots/java/Retcon/src/fnl/bots/framework/geom/Geom.java
new file mode 100644
index 0000000..7622b2f
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/geom/Geom.java
@@ -0,0 +1,40 @@
+package fnl.bots.framework.geom;
+
+/**
+ * Allocation-free geometry on primitive coordinates. Angles follow {@link Angles}: radians, counter-clockwise from
+ * east.
+ */
+public final class Geom {
+
+ private Geom() {
+ }
+
+ /** Absolute direction from (x1, y1) to (x2, y2), in [0, 2π). */
+ public static double bearing(double x1, double y1, double x2, double y2) {
+ return Angles.normalizeAbsolute(Math.atan2(y2 - y1, x2 - x1));
+ }
+
+ public static double distance(double x1, double y1, double x2, double y2) {
+ return Math.hypot(x2 - x1, y2 - y1);
+ }
+
+ public static double distanceSq(double x1, double y1, double x2, double y2) {
+ double dx = x2 - x1;
+ double dy = y2 - y1;
+ return dx * dx + dy * dy;
+ }
+
+ /** X coordinate of the point at {@code distance} from x along {@code heading}. */
+ public static double projectX(double x, double heading, double distance) {
+ return x + Math.cos(heading) * distance;
+ }
+
+ /** Y coordinate of the point at {@code distance} from y along {@code heading}. */
+ public static double projectY(double y, double heading, double distance) {
+ return y + Math.sin(heading) * distance;
+ }
+
+ public static double clamp(double value, double min, double max) {
+ return Math.max(min, Math.min(max, value));
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/geom/Vec2.java b/bots/java/Retcon/src/fnl/bots/framework/geom/Vec2.java
new file mode 100644
index 0000000..40726ca
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/geom/Vec2.java
@@ -0,0 +1,31 @@
+package fnl.bots.framework.geom;
+
+/** A mutable 2D point, meant to be preallocated and reused so hot paths do not allocate. */
+public final class Vec2 {
+
+ public double x;
+ public double y;
+
+ public Vec2 set(double x, double y) {
+ this.x = x;
+ this.y = y;
+ return this;
+ }
+
+ public Vec2 set(Vec2 other) {
+ return set(other.x, other.y);
+ }
+
+ public double distanceTo(double ox, double oy) {
+ return Geom.distance(x, y, ox, oy);
+ }
+
+ public double bearingTo(double ox, double oy) {
+ return Geom.bearing(x, y, ox, oy);
+ }
+
+ @Override
+ public String toString() {
+ return "(" + x + ", " + y + ")";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/motion/Drive.java b/bots/java/Retcon/src/fnl/bots/framework/motion/Drive.java
new file mode 100644
index 0000000..3b81ecf
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/motion/Drive.java
@@ -0,0 +1,39 @@
+package fnl.bots.framework.motion;
+
+import fnl.bots.framework.arbitration.BodyCommand;
+import fnl.bots.framework.geom.Angles;
+
+/**
+ * Drives toward an absolute angle, backing up when the angle is behind the bot, so the body never turns more than
+ * 90°. This is the usual GoTo core of
+ * Movement Fundamentals &
+ * GoTo. Radians, counter-clockwise from east. Allocation-free.
+ */
+public final class Drive {
+
+ private Drive() {
+ }
+
+ /** Sets {@code out} to travel along {@code angle} at {@code speed} (≥ 0) from a body facing {@code heading}. */
+ public static void toward(double heading, double angle, double speed, BodyCommand out) {
+ double turn = Angles.normalizeRelative(angle - heading);
+ if (Math.abs(turn) > Math.PI / 2) {
+ out.turnRate = Angles.normalizeRelative(turn + Math.PI);
+ out.targetSpeed = -speed;
+ } else {
+ out.turnRate = turn;
+ out.targetSpeed = speed;
+ }
+ }
+
+ /** The same decision for a predicted body: returns the target speed and stores the turn rate in {@code out}. */
+ public static double toward(PredictedState state, double angle, double speed, double[] turnOut) {
+ double turn = Angles.normalizeRelative(angle - state.heading);
+ if (Math.abs(turn) > Math.PI / 2) {
+ turnOut[0] = Angles.normalizeRelative(turn + Math.PI);
+ return -speed;
+ }
+ turnOut[0] = turn;
+ return speed;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/motion/MovementPredictor.java b/bots/java/Retcon/src/fnl/bots/framework/motion/MovementPredictor.java
new file mode 100644
index 0000000..87fe272
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/motion/MovementPredictor.java
@@ -0,0 +1,52 @@
+package fnl.bots.framework.motion;
+
+import fnl.bots.framework.geom.Angles;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * Steps a bot's body forward one turn at a time exactly as the server does
+ * ({@link Rules#BODY_MOVES_BEFORE_TURNING}): new speed, move along the old heading, clamp at the walls
+ * ({@link Rules#constrainBotPosition}, speed 0 on a hit), then turn within the new speed's limit.
+ *
+ * Implements Precise
+ * Prediction, the shared engine of
+ * Wave Surfing
+ * Implementations. One instance per thread: it keeps a small scratch buffer, so stepping never allocates.
+ */
+public final class MovementPredictor {
+
+ private final double arenaWidth;
+ private final double arenaHeight;
+ private final double[] constrained = new double[2];
+
+ public MovementPredictor(double arenaWidth, double arenaHeight) {
+ this.arenaWidth = arenaWidth;
+ this.arenaHeight = arenaHeight;
+ }
+
+ /** Advances {@code state} by one turn under the given body intent (turn rate in radians per turn). */
+ public void step(PredictedState state, double targetSpeed, double turnRate) {
+ state.speed = Rules.nextSpeed(state.speed, targetSpeed);
+ double oldX = state.x;
+ double oldY = state.y;
+ double x = oldX + Math.cos(state.heading) * state.speed;
+ double y = oldY + Math.sin(state.heading) * state.speed;
+ state.heading = Angles.normalizeAbsolute(state.heading + Rules.limitBodyTurnRate(turnRate, state.speed));
+ Rules.constrainBotPosition(oldX, oldY, x, y, arenaWidth, arenaHeight, constrained);
+ if (constrained[0] != x || constrained[1] != y) {
+ state.speed = 0;
+ state.hitWall = true;
+ }
+ state.x = constrained[0];
+ state.y = constrained[1];
+ state.turns++;
+ }
+
+ public double arenaWidth() {
+ return arenaWidth;
+ }
+
+ public double arenaHeight() {
+ return arenaHeight;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/motion/PredictedState.java b/bots/java/Retcon/src/fnl/bots/framework/motion/PredictedState.java
new file mode 100644
index 0000000..ded02a0
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/motion/PredictedState.java
@@ -0,0 +1,35 @@
+package fnl.bots.framework.motion;
+
+/** A bot's body state for movement prediction. Mutable and reused, so predicting never allocates. */
+public final class PredictedState {
+
+ public double x;
+ public double y;
+ /** Radians, counter-clockwise from east. */
+ public double heading;
+ public double speed;
+ /** Turns stepped since {@link #set}. */
+ public int turns;
+ /** Whether any step so far hit a wall. */
+ public boolean hitWall;
+
+ public PredictedState set(double x, double y, double heading, double speed) {
+ this.x = x;
+ this.y = y;
+ this.heading = heading;
+ this.speed = speed;
+ this.turns = 0;
+ this.hitWall = false;
+ return this;
+ }
+
+ public PredictedState copyFrom(PredictedState other) {
+ x = other.x;
+ y = other.y;
+ heading = other.heading;
+ speed = other.speed;
+ turns = other.turns;
+ hitWall = other.hitWall;
+ return this;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/motion/WallSmoothing.java b/bots/java/Retcon/src/fnl/bots/framework/motion/WallSmoothing.java
new file mode 100644
index 0000000..2e2b704
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/motion/WallSmoothing.java
@@ -0,0 +1,43 @@
+package fnl.bots.framework.motion;
+
+import fnl.bots.framework.geom.Angles;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * Turns a desired travel angle away from the walls: a point {@code stick} units ahead must stay inside the arena
+ * inset by {@link #MARGIN}; otherwise the angle rotates in small steps toward {@code orientation} until it does.
+ *
+ * Implements Wall Avoidance
+ * & Wall Smoothing, with the rotation direction as a parameter so an orbit or a surfer keeps circling the
+ * same way along a wall. Angles are radians, counter-clockwise from east. Allocation-free.
+ */
+public final class WallSmoothing {
+
+ /** Inset of the safe rectangle: the server keeps a bot's centre this far from a wall. */
+ public static final double MARGIN = Rules.BOT_HIT_RADIUS;
+ private static final double STEP = Math.toRadians(2);
+ private static final int MAX_STEPS = 180; // a full half turn
+
+ private WallSmoothing() {
+ }
+
+ /**
+ * Returns {@code angle}, rotated toward {@code orientation} (+1 counter-clockwise, −1 clockwise) until a point
+ * {@code stick} units ahead of ({@code x}, {@code y}) is inside the safe rectangle.
+ */
+ public static double smooth(double x, double y, double angle, int orientation, double stick, double arenaWidth,
+ double arenaHeight) {
+ double a = angle;
+ for (int i = 0; i < MAX_STEPS; i++) {
+ if (isSafe(x + Math.cos(a) * stick, y + Math.sin(a) * stick, arenaWidth, arenaHeight)) {
+ return Angles.normalizeAbsolute(a);
+ }
+ a += orientation * STEP;
+ }
+ return Angles.normalizeAbsolute(a);
+ }
+
+ public static boolean isSafe(double x, double y, double arenaWidth, double arenaHeight) {
+ return x >= MARGIN && x <= arenaWidth - MARGIN && y >= MARGIN && y <= arenaHeight - MARGIN;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/pipeline/BotProgram.java b/bots/java/Retcon/src/fnl/bots/framework/pipeline/BotProgram.java
new file mode 100644
index 0000000..3682752
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/pipeline/BotProgram.java
@@ -0,0 +1,19 @@
+package fnl.bots.framework.pipeline;
+
+import fnl.bots.framework.sense.GameInfo;
+import fnl.bots.framework.sense.TurnInput;
+
+/**
+ * A bot, seen from an adapter: something that builds its pipeline once the game setup is known. Platform-free, so
+ * the same program can run behind any platform adapter.
+ */
+public interface BotProgram {
+
+ /**
+ * Builds the pipeline for a game.
+ *
+ * @param game the game setup
+ * @param input the adapter's preallocated per-turn input, reused every turn
+ */
+ Pipeline createPipeline(GameInfo game, TurnInput input);
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/pipeline/Phase.java b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Phase.java
new file mode 100644
index 0000000..096a632
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Phase.java
@@ -0,0 +1,13 @@
+package fnl.bots.framework.pipeline;
+
+/** The four phases of every turn, run in this order. */
+public enum Phase {
+ /** Turn the platform's raw input into derived state on the blackboard. */
+ SENSE,
+ /** Update models and collected data from the derived state. */
+ MODEL,
+ /** Role strategies write commands for the actuators they own. */
+ DECIDE,
+ /** The arbiter resolves the commands into one intent. */
+ ARBITRATE
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/pipeline/Pipeline.java b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Pipeline.java
new file mode 100644
index 0000000..e1d2c37
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Pipeline.java
@@ -0,0 +1,115 @@
+package fnl.bots.framework.pipeline;
+
+import fnl.bots.framework.arbitration.Arbiter;
+import fnl.bots.framework.arbitration.Intent;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.sense.TurnInput;
+import fnl.bots.framework.team.TeamOutbox;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The per-turn pipeline: Sense → Model → Decide → Arbitrate. Stages run in the order they were added within their
+ * phase. {@link #step} allocates nothing.
+ */
+public final class Pipeline {
+
+ private final Blackboard bb;
+ private final Arbiter arbiter;
+ private final SenseStage[] sense;
+ private final Stage[] model;
+ private final Stage[] decide;
+ private final Intent intent = new Intent();
+ private final TeamOutbox outbox;
+
+ private Pipeline(Builder builder) {
+ this.bb = builder.bb;
+ this.arbiter = builder.arbiter;
+ this.outbox = builder.outbox;
+ this.sense = builder.sense.toArray(new SenseStage[0]);
+ this.model = builder.model.toArray(new Stage[0]);
+ this.decide = builder.decide.toArray(new Stage[0]);
+ }
+
+ public static Builder builder(Blackboard bb, Arbiter arbiter, TeamOutbox outbox) {
+ return new Builder(bb, arbiter, outbox);
+ }
+
+ public void onRoundStart() {
+ for (SenseStage stage : sense) {
+ stage.onRoundStart();
+ }
+ for (Stage stage : model) {
+ stage.onRoundStart();
+ }
+ for (Stage stage : decide) {
+ stage.onRoundStart();
+ }
+ }
+
+ /** Runs one turn and returns the resolved intent (the same instance every turn). */
+ public Intent step(TurnInput in) {
+ for (SenseStage stage : sense) {
+ stage.sense(in, bb);
+ }
+ for (Stage stage : model) {
+ stage.run(bb);
+ }
+ arbiter.beginTurn();
+ for (Stage stage : decide) {
+ stage.run(bb);
+ }
+ arbiter.resolve(bb.self().speed(), intent);
+ return intent;
+ }
+
+ public Blackboard blackboard() {
+ return bb;
+ }
+
+ public Intent intent() {
+ return intent;
+ }
+
+ /** Team messages queued this turn; the adapter sends and then clears them. */
+ public TeamOutbox outbox() {
+ return outbox;
+ }
+
+ /** Wires stages into phases. Stages claim their slots and actuators in their constructors. */
+ public static final class Builder {
+
+ private final Blackboard bb;
+ private final Arbiter arbiter;
+ private final TeamOutbox outbox;
+ private final List
+ * It turns the gun toward the chosen aim angle and fires only when the current gun heading is already on
+ * target: the server fires before it applies the same intent's gun rotation, so the bullet leaves along the heading
+ * observed this turn ({@link Rules#FIRE_USES_PRE_TURN_GUN_HEADING}).
+ */
+public final class Gun {
+
+ private final Collector collector;
+ private final AimModel[] models;
+ private final AimSelector selector;
+ private final double[] aims;
+
+ public Gun(Collector collector, AimModel[] models, AimSelector selector) {
+ if (models.length == 0) {
+ throw new IllegalArgumentException("A gun needs at least one aim model");
+ }
+ this.collector = collector;
+ this.models = models.clone();
+ this.selector = selector;
+ this.aims = new double[models.length];
+ collector.onModels(this.models);
+ }
+
+ public Collector collector() {
+ return collector;
+ }
+
+ /**
+ * Aims at {@code target} and fires with {@code firepower} when on target.
+ *
+ * @param target the target, or null to leave the gun idle
+ */
+ public void decide(Blackboard bb, EnemyView target, double firepower, GunCommand gun, FireCommand fire) {
+ if (target == null) {
+ return;
+ }
+ double power = firepower >= Rules.MIN_FIREPOWER ? firepower : Rules.MIN_FIREPOWER;
+ AimModel model = models[selector.choose(bb, models)];
+ double aim = model.aim(bb, target, Rules.bulletSpeed(power));
+ if (Double.isNaN(aim)) {
+ return;
+ }
+ SelfView self = bb.self();
+ double offset = Angles.normalizeRelative(aim - self.gunHeading());
+ gun.turnRate = offset;
+
+ double distance = Geom.distance(self.x(), self.y(), target.x(), target.y());
+ boolean onTarget = Math.abs(offset) <= Rules.hitHalfAngle(distance);
+ if (onTarget && firepower >= Rules.MIN_FIREPOWER && Rules.canFire(self.gunHeat(), self.energy(), firepower)) {
+ fire.firepower = firepower;
+ double speed = Rules.bulletSpeed(firepower);
+ for (int i = 0; i < models.length; i++) {
+ aims[i] = models[i].aim(bb, target, speed);
+ }
+ collector.onFire(bb, target, speed, aims);
+ }
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/Mode.java b/bots/java/Retcon/src/fnl/bots/framework/roles/Mode.java
new file mode 100644
index 0000000..79d24f0
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/roles/Mode.java
@@ -0,0 +1,9 @@
+package fnl.bots.framework.roles;
+
+import fnl.bots.framework.blackboard.ModeKind;
+
+/** Chooses the battle mode, and with it the active {@link StrategySet}, from who is still alive. */
+public interface Mode {
+
+ ModeKind select(int enemiesAlive, int teammatesAlive);
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/Movement.java b/bots/java/Retcon/src/fnl/bots/framework/roles/Movement.java
new file mode 100644
index 0000000..3c399e5
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/roles/Movement.java
@@ -0,0 +1,10 @@
+package fnl.bots.framework.roles;
+
+import fnl.bots.framework.arbitration.BodyCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+
+/** Decides the body's speed and turn. Owns the BODY actuator through the movement stage. */
+public interface Movement {
+
+ void decide(Blackboard bb, BodyCommand out);
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/Radar.java b/bots/java/Retcon/src/fnl/bots/framework/roles/Radar.java
new file mode 100644
index 0000000..668cc8c
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/roles/Radar.java
@@ -0,0 +1,10 @@
+package fnl.bots.framework.roles;
+
+import fnl.bots.framework.arbitration.RadarCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+
+/** Decides the radar rotation. Owns the RADAR actuator through the radar stage. */
+public interface Radar {
+
+ void decide(Blackboard bb, RadarCommand out);
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/StrategySet.java b/bots/java/Retcon/src/fnl/bots/framework/roles/StrategySet.java
new file mode 100644
index 0000000..312a3f5
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/roles/StrategySet.java
@@ -0,0 +1,17 @@
+package fnl.bots.framework.roles;
+
+import java.util.Objects;
+
+/** One complete set of role implementations, used while its mode is active. */
+public record StrategySet(Radar radar, Movement movement, TargetSelector target, Gun gun, FirePower power,
+ TeamComms comms) {
+
+ public StrategySet {
+ Objects.requireNonNull(radar, "radar");
+ Objects.requireNonNull(movement, "movement");
+ Objects.requireNonNull(target, "target");
+ Objects.requireNonNull(gun, "gun");
+ Objects.requireNonNull(power, "power");
+ Objects.requireNonNull(comms, "comms");
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/TargetSelector.java b/bots/java/Retcon/src/fnl/bots/framework/roles/TargetSelector.java
new file mode 100644
index 0000000..739d813
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/roles/TargetSelector.java
@@ -0,0 +1,11 @@
+package fnl.bots.framework.roles;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemiesView;
+
+/** Picks the enemy to shoot at (and to lock the radar on). */
+public interface TargetSelector {
+
+ /** Returns an index into {@link Blackboard#enemies()}, or {@link EnemiesView#NONE}. */
+ int select(Blackboard bb);
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/TeamComms.java b/bots/java/Retcon/src/fnl/bots/framework/roles/TeamComms.java
new file mode 100644
index 0000000..341c6d5
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/roles/TeamComms.java
@@ -0,0 +1,13 @@
+package fnl.bots.framework.roles;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.team.TeamInbox;
+import fnl.bots.framework.team.TeamOutbox;
+
+/** Exchanges messages with teammates. */
+public interface TeamComms {
+
+ void receive(Blackboard bb, TeamInbox inbox);
+
+ void send(Blackboard bb, TeamOutbox outbox);
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/rules/Rules.java b/bots/java/Retcon/src/fnl/bots/framework/rules/Rules.java
new file mode 100644
index 0000000..14b9e57
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/rules/Rules.java
@@ -0,0 +1,301 @@
+package fnl.bots.framework.rules;
+
+import fnl.bots.framework.geom.Angles;
+
+/**
+ * All game physics in one place. Strategies call these constants and functions; they never contain physics
+ * literals such as 18, 8 or 20 − 3p.
+ *
+ * Values and turn-order facts are verified against the Tank Royale server v1.3.1 source; every entry cites its
+ * source in {@code docs/physics-facts.md}, and {@code RulesConformanceTest} pins each one. Angles and turn rates are
+ * in radians (see {@link Angles}); distances in units; time in turns.
+ *
+ * Book: Physics Differences.
+ */
+public final class Rules {
+
+ private Rules() {
+ }
+
+ // ---- Bot body --------------------------------------------------------------------------------------------
+
+ /** Radius of the circular hitbox used for bullet hits and scans ({@code BOT_BOUNDING_CIRCLE_RADIUS}). */
+ public static final double BOT_HIT_RADIUS = 18.0;
+ public static final double BOT_DIAMETER = 2 * BOT_HIT_RADIUS;
+ public static final double MAX_SPEED = 8.0;
+ public static final double ACCELERATION = 1.0;
+ /** Deceleration magnitude when slowing down in the same direction. */
+ public static final double DECELERATION = 2.0;
+ public static final double INITIAL_ENERGY = 100.0;
+ public static final double RAM_DAMAGE = 0.6;
+ private static final double DISABLED_ENERGY_EPSILON = 1e-6;
+
+ private static final double MAX_BODY_TURN_RATE = Math.toRadians(10.0);
+ private static final double BODY_TURN_RATE_PER_SPEED = Math.toRadians(0.75);
+ public static final double MAX_GUN_TURN_RATE = Math.toRadians(20.0);
+ public static final double MAX_RADAR_TURN_RATE = Math.toRadians(45.0);
+ public static final double RADAR_RANGE = 1200.0;
+
+ // ---- Gun and bullets -------------------------------------------------------------------------------------
+
+ public static final double MIN_FIREPOWER = 0.1;
+ public static final double MAX_FIREPOWER = 3.0;
+ public static final double INITIAL_GUN_HEAT = 3.0;
+ private static final double BULLET_SPEED_BASE = 20.0;
+ private static final double BULLET_SPEED_PER_POWER = 3.0;
+ private static final double BULLET_HIT_ENERGY_GAIN_FACTOR = 3.0;
+ public static final double MIN_BULLET_SPEED = bulletSpeed(MAX_FIREPOWER);
+ public static final double MAX_BULLET_SPEED = bulletSpeed(MIN_FIREPOWER);
+
+ public static double clampFirepower(double firepower) {
+ return Math.max(MIN_FIREPOWER, Math.min(MAX_FIREPOWER, firepower));
+ }
+
+ /** Bullet speed in units per turn: 20 − 3·p, with p clamped to [0.1, 3]. */
+ public static double bulletSpeed(double firepower) {
+ return BULLET_SPEED_BASE - BULLET_SPEED_PER_POWER * clampFirepower(firepower);
+ }
+
+ /** Damage dealt by a bullet: 4·p, plus 2·(p − 1) when p > 1. */
+ public static double bulletDamage(double firepower) {
+ double p = clampFirepower(firepower);
+ double damage = 4 * p;
+ if (firepower > 1) {
+ damage += 2 * (firepower - 1);
+ }
+ return damage;
+ }
+
+ /** The smallest firepower whose {@link #bulletDamage} is at least {@code damage}, clamped to the legal range. */
+ public static double firepowerForDamage(double damage) {
+ double p = damage <= 4 ? damage / 4 : (damage + 2) / 6;
+ return clampFirepower(p);
+ }
+
+ /** Gun heat added by firing: 1 + p/5. */
+ public static double gunHeat(double firepower) {
+ return 1 + clampFirepower(firepower) / 5;
+ }
+
+ /** Energy returned to the shooter when its bullet hits: 3·p (the bullet's power is already clamped). */
+ public static double energyGainOnHit(double firepower) {
+ return BULLET_HIT_ENERGY_GAIN_FACTOR * clampFirepower(firepower);
+ }
+
+ /**
+ * Energy the shooter pays for firing. The server charges the requested firepower, not the clamped one,
+ * so requesting more than {@link #MAX_FIREPOWER} wastes energy. Always clamp before firing.
+ */
+ public static double fireEnergyCost(double requestedFirepower) {
+ return requestedFirepower;
+ }
+
+ /**
+ * Whether the server will fire this request: the gun must be cold at the start of the turn, the power at least
+ * {@link #MIN_FIREPOWER}, and the energy strictly greater than the requested power.
+ */
+ public static boolean canFire(double gunHeat, double energy, double requestedFirepower) {
+ return gunHeat == 0.0 && requestedFirepower >= MIN_FIREPOWER && energy > requestedFirepower;
+ }
+
+ // ---- Turn rates ------------------------------------------------------------------------------------------
+
+ /** Maximum body turn rate at the given speed: 10° − 0.75°·|v|, in radians per turn. */
+ public static double maxBodyTurnRate(double speed) {
+ return MAX_BODY_TURN_RATE - BODY_TURN_RATE_PER_SPEED * Math.abs(clampSpeed(speed));
+ }
+
+ /** The body turn rate the server applies for a requested {@code turnRate} at {@code speed}. */
+ public static double limitBodyTurnRate(double turnRate, double speed) {
+ double max = maxBodyTurnRate(speed);
+ return Math.max(-max, Math.min(max, turnRate));
+ }
+
+ /**
+ * A body intent updates, in this order: the speed ({@link #nextSpeed}), the position, moved by the new speed
+ * along the old heading, and then the heading, turned by at most {@link #maxBodyTurnRate} of the
+ * new speed. Classic Robocode turns before it moves.
+ */
+ public static final boolean BODY_MOVES_BEFORE_TURNING = true;
+
+ /**
+ * Where the server puts a bot that moved from ({@code oldX}, {@code oldY}) to ({@code x}, {@code y}) and crossed
+ * a wall: back along the move, to {@link #BOT_HIT_RADIUS} from the wall. Written to {@code out} as {x, y}; the
+ * bot hit a wall if either differs from the unconstrained position, and its speed then becomes 0. Mirrors the
+ * server exactly, including that a y-wall recomputes x from the unconstrained move.
+ */
+ public static void constrainBotPosition(double oldX, double oldY, double x, double y, double arenaWidth,
+ double arenaHeight, double[] out) {
+ double newX = x;
+ double newY = y;
+ if (x - BOT_HIT_RADIUS < 0) {
+ newX = BOT_HIT_RADIUS;
+ newY = alongMove(oldY, y - oldY, newX - oldX, x - oldX, y);
+ } else if (x + BOT_HIT_RADIUS > arenaWidth) {
+ newX = arenaWidth - BOT_HIT_RADIUS;
+ newY = alongMove(oldY, y - oldY, newX - oldX, x - oldX, y);
+ }
+ if (y - BOT_HIT_RADIUS < 0) {
+ newY = BOT_HIT_RADIUS;
+ newX = alongMove(oldX, x - oldX, newY - oldY, y - oldY, newX);
+ } else if (y + BOT_HIT_RADIUS > arenaHeight) {
+ newY = arenaHeight - BOT_HIT_RADIUS;
+ newX = alongMove(oldX, x - oldX, newY - oldY, y - oldY, newX);
+ }
+ out[0] = newX;
+ out[1] = newY;
+ }
+
+ /** old + delta · part / whole, or {@code fallback} when the move has no extent along the clamped axis. */
+ private static double alongMove(double old, double delta, double part, double whole, double fallback) {
+ return whole != 0 ? old + delta * part / whole : fallback;
+ }
+
+ /**
+ * A living bot whose energy is within 1e-6 of 0 is disabled: the server drops its movement, so it stands still
+ * while it may still report its old speed.
+ */
+ public static boolean isDisabled(double energy) {
+ return Math.abs(energy) < DISABLED_ENERGY_EPSILON;
+ }
+
+ public static double clampSpeed(double speed) {
+ return Math.max(-MAX_SPEED, Math.min(MAX_SPEED, speed));
+ }
+
+ /** Wall damage for hitting a wall at the given speed: |v|/2 − 1, never below 0. */
+ public static double wallDamage(double speed) {
+ return Math.max(0, Math.abs(clampSpeed(speed)) / 2 - 1);
+ }
+
+ /**
+ * The speed after one turn, exactly as the server computes it ({@code calcNewBotSpeed}).
+ *
+ * Conformance note: when the target speed has the other sign than the current speed, or is 0, the server
+ * does not decelerate by {@link #DECELERATION}. A target of 0 stops the bot at once, and reversing gives
+ * −(1 − |v|/2) at any speed (8 → 3). This contradicts the documented rules and is reported as a probable server
+ * bug in {@code docs/physics-facts.md}; this method follows the server because the server is the truth.
+ */
+ public static double nextSpeed(double currentSpeed, double targetSpeed) {
+ if (currentSpeed < 0.0) {
+ return -nextSpeed(-currentSpeed, -targetSpeed);
+ }
+ if (currentSpeed > 0) {
+ if (Math.signum(currentSpeed) == Math.signum(targetSpeed)) {
+ double diff = targetSpeed - currentSpeed;
+ if (diff >= 0) {
+ return Math.min(currentSpeed + Math.min(diff, ACCELERATION), MAX_SPEED);
+ }
+ return Math.max(currentSpeed + Math.max(diff, -DECELERATION), -MAX_SPEED);
+ }
+ if (targetSpeed == 0.0) {
+ return 0.0;
+ }
+ double decelerationTime = currentSpeed / DECELERATION;
+ return (1 - decelerationTime) * -ACCELERATION;
+ }
+ double diff = targetSpeed;
+ double acceleration = Math.min(Math.abs(diff), ACCELERATION);
+ return diff >= 0 ? Math.min(acceleration, MAX_SPEED) : Math.max(-acceleration, -MAX_SPEED);
+ }
+
+ // ---- Turn order and bullet timing ------------------------------------------------------------------------
+
+ /**
+ * With all three "adjust" flags set, the body, gun and radar headings each change by exactly their own turn
+ * rate, so the actuators are independent. The Tank Royale adapter always sets them.
+ */
+ public static final boolean ACTUATORS_DECOUPLED = true;
+
+ /**
+ * A bot that decides to fire after seeing turn {@code t} gets its bullet in turn t + 1, but the bullet starts at
+ * the bot's position observed at turn {@code t} and flies along the gun heading observed at turn {@code t}:
+ * firing happens before the same intent's movement and gun rotation. So aim with the current gun
+ * heading, not the heading after this turn's gun turn.
+ */
+ public static final boolean FIRE_USES_PRE_TURN_GUN_HEADING = true;
+
+ /** Turn whose observed bot position is the origin of a bullet fired in response to turn {@code decidedAt}. */
+ public static int firingPositionTurn(int decidedAt) {
+ return decidedAt;
+ }
+
+ /** Turn in which the server creates a bullet fired in response to turn {@code decidedAt}. */
+ public static int bulletCreatedTurn(int decidedAt) {
+ return decidedAt + 1;
+ }
+
+ /**
+ * Distance from its origin of a bullet fired in response to turn {@code decidedAt}, as reported at turn
+ * {@code now}: v·(now − decidedAt). The bullet already moves v within the turn it is created.
+ */
+ public static double bulletDistance(int decidedAt, int now, double bulletSpeed) {
+ return bulletSpeed * (now - decidedAt);
+ }
+
+ /**
+ * Far end of the segment the server sweeps for hits in turn {@code now}: the hit test runs from
+ * {@link #bulletDistance} to v·(now − decidedAt + 1) against each bot's post-move position.
+ */
+ public static double hitSweepEnd(int decidedAt, int now, double bulletSpeed) {
+ return bulletSpeed * (now - decidedAt + 1);
+ }
+
+ /**
+ * First turn in which a bullet fired in response to turn {@code decidedAt}, from ({@code originX},
+ * {@code originY}) along {@code heading}, hits a bot standing still at ({@code targetX}, {@code targetY}).
+ * It follows the server's hit test: in turn {@code now} the segment from {@link #bulletDistance} to
+ * {@link #hitSweepEnd} is tested against the {@link #BOT_HIT_RADIUS} circle. The segment of the creation turn
+ * starts at v, not 0, so a target closer than that is passed over.
+ *
+ * @return the turn of the hit, or −1 if the bullet misses
+ */
+ public static int firstHitTurnOnStillTarget(int decidedAt, double originX, double originY, double heading,
+ double targetX, double targetY, double bulletSpeed) {
+ double dx = targetX - originX;
+ double dy = targetY - originY;
+ double cos = Math.cos(heading);
+ double sin = Math.sin(heading);
+ double along = dx * cos + dy * sin;
+ double lateral = Math.abs(-dx * sin + dy * cos);
+ if (lateral > BOT_HIT_RADIUS) {
+ return -1;
+ }
+ double halfChord = Math.sqrt(BOT_HIT_RADIUS * BOT_HIT_RADIUS - lateral * lateral);
+ double entry = along - halfChord;
+ double exit = along + halfChord;
+ int turn = Math.max(bulletCreatedTurn(decidedAt), decidedAt - 1 + (int) Math.ceil(entry / bulletSpeed));
+ return bulletDistance(decidedAt, turn, bulletSpeed) <= exit ? turn : -1;
+ }
+
+ // ---- Enemy fire seen through scans ------------------------------------------------------------------------
+
+ /**
+ * A scan in turn {@code k} reports the scanned bot's energy after that turn's fire cost, wall damage and ram
+ * damage, but before that turn's bullet hits: the victim's damage and the shooter's {@link #energyGainOnHit}
+ * bonus first show in the scan of turn k + 1 ({@link #scanTurnShowingHit}).
+ */
+ public static final boolean SCAN_ENERGY_BEFORE_BULLET_HITS = true;
+
+ /** Wall damage applies only on the first turn of wall contact, not while the bot stays against the wall. */
+ public static final boolean WALL_DAMAGE_FIRST_CONTACT_ONLY = true;
+
+ /**
+ * Decision turn of an enemy shot first seen as an energy drop in the scan of turn {@code detectedAt}. The fire
+ * cost shows in the scan of the turn the bullet is created ({@link #bulletCreatedTurn}), so the shot was decided
+ * one turn earlier, and its origin is the enemy's position scanned then ({@link #firingPositionTurn}).
+ */
+ public static int enemyFireDecisionTurn(int detectedAt) {
+ return detectedAt - 1;
+ }
+
+ /** First turn whose scan shows the energy effect of a bullet hit reported in turn {@code hitTurn}. */
+ public static int scanTurnShowingHit(int hitTurn) {
+ return hitTurn + 1;
+ }
+
+ /** Half the angle a bot's hit circle covers at {@code distance}: atan(18 / d). */
+ public static double hitHalfAngle(double distance) {
+ return Math.atan(BOT_HIT_RADIUS / Math.max(distance, BOT_HIT_RADIUS));
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/sense/BulletRecord.java b/bots/java/Retcon/src/fnl/bots/framework/sense/BulletRecord.java
new file mode 100644
index 0000000..6ef9ed2
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/sense/BulletRecord.java
@@ -0,0 +1,19 @@
+package fnl.bots.framework.sense;
+
+/** A bullet-related event of the current turn, in core units. Which fields are set depends on the event kind. */
+public final class BulletRecord {
+
+ public int bulletId;
+ /** The turn number the event was reported in. */
+ public int turn;
+ public double x;
+ public double y;
+ public double heading;
+ public double power;
+ /** The bot that fired the bullet. */
+ public int ownerId;
+ /** For hits: the bot that was hit. */
+ public int victimId;
+ /** For hits: the victim's energy after the hit. */
+ public double victimEnergy;
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/sense/GameInfo.java b/bots/java/Retcon/src/fnl/bots/framework/sense/GameInfo.java
new file mode 100644
index 0000000..14ddda0
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/sense/GameInfo.java
@@ -0,0 +1,26 @@
+package fnl.bots.framework.sense;
+
+/**
+ * Game setup facts, fixed for a whole game and filled by the adapter when the game starts. Platform-free.
+ */
+public final class GameInfo {
+
+ public int myId;
+ public int arenaWidth;
+ public int arenaHeight;
+ public int numberOfRounds;
+ public double gunCoolingRate;
+ /** Server turn timeout in microseconds, as reported by the platform at runtime. */
+ public int turnTimeoutMicros;
+ /** Ids of teammates; empty when not in a team. Never modified after the game starts. */
+ public int[] teammateIds = new int[0];
+
+ public boolean isTeammate(int botId) {
+ for (int id : teammateIds) {
+ if (id == botId) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/sense/ScanRecord.java b/bots/java/Retcon/src/fnl/bots/framework/sense/ScanRecord.java
new file mode 100644
index 0000000..4b3f564
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/sense/ScanRecord.java
@@ -0,0 +1,12 @@
+package fnl.bots.framework.sense;
+
+/** One scanned bot in the current turn, in core units (radians, counter-clockwise from east). */
+public final class ScanRecord {
+
+ public int botId;
+ public double x;
+ public double y;
+ public double heading;
+ public double speed;
+ public double energy;
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/sense/TurnInput.java b/bots/java/Retcon/src/fnl/bots/framework/sense/TurnInput.java
new file mode 100644
index 0000000..3e58d76
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/sense/TurnInput.java
@@ -0,0 +1,125 @@
+package fnl.bots.framework.sense;
+
+/**
+ * Raw input for one turn, translated by the adapter into core units (radians, counter-clockwise from east). It is
+ * preallocated once and refilled every turn, so the adapter never allocates per turn.
+ *
+ * Only Sense-phase stages read it; they turn it into derived state on the blackboard.
+ */
+public final class TurnInput {
+
+ public static final int MAX_BOTS = 32;
+ public static final int MAX_BULLET_EVENTS = 64;
+ public static final int MAX_TEAM_MESSAGES = 16;
+
+ public int round;
+ public int turn;
+
+ // Own state at this turn.
+ public double x;
+ public double y;
+ public double heading;
+ public double gunHeading;
+ public double radarHeading;
+ public double speed;
+ public double energy;
+ public double gunHeat;
+ public int enemyCount;
+
+ /** Microseconds left of this turn's budget when the input was taken; negative if unknown. */
+ public int timeLeftMicros = -1;
+
+ public final ScanRecord[] scans = newScans();
+ public int scanCount;
+
+ public final int[] deadBotIds = new int[MAX_BOTS];
+ public int deadCount;
+
+ public final BulletRecord[] bulletsFired = newBullets();
+ public int bulletsFiredCount;
+ public final BulletRecord[] bulletHits = newBullets();
+ public int bulletHitCount;
+ public final BulletRecord[] hitsTaken = newBullets();
+ public int hitsTakenCount;
+
+ /** Bots this bot collided with this turn, and whether this bot rammed them (drove into them). */
+ public final int[] collisionBotIds = new int[MAX_BOTS];
+ public final boolean[] collisionRammed = new boolean[MAX_BOTS];
+ public int collisionCount;
+
+ public final String[] teamMessages = new String[MAX_TEAM_MESSAGES];
+ public final int[] teamMessageSenders = new int[MAX_TEAM_MESSAGES];
+ public int teamMessageCount;
+
+ public int skippedTurns;
+ public boolean died;
+
+ /** Clears the per-turn events; the adapter calls it before collecting the next turn. */
+ public void clearEvents() {
+ scanCount = 0;
+ deadCount = 0;
+ bulletsFiredCount = 0;
+ bulletHitCount = 0;
+ hitsTakenCount = 0;
+ collisionCount = 0;
+ for (int i = 0; i < teamMessageCount; i++) {
+ teamMessages[i] = null;
+ }
+ teamMessageCount = 0;
+ skippedTurns = 0;
+ died = false;
+ }
+
+ /** Returns the next free scan slot, or null if the turn already holds {@link #MAX_BOTS} scans. */
+ public ScanRecord addScan() {
+ return scanCount < scans.length ? scans[scanCount++] : null;
+ }
+
+ public BulletRecord addBulletFired() {
+ return bulletsFiredCount < bulletsFired.length ? bulletsFired[bulletsFiredCount++] : null;
+ }
+
+ public BulletRecord addBulletHit() {
+ return bulletHitCount < bulletHits.length ? bulletHits[bulletHitCount++] : null;
+ }
+
+ public BulletRecord addHitTaken() {
+ return hitsTakenCount < hitsTaken.length ? hitsTaken[hitsTakenCount++] : null;
+ }
+
+ public void addCollision(int botId, boolean rammed) {
+ if (collisionCount < collisionBotIds.length) {
+ collisionBotIds[collisionCount] = botId;
+ collisionRammed[collisionCount++] = rammed;
+ }
+ }
+
+ public void addDeath(int botId) {
+ if (deadCount < deadBotIds.length) {
+ deadBotIds[deadCount++] = botId;
+ }
+ }
+
+ public void addTeamMessage(int senderId, String message) {
+ if (teamMessageCount < teamMessages.length) {
+ teamMessageSenders[teamMessageCount] = senderId;
+ teamMessages[teamMessageCount++] = message;
+ }
+ }
+
+ private static ScanRecord[] newScans() {
+ ScanRecord[] records = new ScanRecord[MAX_BOTS];
+ for (int i = 0; i < records.length; i++) {
+ records[i] = new ScanRecord();
+ }
+ return records;
+ }
+
+ private static BulletRecord[] newBullets() {
+ BulletRecord[] records = new BulletRecord[MAX_BULLET_EVENTS];
+ for (int i = 0; i < records.length; i++) {
+ records[i] = new BulletRecord();
+ }
+ return records;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/CollectStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/CollectStage.java
new file mode 100644
index 0000000..f25028e
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/CollectStage.java
@@ -0,0 +1,37 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.pipeline.Stage;
+import fnl.bots.framework.roles.Gun;
+
+/** Model: runs the collector of every gun, whatever the active mode, so no targeting data is lost. */
+public final class CollectStage implements Stage {
+
+ private final Gun[] guns;
+
+ public CollectStage(Blackboard bb, Strategies strategies) {
+ this.guns = strategies.distinctGuns();
+ for (Gun gun : guns) {
+ gun.collector().wire(bb);
+ }
+ }
+
+ @Override
+ public void onRoundStart() {
+ for (Gun gun : guns) {
+ gun.collector().onRoundStart();
+ }
+ }
+
+ @Override
+ public void run(Blackboard bb) {
+ for (Gun gun : guns) {
+ gun.collector().collect(bb);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "CollectStage";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/EnemyFireSense.java b/bots/java/Retcon/src/fnl/bots/framework/stages/EnemyFireSense.java
new file mode 100644
index 0000000..db994b3
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/EnemyFireSense.java
@@ -0,0 +1,249 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemiesView;
+import fnl.bots.framework.blackboard.EnemyTable;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.blackboard.EnemyWave;
+import fnl.bots.framework.blackboard.EnemyWaves;
+import fnl.bots.framework.blackboard.SelfView;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.pipeline.SenseStage;
+import fnl.bots.framework.rules.Rules;
+import fnl.bots.framework.sense.BulletRecord;
+import fnl.bots.framework.sense.TurnInput;
+
+/**
+ * Sense: turns enemy energy drops into waves. Must run after {@link SelfSense} and {@link EnemySense}, which it
+ * reads.
+ *
+ * Implements Dodging Bullets
+ * (hearing the shot) and the wave creation step of
+ * Wave Surfing,
+ * with Tank Royale's timing from physics facts 4 and 5: the scan of turn d shows a shot decided at d − 1, fired from
+ * the position scanned at d − 1. Before testing a drop it removes the energy changes it already knows about: ram and
+ * wall damage from the same turn, and bullet hits reported up to the previous turn. An estimate of the shooter's gun
+ * heat rejects drops that cannot be shots.
+ *
+ * It also matches the bullets that hit this bot to their waves, so movement can learn where it was hit.
+ */
+public final class EnemyFireSense implements SenseStage {
+
+ /** Tolerance on energy comparisons; scans report energy as doubles computed the same way as ours. */
+ private static final double ENERGY_EPSILON = 1e-6;
+ /** The server clamps a bot that hits a wall to exactly {@link Rules#BOT_HIT_RADIUS} from it. */
+ private static final double WALL_EPSILON = 1e-6;
+
+ private final EnemyWaves waves;
+ private final int[] prevTurn = new int[EnemyTable.CAPACITY];
+ private final double[] prevX = new double[EnemyTable.CAPACITY];
+ private final double[] prevY = new double[EnemyTable.CAPACITY];
+ private final double[] prevEnergy = new double[EnemyTable.CAPACITY];
+ private final double[] prevSpeed = new double[EnemyTable.CAPACITY];
+ /** Known energy change not yet seen in a scan: negative for damage, positive for bonuses. */
+ private final double[] knownChange = new double[EnemyTable.CAPACITY];
+ /** Estimated gun heat after turn {@link #heatTurn}; the server cools it every turn the gun is hot. */
+ private final double[] gunHeat = new double[EnemyTable.CAPACITY];
+ private final int[] heatTurn = new int[EnemyTable.CAPACITY];
+
+ private int prevSelfTurn = -1;
+ private double prevSelfX;
+ private double prevSelfY;
+ private double prevSelfHeading;
+ private double prevSelfSpeed;
+ private int lastLateralDirection = 1;
+
+ public EnemyFireSense(Blackboard bb) {
+ this.waves = bb.enemyWavesSlot.claim(this);
+ }
+
+ @Override
+ public void onRoundStart() {
+ waves.clear();
+ for (int i = 0; i < EnemyTable.CAPACITY; i++) {
+ prevTurn[i] = -1;
+ knownChange[i] = 0;
+ gunHeat[i] = Rules.INITIAL_GUN_HEAT;
+ heatTurn[i] = 0;
+ }
+ prevSelfTurn = -1;
+ lastLateralDirection = 1;
+ }
+
+ @Override
+ public void sense(TurnInput in, Blackboard bb) {
+ EnemiesView enemies = bb.enemies();
+ SelfView self = bb.self();
+ int now = in.turn;
+ double cooling = bb.game().gunCoolingRate;
+
+ // Ram damage lands before this turn's scan (fact 4).
+ for (int c = 0; c < in.collisionCount; c++) {
+ int index = enemies.indexOf(in.collisionBotIds[c]);
+ if (index != EnemiesView.NONE) {
+ knownChange[index] -= Rules.RAM_DAMAGE;
+ }
+ }
+
+ removeResolvedWaves(self, now);
+
+ for (int i = 0; i < enemies.count(); i++) {
+ EnemyView enemy = enemies.get(i);
+ if (enemy.lastSeenTurn() != now) {
+ continue;
+ }
+ // Cool through the turns not seen, then decide whether turn `now` could have fired.
+ gunHeat[i] = Math.max(0, gunHeat[i] - cooling * Math.max(0, now - heatTurn[i] - 1));
+ boolean gunReady = gunHeat[i] <= ENERGY_EPSILON;
+ boolean fired = false;
+ if (prevTurn[i] >= 0 && gunReady) {
+ double drop = prevEnergy[i] - enemy.energy() + knownChange[i];
+ if (hitWall(bb, enemy, prevSpeed[i])) {
+ // The impact speed is not observed; bots nearly always drive into a wall accelerating.
+ double impact = Math.min(Rules.MAX_SPEED, Math.abs(prevSpeed[i]) + Rules.ACCELERATION);
+ drop -= Rules.wallDamage(impact);
+ }
+ if (drop >= Rules.MIN_FIREPOWER - ENERGY_EPSILON && drop <= Rules.MAX_FIREPOWER + ENERGY_EPSILON) {
+ double power = Rules.clampFirepower(drop);
+ fired = true;
+ waves.firesDetected++;
+ gunHeat[i] = Rules.gunHeat(power);
+ if (prevTurn[i] == Rules.enemyFireDecisionTurn(now)) {
+ addWave(enemy.id(), i, power, self, now);
+ } else {
+ waves.unanchoredShots++;
+ }
+ }
+ }
+ if (!fired) {
+ gunHeat[i] = Math.max(0, gunHeat[i] - cooling);
+ }
+ heatTurn[i] = now;
+ knownChange[i] = 0;
+ prevTurn[i] = now;
+ prevX[i] = enemy.x();
+ prevY[i] = enemy.y();
+ prevEnergy[i] = enemy.energy();
+ prevSpeed[i] = enemy.speed();
+ }
+
+ // Bullet hits show one scan later (fact 4).
+ for (int h = 0; h < in.bulletHitCount; h++) {
+ BulletRecord hit = in.bulletHits[h];
+ int index = enemies.indexOf(hit.victimId);
+ if (index != EnemiesView.NONE) {
+ knownChange[index] -= Rules.bulletDamage(hit.power);
+ }
+ }
+ for (int h = 0; h < in.hitsTakenCount; h++) {
+ BulletRecord hit = in.hitsTaken[h];
+ int index = enemies.indexOf(hit.ownerId);
+ if (index != EnemiesView.NONE) {
+ knownChange[index] += Rules.energyGainOnHit(hit.power);
+ }
+ matchHit(hit, now);
+ }
+
+ prevSelfTurn = now;
+ prevSelfX = self.x();
+ prevSelfY = self.y();
+ prevSelfHeading = self.heading();
+ prevSelfSpeed = self.speed();
+ }
+
+ private void addWave(int shooterId, int index, double power, SelfView self, int now) {
+ EnemyWave wave = waves.add();
+ if (wave == null) {
+ return;
+ }
+ int decided = Rules.enemyFireDecisionTurn(now);
+ wave.shooterId = shooterId;
+ wave.decisionTurn = decided;
+ wave.originX = prevX[index];
+ wave.originY = prevY[index];
+ wave.power = power;
+ wave.bulletSpeed = Rules.bulletSpeed(power);
+ wave.maxEscapeAngle = Math.asin(Rules.MAX_SPEED / wave.bulletSpeed);
+ // The shooter aimed at where this bot was at the decision turn; fall back to now if that turn was missed.
+ boolean haveSelf = prevSelfTurn == decided;
+ double selfX = haveSelf ? prevSelfX : self.x();
+ double selfY = haveSelf ? prevSelfY : self.y();
+ double selfHeading = haveSelf ? prevSelfHeading : self.heading();
+ double selfSpeed = haveSelf ? prevSelfSpeed : self.speed();
+ wave.directAngle = Geom.bearing(wave.originX, wave.originY, selfX, selfY);
+ double lateral = selfSpeed * Math.sin(selfHeading - wave.directAngle);
+ if (lateral > 0) {
+ lastLateralDirection = 1;
+ } else if (lateral < 0) {
+ lastLateralDirection = -1;
+ }
+ wave.lateralDirection = lastLateralDirection;
+ }
+
+ /** Assigns a bullet that hit this bot to the wave of the same shooter and speed whose radius fits best. */
+ private void matchHit(BulletRecord hit, int now) {
+ waves.hitsTaken++;
+ double speed = Rules.bulletSpeed(hit.power);
+ int best = -1;
+ double bestResidual = Double.MAX_VALUE;
+ for (int w = 0; w < waves.count(); w++) {
+ EnemyWave wave = waves.mutable(w);
+ if (wave.shooterId != hit.ownerId || wave.hitTurn >= 0) {
+ continue;
+ }
+ // Use the real bullet's speed: a shot fired while hitting a wall may have a misjudged power.
+ double residual = Geom.distance(wave.originX, wave.originY, hit.x, hit.y)
+ - Rules.bulletDistance(wave.decisionTurn, now, speed);
+ if (Math.abs(residual) < Math.abs(bestResidual)) {
+ best = w;
+ bestResidual = residual;
+ }
+ }
+ if (best >= 0 && Math.abs(bestResidual) <= 1.5 * speed) {
+ EnemyWave wave = waves.mutable(best);
+ if (Math.abs(wave.bulletSpeed - speed) > ENERGY_EPSILON) {
+ wave.power = hit.power;
+ wave.bulletSpeed = speed;
+ wave.maxEscapeAngle = Math.asin(Rules.MAX_SPEED / speed);
+ waves.powerCorrected++;
+ }
+ wave.hitTurn = now;
+ wave.hitX = hit.x;
+ wave.hitY = hit.y;
+ wave.hitResidual = bestResidual;
+ waves.hitsOnWave++;
+ }
+ }
+
+ /** Drops waves that hit this bot on an earlier turn, and waves whose bullet is now past this bot. */
+ private void removeResolvedWaves(SelfView self, int now) {
+ for (int w = waves.count() - 1; w >= 0; w--) {
+ EnemyWave wave = waves.mutable(w);
+ boolean hitEarlier = wave.hitTurn >= 0 && wave.hitTurn < now;
+ double distance = Geom.distance(wave.originX, wave.originY, self.x(), self.y());
+ boolean passed = wave.radius(now) > distance + 2 * Rules.BOT_HIT_RADIUS;
+ if (hitEarlier || passed) {
+ waves.remove(w);
+ }
+ }
+ }
+
+ /**
+ * Whether the enemy most likely hit a wall this turn: it stands still, clamped against a wall, after moving.
+ * Wall damage only applies on first contact, so an enemy already stopped at the wall took none.
+ */
+ private static boolean hitWall(Blackboard bb, EnemyView enemy, double previousSpeed) {
+ if (enemy.speed() != 0 || previousSpeed == 0) {
+ return false;
+ }
+ double min = Rules.BOT_HIT_RADIUS + WALL_EPSILON;
+ double maxX = bb.game().arenaWidth - min;
+ double maxY = bb.game().arenaHeight - min;
+ return enemy.x() <= min || enemy.y() <= min || enemy.x() >= maxX || enemy.y() >= maxY;
+ }
+
+ @Override
+ public String toString() {
+ return "EnemyFireSense";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/EnemySense.java b/bots/java/Retcon/src/fnl/bots/framework/stages/EnemySense.java
new file mode 100644
index 0000000..712fe55
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/EnemySense.java
@@ -0,0 +1,63 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemiesView;
+import fnl.bots.framework.blackboard.EnemyState;
+import fnl.bots.framework.blackboard.EnemyTable;
+import fnl.bots.framework.blackboard.SelfView;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.pipeline.SenseStage;
+import fnl.bots.framework.sense.ScanRecord;
+import fnl.bots.framework.sense.TurnInput;
+
+/** Sense: tracks enemies from scans and deaths. Must run after {@link SelfSense}, which it reads. */
+public final class EnemySense implements SenseStage {
+
+ private final EnemyTable enemies;
+
+ public EnemySense(Blackboard bb) {
+ this.enemies = bb.enemiesSlot.claim(this);
+ }
+
+ @Override
+ public void onRoundStart() {
+ enemies.clear();
+ }
+
+ @Override
+ public void sense(TurnInput in, Blackboard bb) {
+ SelfView self = bb.self();
+ for (int i = 0; i < in.scanCount; i++) {
+ ScanRecord scan = in.scans[i];
+ if (bb.game().isTeammate(scan.botId)) {
+ continue;
+ }
+ EnemyState enemy = enemies.getOrAdd(scan.botId);
+ if (enemy == null) {
+ continue;
+ }
+ enemy.energyDrop = enemy.lastSeenTurn >= 0 ? enemy.energy - scan.energy : 0;
+ enemy.alive = true;
+ enemy.lastSeenTurn = in.turn;
+ enemy.x = scan.x;
+ enemy.y = scan.y;
+ enemy.heading = scan.heading;
+ enemy.speed = scan.speed;
+ enemy.energy = scan.energy;
+ enemy.distance = Geom.distance(self.x(), self.y(), scan.x, scan.y);
+ enemy.bearing = Geom.bearing(self.x(), self.y(), scan.x, scan.y);
+ }
+ for (int d = 0; d < in.deadCount; d++) {
+ int index = enemies.indexOf(in.deadBotIds[d]);
+ if (index != EnemiesView.NONE) {
+ enemies.mutable(index).alive = false;
+ }
+ }
+ enemies.reportedAlive = in.enemyCount;
+ }
+
+ @Override
+ public String toString() {
+ return "EnemySense";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/GunStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/GunStage.java
new file mode 100644
index 0000000..a361034
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/GunStage.java
@@ -0,0 +1,38 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.arbitration.Arbiter;
+import fnl.bots.framework.arbitration.FireCommand;
+import fnl.bots.framework.arbitration.GunCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.pipeline.Stage;
+import fnl.bots.framework.roles.StrategySet;
+
+/** Decide: the owner of the GUN and FIRE actuators; delegates to the active gun and firepower. */
+public final class GunStage implements Stage {
+
+ private final GunCommand gun;
+ private final FireCommand fire;
+ private final Strategies strategies;
+
+ public GunStage(Arbiter arbiter, Strategies strategies) {
+ this.gun = arbiter.claimGun(this);
+ this.fire = arbiter.claimFire(this);
+ this.strategies = strategies;
+ }
+
+ @Override
+ public void run(Blackboard bb) {
+ EnemyView target = bb.targetEnemy();
+ if (target == null) {
+ return;
+ }
+ StrategySet set = strategies.active(bb);
+ set.gun().decide(bb, target, set.power().power(bb, target), gun, fire);
+ }
+
+ @Override
+ public String toString() {
+ return "GunStage";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/ModeStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/ModeStage.java
new file mode 100644
index 0000000..234e2b0
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/ModeStage.java
@@ -0,0 +1,30 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.ModeState;
+import fnl.bots.framework.pipeline.Stage;
+import fnl.bots.framework.roles.Mode;
+
+/** Model: selects the mode from how many enemies and teammates are alive. */
+public final class ModeStage implements Stage {
+
+ private final ModeState mode;
+ private final Mode selector;
+
+ public ModeStage(Blackboard bb, Mode selector) {
+ this.mode = bb.modeSlot.claim(this);
+ this.selector = selector;
+ }
+
+ @Override
+ public void run(Blackboard bb) {
+ mode.enemiesAlive = bb.enemies().reportedAliveCount();
+ mode.teammatesAlive = bb.self().teammatesAlive();
+ mode.kind = selector.select(mode.enemiesAlive, mode.teammatesAlive);
+ }
+
+ @Override
+ public String toString() {
+ return "ModeStage";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/MovementStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/MovementStage.java
new file mode 100644
index 0000000..6067304
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/MovementStage.java
@@ -0,0 +1,28 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.arbitration.Arbiter;
+import fnl.bots.framework.arbitration.BodyCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.pipeline.Stage;
+
+/** Decide: the owner of the BODY actuator; delegates to the active movement. */
+public final class MovementStage implements Stage {
+
+ private final BodyCommand body;
+ private final Strategies strategies;
+
+ public MovementStage(Arbiter arbiter, Strategies strategies) {
+ this.body = arbiter.claimBody(this);
+ this.strategies = strategies;
+ }
+
+ @Override
+ public void run(Blackboard bb) {
+ strategies.active(bb).movement().decide(bb, body);
+ }
+
+ @Override
+ public String toString() {
+ return "MovementStage";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/RadarStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/RadarStage.java
new file mode 100644
index 0000000..d00d56c
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/RadarStage.java
@@ -0,0 +1,28 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.arbitration.Arbiter;
+import fnl.bots.framework.arbitration.RadarCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.pipeline.Stage;
+
+/** Decide: the owner of the RADAR actuator; delegates to the active radar. */
+public final class RadarStage implements Stage {
+
+ private final RadarCommand radar;
+ private final Strategies strategies;
+
+ public RadarStage(Arbiter arbiter, Strategies strategies) {
+ this.radar = arbiter.claimRadar(this);
+ this.strategies = strategies;
+ }
+
+ @Override
+ public void run(Blackboard bb) {
+ strategies.active(bb).radar().decide(bb, radar);
+ }
+
+ @Override
+ public String toString() {
+ return "RadarStage";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/SelfSense.java b/bots/java/Retcon/src/fnl/bots/framework/stages/SelfSense.java
new file mode 100644
index 0000000..28c1b67
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/SelfSense.java
@@ -0,0 +1,76 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.SelfState;
+import fnl.bots.framework.blackboard.TurnState;
+import fnl.bots.framework.pipeline.SenseStage;
+import fnl.bots.framework.sense.GameInfo;
+import fnl.bots.framework.sense.TurnInput;
+
+/** Sense: writes the turn clock, the compute budget and this bot's own state. */
+public final class SelfSense implements SenseStage {
+
+ private final TurnState turn;
+ private final SelfState self;
+ private final int[] teammateIds;
+ private final boolean[] teammateDead;
+ private final double budgetShare;
+
+ /**
+ * @param budgetShare share of the server turn timeout this bot allows itself for compute, in (0, 1]
+ */
+ public SelfSense(Blackboard bb, double budgetShare) {
+ if (!(budgetShare > 0 && budgetShare <= 1)) {
+ throw new IllegalArgumentException("budgetShare must be in (0, 1]: " + budgetShare);
+ }
+ this.turn = bb.turnSlot.claim(this);
+ this.self = bb.selfSlot.claim(this);
+ GameInfo game = bb.game();
+ this.teammateIds = game.teammateIds.clone();
+ this.teammateDead = new boolean[teammateIds.length];
+ this.budgetShare = budgetShare;
+ turn.turnTimeoutMicros = game.turnTimeoutMicros;
+ turn.computeBudgetMicros = (int) (game.turnTimeoutMicros * budgetShare);
+ }
+
+ @Override
+ public void onRoundStart() {
+ for (int i = 0; i < teammateDead.length; i++) {
+ teammateDead[i] = false;
+ }
+ self.teammatesAlive = teammateIds.length;
+ }
+
+ @Override
+ public void sense(TurnInput in, Blackboard bb) {
+ turn.turnStartNanos = System.nanoTime();
+ turn.round = in.round;
+ turn.turn = in.turn;
+ turn.turnTimeoutMicros = bb.game().turnTimeoutMicros;
+ turn.computeBudgetMicros = (int) (turn.turnTimeoutMicros * budgetShare);
+
+ self.x = in.x;
+ self.y = in.y;
+ self.heading = in.heading;
+ self.gunHeading = in.gunHeading;
+ self.radarHeading = in.radarHeading;
+ self.speed = in.speed;
+ self.energy = in.energy;
+ self.gunHeat = in.gunHeat;
+
+ for (int d = 0; d < in.deadCount; d++) {
+ int deadId = in.deadBotIds[d];
+ for (int i = 0; i < teammateIds.length; i++) {
+ if (teammateIds[i] == deadId && !teammateDead[i]) {
+ teammateDead[i] = true;
+ self.teammatesAlive--;
+ }
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "SelfSense";
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/StandardPipeline.java b/bots/java/Retcon/src/fnl/bots/framework/stages/StandardPipeline.java
new file mode 100644
index 0000000..6758ff1
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stages/StandardPipeline.java
@@ -0,0 +1,52 @@
+package fnl.bots.framework.stages;
+
+import fnl.bots.framework.arbitration.Arbiter;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.ModeKind;
+import fnl.bots.framework.pipeline.Pipeline;
+import fnl.bots.framework.roles.Mode;
+import fnl.bots.framework.roles.StrategySet;
+import fnl.bots.framework.sense.GameInfo;
+import fnl.bots.framework.sense.TurnInput;
+import fnl.bots.framework.team.TeamInbox;
+import fnl.bots.framework.team.TeamOutbox;
+
+import java.util.Map;
+
+/**
+ * Wires the standard pipeline:
+ *
+ * Implements Virtual
+ * Guns. Stats are kept for the whole game; waves in flight are dropped at a new round. Allocation-free once
+ * wired. Only one virtual-gun collector per pipeline can claim {@code gunStats}.
+ */
+public final class VirtualBullets implements Collector {
+
+ private static final int CAPACITY = 32;
+
+ private GunStats stats;
+ private int models;
+ /** Model names, known from the gun before the pipeline is wired; published to the stats once claimed. */
+ private String[] pendingNames;
+ private final boolean[] active = new boolean[CAPACITY];
+ private final int[] decisionTurn = new int[CAPACITY];
+ private final int[] targetId = new int[CAPACITY];
+ private final double[] originX = new double[CAPACITY];
+ private final double[] originY = new double[CAPACITY];
+ private final double[] speed = new double[CAPACITY];
+ private final double[][] aims = new double[CAPACITY][GunStats.MAX_MODELS];
+ private int next;
+
+ @Override
+ public void wire(Blackboard bb) {
+ stats = bb.gunStatsSlot.claim(this);
+ publishModels();
+ }
+
+ @Override
+ public void onModels(AimModel[] aimModels) {
+ if (aimModels.length > GunStats.MAX_MODELS) {
+ throw new IllegalArgumentException("At most " + GunStats.MAX_MODELS + " aim models");
+ }
+ models = aimModels.length;
+ pendingNames = new String[models];
+ for (int i = 0; i < models; i++) {
+ pendingNames[i] = aimModels[i].getClass().getSimpleName();
+ }
+ publishModels();
+ }
+
+ private void publishModels() {
+ if (stats != null && pendingNames != null) {
+ stats.models = models;
+ System.arraycopy(pendingNames, 0, stats.names, 0, models);
+ }
+ }
+
+ @Override
+ public void onRoundStart() {
+ Arrays.fill(active, false);
+ }
+
+ @Override
+ public void onFire(Blackboard bb, EnemyView target, double bulletSpeed, double[] modelAims) {
+ int slot = next;
+ next = (next + 1) % CAPACITY;
+ active[slot] = true;
+ decisionTurn[slot] = bb.turn().turn();
+ targetId[slot] = target.id();
+ originX[slot] = bb.self().x();
+ originY[slot] = bb.self().y();
+ speed[slot] = bulletSpeed;
+ System.arraycopy(modelAims, 0, aims[slot], 0, models);
+ }
+
+ @Override
+ public void collect(Blackboard bb) {
+ if (stats == null) {
+ return;
+ }
+ int now = bb.turn().turn();
+ EnemiesView enemies = bb.enemies();
+ for (int w = 0; w < CAPACITY; w++) {
+ if (!active[w]) {
+ continue;
+ }
+ int index = enemies.indexOf(targetId[w]);
+ EnemyView target = index == EnemiesView.NONE ? null : enemies.get(index);
+ if (target == null || !target.alive()) {
+ active[w] = false;
+ continue;
+ }
+ if (target.lastSeenTurn() != now) {
+ continue;
+ }
+ double distance = Geom.distance(originX[w], originY[w], target.x(), target.y());
+ if (Rules.bulletDistance(decisionTurn[w], now, speed[w]) < distance) {
+ continue;
+ }
+ active[w] = false;
+ double bearing = Geom.bearing(originX[w], originY[w], target.x(), target.y());
+ double tolerance = Rules.hitHalfAngle(distance);
+ for (int m = 0; m < models; m++) {
+ double aim = aims[w][m];
+ if (Double.isNaN(aim)) {
+ continue;
+ }
+ stats.shots[m]++;
+ if (Math.abs(Angles.normalizeRelative(aim - bearing)) <= tolerance) {
+ stats.hits[m]++;
+ }
+ }
+ }
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/team/TeamInbox.java b/bots/java/Retcon/src/fnl/bots/framework/team/TeamInbox.java
new file mode 100644
index 0000000..8058212
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/team/TeamInbox.java
@@ -0,0 +1,25 @@
+package fnl.bots.framework.team;
+
+import fnl.bots.framework.sense.TurnInput;
+
+/** Read-only view of the team messages received this turn. */
+public final class TeamInbox {
+
+ private final TurnInput input;
+
+ public TeamInbox(TurnInput input) {
+ this.input = input;
+ }
+
+ public int count() {
+ return input.teamMessageCount;
+ }
+
+ public String message(int index) {
+ return input.teamMessages[index];
+ }
+
+ public int sender(int index) {
+ return input.teamMessageSenders[index];
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/team/TeamOutbox.java b/bots/java/Retcon/src/fnl/bots/framework/team/TeamOutbox.java
new file mode 100644
index 0000000..e738085
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/team/TeamOutbox.java
@@ -0,0 +1,35 @@
+package fnl.bots.framework.team;
+
+/** Team messages to broadcast at the end of this turn; the adapter sends and clears them. */
+public final class TeamOutbox {
+
+ /** The server accepts at most 5 team messages per bot per turn. */
+ public static final int CAPACITY = 5;
+
+ private final String[] messages = new String[CAPACITY];
+ private int count;
+
+ /** Queues a broadcast; returns false when this turn's capacity is used up. */
+ public boolean broadcast(String message) {
+ if (count == CAPACITY) {
+ return false;
+ }
+ messages[count++] = message;
+ return true;
+ }
+
+ public int count() {
+ return count;
+ }
+
+ public String message(int index) {
+ return messages[index];
+ }
+
+ public void clear() {
+ for (int i = 0; i < count; i++) {
+ messages[i] = null;
+ }
+ count = 0;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/Retcon.java b/bots/java/Retcon/src/fnl/bots/retcon/Retcon.java
new file mode 100644
index 0000000..6beb136
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/Retcon.java
@@ -0,0 +1,68 @@
+package fnl.bots.retcon;
+
+import fnl.bots.framework.adapter.tankroyale.TankRoyaleBot;
+import fnl.bots.framework.blackboard.ModeKind;
+import fnl.bots.framework.pipeline.BotProgram;
+import fnl.bots.framework.pipeline.Pipeline;
+import fnl.bots.framework.roles.AimModel;
+import fnl.bots.framework.roles.Gun;
+import fnl.bots.framework.roles.StrategySet;
+import fnl.bots.framework.sense.GameInfo;
+import fnl.bots.framework.sense.TurnInput;
+import fnl.bots.framework.stages.StandardPipeline;
+import fnl.bots.framework.targeting.VirtualBullets;
+import fnl.bots.retcon.firepower.BasicFirePower;
+import fnl.bots.retcon.gun.BestHitRateSelector;
+import fnl.bots.retcon.gun.HeadOnAimModel;
+import fnl.bots.retcon.gun.LinearAimModel;
+import fnl.bots.retcon.mode.AliveCountMode;
+import fnl.bots.retcon.movement.OrbitMovement;
+import fnl.bots.retcon.movement.WaveSurfMovement;
+import fnl.bots.retcon.radar.PerfectLockRadar;
+import fnl.bots.retcon.radar.SpinRadar;
+import fnl.bots.retcon.target.ClosestTargetSelector;
+import fnl.bots.retcon.team.NoOpTeamComms;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * Retcon, a Tank Royale Rumble bot built to prove Retroactive Hit Analysis. Milestone 1: a perfect radar lock and a
+ * head-on gun on top of the framework pipeline. See {@code docs/design.md}.
+ */
+public final class Retcon implements BotProgram {
+
+ static final String NAME = "Retcon";
+
+ public static void main(String[] args) {
+ if ("1".equals(System.getenv("RUMBLE_SMOKE"))) {
+ System.out.println("RUMBLE_SMOKE_READY " + NAME);
+ return;
+ }
+ TankRoyaleBot.start(NAME + ".json", new Retcon());
+ }
+
+ @Override
+ public Pipeline createPipeline(GameInfo game, TurnInput input) {
+ // Linear first: the selector starts with model 0 until the virtual bullets have results.
+ Gun gun = new Gun(new VirtualBullets(), new AimModel[]{new LinearAimModel(), new HeadOnAimModel()},
+ new BestHitRateSelector());
+ BasicFirePower power = new BasicFirePower();
+ ClosestTargetSelector target = new ClosestTargetSelector();
+ NoOpTeamComms comms = new NoOpTeamComms();
+
+ // Preferred ranges from the book's distancing chapter; orbiting in melee and 2v2 is a stopgap until M4.
+ OrbitMovement duelOrbit = new OrbitMovement(450, 1);
+ OrbitMovement meleeOrbit = new OrbitMovement(650, 2);
+ WaveSurfMovement duelSurf = new WaveSurfMovement(450, duelOrbit);
+
+ StrategySet duel = new StrategySet(new PerfectLockRadar(), duelSurf, target, gun, power, comms);
+ StrategySet melee = new StrategySet(new SpinRadar(), meleeOrbit, target, gun, power, comms);
+
+ Map
+ * Book: Bullet Power
+ * Selection Strategy (hybrid formula, finishing shots) and
+ * Energy as a Resource.
+ */
+public final class BasicFirePower implements FirePower {
+
+ private static final double BASE_POWER = 2.0;
+ private static final double CLOSE = 150;
+ private static final double FAR = 400;
+ private static final double CRITICAL_ENERGY = 15;
+ private static final double LOW_ENERGY = 35;
+
+ @Override
+ public double power(Blackboard bb, EnemyView target) {
+ double distance = Geom.distance(bb.self().x(), bb.self().y(), target.x(), target.y());
+ double energy = bb.self().energy();
+ double distanceFactor = distance < CLOSE ? 1.5 : distance < FAR ? 1.0 : 0.6;
+ double energyFactor = energy < CRITICAL_ENERGY ? 0.3 : energy < LOW_ENERGY ? 0.7 : 1.0;
+ double power = Rules.clampFirepower(BASE_POWER * distanceFactor * energyFactor);
+ return Math.min(power, Rules.firepowerForDamage(target.energy()));
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/gun/BestHitRateSelector.java b/bots/java/Retcon/src/fnl/bots/retcon/gun/BestHitRateSelector.java
new file mode 100644
index 0000000..5792cce
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/gun/BestHitRateSelector.java
@@ -0,0 +1,28 @@
+package fnl.bots.retcon.gun;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.GunStatsView;
+import fnl.bots.framework.roles.AimModel;
+import fnl.bots.framework.roles.AimSelector;
+
+/**
+ * Picks the aim model whose virtual bullets have hit most often this game. Ties, including the start of a game,
+ * go to the first model.
+ *
+ * Book: Virtual
+ * Guns (step 3, select the best gun).
+ */
+public final class BestHitRateSelector implements AimSelector {
+
+ @Override
+ public int choose(Blackboard bb, AimModel[] models) {
+ GunStatsView stats = bb.gunStats();
+ int best = 0;
+ for (int m = 1; m < stats.models() && m < models.length; m++) {
+ if (stats.hitRate(m) > stats.hitRate(best)) {
+ best = m;
+ }
+ }
+ return best;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/gun/HeadOnAimModel.java b/bots/java/Retcon/src/fnl/bots/retcon/gun/HeadOnAimModel.java
new file mode 100644
index 0000000..c74c89a
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/gun/HeadOnAimModel.java
@@ -0,0 +1,20 @@
+package fnl.bots.retcon.gun;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.roles.AimModel;
+
+/**
+ * Aims straight at the target's last scanned position.
+ *
+ * Book: Head-On
+ * Targeting.
+ */
+public final class HeadOnAimModel implements AimModel {
+
+ @Override
+ public double aim(Blackboard bb, EnemyView target, double bulletSpeed) {
+ return Geom.bearing(bb.self().x(), bb.self().y(), target.x(), target.y());
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/gun/LinearAimModel.java b/bots/java/Retcon/src/fnl/bots/retcon/gun/LinearAimModel.java
new file mode 100644
index 0000000..ab39ac3
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/gun/LinearAimModel.java
@@ -0,0 +1,41 @@
+package fnl.bots.retcon.gun;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.roles.AimModel;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * Aims where the target will be if it keeps its heading and speed, stopping it at the walls. It steps the target
+ * forward one turn at a time until the bullet, v·k out after k turns (physics fact 2), reaches the predicted
+ * position, and aims there from this bot's current position, which is where the bullet starts (fact 1).
+ *
+ * Book: Linear Targeting.
+ */
+public final class LinearAimModel implements AimModel {
+
+ private static final int MAX_TURNS = 150;
+
+ @Override
+ public double aim(Blackboard bb, EnemyView target, double bulletSpeed) {
+ double originX = bb.self().x();
+ double originY = bb.self().y();
+ double min = Rules.BOT_HIT_RADIUS;
+ double maxX = bb.game().arenaWidth - min;
+ double maxY = bb.game().arenaHeight - min;
+ double dx = Math.cos(target.heading()) * target.speed();
+ double dy = Math.sin(target.heading()) * target.speed();
+ double x = target.x();
+ double y = target.y();
+ int decided = bb.turn().turn();
+ for (int k = 1; k <= MAX_TURNS; k++) {
+ x = Geom.clamp(x + dx, min, maxX);
+ y = Geom.clamp(y + dy, min, maxY);
+ if (Rules.bulletDistance(decided, decided + k, bulletSpeed) >= Geom.distance(originX, originY, x, y)) {
+ break;
+ }
+ }
+ return Geom.bearing(originX, originY, x, y);
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/mode/AliveCountMode.java b/bots/java/Retcon/src/fnl/bots/retcon/mode/AliveCountMode.java
new file mode 100644
index 0000000..e2baa0a
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/mode/AliveCountMode.java
@@ -0,0 +1,24 @@
+package fnl.bots.retcon.mode;
+
+import fnl.bots.framework.blackboard.ModeKind;
+import fnl.bots.framework.roles.Mode;
+
+/**
+ * One enemy and no teammates is a duel; with a teammate and at most two enemies it is 2v2; anything else is melee.
+ *
+ * Book: Melee Strategy and
+ * Team Basics.
+ */
+public final class AliveCountMode implements Mode {
+
+ @Override
+ public ModeKind select(int enemiesAlive, int teammatesAlive) {
+ if (teammatesAlive == 0 && enemiesAlive <= 1) {
+ return ModeKind.ONE_VS_ONE;
+ }
+ if (teammatesAlive >= 1 && enemiesAlive <= 2) {
+ return ModeKind.TWO_VS_TWO;
+ }
+ return ModeKind.MELEE;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/movement/OrbitMovement.java b/bots/java/Retcon/src/fnl/bots/retcon/movement/OrbitMovement.java
new file mode 100644
index 0000000..e0af277
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/movement/OrbitMovement.java
@@ -0,0 +1,118 @@
+package fnl.bots.retcon.movement;
+
+import fnl.bots.framework.arbitration.BodyCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.blackboard.EnemyWaveView;
+import fnl.bots.framework.blackboard.EnemyWavesView;
+import fnl.bots.framework.blackboard.SelfView;
+import fnl.bots.framework.geom.Angles;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.motion.Drive;
+import fnl.bots.framework.motion.WallSmoothing;
+import fnl.bots.framework.roles.Movement;
+import fnl.bots.framework.rules.Rules;
+
+import java.util.Random;
+
+/**
+ * Circles the target at full speed, perpendicular to it, steering in or out to hold a preferred distance, and slides
+ * along walls in the circling direction. It reverses when a wall turns it too far off its course, and at random when
+ * the target fires, so a gun cannot assume it keeps going one way.
+ *
+ * Book: Distancing (preferred range),
+ * Wall Avoidance & Wall
+ * Smoothing, and Random
+ * Movement (reversing on the enemy's shots).
+ */
+public final class OrbitMovement implements Movement {
+
+ /** How far ahead wall smoothing looks, as in BasicSurfer. */
+ static final double WALL_STICK = 160;
+ /** Largest steer toward or away from the target, in radians off perpendicular. */
+ private static final double MAX_DISTANCE_STEER = Math.toRadians(30);
+ /** A wall pushing the course this far off the orbit makes the bot reverse instead. */
+ private static final double MAX_SMOOTHING = Math.PI / 2;
+ private static final double REVERSE_ON_FIRE_CHANCE = 0.25;
+ private static final int MIN_TURNS_BETWEEN_REVERSALS = 10;
+
+ private final double preferredDistance;
+ private final Random random;
+ private int direction = 1;
+ private int lastReversalTurn = Integer.MIN_VALUE / 2;
+ private int lastWaveTurn = -1;
+
+ public OrbitMovement(double preferredDistance, long seed) {
+ this.preferredDistance = preferredDistance;
+ this.random = new Random(seed);
+ }
+
+ @Override
+ public void decide(Blackboard bb, BodyCommand out) {
+ SelfView self = bb.self();
+ int now = bb.turn().turn();
+ double arenaWidth = bb.game().arenaWidth;
+ double arenaHeight = bb.game().arenaHeight;
+ EnemyView target = bb.targetEnemy();
+ double centerX = target != null ? target.x() : arenaWidth / 2;
+ double centerY = target != null ? target.y() : arenaHeight / 2;
+
+ if (target != null && enemyJustFired(bb.enemyWaves(), target.id(), now)
+ && random.nextDouble() < REVERSE_ON_FIRE_CHANCE) {
+ reverse(now);
+ }
+
+ double angle = travelAngle(self, centerX, centerY, direction);
+ double smoothed = WallSmoothing.smooth(self.x(), self.y(), angle, direction, WALL_STICK, arenaWidth,
+ arenaHeight);
+ if (Math.abs(Angles.normalizeRelative(smoothed - angle)) > MAX_SMOOTHING && reverse(now)) {
+ angle = travelAngle(self, centerX, centerY, direction);
+ smoothed = WallSmoothing.smooth(self.x(), self.y(), angle, direction, WALL_STICK, arenaWidth,
+ arenaHeight);
+ }
+ Drive.toward(self.heading(), smoothed, Rules.MAX_SPEED, out);
+ }
+
+ /**
+ * Perpendicular to the line from the centre to this bot, turned toward the centre when too far and away when too
+ * close. The same convention as BasicSurfer: from "away from the centre", a quarter turn in {@code direction}.
+ */
+ double travelAngle(SelfView self, double centerX, double centerY, int direction) {
+ return travelAngle(self.x(), self.y(), centerX, centerY, preferredDistance, direction);
+ }
+
+ /** {@link #travelAngle(SelfView, double, double, int)} for any position, e.g. a predicted one. */
+ static double travelAngle(double x, double y, double centerX, double centerY, double preferredDistance,
+ int direction) {
+ double away = Geom.bearing(centerX, centerY, x, y);
+ double distance = Geom.distance(centerX, centerY, x, y);
+ double error = (distance - preferredDistance) / preferredDistance;
+ double steer = Geom.clamp(error, -1, 1) * MAX_DISTANCE_STEER;
+ return Angles.normalizeAbsolute(away + direction * (Math.PI / 2 + steer));
+ }
+
+ int direction() {
+ return direction;
+ }
+
+ private boolean reverse(int now) {
+ if (now - lastReversalTurn < MIN_TURNS_BETWEEN_REVERSALS) {
+ return false;
+ }
+ direction = -direction;
+ lastReversalTurn = now;
+ return true;
+ }
+
+ /** Whether a wave from {@code shooterId} appeared this turn (it was decided the turn before). */
+ private boolean enemyJustFired(EnemyWavesView waves, int shooterId, int now) {
+ for (int i = 0; i < waves.count(); i++) {
+ EnemyWaveView wave = waves.get(i);
+ if (wave.shooterId() == shooterId && wave.decisionTurn() == now - 1 && lastWaveTurn != now) {
+ lastWaveTurn = now;
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/movement/WaveSurfMovement.java b/bots/java/Retcon/src/fnl/bots/retcon/movement/WaveSurfMovement.java
new file mode 100644
index 0000000..253b454
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/movement/WaveSurfMovement.java
@@ -0,0 +1,150 @@
+package fnl.bots.retcon.movement;
+
+import fnl.bots.framework.arbitration.BodyCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemyWaveView;
+import fnl.bots.framework.blackboard.EnemyWavesView;
+import fnl.bots.framework.blackboard.SelfView;
+import fnl.bots.framework.geom.Angles;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.motion.Drive;
+import fnl.bots.framework.motion.MovementPredictor;
+import fnl.bots.framework.motion.PredictedState;
+import fnl.bots.framework.motion.WallSmoothing;
+import fnl.bots.framework.roles.Movement;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * True Surfing in the style of BasicSurfer. Each turn it takes the nearest enemy wave that has not reached it yet,
+ * predicts where orbiting the wave's origin clockwise and counter-clockwise would put it when the bullet arrives, and
+ * goes the way whose GuessFactor has been hit least. It learns from the bullets that hit it, remembered across the
+ * rounds of a game. Without a wave to surf it orbits like {@link OrbitMovement}.
+ *
+ * Book: Wave Surfing
+ * Introduction and the True Surfing section of
+ * Wave Surfing
+ * Implementations. Waves come from {@code EnemyFireSense}; prediction is {@link MovementPredictor}, which follows
+ * the server's move-then-turn order (physics fact 6).
+ */
+public final class WaveSurfMovement implements Movement {
+
+ static final int BINS = 47;
+ private static final int MIDDLE = (BINS - 1) / 2;
+ /** Danger at GuessFactor 0 before any hit: many simple guns aim head-on. */
+ private static final double HEAD_ON_PRIOR = 0.1;
+ /** Longest prediction; a wave from across the arena at the slowest bullet speed takes about 90 turns. */
+ private static final int MAX_PREDICTION_TURNS = 150;
+
+ private final double preferredDistance;
+ private final OrbitMovement fallback;
+ private final double[] danger = new double[BINS];
+ private final PredictedState predicted = new PredictedState();
+ private final double[] turnOut = new double[1];
+ private MovementPredictor predictor;
+ private int direction = 1;
+
+ public WaveSurfMovement(double preferredDistance, OrbitMovement fallback) {
+ this.preferredDistance = preferredDistance;
+ this.fallback = fallback;
+ danger[MIDDLE] = HEAD_ON_PRIOR;
+ }
+
+ @Override
+ public void decide(Blackboard bb, BodyCommand out) {
+ int now = bb.turn().turn();
+ EnemyWavesView waves = bb.enemyWaves();
+ learnFromHits(waves, now);
+
+ SelfView self = bb.self();
+ EnemyWaveView wave = nearestUnreachedWave(waves, self, now);
+ if (wave == null) {
+ fallback.decide(bb, out);
+ return;
+ }
+ if (predictor == null) {
+ predictor = new MovementPredictor(bb.game().arenaWidth, bb.game().arenaHeight);
+ }
+ double dangerClockwise = dangerOf(wave, self, now, -1);
+ double dangerCounterClockwise = dangerOf(wave, self, now, 1);
+ if (dangerClockwise < dangerCounterClockwise) {
+ direction = -1;
+ } else if (dangerCounterClockwise < dangerClockwise) {
+ direction = 1;
+ }
+ double angle = surfAngle(self.x(), self.y(), wave, direction);
+ Drive.toward(self.heading(), angle, Rules.MAX_SPEED, out);
+ }
+
+ /** Records the GuessFactor of every bullet that hit this bot this turn. */
+ private void learnFromHits(EnemyWavesView waves, int now) {
+ for (int i = 0; i < waves.count(); i++) {
+ EnemyWaveView wave = waves.get(i);
+ if (wave.hitTurn() == now) {
+ int index = bin(wave, wave.hitX(), wave.hitY());
+ for (int b = 0; b < BINS; b++) {
+ danger[b] += 1.0 / ((b - index) * (b - index) + 1);
+ }
+ }
+ }
+ }
+
+ /** The wave whose bullet reaches this bot soonest, among those still more than a bullet step away. */
+ private static EnemyWaveView nearestUnreachedWave(EnemyWavesView waves, SelfView self, int now) {
+ EnemyWaveView best = null;
+ double bestTime = Double.MAX_VALUE;
+ for (int i = 0; i < waves.count(); i++) {
+ EnemyWaveView wave = waves.get(i);
+ if (wave.hitTurn() >= 0) {
+ continue;
+ }
+ double gap = Geom.distance(wave.originX(), wave.originY(), self.x(), self.y()) - wave.radius(now);
+ if (gap > wave.bulletSpeed()) {
+ double time = gap / wave.bulletSpeed();
+ if (time < bestTime) {
+ bestTime = time;
+ best = wave;
+ }
+ }
+ }
+ return best;
+ }
+
+ /** Danger at the spot this bot reaches when {@code wave} arrives, surfing in {@code orbit} direction. */
+ private double dangerOf(EnemyWaveView wave, SelfView self, int now, int orbit) {
+ predicted.set(self.x(), self.y(), self.heading(), self.speed());
+ int turn = now;
+ while (predicted.turns < MAX_PREDICTION_TURNS) {
+ double angle = surfAngle(predicted.x, predicted.y, wave, orbit);
+ double speed = Drive.toward(predicted, angle, Rules.MAX_SPEED, turnOut);
+ predictor.step(predicted, speed, turnOut[0]);
+ turn++;
+ double distance = Geom.distance(wave.originX(), wave.originY(), predicted.x, predicted.y);
+ if (Rules.hitSweepEnd(wave.decisionTurn(), turn, wave.bulletSpeed()) >= distance) {
+ break;
+ }
+ }
+ return danger[bin(wave, predicted.x, predicted.y)];
+ }
+
+ private double surfAngle(double x, double y, EnemyWaveView wave, int orbit) {
+ double angle = OrbitMovement.travelAngle(x, y, wave.originX(), wave.originY(), preferredDistance, orbit);
+ return WallSmoothing.smooth(x, y, angle, orbit, OrbitMovement.WALL_STICK, predictor.arenaWidth(),
+ predictor.arenaHeight());
+ }
+
+ /** GuessFactor bin of the point ({@code x}, {@code y}) on {@code wave}. */
+ static int bin(EnemyWaveView wave, double x, double y) {
+ double offset = Angles.normalizeRelative(Geom.bearing(wave.originX(), wave.originY(), x, y)
+ - wave.directAngle());
+ double guessFactor = Geom.clamp(offset * wave.lateralDirection() / wave.maxEscapeAngle(), -1, 1);
+ return (int) Math.round((guessFactor + 1) / 2 * (BINS - 1));
+ }
+
+ double danger(int bin) {
+ return danger[bin];
+ }
+
+ int direction() {
+ return direction;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/radar/PerfectLockRadar.java b/bots/java/Retcon/src/fnl/bots/retcon/radar/PerfectLockRadar.java
new file mode 100644
index 0000000..b90b4f8
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/radar/PerfectLockRadar.java
@@ -0,0 +1,40 @@
+package fnl.bots.retcon.radar;
+
+import fnl.bots.framework.arbitration.RadarCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.blackboard.SelfView;
+import fnl.bots.framework.geom.Angles;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.roles.Radar;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * Width Lock: turns the radar to the target's bearing and past it by the target's half-width at that distance, so
+ * the beam sweeps over the target again next turn. Tank Royale scans post-move positions, so the overshoot also
+ * covers one turn of the target's movement. Without a fresh scan it spins to reacquire.
+ *
+ * Book: Perfect Locks
+ * (Width Lock).
+ */
+public final class PerfectLockRadar implements Radar {
+
+ private double spinDirection = 1;
+
+ @Override
+ public void decide(Blackboard bb, RadarCommand out) {
+ EnemyView target = bb.targetEnemy();
+ if (target == null || target.lastSeenTurn() < bb.turn().turn() - 1) {
+ out.turnRate = spinDirection * Rules.MAX_RADAR_TURN_RATE;
+ return;
+ }
+ SelfView self = bb.self();
+ double bearing = Geom.bearing(self.x(), self.y(), target.x(), target.y());
+ double distance = Geom.distance(self.x(), self.y(), target.x(), target.y());
+ double needed = Angles.normalizeRelative(bearing - self.radarHeading());
+ double overshoot = Math.atan((Rules.BOT_HIT_RADIUS + Rules.MAX_SPEED) / Math.max(distance, 1));
+ double direction = needed >= 0 ? 1 : -1;
+ out.turnRate = needed + direction * overshoot;
+ spinDirection = direction;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/radar/SpinRadar.java b/bots/java/Retcon/src/fnl/bots/retcon/radar/SpinRadar.java
new file mode 100644
index 0000000..afe5bd9
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/radar/SpinRadar.java
@@ -0,0 +1,19 @@
+package fnl.bots.retcon.radar;
+
+import fnl.bots.framework.arbitration.RadarCommand;
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.roles.Radar;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * Spins the radar at full rate, sweeping the whole field every eight turns. The M1 melee radar.
+ *
+ * Book: Spinning Radar.
+ */
+public final class SpinRadar implements Radar {
+
+ @Override
+ public void decide(Blackboard bb, RadarCommand out) {
+ out.turnRate = Rules.MAX_RADAR_TURN_RATE;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/target/ClosestTargetSelector.java b/bots/java/Retcon/src/fnl/bots/retcon/target/ClosestTargetSelector.java
new file mode 100644
index 0000000..0214f96
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/target/ClosestTargetSelector.java
@@ -0,0 +1,37 @@
+package fnl.bots.retcon.target;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemiesView;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.roles.TargetSelector;
+
+/**
+ * Targets the closest living enemy that has been scanned, measured from this bot's current position to the
+ * enemy's last known position.
+ *
+ * Book: Melee Strategy.
+ */
+public final class ClosestTargetSelector implements TargetSelector {
+
+ @Override
+ public int select(Blackboard bb) {
+ EnemiesView enemies = bb.enemies();
+ double x = bb.self().x();
+ double y = bb.self().y();
+ int best = EnemiesView.NONE;
+ double bestDistanceSq = Double.MAX_VALUE;
+ for (int i = 0; i < enemies.count(); i++) {
+ EnemyView enemy = enemies.get(i);
+ if (!enemy.alive() || enemy.lastSeenTurn() < 0) {
+ continue;
+ }
+ double d = Geom.distanceSq(x, y, enemy.x(), enemy.y());
+ if (d < bestDistanceSq) {
+ bestDistanceSq = d;
+ best = i;
+ }
+ }
+ return best;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/retcon/team/NoOpTeamComms.java b/bots/java/Retcon/src/fnl/bots/retcon/team/NoOpTeamComms.java
new file mode 100644
index 0000000..4ad0870
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/retcon/team/NoOpTeamComms.java
@@ -0,0 +1,22 @@
+package fnl.bots.retcon.team;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.roles.TeamComms;
+import fnl.bots.framework.team.TeamInbox;
+import fnl.bots.framework.team.TeamOutbox;
+
+/**
+ * Sends and reads nothing. Team play comes after milestone 1.
+ *
+ * Book: Team Basics.
+ */
+public final class NoOpTeamComms implements TeamComms {
+
+ @Override
+ public void receive(Blackboard bb, TeamInbox inbox) {
+ }
+
+ @Override
+ public void send(Blackboard bb, TeamOutbox outbox) {
+ }
+}
diff --git a/bots/java/RetconTwins/RetconTwins.json b/bots/java/RetconTwins/RetconTwins.json
new file mode 100644
index 0000000..65b09d5
--- /dev/null
+++ b/bots/java/RetconTwins/RetconTwins.json
@@ -0,0 +1,8 @@
+{
+ "name": "RetconTwins",
+ "version": "0.2.0",
+ "authors": ["Flemming N. Larsen"],
+ "description": "Two Retcons as a 2v2 team.",
+ "license": "MIT",
+ "teamMembers": ["Retcon 0.2.0", "Retcon 0.2.0"]
+}
+ *
+ */
+public final class StandardPipeline {
+
+ /** Default share of the server turn timeout a bot allows itself for compute (5 ms of a 30 ms timeout). */
+ public static final double DEFAULT_BUDGET_SHARE = 1.0 / 6;
+
+ private StandardPipeline() {
+ }
+
+ public static Pipeline create(GameInfo game, TurnInput input, Mode mode, Map
+ *
+ * Allocation-free: shots are kept in a small fixed ring.
+ */
+public final class TimingProbe {
+
+ private static final int RING = 64;
+ private static final double POSITION_EPSILON = 1e-6;
+ private static final double ANGLE_EPSILON = 1e-6;
+
+ private final int[] bulletIds = new int[RING];
+ private final int[] decidedAt = new int[RING];
+ private final double[] originX = new double[RING];
+ private final double[] originY = new double[RING];
+ private final double[] heading = new double[RING];
+ private final double[] speed = new double[RING];
+ private final double[] targetX = new double[RING];
+ private final double[] targetY = new double[RING];
+ private int next;
+ /** Bullet ids restart every round, so the ring is cleared when the round changes. */
+ private int round = -1;
+
+ private boolean pending;
+ private int pendingTurn;
+ private double pendingX;
+ private double pendingY;
+ private double pendingHeading;
+ private double pendingTargetX;
+ private double pendingTargetY;
+
+ /** All bullets this bot fired and all its bullets that hit a bot, for the real hit rate. */
+ public long bulletsFired;
+ public long bulletHits;
+ public long firesChecked;
+ public long firesNotCreated;
+ public long createdTurnMatches;
+ /** Fired event position equals the position observed when the shot was decided. */
+ public long originMatches;
+ /** Fired event position equals that origin plus one bullet step (reported after the first move). */
+ public long originMatchesAfterFirstMove;
+ public long headingMatches;
+ public long stillHitsChecked;
+ public long stillHitMatches;
+ /** Hits whose victim had moved since the shot was decided; their hit turn is not predicted. */
+ public long hitsOnMovingTargets;
+ public final StringBuilder mismatches = new StringBuilder();
+ private int mismatchCount;
+
+ /**
+ * The bot requested a shot in response to {@code turn}, from the given position and gun heading, at a target
+ * last seen at ({@code targetX}, {@code targetY}).
+ */
+ public void onFireRequested(int turn, double x, double y, double gunHeading, double targetX, double targetY) {
+ pending = true;
+ pendingTurn = turn;
+ pendingX = x;
+ pendingY = y;
+ pendingHeading = gunHeading;
+ pendingTargetX = targetX;
+ pendingTargetY = targetY;
+ }
+
+ /** Feeds this turn's bullet events. Call after the adapter has filled the input. */
+ public void observe(TurnInput in) {
+ if (in.round != round) {
+ round = in.round;
+ Arrays.fill(bulletIds, -1);
+ }
+ bulletsFired += in.bulletsFiredCount;
+ bulletHits += in.bulletHitCount;
+ for (int i = 0; i < in.bulletsFiredCount; i++) {
+ onBulletFired(in.bulletsFired[i]);
+ }
+ if (pending && in.turn > pendingTurn) {
+ // Requested, but no bullet appeared in the next turn (e.g. a skipped turn or changed energy).
+ pending = false;
+ firesNotCreated++;
+ }
+ for (int i = 0; i < in.bulletHitCount; i++) {
+ onBulletHit(in, in.bulletHits[i]);
+ }
+ }
+
+ private void onBulletFired(BulletRecord fired) {
+ if (!pending) {
+ return;
+ }
+ pending = false;
+ firesChecked++;
+ if (fired.turn == Rules.bulletCreatedTurn(pendingTurn)) {
+ createdTurnMatches++;
+ } else {
+ mismatch("created turn " + fired.turn + " != " + Rules.bulletCreatedTurn(pendingTurn));
+ }
+ double v = Rules.bulletSpeed(fired.power);
+ if (near(fired.x, pendingX) && near(fired.y, pendingY)) {
+ originMatches++;
+ } else if (near(fired.x - Math.cos(fired.heading) * v, pendingX)
+ && near(fired.y - Math.sin(fired.heading) * v, pendingY)) {
+ originMatchesAfterFirstMove++;
+ } else {
+ mismatch("origin (" + fired.x + "," + fired.y + ") vs observed (" + pendingX + "," + pendingY + ")");
+ }
+ if (Math.abs(Angles.normalizeRelative(fired.heading - pendingHeading)) < ANGLE_EPSILON) {
+ headingMatches++;
+ } else {
+ mismatch("heading " + fired.heading + " vs observed gun heading " + pendingHeading);
+ }
+ int slot = next;
+ next = (next + 1) % RING;
+ bulletIds[slot] = fired.bulletId;
+ decidedAt[slot] = pendingTurn;
+ originX[slot] = pendingX;
+ originY[slot] = pendingY;
+ heading[slot] = fired.heading;
+ speed[slot] = v;
+ targetX[slot] = pendingTargetX;
+ targetY[slot] = pendingTargetY;
+ }
+
+ private void onBulletHit(TurnInput in, BulletRecord hit) {
+ int slot = find(hit.bulletId);
+ if (slot < 0) {
+ return;
+ }
+ ScanRecord victim = null;
+ for (int i = 0; i < in.scanCount; i++) {
+ if (in.scans[i].botId == hit.victimId) {
+ victim = in.scans[i];
+ }
+ }
+ if (victim == null || victim.speed != 0 || !near(victim.x, targetX[slot]) || !near(victim.y, targetY[slot])) {
+ hitsOnMovingTargets++;
+ return;
+ }
+ stillHitsChecked++;
+ int predicted = Rules.firstHitTurnOnStillTarget(decidedAt[slot], originX[slot], originY[slot], heading[slot],
+ victim.x, victim.y, speed[slot]);
+ if (predicted == hit.turn) {
+ stillHitMatches++;
+ } else {
+ mismatch("hit turn " + hit.turn + " != predicted " + predicted + " (d="
+ + Geom.distance(originX[slot], originY[slot], victim.x, victim.y) + ", v=" + speed[slot] + ")");
+ }
+ }
+
+ private static boolean near(double a, double b) {
+ return Math.abs(a - b) < POSITION_EPSILON;
+ }
+
+ private int find(int bulletId) {
+ for (int i = 0; i < RING; i++) {
+ if (bulletIds[i] == bulletId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void mismatch(String text) {
+ if (mismatchCount++ < 10) {
+ mismatches.append(text).append("; ");
+ }
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stats/TurnStats.java b/bots/java/Retcon/src/fnl/bots/framework/stats/TurnStats.java
new file mode 100644
index 0000000..1ce4073
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stats/TurnStats.java
@@ -0,0 +1,134 @@
+package fnl.bots.framework.stats;
+
+/**
+ * Per-game turn statistics: compute time per turn (a fixed histogram, so recording never allocates), skipped turns
+ * per round and the first {@value #MAX_SKIPS_LISTED} skipped turns by round and turn, and turns over the compute
+ * budget. The list tells a machine-wide stall (teammates skip the same turns) from a slow bot.
+ */
+public final class TurnStats {
+
+ private static final int BUCKET_MICROS = 10;
+ private static final int BUCKETS = 10_000; // 0..100 ms
+ static final int MAX_SKIPS_LISTED = 256;
+
+ private final long[] histogram = new long[BUCKETS + 1];
+ private final int[] skippedPerRound;
+ private final int[] skippedRounds = new int[MAX_SKIPS_LISTED];
+ private final int[] skippedTurns = new int[MAX_SKIPS_LISTED];
+ private long turns;
+ private long maxMicros;
+ private long overBudget;
+ private int skippedTotal;
+ private int computeBudgetMicros;
+ private int turnTimeoutMicros;
+
+ public TurnStats(int numberOfRounds) {
+ this.skippedPerRound = new int[numberOfRounds + 1];
+ }
+
+ public void setBudget(int turnTimeoutMicros, int computeBudgetMicros) {
+ this.turnTimeoutMicros = turnTimeoutMicros;
+ this.computeBudgetMicros = computeBudgetMicros;
+ }
+
+ public void recordCompute(long micros) {
+ turns++;
+ if (micros > maxMicros) {
+ maxMicros = micros;
+ }
+ if (computeBudgetMicros > 0 && micros > computeBudgetMicros) {
+ overBudget++;
+ }
+ int bucket = (int) Math.min(BUCKETS, micros / BUCKET_MICROS);
+ histogram[bucket]++;
+ }
+
+ /** Records one skipped turn: the round it was in and the turn number the server reported as skipped. */
+ public void recordSkipped(int round, int turn) {
+ if (round >= 0 && round < skippedPerRound.length) {
+ skippedPerRound[round]++;
+ }
+ if (skippedTotal < MAX_SKIPS_LISTED) {
+ skippedRounds[skippedTotal] = round;
+ skippedTurns[skippedTotal] = turn;
+ }
+ skippedTotal++;
+ }
+
+ /** Compute time at the given percentile (0..100), in microseconds, rounded up to the histogram resolution. */
+ public long percentileMicros(double percentile) {
+ if (turns == 0) {
+ return 0;
+ }
+ long rank = (long) Math.ceil(percentile / 100.0 * turns);
+ long seen = 0;
+ for (int i = 0; i < histogram.length; i++) {
+ seen += histogram[i];
+ if (seen >= rank) {
+ return (long) (i + 1) * BUCKET_MICROS;
+ }
+ }
+ return maxMicros;
+ }
+
+ /** Non-empty histogram buckets as {@code "bucketStartMicros:count,..."}, so readers can apply any budget. */
+ public String histogram() {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < histogram.length; i++) {
+ if (histogram[i] > 0) {
+ if (!sb.isEmpty()) {
+ sb.append(',');
+ }
+ sb.append((long) i * BUCKET_MICROS).append(':').append(histogram[i]);
+ }
+ }
+ return sb.toString();
+ }
+
+ public long turns() {
+ return turns;
+ }
+
+ public long maxMicros() {
+ return maxMicros;
+ }
+
+ public long overBudget() {
+ return overBudget;
+ }
+
+ public int skippedTotal() {
+ return skippedTotal;
+ }
+
+ public int[] skippedPerRound() {
+ return skippedPerRound.clone();
+ }
+
+ /**
+ * The listed skipped turns as {@code "round:turn ..."} in the order they happened, with {@code "+N"} appended
+ * when more than {@value #MAX_SKIPS_LISTED} were skipped.
+ */
+ public String skippedTurnList() {
+ StringBuilder sb = new StringBuilder();
+ int listed = Math.min(skippedTotal, MAX_SKIPS_LISTED);
+ for (int i = 0; i < listed; i++) {
+ if (i > 0) {
+ sb.append(' ');
+ }
+ sb.append(skippedRounds[i]).append(':').append(skippedTurns[i]);
+ }
+ if (skippedTotal > listed) {
+ sb.append(" +").append(skippedTotal - listed);
+ }
+ return sb.toString();
+ }
+
+ public int computeBudgetMicros() {
+ return computeBudgetMicros;
+ }
+
+ public int turnTimeoutMicros() {
+ return turnTimeoutMicros;
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/stats/WaveProbe.java b/bots/java/Retcon/src/fnl/bots/framework/stats/WaveProbe.java
new file mode 100644
index 0000000..b03df2c
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/stats/WaveProbe.java
@@ -0,0 +1,52 @@
+package fnl.bots.framework.stats;
+
+import fnl.bots.framework.blackboard.EnemyWaveView;
+import fnl.bots.framework.blackboard.EnemyWavesView;
+import fnl.bots.framework.rules.Rules;
+
+/**
+ * Checks physics fact 5 against the live server: a bullet that hits this bot lies exactly
+ * {@link Rules#bulletDistance} from the origin of the wave made from the shooter's energy drop. A residual of
+ * ±v instead of 0 would mean the wave is one turn off. Allocation-free.
+ */
+public final class WaveProbe {
+
+ private static final double EPSILON = 1e-6;
+ private static final int MAX_MISMATCHES = 5;
+
+ public long hitsChecked;
+ public long exact;
+ /** Hits one bullet step off: the wave's decision turn would be one turn early or late. */
+ public long offByOneStep;
+ public long otherResidual;
+ public final StringBuilder mismatches = new StringBuilder();
+ private int mismatchCount;
+
+ /** Call once per turn, after the pipeline step, with the turn's waves. */
+ public void observe(EnemyWavesView waves, int now) {
+ for (int i = 0; i < waves.count(); i++) {
+ EnemyWaveView wave = waves.get(i);
+ if (wave.hitTurn() != now) {
+ continue;
+ }
+ hitsChecked++;
+ double distance = Math.hypot(wave.hitX() - wave.originX(), wave.hitY() - wave.originY());
+ double residual = distance - wave.radius(now);
+ if (Math.abs(residual) <= EPSILON) {
+ exact++;
+ } else if (Math.abs(Math.abs(residual) - wave.bulletSpeed()) <= EPSILON) {
+ offByOneStep++;
+ } else {
+ otherResidual++;
+ }
+ if (Math.abs(residual) > EPSILON && mismatchCount < MAX_MISMATCHES) {
+ mismatchCount++;
+ if (!mismatches.isEmpty()) {
+ mismatches.append("; ");
+ }
+ mismatches.append("turn ").append(now).append(" decided ").append(wave.decisionTurn())
+ .append(" v ").append(wave.bulletSpeed()).append(" residual ").append(residual);
+ }
+ }
+ }
+}
diff --git a/bots/java/Retcon/src/fnl/bots/framework/targeting/VirtualBullets.java b/bots/java/Retcon/src/fnl/bots/framework/targeting/VirtualBullets.java
new file mode 100644
index 0000000..b62c5f6
--- /dev/null
+++ b/bots/java/Retcon/src/fnl/bots/framework/targeting/VirtualBullets.java
@@ -0,0 +1,126 @@
+package fnl.bots.framework.targeting;
+
+import fnl.bots.framework.blackboard.Blackboard;
+import fnl.bots.framework.blackboard.EnemiesView;
+import fnl.bots.framework.blackboard.EnemyView;
+import fnl.bots.framework.blackboard.GunStats;
+import fnl.bots.framework.geom.Angles;
+import fnl.bots.framework.geom.Geom;
+import fnl.bots.framework.roles.AimModel;
+import fnl.bots.framework.roles.Collector;
+import fnl.bots.framework.rules.Rules;
+
+import java.util.Arrays;
+
+/**
+ * Virtual guns: every real shot also fires one virtual bullet per aim model, along that model's aim. When the shot's
+ * wave reaches the target, each virtual bullet counts as a hit if the target is inside its hit angle
+ * ({@link Rules#hitHalfAngle}). The totals go to the blackboard's {@code gunStats}, where an aim selector can pick
+ * the best model. The wave starts at this bot's position at the decision turn and is v·(now − t) out at turn
+ * {@code now} (physics facts 1 and 2).
+ *