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 teammates = getTeammateIds(); + game.teammateIds = teammates.stream().mapToInt(Integer::intValue).sorted().toArray(); + } + + private void readState() { + input.round = getRoundNumber(); + input.turn = getTurnNumber(); + input.x = getX(); + input.y = getY(); + input.heading = TankRoyaleAngles.headingToCore(getDirection()); + input.gunHeading = TankRoyaleAngles.headingToCore(getGunDirection()); + input.radarHeading = TankRoyaleAngles.headingToCore(getRadarDirection()); + input.speed = getSpeed(); + input.energy = getEnergy(); + input.gunHeat = getGunHeat(); + input.enemyCount = getEnemyCount(); + input.timeLeftMicros = getTimeLeft(); + } + + private void apply(Intent intent) { + setTargetSpeed(intent.targetSpeed); + setTurnRate(TankRoyaleAngles.turnRateToPlatform(intent.bodyTurnRate)); + setGunTurnRate(TankRoyaleAngles.turnRateToPlatform(intent.gunTurnRate)); + setRadarTurnRate(TankRoyaleAngles.turnRateToPlatform(intent.radarTurnRate)); + // setFire persists until a bullet is fired, so a cold gun that holds fire must clear it explicitly. + if (intent.firepower > 0) { + if (setFire(intent.firepower)) { + EnemyView target = pipeline.blackboard().targetEnemy(); + probe.onFireRequested(input.turn, input.x, input.y, input.gunHeading, + target != null ? target.x() : Double.NaN, target != null ? target.y() : Double.NaN); + } + } else if (getGunHeat() == 0) { + setFire(0); + } + TeamOutbox outbox = pipeline.outbox(); + for (int i = 0; i < outbox.count(); i++) { + broadcastTeamMessage(outbox.message(i)); + } + outbox.clear(); + } + + // ---- Events -> TurnInput ----------------------------------------------------------------------------------- + + @Override + public void onScannedBot(ScannedBotEvent e) { + ScanRecord scan = input.addScan(); + if (scan != null) { + scan.botId = e.getScannedBotId(); + scan.x = e.getX(); + scan.y = e.getY(); + scan.heading = TankRoyaleAngles.headingToCore(e.getDirection()); + scan.speed = e.getSpeed(); + scan.energy = e.getEnergy(); + } + } + + @Override + public void onBotDeath(BotDeathEvent e) { + input.addDeath(e.getVictimId()); + } + + @Override + public void onBulletFired(BulletFiredEvent e) { + fillBullet(input.addBulletFired(), e.getTurnNumber(), e.getBullet()); + } + + @Override + public void onBulletHit(BulletHitBotEvent e) { + BulletRecord hit = input.addBulletHit(); + if (fillBullet(hit, e.getTurnNumber(), e.getBullet())) { + hit.victimId = e.getVictimId(); + hit.victimEnergy = e.getEnergy(); + } + } + + @Override + public void onHitByBullet(HitByBulletEvent e) { + fillBullet(input.addHitTaken(), e.getTurnNumber(), e.getBullet()); + } + + @Override + public void onHitBot(HitBotEvent e) { + input.addCollision(e.getVictimId(), e.isRammed()); + } + + @Override + public void onTeamMessage(TeamMessageEvent e) { + input.addTeamMessage(e.getSenderId(), String.valueOf(e.getMessage())); + } + + @Override + public void onSkippedTurn(SkippedTurnEvent e) { + input.skippedTurns++; + if (stats != null) { + stats.recordSkipped(getRoundNumber(), e.getTurnNumber()); + } + } + + @Override + public void onRoundEnded(RoundEndedEvent e) { + roundsRecorded = e.getRoundNumber(); + writeStats(); + } + + @Override + public void onGameEnded(GameEndedEvent e) { + writeStats(); + } + + private void writeStats() { + if (stats != null) { + Blackboard bb = pipeline.blackboard(); + StatsWriter.write(statsFile, name, game.myId, roundsRecorded, stats, probe, bb.enemyWaves(), waveProbe, + movementProbe, bb.gunStats()); + } + } + + private static boolean fillBullet(BulletRecord record, int turn, BulletState bullet) { + if (record == null) { + return false; + } + record.bulletId = bullet.getBulletId(); + record.turn = turn; + record.x = bullet.getX(); + record.y = bullet.getY(); + record.heading = TankRoyaleAngles.headingToCore(bullet.getDirection()); + record.power = Rules.clampFirepower(bullet.getPower()); + record.ownerId = bullet.getOwnerId(); + return true; + } + + private static BotInfo loadBotInfo(String configFile) { + try { + return BotInfo.fromFile(configFile); + } catch (BotException e) { + return null; + } + } + + private static String envOrDefault(String key, String fallback) { + String value = System.getenv(key); + return value == null || value.isBlank() ? fallback : value; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/Actuator.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/Actuator.java new file mode 100644 index 0000000..7066457 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/Actuator.java @@ -0,0 +1,9 @@ +package fnl.bots.framework.arbitration; + +/** The bot's independent outputs. Each has exactly one owner, claimed from the {@link Arbiter} at wiring time. */ +public enum Actuator { + BODY, + GUN, + RADAR, + FIRE +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/Arbiter.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/Arbiter.java new file mode 100644 index 0000000..abafeef --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/Arbiter.java @@ -0,0 +1,76 @@ +package fnl.bots.framework.arbitration; + +import fnl.bots.framework.geom.Geom; +import fnl.bots.framework.rules.Rules; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Enforces exactly one owner per {@link Actuator}. Owners claim their command object once, while the pipeline is + * wired; each turn the arbiter clears all commands before the Decide phase and resolves them into an {@link Intent} + * afterwards, clamping everything to the {@link Rules} limits. An unclaimed actuator stays idle. + */ +public final class Arbiter { + + private final Map owners = new EnumMap<>(Actuator.class); + + private final BodyCommand body = new BodyCommand(); + private final GunCommand gun = new GunCommand(); + private final RadarCommand radar = new RadarCommand(); + private final FireCommand fire = new FireCommand(); + + public BodyCommand claimBody(Object owner) { + claim(Actuator.BODY, owner); + return body; + } + + public GunCommand claimGun(Object owner) { + claim(Actuator.GUN, owner); + return gun; + } + + public RadarCommand claimRadar(Object owner) { + claim(Actuator.RADAR, owner); + return radar; + } + + public FireCommand claimFire(Object owner) { + claim(Actuator.FIRE, owner); + return fire; + } + + public Object ownerOf(Actuator actuator) { + return owners.get(actuator); + } + + /** Clears all commands; called before the Decide phase. */ + public void beginTurn() { + body.clear(); + gun.clear(); + radar.clear(); + fire.clear(); + } + + /** + * Resolves this turn's commands into {@code out}, clamped to the physics limits. + * + * @param currentSpeed the bot's speed, which limits the body turn rate + */ + public void resolve(double currentSpeed, Intent out) { + double maxBodyTurn = Rules.maxBodyTurnRate(currentSpeed); + out.targetSpeed = Rules.clampSpeed(body.targetSpeed); + out.bodyTurnRate = Geom.clamp(body.turnRate, -maxBodyTurn, maxBodyTurn); + out.gunTurnRate = Geom.clamp(gun.turnRate, -Rules.MAX_GUN_TURN_RATE, Rules.MAX_GUN_TURN_RATE); + out.radarTurnRate = Geom.clamp(radar.turnRate, -Rules.MAX_RADAR_TURN_RATE, Rules.MAX_RADAR_TURN_RATE); + out.firepower = fire.firepower >= Rules.MIN_FIREPOWER ? Rules.clampFirepower(fire.firepower) : 0; + } + + private void claim(Actuator actuator, Object owner) { + Object existing = owners.putIfAbsent(actuator, owner); + if (existing != null) { + throw new IllegalStateException( + "Actuator " + actuator + " is already owned by " + existing + "; rejected " + owner); + } + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/BodyCommand.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/BodyCommand.java new file mode 100644 index 0000000..fef7bbf --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/BodyCommand.java @@ -0,0 +1,13 @@ +package fnl.bots.framework.arbitration; + +/** Body output for one turn: target speed (units/turn) and turn rate (radians/turn, counter-clockwise positive). */ +public final class BodyCommand { + + public double targetSpeed; + public double turnRate; + + public void clear() { + targetSpeed = 0; + turnRate = 0; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/FireCommand.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/FireCommand.java new file mode 100644 index 0000000..e94b4b5 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/FireCommand.java @@ -0,0 +1,11 @@ +package fnl.bots.framework.arbitration; + +/** Fire output for one turn: the firepower to fire with, or 0 to hold fire. */ +public final class FireCommand { + + public double firepower; + + public void clear() { + firepower = 0; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/GunCommand.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/GunCommand.java new file mode 100644 index 0000000..8a6c390 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/GunCommand.java @@ -0,0 +1,11 @@ +package fnl.bots.framework.arbitration; + +/** Gun rotation for one turn, in radians/turn (counter-clockwise positive), independent of the body turn. */ +public final class GunCommand { + + public double turnRate; + + public void clear() { + turnRate = 0; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/Intent.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/Intent.java new file mode 100644 index 0000000..bf2e310 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/Intent.java @@ -0,0 +1,23 @@ +package fnl.bots.framework.arbitration; + +/** + * The resolved, platform-free output of one turn. Turn rates are in radians/turn, counter-clockwise positive, and + * each actuator's rate is independent of the others; the adapter translates this to the platform. + */ +public final class Intent { + + public double targetSpeed; + public double bodyTurnRate; + public double gunTurnRate; + public double radarTurnRate; + /** Firepower to fire with this turn; 0 holds fire. */ + public double firepower; + + public void clear() { + targetSpeed = 0; + bodyTurnRate = 0; + gunTurnRate = 0; + radarTurnRate = 0; + firepower = 0; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/arbitration/RadarCommand.java b/bots/java/Retcon/src/fnl/bots/framework/arbitration/RadarCommand.java new file mode 100644 index 0000000..6af3736 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/arbitration/RadarCommand.java @@ -0,0 +1,11 @@ +package fnl.bots.framework.arbitration; + +/** Radar rotation for one turn, in radians/turn (counter-clockwise positive), independent of body and gun. */ +public final class RadarCommand { + + public double turnRate; + + public void clear() { + turnRate = 0; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/Blackboard.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/Blackboard.java new file mode 100644 index 0000000..b3a4cc0 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/Blackboard.java @@ -0,0 +1,64 @@ +package fnl.bots.framework.blackboard; + +import fnl.bots.framework.sense.GameInfo; + +/** + * Derived state shared by the pipeline stages. It holds no raw events: Sense-phase stages turn the adapter's + * {@code TurnInput} into these fields. Every field is a {@link Slot} with exactly one writer, claimed when the + * pipeline is wired; everyone else reads through the view types. + */ +public final class Blackboard { + + private final GameInfo game; + + public final Slot turnSlot = new Slot<>("turn", new TurnState()); + public final Slot selfSlot = new Slot<>("self", new SelfState()); + public final Slot enemiesSlot = new Slot<>("enemies", new EnemyTable()); + public final Slot modeSlot = new Slot<>("mode", new ModeState()); + public final Slot targetSlot = new Slot<>("target", new TargetState()); + public final Slot enemyWavesSlot = new Slot<>("enemyWaves", new EnemyWaves()); + public final Slot gunStatsSlot = new Slot<>("gunStats", new GunStats()); + + public Blackboard(GameInfo game) { + this.game = game; + } + + /** Game setup; fixed for the whole game. */ + public GameInfo game() { + return game; + } + + public TurnView turn() { + return turnSlot.get(); + } + + public SelfView self() { + return selfSlot.get(); + } + + public EnemiesView enemies() { + return enemiesSlot.get(); + } + + public ModeView mode() { + return modeSlot.get(); + } + + public TargetView target() { + return targetSlot.get(); + } + + public EnemyWavesView enemyWaves() { + return enemyWavesSlot.get(); + } + + public GunStatsView gunStats() { + return gunStatsSlot.get(); + } + + /** The current target, or null if there is none. */ + public EnemyView targetEnemy() { + int index = target().enemyIndex(); + return index == EnemiesView.NONE ? null : enemies().get(index); + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemiesView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemiesView.java new file mode 100644 index 0000000..e17ccb3 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemiesView.java @@ -0,0 +1,21 @@ +package fnl.bots.framework.blackboard; + +/** Read-only access to all tracked enemies, by index. Indices are stable within a round. */ +public interface EnemiesView { + + int NONE = -1; + + /** Number of enemies tracked this round (alive or dead). */ + int count(); + + EnemyView get(int index); + + /** Index of the enemy with the given bot id, or {@link #NONE}. */ + int indexOf(int botId); + + /** Tracked enemies not known to be dead. */ + int aliveCount(); + + /** Enemies alive according to the platform, including enemies never scanned. */ + int reportedAliveCount(); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyState.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyState.java new file mode 100644 index 0000000..3e9cfa2 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyState.java @@ -0,0 +1,79 @@ +package fnl.bots.framework.blackboard; + +/** Mutable {@link EnemyView}, owned by the {@link EnemyTable} writer. */ +public final class EnemyState implements EnemyView { + + public int id = -1; + public boolean alive; + public int lastSeenTurn = -1; + public double x; + public double y; + public double heading; + public double speed; + public double energy; + public double energyDrop; + public double distance; + public double bearing; + + void reset() { + id = -1; + alive = false; + lastSeenTurn = -1; + x = y = heading = speed = energy = energyDrop = distance = bearing = 0; + } + + @Override + public int id() { + return id; + } + + @Override + public boolean alive() { + return alive; + } + + @Override + public int lastSeenTurn() { + return lastSeenTurn; + } + + @Override + public double x() { + return x; + } + + @Override + public double y() { + return y; + } + + @Override + public double heading() { + return heading; + } + + @Override + public double speed() { + return speed; + } + + @Override + public double energy() { + return energy; + } + + @Override + public double energyDrop() { + return energyDrop; + } + + @Override + public double distance() { + return distance; + } + + @Override + public double bearing() { + return bearing; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyTable.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyTable.java new file mode 100644 index 0000000..1b58e87 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyTable.java @@ -0,0 +1,80 @@ +package fnl.bots.framework.blackboard; + +/** Fixed-capacity table of enemy states; preallocated, so tracking never allocates. */ +public final class EnemyTable implements EnemiesView { + + public static final int CAPACITY = 32; + + private final EnemyState[] enemies = new EnemyState[CAPACITY]; + private int count; + /** Enemies alive according to the platform, including enemies never scanned. */ + public int reportedAlive; + + public EnemyTable() { + for (int i = 0; i < CAPACITY; i++) { + enemies[i] = new EnemyState(); + } + } + + public void clear() { + for (int i = 0; i < count; i++) { + enemies[i].reset(); + } + count = 0; + } + + /** Returns the state for {@code botId}, adding it if new; null if the table is full. */ + public EnemyState getOrAdd(int botId) { + int index = indexOf(botId); + if (index != NONE) { + return enemies[index]; + } + if (count == CAPACITY) { + return null; + } + EnemyState enemy = enemies[count++]; + enemy.id = botId; + enemy.alive = true; + return enemy; + } + + public EnemyState mutable(int index) { + return enemies[index]; + } + + @Override + public int count() { + return count; + } + + @Override + public EnemyView get(int index) { + return enemies[index]; + } + + @Override + public int indexOf(int botId) { + for (int i = 0; i < count; i++) { + if (enemies[i].id == botId) { + return i; + } + } + return NONE; + } + + @Override + public int reportedAliveCount() { + return reportedAlive; + } + + @Override + public int aliveCount() { + int alive = 0; + for (int i = 0; i < count; i++) { + if (enemies[i].alive) { + alive++; + } + } + return alive; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyView.java new file mode 100644 index 0000000..abf997c --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyView.java @@ -0,0 +1,31 @@ +package fnl.bots.framework.blackboard; + +/** Last known state of one enemy. Angles in radians, counter-clockwise from east. */ +public interface EnemyView { + + int id(); + + boolean alive(); + + /** Turn of the latest scan; −1 if never scanned this round. */ + int lastSeenTurn(); + + double x(); + + double y(); + + double heading(); + + double speed(); + + double energy(); + + /** Energy drop since the previous scan (positive when the enemy lost energy), 0 if unknown. */ + double energyDrop(); + + /** Distance from this bot at the time of the latest scan. */ + double distance(); + + /** Absolute bearing from this bot at the time of the latest scan. */ + double bearing(); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWave.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWave.java new file mode 100644 index 0000000..dc6a5a2 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWave.java @@ -0,0 +1,96 @@ +package fnl.bots.framework.blackboard; + +import fnl.bots.framework.rules.Rules; + +/** Mutable {@link EnemyWaveView}, owned by the {@link EnemyWaves} writer. */ +public final class EnemyWave implements EnemyWaveView { + + public int shooterId; + public int decisionTurn; + public double originX; + public double originY; + public double power; + public double bulletSpeed; + public double directAngle; + public int lateralDirection; + public double maxEscapeAngle; + public int hitTurn; + public double hitX; + public double hitY; + /** Bullet distance from the origin at the hit minus {@link #radius}; 0 when fact 5 holds exactly. */ + public double hitResidual; + + void reset() { + shooterId = -1; + decisionTurn = 0; + originX = originY = power = bulletSpeed = directAngle = maxEscapeAngle = 0; + lateralDirection = 1; + hitTurn = -1; + hitX = hitY = hitResidual = 0; + } + + @Override + public int shooterId() { + return shooterId; + } + + @Override + public int decisionTurn() { + return decisionTurn; + } + + @Override + public double originX() { + return originX; + } + + @Override + public double originY() { + return originY; + } + + @Override + public double power() { + return power; + } + + @Override + public double bulletSpeed() { + return bulletSpeed; + } + + @Override + public double directAngle() { + return directAngle; + } + + @Override + public int lateralDirection() { + return lateralDirection; + } + + @Override + public double maxEscapeAngle() { + return maxEscapeAngle; + } + + @Override + public double radius(int now) { + return Rules.bulletDistance(decisionTurn, now, bulletSpeed); + } + + @Override + public int hitTurn() { + return hitTurn; + } + + @Override + public double hitX() { + return hitX; + } + + @Override + public double hitY() { + return hitY; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWaveView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWaveView.java new file mode 100644 index 0000000..32b9b4c --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWaveView.java @@ -0,0 +1,41 @@ +package fnl.bots.framework.blackboard; + +/** + * One enemy shot in flight, detected from the shooter's energy drop (physics facts 4 and 5). Angles in radians, + * counter-clockwise from east. + */ +public interface EnemyWaveView { + + int shooterId(); + + /** Turn the shooter decided the shot: the bullet starts at its position scanned then. */ + int decisionTurn(); + + double originX(); + + double originY(); + + double power(); + + double bulletSpeed(); + + /** Absolute angle from the origin to this bot at {@link #decisionTurn}: GuessFactor 0. */ + double directAngle(); + + /** +1 or −1: this bot's lateral direction around the origin at {@link #decisionTurn}. */ + int lateralDirection(); + + /** Largest angle this bot could reach from {@link #directAngle} before the bullet arrives: asin(8 / v). */ + double maxEscapeAngle(); + + /** Distance of the bullet from the origin at turn {@code now}. */ + double radius(int now); + + /** Turn this wave's bullet hit this bot, or −1. */ + int hitTurn(); + + /** Where the bullet was when it hit this bot; valid when {@link #hitTurn} ≥ 0. */ + double hitX(); + + double hitY(); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWaves.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWaves.java new file mode 100644 index 0000000..882b117 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWaves.java @@ -0,0 +1,91 @@ +package fnl.bots.framework.blackboard; + +/** Fixed-capacity table of enemy waves; preallocated, so tracking never allocates. */ +public final class EnemyWaves implements EnemyWavesView { + + public static final int CAPACITY = 64; + + private final EnemyWave[] waves = new EnemyWave[CAPACITY]; + private int count; + public long firesDetected; + public long unanchoredShots; + public long hitsTaken; + public long hitsOnWave; + public long powerCorrected; + /** Waves dropped because the table was full. */ + public long overflow; + + public EnemyWaves() { + for (int i = 0; i < CAPACITY; i++) { + waves[i] = new EnemyWave(); + waves[i].reset(); + } + } + + /** Removes all waves; the game counters are kept. */ + public void clear() { + for (int i = 0; i < count; i++) { + waves[i].reset(); + } + count = 0; + } + + /** Returns a fresh wave slot, or null (counted as overflow) if the table is full. */ + public EnemyWave add() { + if (count == CAPACITY) { + overflow++; + return null; + } + EnemyWave wave = waves[count++]; + wave.reset(); + return wave; + } + + /** Removes the wave at {@code index} by moving the last wave into its place. */ + public void remove(int index) { + EnemyWave removed = waves[index]; + waves[index] = waves[count - 1]; + waves[count - 1] = removed; + removed.reset(); + count--; + } + + public EnemyWave mutable(int index) { + return waves[index]; + } + + @Override + public int count() { + return count; + } + + @Override + public EnemyWaveView get(int index) { + return waves[index]; + } + + @Override + public long firesDetected() { + return firesDetected; + } + + @Override + public long unanchoredShots() { + return unanchoredShots; + } + + @Override + public long hitsTaken() { + return hitsTaken; + } + + @Override + public long hitsOnWave() { + return hitsOnWave; + } + + @Override + public long powerCorrected() { + return powerCorrected; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWavesView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWavesView.java new file mode 100644 index 0000000..35143ba --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/EnemyWavesView.java @@ -0,0 +1,23 @@ +package fnl.bots.framework.blackboard; + +/** Enemy shots in flight, by index. Indices are only stable within a turn. */ +public interface EnemyWavesView { + + int count(); + + EnemyWaveView get(int index); + + /** Energy drops read as shots this game. */ + long firesDetected(); + + /** Shots whose origin was unknown because the shooter was not scanned the turn before; no wave was made. */ + long unanchoredShots(); + + /** Bullets that hit this bot this game, and how many of them belonged to a tracked wave. */ + long hitsTaken(); + + long hitsOnWave(); + + /** Waves whose power was misjudged from the energy drop and corrected from the bullet that hit this bot. */ + long powerCorrected(); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/GunStats.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/GunStats.java new file mode 100644 index 0000000..411b6c3 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/GunStats.java @@ -0,0 +1,32 @@ +package fnl.bots.framework.blackboard; + +/** Mutable {@link GunStatsView}, owned by the one virtual-gun collector that claims it. */ +public final class GunStats implements GunStatsView { + + public static final int MAX_MODELS = 8; + + public final String[] names = new String[MAX_MODELS]; + public final long[] shots = new long[MAX_MODELS]; + public final long[] hits = new long[MAX_MODELS]; + public int models; + + @Override + public int models() { + return models; + } + + @Override + public String modelName(int model) { + return names[model]; + } + + @Override + public long virtualShots(int model) { + return shots[model]; + } + + @Override + public long virtualHits(int model) { + return hits[model]; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/GunStatsView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/GunStatsView.java new file mode 100644 index 0000000..35768a1 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/GunStatsView.java @@ -0,0 +1,21 @@ +package fnl.bots.framework.blackboard; + +/** Virtual-gun statistics: how often each of the gun's aim models would have hit, over the whole game. */ +public interface GunStatsView { + + /** Number of aim models tracked; 0 until the gun has fired. */ + int models(); + + /** Simple class name of model {@code model}, for reports. */ + String modelName(int model); + + long virtualShots(int model); + + long virtualHits(int model); + + /** Hits over shots, or 0 without shots. */ + default double hitRate(int model) { + long shots = virtualShots(model); + return shots == 0 ? 0 : (double) virtualHits(model) / shots; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeKind.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeKind.java new file mode 100644 index 0000000..f8209cf --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeKind.java @@ -0,0 +1,8 @@ +package fnl.bots.framework.blackboard; + +/** The battle situation that selects which strategy set is active. */ +public enum ModeKind { + ONE_VS_ONE, + MELEE, + TWO_VS_TWO +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeState.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeState.java new file mode 100644 index 0000000..c6fcb00 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeState.java @@ -0,0 +1,24 @@ +package fnl.bots.framework.blackboard; + +/** Mutable {@link ModeView}, written only by its Model-phase owner. */ +public final class ModeState implements ModeView { + + public ModeKind kind = ModeKind.MELEE; + public int enemiesAlive; + public int teammatesAlive; + + @Override + public ModeKind kind() { + return kind; + } + + @Override + public int enemiesAlive() { + return enemiesAlive; + } + + @Override + public int teammatesAlive() { + return teammatesAlive; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeView.java new file mode 100644 index 0000000..2a41a53 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/ModeView.java @@ -0,0 +1,11 @@ +package fnl.bots.framework.blackboard; + +/** The active mode and the alive counts it was chosen from. */ +public interface ModeView { + + ModeKind kind(); + + int enemiesAlive(); + + int teammatesAlive(); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/SelfState.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/SelfState.java new file mode 100644 index 0000000..ca73d1a --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/SelfState.java @@ -0,0 +1,60 @@ +package fnl.bots.framework.blackboard; + +/** Mutable {@link SelfView}, written only by its Sense-phase owner. */ +public final class SelfState implements SelfView { + + 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 teammatesAlive; + + @Override + public double x() { + return x; + } + + @Override + public double y() { + return y; + } + + @Override + public double heading() { + return heading; + } + + @Override + public double gunHeading() { + return gunHeading; + } + + @Override + public double radarHeading() { + return radarHeading; + } + + @Override + public double speed() { + return speed; + } + + @Override + public double energy() { + return energy; + } + + @Override + public double gunHeat() { + return gunHeat; + } + + @Override + public int teammatesAlive() { + return teammatesAlive; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/SelfView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/SelfView.java new file mode 100644 index 0000000..ceb4f42 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/SelfView.java @@ -0,0 +1,23 @@ +package fnl.bots.framework.blackboard; + +/** This bot's own state at the current turn. Angles in radians, counter-clockwise from east. */ +public interface SelfView { + + double x(); + + double y(); + + double heading(); + + double gunHeading(); + + double radarHeading(); + + double speed(); + + double energy(); + + double gunHeat(); + + int teammatesAlive(); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/Slot.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/Slot.java new file mode 100644 index 0000000..f207d78 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/Slot.java @@ -0,0 +1,48 @@ +package fnl.bots.framework.blackboard; + +/** + * One blackboard field with exactly one writer. Readers see the value through its read-only view type {@code V}; + * the single owner claims the mutable type {@code M} once, when the pipeline is wired, and mutates it in place every + * turn. A second claim fails fast, so the one-writer rule is enforced at construction time and costs nothing per turn. + * + * @param read-only view type + * @param mutable type, owned by one writer + */ +public final class Slot { + + private final String name; + private final M value; + private Object owner; + + public Slot(String name, M value) { + this.name = name; + this.value = value; + } + + /** The read-only view of the value. */ + public V get() { + return value; + } + + /** + * Claims write access for {@code owner}. + * + * @throws IllegalStateException if the slot already has an owner + */ + public M claim(Object owner) { + if (this.owner != null) { + throw new IllegalStateException( + "Blackboard slot '" + name + "' is already written by " + this.owner + "; rejected " + owner); + } + this.owner = owner; + return value; + } + + public Object owner() { + return owner; + } + + public String name() { + return name; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/TargetState.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TargetState.java new file mode 100644 index 0000000..31ba581 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TargetState.java @@ -0,0 +1,12 @@ +package fnl.bots.framework.blackboard; + +/** Mutable {@link TargetView}, written only by its Decide-phase owner. */ +public final class TargetState implements TargetView { + + public int enemyIndex = EnemiesView.NONE; + + @Override + public int enemyIndex() { + return enemyIndex; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/TargetView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TargetView.java new file mode 100644 index 0000000..d86db0c --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TargetView.java @@ -0,0 +1,12 @@ +package fnl.bots.framework.blackboard; + +/** The enemy currently selected as target. */ +public interface TargetView { + + /** Index into {@link EnemiesView}, or {@link EnemiesView#NONE}. */ + int enemyIndex(); + + default boolean hasTarget() { + return enemyIndex() != EnemiesView.NONE; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/TurnState.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TurnState.java new file mode 100644 index 0000000..c18040e --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TurnState.java @@ -0,0 +1,36 @@ +package fnl.bots.framework.blackboard; + +/** Mutable {@link TurnView}, written only by its Sense-phase owner. */ +public final class TurnState implements TurnView { + + public int round; + public int turn; + public int turnTimeoutMicros; + public int computeBudgetMicros; + public long turnStartNanos; + + @Override + public int round() { + return round; + } + + @Override + public int turn() { + return turn; + } + + @Override + public int turnTimeoutMicros() { + return turnTimeoutMicros; + } + + @Override + public int computeBudgetMicros() { + return computeBudgetMicros; + } + + @Override + public long turnStartNanos() { + return turnStartNanos; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/blackboard/TurnView.java b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TurnView.java new file mode 100644 index 0000000..3486c6e --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/blackboard/TurnView.java @@ -0,0 +1,23 @@ +package fnl.bots.framework.blackboard; + +/** Clock and time budget of the current turn. */ +public interface TurnView { + + int round(); + + int turn(); + + /** Server turn timeout in microseconds, read from the platform at runtime. */ + int turnTimeoutMicros(); + + /** Microseconds of compute this bot allows itself per turn: a configured share of the turn timeout. */ + int computeBudgetMicros(); + + /** {@link System#nanoTime()} when the pipeline started this turn; heavy stages use it to stop early. */ + long turnStartNanos(); + + /** True when this turn's compute has used up {@link #computeBudgetMicros()}. */ + default boolean overBudget() { + return (System.nanoTime() - turnStartNanos()) / 1000 >= computeBudgetMicros(); + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/geom/Angles.java b/bots/java/Retcon/src/fnl/bots/framework/geom/Angles.java new file mode 100644 index 0000000..3b3584b --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/geom/Angles.java @@ -0,0 +1,41 @@ +package fnl.bots.framework.geom; + +/** + * The single angle convention of the framework core: radians, counter-clockwise from east (the positive x-axis). + * Absolute headings are normalized to [0, 2π); relative angles (turns, differences) to (−π, π]. + *

+ * 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 sense = new ArrayList<>(); + private final List model = new ArrayList<>(); + private final List decide = new ArrayList<>(); + + private Builder(Blackboard bb, Arbiter arbiter, TeamOutbox outbox) { + this.bb = bb; + this.arbiter = arbiter; + this.outbox = outbox; + } + + public Builder sense(SenseStage stage) { + sense.add(stage); + return this; + } + + public Builder model(Stage stage) { + model.add(stage); + return this; + } + + public Builder decide(Stage stage) { + decide.add(stage); + return this; + } + + public Pipeline build() { + return new Pipeline(this); + } + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/pipeline/SenseStage.java b/bots/java/Retcon/src/fnl/bots/framework/pipeline/SenseStage.java new file mode 100644 index 0000000..5d1a163 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/pipeline/SenseStage.java @@ -0,0 +1,14 @@ +package fnl.bots.framework.pipeline; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.sense.TurnInput; + +/** A step of the Sense phase: the only kind of stage that reads the raw {@link TurnInput}. */ +public interface SenseStage { + + void sense(TurnInput in, Blackboard bb); + + /** Called when a new round starts, before its first turn. */ + default void onRoundStart() { + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/pipeline/Stage.java b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Stage.java new file mode 100644 index 0000000..0f9f784 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Stage.java @@ -0,0 +1,16 @@ +package fnl.bots.framework.pipeline; + +import fnl.bots.framework.blackboard.Blackboard; + +/** + * A step of the Model or Decide phase. A stage claims the blackboard slots and actuators it writes when it is + * constructed, and must not allocate in {@link #run}. + */ +public interface Stage { + + void run(Blackboard bb); + + /** Called when a new round starts, before its first turn. */ + default void onRoundStart() { + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/pipeline/Warmup.java b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Warmup.java new file mode 100644 index 0000000..3f1d832 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/pipeline/Warmup.java @@ -0,0 +1,68 @@ +package fnl.bots.framework.pipeline; + +import fnl.bots.framework.sense.GameInfo; +import fnl.bots.framework.sense.ScanRecord; +import fnl.bots.framework.sense.TurnInput; + +/** + * Runs a bot's pipeline on synthetic turns before the bot connects. Under the Rumble's source launcher every class + * is compiled in memory the first time it is loaded, and the JIT has not run yet; doing both before the game starts + * keeps round 1 from skipping turns. Platform-free, allocation outside the measured game. + */ +public final class Warmup { + + private Warmup() { + } + + /** + * Runs {@code turns} synthetic turns in total, split over a 1v1, a melee and a 2v2 game, so each strategy set a + * {@code Mode} can pick is compiled and warm before the bot connects. + */ + public static void run(BotProgram program, int turns) { + int perGame = Math.max(1, turns / 3); + runGame(program, perGame, new int[0], 1); + runGame(program, perGame, new int[0], 2); + runGame(program, perGame, new int[]{2}, 2); + } + + private static void runGame(BotProgram program, int turns, int[] teammateIds, int enemies) { + GameInfo game = new GameInfo(); + game.myId = 1; + game.arenaWidth = 800; + game.arenaHeight = 600; + game.numberOfRounds = 1; + game.gunCoolingRate = 0.1; + game.turnTimeoutMicros = 30_000; + game.teammateIds = teammateIds; + int firstEnemyId = 2 + teammateIds.length; + TurnInput input = new TurnInput(); + Pipeline pipeline = program.createPipeline(game, input); + pipeline.onRoundStart(); + for (int turn = 1; turn <= turns; turn++) { + double angle = turn * 0.05; + input.clearEvents(); + input.round = 1; + input.turn = turn; + input.x = 400; + input.y = 300; + input.gunHeading = angle; + input.radarHeading = angle * 2; + input.energy = 100; + input.gunHeat = turn % 16 == 0 ? 0 : 0.5; + input.enemyCount = enemies; + addScan(input, firstEnemyId, 400 + Math.cos(angle) * 250, 300 + Math.sin(angle) * 200); + if (enemies > 1 && turn % 3 == 0) { + addScan(input, firstEnemyId + 1, 400 - Math.cos(angle) * 150, 300 + Math.sin(angle) * 100); + } + pipeline.step(input); + } + } + + private static void addScan(TurnInput input, int id, double x, double y) { + ScanRecord scan = input.addScan(); + scan.botId = id; + scan.x = x; + scan.y = y; + scan.energy = 100; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/AimModel.java b/bots/java/Retcon/src/fnl/bots/framework/roles/AimModel.java new file mode 100644 index 0000000..304fe50 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/roles/AimModel.java @@ -0,0 +1,14 @@ +package fnl.bots.framework.roles; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.EnemyView; + +/** Turns collected data into a firing angle. */ +public interface AimModel { + + /** + * Returns the absolute angle (radians, counter-clockwise from east) to fire at {@code target} with a bullet of + * {@code bulletSpeed}, from this bot's current position; {@link Double#NaN} when the model has no opinion. + */ + double aim(Blackboard bb, EnemyView target, double bulletSpeed); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/AimSelector.java b/bots/java/Retcon/src/fnl/bots/framework/roles/AimSelector.java new file mode 100644 index 0000000..5a28116 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/roles/AimSelector.java @@ -0,0 +1,10 @@ +package fnl.bots.framework.roles; + +import fnl.bots.framework.blackboard.Blackboard; + +/** Chooses which of a gun's aim models to use this turn (a virtual-gun selector). */ +public interface AimSelector { + + /** Returns an index into {@code models}. */ + int choose(Blackboard bb, AimModel[] models); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/Collector.java b/bots/java/Retcon/src/fnl/bots/framework/roles/Collector.java new file mode 100644 index 0000000..d987038 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/roles/Collector.java @@ -0,0 +1,32 @@ +package fnl.bots.framework.roles; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.EnemyView; + +/** + * Collects targeting data in the Model phase, every turn, whichever mode is active. Waves, GuessFactor statistics + * and the Retroactive Hit Analysis history buffer plug in here. + */ +public interface Collector { + + void collect(Blackboard bb); + + /** Called when a new round starts. */ + default void onRoundStart() { + } + + /** Called once while the pipeline is wired, so a collector can claim the blackboard slot it writes. */ + default void wire(Blackboard bb) { + } + + /** Called once by the {@link Gun} with its aim models, in the order its selector indexes them. */ + default void onModels(AimModel[] models) { + } + + /** + * Called when the gun fires at {@code target}: {@code aims[i]} is model i's absolute aim angle for this shot + * ({@link Double#NaN} when it had no opinion). The array is reused; copy what you keep. + */ + default void onFire(Blackboard bb, EnemyView target, double bulletSpeed, double[] aims) { + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/FirePower.java b/bots/java/Retcon/src/fnl/bots/framework/roles/FirePower.java new file mode 100644 index 0000000..0d1b2f6 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/roles/FirePower.java @@ -0,0 +1,11 @@ +package fnl.bots.framework.roles; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.EnemyView; + +/** Chooses the firepower for the next shot. */ +public interface FirePower { + + /** Returns the firepower to use against {@code target}, or 0 to hold fire. */ + double power(Blackboard bb, EnemyView target); +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/roles/Gun.java b/bots/java/Retcon/src/fnl/bots/framework/roles/Gun.java new file mode 100644 index 0000000..69743bd --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/roles/Gun.java @@ -0,0 +1,71 @@ +package fnl.bots.framework.roles; + +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.blackboard.SelfView; +import fnl.bots.framework.geom.Angles; +import fnl.bots.framework.geom.Geom; +import fnl.bots.framework.rules.Rules; + +/** + * A gun is a {@link Collector}, one or more {@link AimModel}s and an {@link AimSelector} that picks among them. + *

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

+ */ +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 sets, + double budgetShare) { + Blackboard bb = new Blackboard(game); + Arbiter arbiter = new Arbiter(); + TeamOutbox outbox = new TeamOutbox(); + Strategies strategies = new Strategies(sets); + return Pipeline.builder(bb, arbiter, outbox) + .sense(new SelfSense(bb, budgetShare)) + .sense(new EnemySense(bb)) + .sense(new EnemyFireSense(bb)) + .model(new ModeStage(bb, mode)) + .model(new CollectStage(bb, strategies)) + .decide(new TargetStage(bb, strategies)) + .decide(new TeamStage(strategies, new TeamInbox(input), outbox)) + .decide(new MovementStage(arbiter, strategies)) + .decide(new RadarStage(arbiter, strategies)) + .decide(new GunStage(arbiter, strategies)) + .build(); + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/Strategies.java b/bots/java/Retcon/src/fnl/bots/framework/stages/Strategies.java new file mode 100644 index 0000000..7e6981e --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/stages/Strategies.java @@ -0,0 +1,45 @@ +package fnl.bots.framework.stages; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.ModeKind; +import fnl.bots.framework.roles.Gun; +import fnl.bots.framework.roles.StrategySet; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** The strategy set for every mode. Lookups are allocation-free. */ +public final class Strategies { + + private final EnumMap sets; + private final Gun[] distinctGuns; + + /** @param sets a strategy set for every {@link ModeKind} */ + public Strategies(Map sets) { + this.sets = new EnumMap<>(sets); + for (ModeKind kind : ModeKind.values()) { + if (!this.sets.containsKey(kind)) { + throw new IllegalArgumentException("No strategy set for mode " + kind); + } + } + List guns = new ArrayList<>(); + for (StrategySet set : this.sets.values()) { + if (guns.stream().noneMatch(g -> g == set.gun())) { + guns.add(set.gun()); + } + } + this.distinctGuns = guns.toArray(new Gun[0]); + } + + /** The strategy set of the mode currently on the blackboard. */ + public StrategySet active(Blackboard bb) { + return sets.get(bb.mode().kind()); + } + + /** Every distinct gun across all modes; their collectors run whatever the mode. */ + Gun[] distinctGuns() { + return distinctGuns; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/TargetStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/TargetStage.java new file mode 100644 index 0000000..edfb745 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/stages/TargetStage.java @@ -0,0 +1,27 @@ +package fnl.bots.framework.stages; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.TargetState; +import fnl.bots.framework.pipeline.Stage; + +/** Decide: selects the target with the active strategy set's target selector. Runs first in Decide. */ +public final class TargetStage implements Stage { + + private final TargetState target; + private final Strategies strategies; + + public TargetStage(Blackboard bb, Strategies strategies) { + this.target = bb.targetSlot.claim(this); + this.strategies = strategies; + } + + @Override + public void run(Blackboard bb) { + target.enemyIndex = strategies.active(bb).target().select(bb); + } + + @Override + public String toString() { + return "TargetStage"; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/stages/TeamStage.java b/bots/java/Retcon/src/fnl/bots/framework/stages/TeamStage.java new file mode 100644 index 0000000..6707c5b --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/stages/TeamStage.java @@ -0,0 +1,32 @@ +package fnl.bots.framework.stages; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.pipeline.Stage; +import fnl.bots.framework.team.TeamInbox; +import fnl.bots.framework.team.TeamOutbox; + +/** Decide: lets the active team comms read this turn's messages and queue new ones. */ +public final class TeamStage implements Stage { + + private final Strategies strategies; + private final TeamInbox inbox; + private final TeamOutbox outbox; + + public TeamStage(Strategies strategies, TeamInbox inbox, TeamOutbox outbox) { + this.strategies = strategies; + this.inbox = inbox; + this.outbox = outbox; + } + + @Override + public void run(Blackboard bb) { + var comms = strategies.active(bb).comms(); + comms.receive(bb, inbox); + comms.send(bb, outbox); + } + + @Override + public String toString() { + return "TeamStage"; + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/stats/MovementProbe.java b/bots/java/Retcon/src/fnl/bots/framework/stats/MovementProbe.java new file mode 100644 index 0000000..7467e13 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/stats/MovementProbe.java @@ -0,0 +1,103 @@ +package fnl.bots.framework.stats; + +import fnl.bots.framework.motion.MovementPredictor; +import fnl.bots.framework.motion.PredictedState; +import fnl.bots.framework.rules.Rules; +import fnl.bots.framework.sense.TurnInput; + +/** + * Checks {@link MovementPredictor} (and so {@link Rules#BODY_MOVES_BEFORE_TURNING}, {@link Rules#nextSpeed} and + * {@link Rules#constrainBotPosition}) against the live server: each turn it predicts this bot's own position, + * heading and speed from the previous turn's state and body intent, and compares them with what the server reports. + * Turns after a skipped turn, a bot collision or while disabled ({@link Rules#isDisabled}) are not checked: the + * intent was not applied, or the bot was pushed or held. A skip is reported a turn late, so the turn it spoiled is + * reclassified then. + * Allocation-free. + */ +public final class MovementProbe { + + private static final double POSITION_EPSILON = 1e-6; + private static final double HEADING_EPSILON = 1e-6; + private static final int MAX_MISMATCHES = 5; + + private MovementPredictor predictor; + private final PredictedState predicted = new PredictedState(); + private boolean pending; + private int pendingRound; + private int pendingTurn; + private boolean pendingDisabled; + private boolean lastWasMismatch; + private boolean lastMismatchListed; + private int lastMismatchTextLength; + + public long turnsChecked; + public long matches; + public long wallHitsChecked; + public long notChecked; + public final StringBuilder mismatches = new StringBuilder(); + private int mismatchCount; + + public void setArena(double arenaWidth, double arenaHeight) { + predictor = new MovementPredictor(arenaWidth, arenaHeight); + } + + /** The bot sends this body intent in response to the turn in {@code in}. */ + public void onIntent(TurnInput in, double targetSpeed, double bodyTurnRate) { + if (predictor == null) { + return; + } + predicted.set(in.x, in.y, in.heading, in.speed); + predictor.step(predicted, targetSpeed, bodyTurnRate); + pending = true; + pendingRound = in.round; + pendingTurn = in.turn; + pendingDisabled = Rules.isDisabled(in.energy); + } + + /** Compares the new turn's own state with the prediction made on the previous turn. */ + public void observe(TurnInput in) { + if (!pending) { + return; + } + pending = false; + if (in.skippedTurns > 0 && lastWasMismatch) { + turnsChecked--; + notChecked++; + if (lastMismatchListed) { + mismatchCount--; + mismatches.setLength(lastMismatchTextLength); + } + } + lastWasMismatch = false; + if (in.round != pendingRound || in.turn != pendingTurn + 1 || in.skippedTurns > 0 || in.collisionCount > 0 + || pendingDisabled || Rules.isDisabled(in.energy)) { + notChecked++; + return; + } + turnsChecked++; + if (predicted.hitWall) { + wallHitsChecked++; + } + double headingError = Math.abs(Math.IEEEremainder(in.heading - predicted.heading, 2 * Math.PI)); + boolean ok = Math.abs(in.x - predicted.x) <= POSITION_EPSILON + && Math.abs(in.y - predicted.y) <= POSITION_EPSILON + && Math.abs(in.speed - predicted.speed) <= POSITION_EPSILON + && headingError <= HEADING_EPSILON; + if (ok) { + matches++; + return; + } + lastWasMismatch = true; + lastMismatchTextLength = mismatches.length(); + lastMismatchListed = mismatchCount < MAX_MISMATCHES; + if (lastMismatchListed) { + mismatchCount++; + if (!mismatches.isEmpty()) { + mismatches.append("; "); + } + mismatches.append("turn ").append(in.turn).append(" predicted ").append(predicted.x).append(',') + .append(predicted.y).append(" v ").append(predicted.speed).append(" got ").append(in.x) + .append(',').append(in.y).append(" v ").append(in.speed); + } + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/stats/StatsWriter.java b/bots/java/Retcon/src/fnl/bots/framework/stats/StatsWriter.java new file mode 100644 index 0000000..be9246b --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/stats/StatsWriter.java @@ -0,0 +1,128 @@ +package fnl.bots.framework.stats; + +import fnl.bots.framework.blackboard.EnemyWavesView; +import fnl.bots.framework.blackboard.GunStatsView; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; + +/** + * Writes a game's stats as one JSON file, for the battle harness. It writes only when the environment variable + * {@value #ENV_DIR} is set and points inside {@code java.io.tmpdir}, so ranked Rumble runs never write anything and + * the bot never writes outside its temporary directory. + */ +public final class StatsWriter { + + public static final String ENV_DIR = "RETCON_STATS_DIR"; + + private StatsWriter() { + } + + /** Returns the stats directory, or null when stats are disabled or the directory is not inside the temp dir. */ + public static Path statsDirectory() { + String dir = System.getenv(ENV_DIR); + if (dir == null || dir.isBlank()) { + return null; + } + Path path = Path.of(dir).toAbsolutePath().normalize(); + Path temp = Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize(); + return path.startsWith(temp) ? path : null; + } + + /** + * Returns the file this game's stats go to, or null when stats are disabled. One file per game, rewritten after + * every round, so the harness still gets the stats if the bot process is stopped right as the game ends. + */ + public static Path statsFile(String botName, int botId) { + Path dir = statsDirectory(); + if (dir == null) { + return null; + } + return dir.resolve(botName.replaceAll("[^A-Za-z0-9._-]", "_") + "-" + botId + "-" + System.nanoTime() + + ".json"); + } + + /** Atomically (re)writes the stats file; does nothing when {@code file} is null. */ + public static void write(Path file, String botName, int botId, int roundsRecorded, TurnStats stats, + TimingProbe probe, EnemyWavesView waves, WaveProbe waveProbe, + MovementProbe movementProbe, GunStatsView gunStats) { + if (file == null) { + return; + } + String json = "{" + + "\"bot\":\"" + botName.replace("\"", "'") + "\"," + + "\"botId\":" + botId + "," + + "\"roundsRecorded\":" + roundsRecorded + "," + + "\"turnTimeoutMicros\":" + stats.turnTimeoutMicros() + "," + + "\"computeBudgetMicros\":" + stats.computeBudgetMicros() + "," + + "\"turns\":" + stats.turns() + "," + + "\"computeP50Micros\":" + stats.percentileMicros(50) + "," + + "\"computeP99Micros\":" + stats.percentileMicros(99) + "," + + "\"computeMaxMicros\":" + stats.maxMicros() + "," + + "\"overBudgetTurns\":" + stats.overBudget() + "," + + "\"skippedTurns\":" + stats.skippedTotal() + "," + + "\"skippedPerRound\":" + Arrays.toString(stats.skippedPerRound()) + "," + + "\"skippedTurnList\":\"" + stats.skippedTurnList() + "\"," + + "\"computeHistogram10us\":\"" + stats.histogram() + "\"," + + "\"timing\":{" + + "\"firesChecked\":" + probe.firesChecked + "," + + "\"firesNotCreated\":" + probe.firesNotCreated + "," + + "\"createdTurnMatches\":" + probe.createdTurnMatches + "," + + "\"originMatches\":" + probe.originMatches + "," + + "\"originMatchesAfterFirstMove\":" + probe.originMatchesAfterFirstMove + "," + + "\"headingMatches\":" + probe.headingMatches + "," + + "\"stillHitsChecked\":" + probe.stillHitsChecked + "," + + "\"stillHitMatches\":" + probe.stillHitMatches + "," + + "\"hitsOnMovingTargets\":" + probe.hitsOnMovingTargets + "," + + "\"mismatches\":\"" + probe.mismatches.toString().replace("\"", "'") + "\"" + + "}," + + "\"waves\":{" + + "\"firesDetected\":" + waves.firesDetected() + "," + + "\"unanchoredShots\":" + waves.unanchoredShots() + "," + + "\"hitsTaken\":" + waves.hitsTaken() + "," + + "\"hitsOnWave\":" + waves.hitsOnWave() + "," + + "\"wavePowerCorrected\":" + waves.powerCorrected() + "," + + "\"waveHitsExact\":" + waveProbe.exact + "," + + "\"waveHitsOffByOneStep\":" + waveProbe.offByOneStep + "," + + "\"waveHitsOtherResidual\":" + waveProbe.otherResidual + "," + + "\"waveMismatches\":\"" + waveProbe.mismatches.toString().replace("\"", "'") + "\"" + + "}," + + "\"movement\":{" + + "\"moveTurnsChecked\":" + movementProbe.turnsChecked + "," + + "\"moveMatches\":" + movementProbe.matches + "," + + "\"moveWallHitsChecked\":" + movementProbe.wallHitsChecked + "," + + "\"moveNotChecked\":" + movementProbe.notChecked + "," + + "\"moveMismatches\":\"" + movementProbe.mismatches.toString().replace("\"", "'") + "\"" + + "}," + + "\"gun\":{" + + "\"bulletsFired\":" + probe.bulletsFired + "," + + "\"bulletHits\":" + probe.bulletHits + "," + + "\"virtualGuns\":\"" + virtualGuns(gunStats) + "\"" + + "}}"; + try { + Files.createDirectories(file.getParent()); + Path temp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.writeString(temp, json, StandardCharsets.UTF_8); + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + System.err.println("Could not write stats: " + e); + } + } + + /** "Model:hits/shots" per aim model, space-separated. */ + private static String virtualGuns(GunStatsView stats) { + StringBuilder sb = new StringBuilder(); + for (int m = 0; m < stats.models(); m++) { + if (m > 0) { + sb.append(' '); + } + sb.append(stats.modelName(m)).append(':').append(stats.virtualHits(m)).append('/') + .append(stats.virtualShots(m)); + } + return sb.toString(); + } +} diff --git a/bots/java/Retcon/src/fnl/bots/framework/stats/TimingProbe.java b/bots/java/Retcon/src/fnl/bots/framework/stats/TimingProbe.java new file mode 100644 index 0000000..76dfa81 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/framework/stats/TimingProbe.java @@ -0,0 +1,182 @@ +package fnl.bots.framework.stats; + +import fnl.bots.framework.geom.Angles; +import fnl.bots.framework.geom.Geom; +import fnl.bots.framework.rules.Rules; +import fnl.bots.framework.sense.BulletRecord; +import fnl.bots.framework.sense.ScanRecord; +import fnl.bots.framework.sense.TurnInput; + +import java.util.Arrays; + +/** + * Checks the {@link Rules} turn-order facts against the live server: + *
    + *
  1. a bullet requested after turn t is created in turn t + 1 ({@link Rules#bulletCreatedTurn}),
  2. + *
  3. from the position observed at t, along the gun heading observed at t + * ({@link Rules#FIRE_USES_PRE_TURN_GUN_HEADING}),
  4. + *
  5. and hits a still target in the turn {@link Rules#firstHitTurnOnStillTarget} predicts.
  6. + *
+ * 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). + *

+ * 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 sets = new EnumMap<>(ModeKind.class); + sets.put(ModeKind.ONE_VS_ONE, duel); + sets.put(ModeKind.MELEE, melee); + sets.put(ModeKind.TWO_VS_TWO, melee); + return StandardPipeline.create(game, input, new AliveCountMode(), sets, StandardPipeline.DEFAULT_BUDGET_SHARE); + } +} diff --git a/bots/java/Retcon/src/fnl/bots/retcon/firepower/BasicFirePower.java b/bots/java/Retcon/src/fnl/bots/retcon/firepower/BasicFirePower.java new file mode 100644 index 0000000..0179d16 --- /dev/null +++ b/bots/java/Retcon/src/fnl/bots/retcon/firepower/BasicFirePower.java @@ -0,0 +1,34 @@ +package fnl.bots.retcon.firepower; + +import fnl.bots.framework.blackboard.Blackboard; +import fnl.bots.framework.blackboard.EnemyView; +import fnl.bots.framework.geom.Geom; +import fnl.bots.framework.roles.FirePower; +import fnl.bots.framework.rules.Rules; + +/** + * The book's hybrid formula from distance and own energy (no hit-rate factor yet), capped at the least power that + * finishes the target. + *

+ * 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"] +}