diff --git a/bots/leader-targeter/Dockerfile b/bots/leader-targeter/Dockerfile new file mode 100644 index 0000000..4e19702 --- /dev/null +++ b/bots/leader-targeter/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM eclipse-temurin:21-jdk-alpine AS builder + +WORKDIR /app +COPY pom.xml ./ +COPY src ./src + +# Install Maven and build +RUN apk add --no-cache maven && \ + mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app +COPY --from=builder /app/target/leader-targeter-bot-1.0.0.jar /app/leader-targeter-bot.jar + +ENV BOT_PORT=8085 +ENV BOT_SECRET="" + +EXPOSE 8085 + +CMD ["java", "-jar", "leader-targeter-bot.jar"] diff --git a/bots/leader-targeter/dependency-reduced-pom.xml b/bots/leader-targeter/dependency-reduced-pom.xml new file mode 100644 index 0000000..3cc8429 --- /dev/null +++ b/bots/leader-targeter/dependency-reduced-pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + com.acb + leader-targeter-bot + LeaderTargeterBot + 1.0.0 + Multi-player score leader targeting bot for AI Code Battle + + + + maven-shade-plugin + 3.5.2 + + + package + + shade + + + + + com.acb.targeter.App + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + 21 + 21 + UTF-8 + 2.17.0 + 6.3.0 + + diff --git a/bots/leader-targeter/pom.xml b/bots/leader-targeter/pom.xml new file mode 100644 index 0000000..cbf908f --- /dev/null +++ b/bots/leader-targeter/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.acb + leader-targeter-bot + 1.0.0 + jar + + LeaderTargeterBot + Multi-player score leader targeting bot for AI Code Battle + + + 21 + 21 + UTF-8 + 6.3.0 + 2.17.0 + + + + + io.javalin + javalin + ${javalin.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.slf4j + slf4j-simple + 2.0.12 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + + shade + + + + + com.acb.targeter.App + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/bots/leader-targeter/src/main/java/com/acb/targeter/App.java b/bots/leader-targeter/src/main/java/com/acb/targeter/App.java new file mode 100644 index 0000000..1e00840 --- /dev/null +++ b/bots/leader-targeter/src/main/java/com/acb/targeter/App.java @@ -0,0 +1,137 @@ +package com.acb.targeter; + +import io.javalin.Javalin; +import io.javalin.http.Context; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +/** + * LeaderTargeterBot - Multi-player score leader targeting bot. + * + * Strategy: In N>2 games, always direct all units toward the current score leader. + * - Identify all visible opponents and their scores (cores count as proxy: each active core ≈ +2 score) + * - Pick primary target: opponent with highest inferred score (tiebreak: nearest) + * - Send all bots toward primary target's centroid (mean of target's visible bots + cores) + * - Exception: if own core is under direct threat (enemy bot within 6 tiles), detach 2 bots to defend + * - In 2-player games: fall back to straight aggressor (target the only opponent) + * + * This creates a natural kingmaker dynamic that prevents any single bot from running away with the game. + */ +public class App { + private static final int DEFAULT_PORT = 8085; + private static String SECRET; + private static final LeaderTargeterStrategy STRATEGY = new LeaderTargeterStrategy(); + + public static void main(String[] args) { + String portStr = System.getenv("BOT_PORT"); + int port = portStr != null ? Integer.parseInt(portStr) : DEFAULT_PORT; + + SECRET = System.getenv("BOT_SECRET"); + if (SECRET == null || SECRET.isEmpty()) { + System.err.println("ERROR: BOT_SECRET environment variable is required"); + System.exit(1); + } + + Javalin app = Javalin.create(); + + app.get("/health", ctx -> ctx.result("OK")); + + app.post("/turn", App::handleTurn); + + app.start(port); + System.out.println("LeaderTargeterBot starting on port " + port); + } + + private static void handleTurn(Context ctx) { + // Extract auth headers + String matchId = ctx.header("X-ACB-Match-Id"); + String turnStr = ctx.header("X-ACB-Turn"); + String timestamp = ctx.header("X-ACB-Timestamp"); + String signature = ctx.header("X-ACB-Signature"); + + if (matchId == null || turnStr == null || timestamp == null || signature == null) { + ctx.status(401).result("Missing auth headers"); + return; + } + + String body = ctx.body(); + + // Verify signature + if (!verifySignature(SECRET, matchId, turnStr, timestamp, body, signature)) { + ctx.status(401).result("Invalid signature"); + return; + } + + // Parse game state + GameState state; + try { + state = GameState.fromJson(body); + } catch (Exception e) { + ctx.status(400).result("Invalid JSON: " + e.getMessage()); + return; + } + + // Compute moves + var moves = STRATEGY.computeMoves(state); + int turn = Integer.parseInt(turnStr); + + System.out.println("Turn " + turn + ": " + moves.size() + " moves computed"); + + // Build response + String responseBody = MoveResponse.toJson(moves); + + // Sign response + String responseSig = signResponse(SECRET, matchId, turn, responseBody); + + ctx.header("X-ACB-Signature", responseSig); + ctx.contentType("application/json"); + ctx.result(responseBody); + } + + private static boolean verifySignature(String secret, String matchId, String turn, + String timestamp, String body, String signature) { + try { + String bodyHash = sha256Hex(body); + String signingString = matchId + "." + turn + "." + bodyHash; + + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(keySpec); + byte[] expected = mac.doFinal(signingString.getBytes(StandardCharsets.UTF_8)); + + return MessageDigest.isEqual( + HexFormat.of().parseHex(signature), + expected + ); + } catch (Exception e) { + return false; + } + } + + private static String signResponse(String secret, String matchId, int turn, String body) { + try { + String bodyHash = sha256Hex(body); + String signingString = matchId + "." + turn + "." + bodyHash; + + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(keySpec); + return HexFormat.of().formatHex(mac.doFinal(signingString.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new RuntimeException("Failed to sign response", e); + } + } + + private static String sha256Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new RuntimeException("Failed to hash", e); + } + } +} diff --git a/bots/leader-targeter/src/main/java/com/acb/targeter/GameState.java b/bots/leader-targeter/src/main/java/com/acb/targeter/GameState.java new file mode 100644 index 0000000..aa30695 --- /dev/null +++ b/bots/leader-targeter/src/main/java/com/acb/targeter/GameState.java @@ -0,0 +1,211 @@ +package com.acb.targeter; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; + +import java.util.List; +import java.util.Collections; + +/** + * Game state types for AI Code Battle protocol. + */ +public class GameState { + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @JsonProperty("match_id") + private String matchId; + + private int turn; + private GameConfig config; + private PlayerInfo you; + + private List bots = Collections.emptyList(); + private List energy = Collections.emptyList(); + private List cores = Collections.emptyList(); + private List walls = Collections.emptyList(); + private List dead = Collections.emptyList(); + + // Getters + public String getMatchId() { return matchId; } + public int getTurn() { return turn; } + public GameConfig getConfig() { return config; } + public PlayerInfo getYou() { return you; } + public List getBots() { return bots; } + public List getEnergy() { return energy; } + public List getCores() { return cores; } + public List getWalls() { return walls; } + public List getDead() { return dead; } + + public static GameState fromJson(String json) throws Exception { + return MAPPER.readValue(json, GameState.class); + } +} + +class GameConfig { + private int rows; + private int cols; + + @JsonProperty("max_turns") + private int maxTurns; + + @JsonProperty("vision_radius2") + private int visionRadius2; + + @JsonProperty("attack_radius2") + private int attackRadius2; + + @JsonProperty("spawn_cost") + private int spawnCost; + + @JsonProperty("energy_interval") + private int energyInterval; + + // Getters + public int getRows() { return rows; } + public int getCols() { return cols; } + public int getMaxTurns() { return maxTurns; } + public int getVisionRadius2() { return visionRadius2; } + public int getAttackRadius2() { return attackRadius2; } + public int getSpawnCost() { return spawnCost; } + public int getEnergyInterval() { return energyInterval; } +} + +class PlayerInfo { + private int id; + private int energy; + private int score; + + // Getters + public int getId() { return id; } + public int getEnergy() { return energy; } + public int getScore() { return score; } +} + +class Position { + private int row; + private int col; + + // Default constructor for Jackson + public Position() {} + + public Position(int row, int col) { + this.row = row; + this.col = col; + } + + public int getRow() { return row; } + public int getCol() { return col; } + + /** + * Move in a direction with toroidal wrapping + */ + public Position moveToward(Direction dir, int rows, int cols) { + return switch (dir) { + case N -> new Position((row - 1 + rows) % rows, col); + case E -> new Position(row, (col + 1) % cols); + case S -> new Position((row + 1) % rows, col); + case W -> new Position(row, (col - 1 + cols) % cols); + }; + } + + /** + * Calculate squared distance with toroidal wrapping + */ + public int distance2(Position other, int rows, int cols) { + int dr = Math.abs(row - other.row); + int dc = Math.abs(col - other.col); + dr = Math.min(dr, rows - dr); + dc = Math.min(dc, cols - dc); + return dr * dr + dc * dc; + } + + /** + * Manhattan distance with toroidal wrapping + */ + public int manhattanDistance(Position other, int rows, int cols) { + int dr = Math.abs(row - other.row); + int dc = Math.abs(col - other.col); + dr = Math.min(dr, rows - dr); + dc = Math.min(dc, cols - dc); + return dr + dc; + } + + public String key() { + return row + "," + col; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Position position = (Position) o; + return row == position.row && col == position.col; + } + + @Override + public int hashCode() { + return 31 * row + col; + } +} + +class VisibleBot { + private Position position; + private int owner; + + public Position getPosition() { return position; } + public int getOwner() { return owner; } +} + +class VisibleCore { + private Position position; + private int owner; + private boolean active; + + public Position getPosition() { return position; } + public int getOwner() { return owner; } + public boolean isActive() { return active; } +} + +enum Direction { + N, E, S, W; + + public static Direction[] all() { + return values(); + } +} + +class Move { + private final Position position; + private final Direction direction; + + public Move(Position position, Direction direction) { + this.position = position; + this.direction = direction; + } + + public Position getPosition() { return position; } + public Direction getDirection() { return direction; } +} + +class MoveResponse { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final List moves; + + public MoveResponse(List moves) { + this.moves = moves; + } + + public List getMoves() { return moves; } + + public static String toJson(List moves) { + try { + var response = new MoveResponse(moves); + return MAPPER.writeValueAsString(response); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize moves", e); + } + } +} diff --git a/bots/leader-targeter/src/main/java/com/acb/targeter/LeaderTargeterStrategy.java b/bots/leader-targeter/src/main/java/com/acb/targeter/LeaderTargeterStrategy.java new file mode 100644 index 0000000..dfb1f13 --- /dev/null +++ b/bots/leader-targeter/src/main/java/com/acb/targeter/LeaderTargeterStrategy.java @@ -0,0 +1,386 @@ +package com.acb.targeter; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * LeaderTargeterStrategy - Multi-player score leader targeting bot. + * + * Strategy: In N>2 games, always direct all units toward the current score leader. + * - Identify all visible opponents and their scores (cores count as proxy: each active core ≈ +2 score) + * - Pick primary target: opponent with highest inferred score (tiebreak: nearest) + * - Send all bots toward primary target's centroid (mean of target's visible bots + cores) + * - Exception: if own core is under direct threat (enemy bot within 6 tiles), detach 2 bots to defend + * - In 2-player games: fall back to straight aggressor (target the only opponent) + * + * This creates a natural kingmaker dynamic that prevents any single bot from running away with the game. + */ +public class LeaderTargeterStrategy { + private static final int CORE_DEFENSE_THRESHOLD = 36; // 6 tiles squared distance + private static final int DEFENDERS_COUNT = 2; // Number of bots to detach for core defense + private static final int SCORE_PER_ACTIVE_CORE = 2; // Approximate score contribution per active core + + /** + * Compute moves for all owned bots + */ + public List computeMoves(GameState state) { + int myId = state.getYou().getId(); + GameConfig config = state.getConfig(); + int rows = config.getRows(); + int cols = config.getCols(); + + // Separate my bots from enemies + List myBots = new ArrayList<>(); + List enemyBots = new ArrayList<>(); + + for (VisibleBot bot : state.getBots()) { + if (bot.getOwner() == myId) { + myBots.add(bot); + } else { + enemyBots.add(bot); + } + } + + if (myBots.isEmpty()) { + return Collections.emptyList(); + } + + // Calculate opponent scores + Map opponentScores = calculateOpponentScores(enemyBots, state.getCores()); + + // Find all unique opponent IDs + Set opponentIds = new HashSet<>(); + for (VisibleBot bot : enemyBots) { + opponentIds.add(bot.getOwner()); + } + for (VisibleCore core : state.getCores()) { + if (core.getOwner() != myId) { + opponentIds.add(core.getOwner()); + } + } + + // Build position lookups + Set walls = buildPositionSet(state.getWalls()); + Set enemyPositions = buildPositionSet( + enemyBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList()) + ); + Set myBotPositions = buildPositionSet( + myBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList()) + ); + + // Check if own core is under threat + Position myCorePosition = findOwnCore(state.getCores(), myId); + boolean coreUnderThreat = false; + if (myCorePosition != null && !enemyBots.isEmpty()) { + for (VisibleBot enemyBot : enemyBots) { + if (enemyBot.getPosition().distance2(myCorePosition, rows, cols) <= CORE_DEFENSE_THRESHOLD) { + coreUnderThreat = true; + break; + } + } + } + + // Select target based on game type + Position targetPosition; + int targetOwnerId; + + if (opponentIds.size() <= 1) { + // 2-player game: target the only opponent + targetOwnerId = opponentIds.isEmpty() ? -1 : opponentIds.iterator().next(); + targetPosition = findOpponentCentroid(enemyBots, state.getCores(), targetOwnerId, rows, cols); + System.out.println("2-player game: targeting opponent " + targetOwnerId); + } else { + // Multi-player game: target the score leader + int scoreLeaderId = findScoreLeader(opponentScores, opponentIds, rows, cols, + enemyBots, state.getCores(), myBots.get(0).getPosition()); + targetOwnerId = scoreLeaderId; + targetPosition = findOpponentCentroid(enemyBots, state.getCores(), scoreLeaderId, rows, cols); + System.out.println("Multi-player game: score leader is opponent " + scoreLeaderId + + " with score ~" + opponentScores.getOrDefault(scoreLeaderId, 0)); + } + + if (targetPosition == null) { + // No valid target, move toward center + targetPosition = new Position(rows / 2, cols / 2); + System.out.println("No valid target, moving toward center"); + } + + // Compute moves + List moves = new ArrayList<>(); + Set assignedBots = new HashSet<>(); + + // If core is under threat, assign defenders first + if (coreUnderThreat && myCorePosition != null) { + List defenders = findNearestBots(myBots, myCorePosition, DEFENDERS_COUNT, rows, cols); + for (VisibleBot defender : defenders) { + Move move = computeDefensiveMove(defender, myCorePosition, enemyPositions, walls, + myBotPositions, rows, cols); + if (move != null) { + moves.add(move); + assignedBots.add(defender.getPosition().key()); + myBotPositions.remove(defender.getPosition().key()); + } + } + System.out.println("Core under threat! Assigned " + defenders.size() + " defenders"); + } + + // Assign remaining bots to attack the primary target + for (VisibleBot bot : myBots) { + if (assignedBots.contains(bot.getPosition().key())) { + continue; // Already assigned as defender + } + + Move move = computeAttackMove(bot, targetPosition, enemyPositions, walls, myBotPositions, rows, cols); + if (move != null) { + moves.add(move); + myBotPositions.remove(bot.getPosition().key()); + } + } + + System.out.println("Computed " + moves.size() + " moves for " + myBots.size() + " bots"); + + return moves; + } + + /** + * Calculate approximate scores for all opponents based on visible bots and cores + */ + private Map calculateOpponentScores(List enemyBots, List cores) { + Map scores = new HashMap<>(); + + // Count bots per opponent + Map botCounts = new HashMap<>(); + for (VisibleBot bot : enemyBots) { + botCounts.merge(bot.getOwner(), 1, Integer::sum); + } + + // Count active cores per opponent + Map coreCounts = new HashMap<>(); + for (VisibleCore core : cores) { + if (core.isActive()) { + coreCounts.merge(core.getOwner(), 1, Integer::sum); + } + } + + // Calculate approximate scores: botCount * 10 + activeCoreCount * SCORE_PER_ACTIVE_CORE + // (Each bot is worth ~10 points based on spawn cost) + for (Integer ownerId : botCounts.keySet()) { + int botCount = botCounts.get(ownerId); + int coreCount = coreCounts.getOrDefault(ownerId, 0); + int estimatedScore = botCount * 10 + coreCount * SCORE_PER_ACTIVE_CORE; + scores.put(ownerId, estimatedScore); + } + + // Include opponents with only cores visible + for (Integer ownerId : coreCounts.keySet()) { + if (!scores.containsKey(ownerId)) { + int coreCount = coreCounts.get(ownerId); + scores.put(ownerId, coreCount * SCORE_PER_ACTIVE_CORE); + } + } + + return scores; + } + + /** + * Find the score leader among opponents + */ + private int findScoreLeader(Map opponentScores, Set opponentIds, + int rows, int cols, List enemyBots, + List cores, Position referencePosition) { + int leaderId = -1; + int maxScore = Integer.MIN_VALUE; + int minDistance = Integer.MAX_VALUE; + + for (Integer ownerId : opponentIds) { + int score = opponentScores.getOrDefault(ownerId, 0); + + // Find this opponent's centroid for distance calculation + Position centroid = findOpponentCentroid(enemyBots, cores, ownerId, rows, cols); + int distance = centroid != null ? referencePosition.distance2(centroid, rows, cols) : Integer.MAX_VALUE; + + // Prefer higher score, tiebreak by nearest distance + if (score > maxScore || (score == maxScore && distance < minDistance)) { + maxScore = score; + minDistance = distance; + leaderId = ownerId; + } + } + + return leaderId != -1 ? leaderId : opponentIds.iterator().next(); + } + + /** + * Find the centroid (average position) of an opponent's visible assets + */ + private Position findOpponentCentroid(List enemyBots, List cores, + int ownerId, int rows, int cols) { + List positions = new ArrayList<>(); + + // Add bot positions + for (VisibleBot bot : enemyBots) { + if (bot.getOwner() == ownerId) { + positions.add(bot.getPosition()); + } + } + + // Add core positions + for (VisibleCore core : cores) { + if (core.getOwner() == ownerId) { + positions.add(core.getPosition()); + } + } + + if (positions.isEmpty()) { + return null; + } + + // Calculate average position + double avgRow = 0; + double avgCol = 0; + for (Position pos : positions) { + avgRow += pos.getRow(); + avgCol += pos.getCol(); + } + avgRow /= positions.size(); + avgCol /= positions.size(); + + return new Position((int) Math.round(avgRow), (int) Math.round(avgCol)); + } + + /** + * Find own core position + */ + private Position findOwnCore(List cores, int myId) { + for (VisibleCore core : cores) { + if (core.getOwner() == myId) { + return core.getPosition(); + } + } + return null; + } + + /** + * Find the N nearest bots to a target position + */ + private List findNearestBots(List bots, Position target, + int count, int rows, int cols) { + List sorted = new ArrayList<>(bots); + sorted.sort((a, b) -> { + int distA = a.getPosition().distance2(target, rows, cols); + int distB = b.getPosition().distance2(target, rows, cols); + return Integer.compare(distA, distB); + }); + + return sorted.stream().limit(count).collect(Collectors.toList()); + } + + /** + * Compute a defensive move to protect own core + */ + private Move computeDefensiveMove(VisibleBot bot, Position corePosition, + Set enemyPositions, Set walls, + Set myBotPositions, int rows, int cols) { + // Move to position core to intercept enemies + // Prefer positions that are between core and nearest enemy + Direction bestDir = null; + int bestScore = Integer.MIN_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String newPosKey = newPos.key(); + + if (walls.contains(newPosKey)) { + continue; + } + + if (myBotPositions.contains(newPosKey)) { + continue; + } + + // Score: prefer being close to core but not on it + int distToCore = newPos.distance2(corePosition, rows, cols); + int score = -distToCore; + + // Bonus for being between core and potential enemies + if (distToCore > 0 && distToCore <= 9) { // 2-3 tiles from core + score += 50; + } + + if (score > bestScore) { + bestScore = score; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + + return null; + } + + /** + * Compute an attack move toward the target position + */ + private Move computeAttackMove(VisibleBot bot, Position target, + Set enemyPositions, Set walls, + Set myBotPositions, int rows, int cols) { + Direction bestDir = null; + int bestScore = Integer.MIN_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String newPosKey = newPos.key(); + + if (walls.contains(newPosKey)) { + continue; + } + + if (myBotPositions.contains(newPosKey)) { + continue; + } + + // Score: prefer getting closer to target + int distToTarget = newPos.distance2(target, rows, cols); + int currentDistToTarget = bot.getPosition().distance2(target, rows, cols); + int score = currentDistToTarget - distToTarget; + + // Bonus for being in attack range of target + if (distToTarget <= 5) { // attack_radius2 + score += 20; + } + + // Small penalty for moving adjacent to multiple enemies (but less strict than hunter) + int adjacentEnemies = 0; + for (String enemyPosKey : enemyPositions) { + String[] parts = enemyPosKey.split(","); + Position enemyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + if (newPos.distance2(enemyPos, rows, cols) <= 2) { + adjacentEnemies++; + } + } + score -= adjacentEnemies * 5; // Lower penalty than hunter, we're aggressive + + if (score > bestScore) { + bestScore = score; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + + return null; + } + + /** + * Build a set of position keys for O(1) lookup + */ + private Set buildPositionSet(List positions) { + return positions.stream() + .map(Position::key) + .collect(Collectors.toSet()); + } +}