diff --git a/bots/raider/Dockerfile b/bots/raider/Dockerfile new file mode 100644 index 0000000..e8b524a --- /dev/null +++ b/bots/raider/Dockerfile @@ -0,0 +1,20 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder + +WORKDIR /app +COPY pom.xml ./ +COPY src ./src + +RUN apk add --no-cache maven && \ + mvn clean package -DskipTests + +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app +COPY --from=builder /app/target/raider-bot-1.0.0.jar /app/raider-bot.jar + +ENV BOT_PORT=8086 +ENV BOT_SECRET="" + +EXPOSE 8086 + +CMD ["java", "-jar", "raider-bot.jar"] diff --git a/bots/raider/pom.xml b/bots/raider/pom.xml new file mode 100644 index 0000000..4cbc579 --- /dev/null +++ b/bots/raider/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.acb + raider-bot + 1.0.0 + jar + + RaiderBot + Hit-and-run harasser strategy 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.raider.App + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/bots/raider/src/main/java/com/acb/raider/App.java b/bots/raider/src/main/java/com/acb/raider/App.java new file mode 100644 index 0000000..685cd60 --- /dev/null +++ b/bots/raider/src/main/java/com/acb/raider/App.java @@ -0,0 +1,127 @@ +package com.acb.raider; + +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; + +/** + * RaiderBot - Hit-and-run harasser archetype. + * + * Strategy: Attack weak targets, disengage before reinforcements arrive. + * - Units scout for lone enemy bots (no allies within 2 cells) + * - On finding one, attack from flank + * - After 1-2 attack turns, retreat regardless of outcome + * - Never attack groups of >=3 enemies + * - Home base rotates: if own core under pressure, abandon raid and defend + */ +public class App { + private static final int DEFAULT_PORT = 8086; + private static String SECRET; + private static final RaiderStrategy STRATEGY = new RaiderStrategy(); + + 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("RaiderBot starting on port " + port); + } + + private static void handleTurn(Context ctx) { + 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(); + + if (!verifySignature(SECRET, matchId, turnStr, timestamp, body, signature)) { + ctx.status(401).result("Invalid signature"); + return; + } + + GameState state; + try { + state = GameState.fromJson(body); + } catch (Exception e) { + ctx.status(400).result("Invalid JSON: " + e.getMessage()); + return; + } + + var moves = STRATEGY.computeMoves(state); + int turn = Integer.parseInt(turnStr); + + System.out.println("Turn " + turn + ": " + moves.size() + " moves computed"); + + String responseBody = MoveResponse.toJson(moves); + 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 + "." + timestamp + "." + 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/raider/src/main/java/com/acb/raider/GameState.java b/bots/raider/src/main/java/com/acb/raider/GameState.java new file mode 100644 index 0000000..d82ed53 --- /dev/null +++ b/bots/raider/src/main/java/com/acb/raider/GameState.java @@ -0,0 +1,195 @@ +package com.acb.raider; + +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; + +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(); + + 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; + + 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; + + public int getId() { return id; } + public int getEnergy() { return energy; } + public int getScore() { return score; } +} + +class Position { + private int row; + private int col; + + public Position() {} + + public Position(int row, int col) { + this.row = row; + this.col = col; + } + + public int getRow() { return row; } + public int getCol() { return col; } + + 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); + }; + } + + 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; + } + + 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/raider/src/main/java/com/acb/raider/RaiderStrategy.java b/bots/raider/src/main/java/com/acb/raider/RaiderStrategy.java new file mode 100644 index 0000000..8929289 --- /dev/null +++ b/bots/raider/src/main/java/com/acb/raider/RaiderStrategy.java @@ -0,0 +1,526 @@ +package com.acb.raider; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * RaiderBot strategy: hit-and-run harassment. + * + * - Scouts for lone enemy bots (no allies within 2 cells) + * - Attacks isolated targets from flank + * - After 1-2 attack turns, retreats regardless of outcome + * - Never attacks groups of >=3 enemies + * - Defends own core if under pressure + */ +public class RaiderStrategy { + + private static final int GROUP_AVOID_THRESHOLD = 3; + private static final int MAX_ENGAGEMENT_TURNS = 2; + private static final int ISOLATION_MANHATTAN = 4; // 2 cells squared-distance-ish radius + + // Per-bot engagement tracking + private final Map engagementTrackers = new HashMap<>(); + + public List computeMoves(GameState state) { + int myId = state.getYou().getId(); + GameConfig config = state.getConfig(); + int rows = config.getRows(); + int cols = config.getCols(); + int attackR2 = config.getAttackRadius2(); + + 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(); + } + + Set walls = buildPositionSet(state.getWalls()); + Set enemyPositions = buildPositionSet( + enemyBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList()) + ); + Set energyPositions = buildPositionSet(state.getEnergy()); + + // Identify own active cores + List myCores = new ArrayList<>(); + for (VisibleCore core : state.getCores()) { + if (core.getOwner() == myId && core.isActive()) { + myCores.add(core.getPosition()); + } + } + + // Check if own core is under pressure + boolean coreUnderPressure = isCoreUnderPressure(myCores, enemyBots, rows, cols); + + // Find lone enemy bots + List loneEnemies = findLoneEnemies(enemyBots, rows, cols); + + // Build assignments + List moves = new ArrayList<>(); + Set assignedBots = new HashSet<>(); + Set claimedDests = new HashSet<>(); + + if (coreUnderPressure && !myCores.isEmpty()) { + // Core defense: bots near core intercept enemies, others continue raiding + List defenders = new ArrayList<>(); + List raiders = new ArrayList<>(); + + for (VisibleBot bot : myBots) { + int nearestCoreDist = minDistToAny(bot.getPosition(), myCores, rows, cols); + if (nearestCoreDist <= 25) { // within ~5 tiles of a core + defenders.add(bot); + } else { + raiders.add(bot); + } + } + + // Defenders move toward nearest enemy near core + for (VisibleBot defender : defenders) { + Position target = nearestEnemyToCore(myCores, enemyBots, rows, cols); + Move move = computeDefendMove(defender, target, walls, enemyPositions, + claimedDests, rows, cols); + if (move != null) { + assignedBots.add(defender.getPosition().key()); + Position dest = defender.getPosition().moveToward(move.getDirection(), rows, cols); + claimedDests.add(dest.key()); + moves.add(move); + } + } + + // Raiders continue hit-and-run + myBots = raiders; + } + + // Assign raiders to lone enemy targets + Map raidAssignments = assignRaiders(myBots, loneEnemies, + assignedBots, rows, cols); + + for (Map.Entry entry : raidAssignments.entrySet()) { + VisibleBot raider = entry.getKey(); + VisibleBot target = entry.getValue(); + String trackerKey = raider.getPosition().key() + "->" + target.getPosition().key(); + EngagementTracker tracker = engagementTrackers.computeIfAbsent( + trackerKey, k -> new EngagementTracker()); + + Move move; + if (tracker.engagementTurns >= MAX_ENGAGEMENT_TURNS) { + // Retreat after max engagement turns + move = computeRetreatMove(raider, target.getPosition(), walls, enemyPositions, + claimedDests, rows, cols); + if (tracker.engagementTurns >= MAX_ENGAGEMENT_TURNS + 2) { + tracker.engagementTurns = 0; // reset after sufficient retreat + } + } else { + // Attack the lone target + move = computeAttackMove(raider, target.getPosition(), walls, enemyPositions, + claimedDests, rows, cols, attackR2); + } + + if (move != null) { + assignedBots.add(raider.getPosition().key()); + Position dest = raider.getPosition().moveToward(move.getDirection(), rows, cols); + claimedDests.add(dest.key()); + moves.add(move); + tracker.engagementTurns++; + } + } + + // Remaining bots: gather energy or explore + for (VisibleBot bot : myBots) { + if (assignedBots.contains(bot.getPosition().key())) continue; + + Move move; + if (!state.getEnergy().isEmpty()) { + move = computeGatherMove(bot, energyPositions, walls, enemyPositions, + claimedDests, rows, cols); + } else { + move = computeExploreMove(bot, walls, claimedDests, rows, cols); + } + + if (move != null) { + Position dest = bot.getPosition().moveToward(move.getDirection(), rows, cols); + if (!claimedDests.contains(dest.key())) { + claimedDests.add(dest.key()); + moves.add(move); + } + } + } + + return moves; + } + + /** + * Find lone enemies: no allied bots within ~2 manhattan cells + */ + private List findLoneEnemies(List enemyBots, int rows, int cols) { + List loneEnemies = new ArrayList<>(); + + for (VisibleBot bot : enemyBots) { + int nearbyAllies = 0; + + // Count enemies within attack_radius2 (same owner = allies of this enemy) + for (VisibleBot other : enemyBots) { + if (bot == other) continue; + int dist2 = bot.getPosition().distance2(other.getPosition(), rows, cols); + if (dist2 <= 4) { // ~2 cells squared + nearbyAllies++; + } + } + + // Lone if no nearby allies and check group size + int localGroupSize = countLocalGroup(bot, enemyBots, rows, cols); + if (nearbyAllies == 0 && localGroupSize < GROUP_AVOID_THRESHOLD) { + loneEnemies.add(bot); + } + } + + return loneEnemies; + } + + /** + * Count how many enemy bots are clustered near this bot (within ~4 manhattan tiles) + */ + private int countLocalGroup(VisibleBot bot, List enemyBots, int rows, int cols) { + int count = 1; // include self + for (VisibleBot other : enemyBots) { + if (bot == other) continue; + int dist = bot.getPosition().manhattanDistance(other.getPosition(), rows, cols); + if (dist <= ISOLATION_MANHATTAN) { + count++; + } + } + return count; + } + + /** + * Check if any enemy is close to our core + */ + private boolean isCoreUnderPressure(List myCores, List enemyBots, + int rows, int cols) { + for (Position core : myCores) { + for (VisibleBot enemy : enemyBots) { + if (core.distance2(enemy.getPosition(), rows, cols) <= 36) { // ~6 tiles + return true; + } + } + } + return false; + } + + /** + * Find nearest enemy to any of our cores + */ + private Position nearestEnemyToCore(List myCores, List enemyBots, + int rows, int cols) { + Position nearest = null; + int bestDist = Integer.MAX_VALUE; + + for (Position core : myCores) { + for (VisibleBot enemy : enemyBots) { + int dist = core.distance2(enemy.getPosition(), rows, cols); + if (dist < bestDist) { + bestDist = dist; + nearest = enemy.getPosition(); + } + } + } + return nearest; + } + + /** + * Assign raiders to lone targets using greedy closest-first matching + */ + private Map assignRaiders(List myBots, + List loneEnemies, + Set alreadyAssigned, + int rows, int cols) { + Map assignments = new HashMap<>(); + if (loneEnemies.isEmpty()) return assignments; + + List available = myBots.stream() + .filter(b -> !alreadyAssigned.contains(b.getPosition().key())) + .collect(Collectors.toList()); + + // Sort targets by isolation (most isolated first — easier prey) + loneEnemies.sort((a, b) -> { + int groupA = countLocalGroup(a, Collections.emptyList(), rows, cols); // 1 always + int groupB = countLocalGroup(b, Collections.emptyList(), rows, cols); + return Integer.compare(groupA, groupB); + }); + + for (VisibleBot target : loneEnemies) { + if (available.isEmpty()) break; + + // Assign 1-2 raiders per target + available.sort((a, b) -> { + int distA = a.getPosition().distance2(target.getPosition(), rows, cols); + int distB = b.getPosition().distance2(target.getPosition(), rows, cols); + return Integer.compare(distA, distB); + }); + + int assigned = 0; + Iterator iter = available.iterator(); + while (iter.hasNext() && assigned < 2) { + VisibleBot raider = iter.next(); + assignments.put(raider, target); + iter.remove(); + assigned++; + } + } + + return assignments; + } + + /** + * Compute attack move toward a target, using flanking when possible. + * Approach from an offset angle rather than head-on. + */ + private Move computeAttackMove(VisibleBot bot, Position target, Set walls, + Set enemyPositions, Set claimedDests, + int rows, int cols, int attackR2) { + Direction bestDir = null; + int bestScore = Integer.MIN_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String key = newPos.key(); + + if (walls.contains(key)) continue; + if (claimedDests.contains(key)) continue; + + int score = 0; + int distToTarget = newPos.distance2(target, rows, cols); + int currentDist = bot.getPosition().distance2(target, rows, cols); + + // Reward getting closer + score += (currentDist - distToTarget) * 10; + + // Big bonus for entering attack range + if (distToTarget <= attackR2) { + score += 50; + } + + // Flanking: prefer approaching from sides (perpendicular offset) + // rather than directly head-on + int dr = newPos.getRow() - target.getRow(); + int dc = newPos.getCol() - target.getCol(); + // Wrap differences + if (Math.abs(dr) > rows / 2) dr = dr > 0 ? dr - rows : dr + rows; + if (Math.abs(dc) > cols / 2) dc = dc > 0 ? dc - cols : dc + cols; + // Diagonal approach is flanking + if (dr != 0 && dc != 0) { + score += 5; // prefer diagonal/flanking approach + } + + // Penalty for nearby enemies (more than the target) + int nearbyEnemies = 0; + for (String epk : enemyPositions) { + if (epk.equals(target.key())) continue; + String[] parts = epk.split(","); + Position ep = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + if (newPos.distance2(ep, rows, cols) <= attackR2) { + nearbyEnemies++; + } + } + score -= nearbyEnemies * 30; // heavy penalty for enemy reinforcements + + if (score > bestScore) { + bestScore = score; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + return null; + } + + /** + * Compute retreat move away from a target + */ + private Move computeRetreatMove(VisibleBot bot, Position target, Set walls, + Set enemyPositions, Set claimedDests, + 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 key = newPos.key(); + + if (walls.contains(key)) continue; + if (claimedDests.contains(key)) continue; + + int distFromTarget = newPos.distance2(target, rows, cols); + + // Maximize distance from target + int score = distFromTarget; + + // Penalty for being near any other enemy + for (String epk : enemyPositions) { + String[] parts = epk.split(","); + Position ep = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + if (newPos.distance2(ep, rows, cols) <= 5) { + score -= 20; + } + } + + // Reward moving toward nearest energy (opportunistic while retreating) + // (energy positions not passed here for simplicity) + + if (score > bestScore) { + bestScore = score; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + return null; + } + + /** + * Compute defensive move: intercept enemy approaching core + */ + private Move computeDefendMove(VisibleBot bot, Position target, Set walls, + Set enemyPositions, Set claimedDests, + int rows, int cols) { + if (target == null) return null; + + Direction bestDir = null; + int bestDist = Integer.MAX_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String key = newPos.key(); + + if (walls.contains(key)) continue; + if (claimedDests.contains(key)) continue; + + int dist = newPos.distance2(target, rows, cols); + if (dist < bestDist) { + bestDist = dist; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + return null; + } + + /** + * Compute energy-gathering move toward nearest energy + */ + private Move computeGatherMove(VisibleBot bot, Set energyPositions, + Set walls, Set enemyPositions, + Set claimedDests, int rows, int cols) { + Position nearestEnergy = null; + int nearestDist = Integer.MAX_VALUE; + + for (String ek : energyPositions) { + String[] parts = ek.split(","); + Position ep = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + int dist = bot.getPosition().distance2(ep, rows, cols); + if (dist < nearestDist) { + nearestDist = dist; + nearestEnergy = ep; + } + } + + if (nearestEnergy != null) { + Direction bestDir = null; + int bestDist = Integer.MAX_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String key = newPos.key(); + + if (walls.contains(key)) continue; + if (claimedDests.contains(key)) continue; + + int dist = newPos.distance2(nearestEnergy, rows, cols); + + // Avoid enemies while gathering + for (String epk : enemyPositions) { + String[] parts = epk.split(","); + Position ep = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + if (newPos.distance2(ep, rows, cols) <= 5) { + dist += 20; // penalty + } + } + + if (dist < bestDist) { + bestDist = dist; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + } + return null; + } + + /** + * Explore: move toward grid center or away from friendly bots + */ + private Move computeExploreMove(VisibleBot bot, Set walls, + Set claimedDests, int rows, int cols) { + Position center = new Position(rows / 2, cols / 2); + Direction bestDir = null; + int bestDist = Integer.MAX_VALUE; + + for (Direction dir : Direction.all()) { + Position newPos = bot.getPosition().moveToward(dir, rows, cols); + String key = newPos.key(); + + if (walls.contains(key)) continue; + if (claimedDests.contains(key)) continue; + + int dist = newPos.distance2(center, rows, cols); + if (dist < bestDist) { + bestDist = dist; + bestDir = dir; + } + } + + if (bestDir != null) { + return new Move(bot.getPosition(), bestDir); + } + return null; + } + + private int minDistToAny(Position pos, List targets, int rows, int cols) { + int minDist = Integer.MAX_VALUE; + for (Position t : targets) { + minDist = Math.min(minDist, pos.distance2(t, rows, cols)); + } + return minDist; + } + + private Set buildPositionSet(List positions) { + return positions.stream() + .map(Position::key) + .collect(Collectors.toSet()); + } + + /** + * Tracks how long a raider has been engaged with a target + */ + private static class EngagementTracker { + int engagementTurns = 0; + } +}