feat(bot): add Defender bot (C#) — core-hugging perimeter archetype

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 16:19:08 -04:00
parent 7f2407ed00
commit 3cefabb9ed
5 changed files with 594 additions and 0 deletions

19
bots/defender/Dockerfile Normal file
View file

@ -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"]

79
bots/defender/Grid.cs Normal file
View file

@ -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<Position>? Bfs(Position start, Position goal,
Func<Position, bool> 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<Position> 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<Position>(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;
}
}

154
bots/defender/Program.cs Normal file
View file

@ -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<GameState>(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<VisibleBot> Bots { get; init; } = [];
public List<Position> Energy { get; init; } = [];
public List<VisibleCore> Cores { get; init; } = [];
public List<Position> Walls { get; init; } = [];
public List<VisibleBot> 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; } = "";
}

332
bots/defender/Strategy.cs Normal file
View file

@ -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<Move> 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<VisibleBot>();
var enemies = new List<VisibleBot>();
foreach (var b in state.Bots)
{
if (b.Owner == myId) myBots.Add(b);
else enemies.Add(b);
}
var myCores = new List<VisibleCore>();
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<VisibleBot, VisibleCore>();
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<VisibleCore, List<VisibleBot>>();
foreach (var c in myCores) groups[c] = [];
foreach (var kvp in botToCore) groups[kvp.Value].Add(kvp.Key);
var moves = new List<Move>();
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<int>();
// Enemies within intercept range of this core.
var threats = new List<VisibleBot>();
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<Position> 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<Position> ComputePatrolSlots(Position core, int numBots,
int rows, int cols, HashSet<(int, int)> wallSet)
{
var slots = new List<Position>();
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<Move> GatherFallback(GameState state, List<VisibleBot> myBots,
List<VisibleBot> enemies, HashSet<(int, int)> wallSet, int rows, int cols)
{
var moves = new List<Move>();
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<VisibleBot> 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<Position> walls)
{
var set = new HashSet<(int, int)>(walls.Count);
foreach (var w in walls) set.Add((w.Row, w.Col));
return set;
}
}

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>