From 3cefabb9ed95038d01bbd35da76ce2a244018175 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 16:19:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(bot):=20add=20Defender=20bot=20(C#)=20?= =?UTF-8?q?=E2=80=94=20core-hugging=20perimeter=20archetype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core-defense strategy: bots patrol evenly-spaced slots around their own core, intercept enemies entering the perimeter radius, and never chase past the perimeter. Falls back to energy gathering when cores are lost. MAP-Elites profile: Low Aggression, Low Economy, Low Exploration, High Formation. Co-Authored-By: Claude Opus 4.7 --- bots/defender/Dockerfile | 19 ++ bots/defender/Grid.cs | 79 ++++++++ bots/defender/Program.cs | 154 ++++++++++++++++ bots/defender/Strategy.cs | 332 ++++++++++++++++++++++++++++++++++ bots/defender/defender.csproj | 10 + 5 files changed, 594 insertions(+) create mode 100644 bots/defender/Dockerfile create mode 100644 bots/defender/Grid.cs create mode 100644 bots/defender/Program.cs create mode 100644 bots/defender/Strategy.cs create mode 100644 bots/defender/defender.csproj diff --git a/bots/defender/Dockerfile b/bots/defender/Dockerfile new file mode 100644 index 0000000..ede7b24 --- /dev/null +++ b/bots/defender/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder + +WORKDIR /app +COPY defender.csproj . +RUN dotnet restore +COPY Program.cs Grid.cs Strategy.cs . +RUN dotnet publish -c Release -o /out + +FROM mcr.microsoft.com/dotnet/runtime:9.0-alpine + +WORKDIR /app +COPY --from=builder /out . + +ENV BOT_PORT=8080 +ENV BOT_SECRET="" + +EXPOSE 8080 + +CMD ["dotnet", "defender.dll"] diff --git a/bots/defender/Grid.cs b/bots/defender/Grid.cs new file mode 100644 index 0000000..3879fba --- /dev/null +++ b/bots/defender/Grid.cs @@ -0,0 +1,79 @@ +// Grid utility functions for AI Code Battle. +// +// Provides toroidal distance calculations, neighbor enumeration, +// and BFS pathfinding on a wrapping grid. + +using System.Collections.Generic; + +static class Grid +{ + private static readonly (int dr, int dc)[] Offsets = + { + (-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1), + }; + + /// Manhattan distance with wrap-around on a toroidal grid. + public static int ToroidalManhattan(int r1, int c1, int r2, int c2, int rows, int cols) + { + int dr = Math.Min(Math.Abs(r1 - r2), rows - Math.Abs(r1 - r2)); + int dc = Math.Min(Math.Abs(c1 - c2), cols - Math.Abs(c1 - c2)); + return dr + dc; + } + + /// Chebyshev distance with wrap-around on a toroidal grid. + public static int ToroidalChebyshev(int r1, int c1, int r2, int c2, int rows, int cols) + { + int dr = Math.Min(Math.Abs(r1 - r2), rows - Math.Abs(r1 - r2)); + int dc = Math.Min(Math.Abs(c1 - c2), cols - Math.Abs(c1 - c2)); + return Math.Max(dr, dc); + } + + /// 8-directional neighbors with wrap-around. + public static Position[] Neighbors(Position p, int rows, int cols) + { + var result = new Position[8]; + for (int i = 0; i < Offsets.Length; i++) + { + result[i] = new Position + { + Row = (p.Row + Offsets[i].dr + rows) % rows, + Col = (p.Col + Offsets[i].dc + cols) % cols, + }; + } + return result; + } + + /// BFS pathfinding on a toroidal grid. + /// Returns path (excluding start) or null if unreachable. + public static List? Bfs(Position start, Position goal, + Func passable, int rows, int cols) + { + if (start.Row == goal.Row && start.Col == goal.Col) + return []; + + var visited = new HashSet<(int, int)> { (start.Row, start.Col) }; + var queue = new Queue<(Position pos, List path)>(); + queue.Enqueue((start, [])); + + while (queue.Count > 0) + { + var (cur, path) = queue.Dequeue(); + foreach (var nb in Neighbors(cur, rows, cols)) + { + var newPath = new List(path) { nb }; + if (nb.Row == goal.Row && nb.Col == goal.Col) + return newPath; + + var key = (nb.Row, nb.Col); + if (!visited.Contains(key) && passable(nb)) + { + visited.Add(key); + queue.Enqueue((nb, newPath)); + } + } + } + return null; + } +} diff --git a/bots/defender/Program.cs b/bots/defender/Program.cs new file mode 100644 index 0000000..00bfa67 --- /dev/null +++ b/bots/defender/Program.cs @@ -0,0 +1,154 @@ +// AI Code Battle - Defender Bot (C#) +// +// Core-defense archetype. Bots stay within N cells of their own core, +// intercepting any enemy unit that enters the perimeter. + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +var port = Environment.GetEnvironmentVariable("BOT_PORT") ?? "8080"; +var secret = Environment.GetEnvironmentVariable("BOT_SECRET") ?? ""; + +if (string.IsNullOrEmpty(secret)) +{ + Console.Error.WriteLine("ERROR: BOT_SECRET environment variable is required"); + Environment.Exit(1); +} + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); +var app = builder.Build(); + +app.MapGet("/health", () => Results.Ok("OK")); + +app.MapPost("/turn", (HttpContext ctx) => +{ + var signature = ctx.Request.Headers["X-ACB-Signature"].FirstOrDefault() ?? ""; + var matchId = ctx.Request.Headers["X-ACB-Match-Id"].FirstOrDefault() ?? ""; + var turnStr = ctx.Request.Headers["X-ACB-Turn"].FirstOrDefault() ?? "0"; + var timestamp = ctx.Request.Headers["X-ACB-Timestamp"].FirstOrDefault() ?? ""; + + if (string.IsNullOrEmpty(signature)) + return Results.Unauthorized(); + + using var reader = new StreamReader(ctx.Request.Body); + var body = reader.ReadToEndAsync().GetAwaiter().GetResult(); + + if (!VerifySignature(secret, matchId, turnStr, timestamp, body, signature)) + return Results.Unauthorized(); + + GameState? state; + try + { + state = JsonSerializer.Deserialize(body); + if (state == null) return Results.BadRequest("Invalid game state"); + } + catch + { + return Results.BadRequest("Invalid JSON"); + } + + var moves = DefenderStrategy.ComputeMoves(state); + var responseBody = JsonSerializer.Serialize(new { moves }); + var turn = int.Parse(turnStr); + var responseSig = SignResponse(secret, matchId, turn, responseBody); + + ctx.Response.Headers["X-ACB-Signature"] = responseSig; + return Results.Text(responseBody, "application/json"); +}); + +app.Run(); + +// --- HMAC helpers --- + +static bool VerifySignature(string secret, string matchId, string turn, + string timestamp, string body, string signature) +{ + var bodyHash = Sha256Hex(Encoding.UTF8.GetBytes(body)); + var signingString = $"{matchId}.{turn}.{timestamp}.{bodyHash}"; + var expected = HmacSha256(secret, signingString); + return CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(signature), + Convert.FromHexString(expected) + ); +} + +static string SignResponse(string secret, string matchId, int turn, string body) +{ + var bodyHash = Sha256Hex(Encoding.UTF8.GetBytes(body)); + var signingString = $"{matchId}.{turn}.{bodyHash}"; + return HmacSha256(secret, signingString); +} + +static string HmacSha256(string key, string data) +{ + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key)); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(data))).ToLower(); +} + +static string Sha256Hex(byte[] data) +{ + return Convert.ToHexString(SHA256.HashData(data)).ToLower(); +} + +// --- Types --- + +record GameState +{ + public string MatchId { get; init; } = ""; + public int Turn { get; init; } + public GameConfig Config { get; init; } = new(); + public You You { get; init; } = new(); + public List Bots { get; init; } = []; + public List Energy { get; init; } = []; + public List Cores { get; init; } = []; + public List Walls { get; init; } = []; + public List Dead { get; init; } = []; +} + +record GameConfig +{ + public int Rows { get; init; } + public int Cols { get; init; } + public int MaxTurns { get; init; } + public int VisionRadius2 { get; init; } + public int AttackRadius2 { get; init; } + public int SpawnCost { get; init; } + public int EnergyInterval { get; init; } +} + +record You +{ + public int Id { get; init; } + public int Energy { get; init; } + public int Score { get; init; } +} + +record VisibleBot +{ + public Position Position { get; init; } = new(); + public int Owner { get; init; } +} + +record VisibleCore +{ + public Position Position { get; init; } = new(); + public int Owner { get; init; } + public bool Active { get; init; } +} + +record Position +{ + public int Row { get; init; } + public int Col { get; init; } +} + +record Move +{ + public Position Position { get; init; } = new(); + public string Direction { get; init; } = ""; +} diff --git a/bots/defender/Strategy.cs b/bots/defender/Strategy.cs new file mode 100644 index 0000000..1a930ec --- /dev/null +++ b/bots/defender/Strategy.cs @@ -0,0 +1,332 @@ +// Defender strategy: core-hugging perimeter defense. +// +// Archetype: Low Aggression, Low Economy, Low Exploration, High Formation. +// +// Each bot is assigned to patrol an evenly-spaced slot around the nearest +// active core. If an enemy enters the perimeter, the closest defender +// intercepts. Bots never chase enemies past the perimeter radius. + +using System.Collections.Generic; + +static class DefenderStrategy +{ + const int PerimeterRadius = 8; + const int MaxInterceptRadius = 12; + + static readonly (int dr, int dc, string dir)[] Cardinal = + { + (-1, 0, "N"), (0, 1, "E"), (1, 0, "S"), (0, -1, "W"), + }; + + public static List ComputeMoves(GameState state) + { + var rows = state.Config.Rows; + var cols = state.Config.Cols; + var myId = state.You.Id; + var wallSet = BuildWallSet(state.Walls); + + var myBots = new List(); + var enemies = new List(); + foreach (var b in state.Bots) + { + if (b.Owner == myId) myBots.Add(b); + else enemies.Add(b); + } + + var myCores = new List(); + foreach (var c in state.Cores) + { + if (c.Owner == myId && c.Active) myCores.Add(c); + } + + if (myCores.Count == 0) + return GatherFallback(state, myBots, enemies, wallSet, rows, cols); + + // Assign each bot to its nearest active core. + var botToCore = new Dictionary(); + foreach (var bot in myBots) + { + var nearest = myCores[0]; + var bestDist = DistSq(bot.Position, nearest.Position, rows, cols); + for (int i = 1; i < myCores.Count; i++) + { + var d = DistSq(bot.Position, myCores[i].Position, rows, cols); + if (d < bestDist) { bestDist = d; nearest = myCores[i]; } + } + botToCore[bot] = nearest; + } + + // Group bots by core. + var groups = new Dictionary>(); + foreach (var c in myCores) groups[c] = []; + foreach (var kvp in botToCore) groups[kvp.Value].Add(kvp.Key); + + var moves = new List(); + var assignedThreats = new HashSet<(int, int)>(); + var perimSq = PerimeterRadius * PerimeterRadius; + var interceptSq = MaxInterceptRadius * MaxInterceptRadius; + + foreach (var (core, bots) in groups) + { + if (bots.Count == 0) continue; + + // Compute patrol slots (evenly spaced circle around the core). + var slots = ComputePatrolSlots(core.Position, bots.Count, rows, cols, wallSet); + var usedSlots = new HashSet(); + + // Enemies within intercept range of this core. + var threats = new List(); + foreach (var e in enemies) + { + if (DistSq(e.Position, core.Position, rows, cols) <= interceptSq) + threats.Add(e); + } + threats.Sort((a, b) => + DistSq(a.Position, core.Position, rows, cols) + .CompareTo(DistSq(b.Position, core.Position, rows, cols))); + + // Sort bots: inner bots first (they're already in position, better interceptors). + bots.Sort((a, b) => + DistSq(a.Position, core.Position, rows, cols) + .CompareTo(DistSq(b.Position, core.Position, rows, cols))); + + foreach (var bot in bots) + { + Move? move = null; + + // Phase 1: intercept threats inside the perimeter. + foreach (var threat in threats) + { + var tKey = (threat.Position.Row, threat.Position.Col); + if (assignedThreats.Contains(tKey)) continue; + + if (DistSq(threat.Position, core.Position, rows, cols) > perimSq) + continue; // outside perimeter — will be handled by outer ring + + assignedThreats.Add(tKey); + move = GreedyMove(bot.Position, threat.Position, rows, cols, wallSet, + core.Position, interceptSq); + break; + } + + // Phase 2: collect energy within perimeter if safe. + if (move == null) + { + var energyTarget = BestEnergy(state.Energy, bot, core, perimSq, rows, cols); + if (energyTarget != null) + move = GreedyMove(bot.Position, energyTarget, rows, cols, wallSet); + } + + // Phase 3: patrol assigned slot. + if (move == null) + { + int bestSlotIdx = -1; + int bestSlotDist = int.MaxValue; + for (int i = 0; i < slots.Count; i++) + { + if (usedSlots.Contains(i)) continue; + var d = DistSq(bot.Position, slots[i], rows, cols); + if (d < bestSlotDist) { bestSlotDist = d; bestSlotIdx = i; } + } + + if (bestSlotIdx >= 0) + { + usedSlots.Add(bestSlotIdx); + if (bestSlotDist > 2) + move = GreedyMove(bot.Position, slots[bestSlotIdx], rows, cols, wallSet); + } + else + { + // All slots taken — loiter near core. + var distToCore = DistSq(bot.Position, core.Position, rows, cols); + if (distToCore > perimSq) + move = GreedyMove(bot.Position, core.Position, rows, cols, wallSet); + } + } + + if (move != null) + moves.Add(move); + } + } + + return moves; + } + + // Find the best energy to collect: within perimeter, closest to bot, not + // too far from core. + static Position? BestEnergy(List energy, VisibleBot bot, + VisibleCore core, int perimSq, int rows, int cols) + { + Position? best = null; + int bestDist = int.MaxValue; + foreach (var e in energy) + { + if (DistSq(e, core.Position, rows, cols) > perimSq) continue; + var d = DistSq(e, bot.Position, rows, cols); + if (d < bestDist && d <= 9) // only divert if very close (within 3 tiles) + { + bestDist = d; + best = e; + } + } + return best; + } + + // Generate evenly-spaced patrol positions around a core. + static List ComputePatrolSlots(Position core, int numBots, + int rows, int cols, HashSet<(int, int)> wallSet) + { + var slots = new List(); + int n = Math.Max(numBots, 6); + int candidates = n * 3; // extra to skip walls + for (int i = 0; i < candidates && slots.Count < n; i++) + { + double angle = 2.0 * Math.PI * i / candidates; + int dr = (int)Math.Round(PerimeterRadius * Math.Sin(angle)); + int dc = (int)Math.Round(PerimeterRadius * Math.Cos(angle)); + int r = (core.Row + dr + rows) % rows; + int c = (core.Col + dc + cols) % cols; + if (!wallSet.Contains((r, c))) + slots.Add(new Position { Row = r, Col = c }); + } + return slots; + } + + // Greedy one-step move: pick the cardinal direction that minimizes + // distance to target, optionally constrained to stay near an anchor. + static Move? GreedyMove(Position from, Position to, int rows, int cols, + HashSet<(int, int)> wallSet, Position? anchor = null, int maxAnchorDistSq = int.MaxValue) + { + string? bestDir = null; + int bestDist = int.MaxValue; + var targetDist = DistSq(from, to, rows, cols); + + foreach (var (dr, dc, dir) in Cardinal) + { + int nr = (from.Row + dr + rows) % rows; + int nc = (from.Col + dc + cols) % cols; + if (wallSet.Contains((nr, nc))) continue; + + // Don't move further from target. + var newDist = DistSq(new Position { Row = nr, Col = nc }, to, rows, cols); + if (newDist > targetDist && targetDist > 0) continue; + + // Stay near anchor if specified (prevents chasing past perimeter). + if (anchor != null) + { + var dToAnchor = DistSq(new Position { Row = nr, Col = nc }, anchor, rows, cols); + if (dToAnchor > maxAnchorDistSq) continue; + } + + if (newDist < bestDist) { bestDist = newDist; bestDir = dir; } + } + + // Fallback: any non-wall direction if all better moves blocked. + if (bestDir == null) + { + foreach (var (dr, dc, dir) in Cardinal) + { + int nr = (from.Row + dr + rows) % rows; + int nc = (from.Col + dc + cols) % cols; + if (!wallSet.Contains((nr, nc))) + { + if (anchor != null) + { + var dToAnchor = DistSq(new Position { Row = nr, Col = nc }, anchor, rows, cols); + if (dToAnchor > maxAnchorDistSq) continue; + } + bestDir = dir; + break; + } + } + } + + if (bestDir == null) return null; + return new Move { Position = from, Direction = bestDir }; + } + + // Fallback strategy when all cores are lost: gather energy, flee enemies. + static List GatherFallback(GameState state, List myBots, + List enemies, HashSet<(int, int)> wallSet, int rows, int cols) + { + var moves = new List(); + var claimedEnergy = new HashSet<(int, int)>(); + + foreach (var bot in myBots) + { + // Flee from nearby enemies. + var nearEnemy = ClosestEnemy(bot, enemies, state.Config.AttackRadius2 + 4, rows, cols); + if (nearEnemy != null) + { + var flee = FleeMove(bot.Position, nearEnemy.Position, rows, cols, wallSet); + if (flee != null) { moves.Add(flee); continue; } + } + + // Collect nearest unclaimed energy. + Position? bestEnergy = null; + int bestDist = int.MaxValue; + foreach (var e in state.Energy) + { + if (claimedEnergy.Contains((e.Row, e.Col))) continue; + var d = DistSq(e, bot.Position, rows, cols); + if (d < bestDist) { bestDist = d; bestEnergy = e; } + } + + if (bestEnergy != null) + { + claimedEnergy.Add((bestEnergy.Row, bestEnergy.Col)); + var move = GreedyMove(bot.Position, bestEnergy, rows, cols, wallSet); + if (move != null) moves.Add(move); + } + } + + return moves; + } + + // Find the closest enemy within a squared distance threshold. + static VisibleBot? ClosestEnemy(VisibleBot bot, List enemies, + int thresholdSq, int rows, int cols) + { + VisibleBot? closest = null; + int closestDist = int.MaxValue; + foreach (var e in enemies) + { + var d = DistSq(bot.Position, e.Position, rows, cols); + if (d <= thresholdSq && d < closestDist) { closestDist = d; closest = e; } + } + return closest; + } + + // Pick the cardinal direction that maximizes distance from a threat. + static Move? FleeMove(Position from, Position threat, int rows, int cols, + HashSet<(int, int)> wallSet) + { + string? bestDir = null; + int bestDist = -1; + foreach (var (dr, dc, dir) in Cardinal) + { + int nr = (from.Row + dr + rows) % rows; + int nc = (from.Col + dc + cols) % cols; + if (wallSet.Contains((nr, nc))) continue; + var d = DistSq(new Position { Row = nr, Col = nc }, threat, rows, cols); + if (d > bestDist) { bestDist = d; bestDir = dir; } + } + if (bestDir == null) return null; + return new Move { Position = from, Direction = bestDir }; + } + + // Squared Euclidean distance on a toroidal grid. + static int DistSq(Position a, Position b, int rows, int cols) + { + int dr = Math.Min(Math.Abs(a.Row - b.Row), rows - Math.Abs(a.Row - b.Row)); + int dc = Math.Min(Math.Abs(a.Col - b.Col), cols - Math.Abs(a.Col - b.Col)); + return dr * dr + dc * dc; + } + + static HashSet<(int, int)> BuildWallSet(List walls) + { + var set = new HashSet<(int, int)>(walls.Count); + foreach (var w in walls) set.Add((w.Row, w.Col)); + return set; + } +} diff --git a/bots/defender/defender.csproj b/bots/defender/defender.csproj new file mode 100644 index 0000000..a31cf80 --- /dev/null +++ b/bots/defender/defender.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + Exe + + +