diff --git a/cmd/acb-evolver/internal/arena/arena.go b/cmd/acb-evolver/internal/arena/arena.go index a4eccbf..a7f2583 100644 --- a/cmd/acb-evolver/internal/arena/arena.go +++ b/cmd/acb-evolver/internal/arena/arena.go @@ -139,7 +139,7 @@ func New(db *sql.DB, cfg Config) *Arena { // Run executes a mini-tournament for the candidate bot. // // code is the candidate's source code; language is one of -// go|python|rust|typescript|java|php. +// go|python|rust|typescript|java|php|csharp. // // The candidate is built and started as a local subprocess, then played // against cfg.NumMatches opponents sampled from the live bot fleet. @@ -470,6 +470,26 @@ func buildCandidate(ctx context.Context, code, language, dir string) (string, [] } return "php", []string{src}, nil + case "csharp": + src := dir + "/bot.cs" + if err := os.WriteFile(src, []byte(code), 0o600); err != nil { + return "", nil, err + } + // Check for dotnet-script first (no compilation needed) + if _, err := exec.LookPath("dotnet-script"); err == nil { + return "dotnet-script", []string{src}, nil + } + // Fallback to mcs (Mono C# compiler) + if _, err := exec.LookPath("mcs"); err == nil { + exe := dir + "/bot.exe" + cmd := exec.CommandContext(ctx, "mcs", "-out:"+exe, src) + if out, err := cmd.CombinedOutput(); err != nil { + return "", nil, fmt.Errorf("mcs: %s", truncate(string(out), 512)) + } + return "mono", []string{exe}, nil + } + return "", nil, fmt.Errorf("csharp requires dotnet-script or mcs (mono)") + default: return "", nil, fmt.Errorf("unsupported language: %s", language) } diff --git a/cmd/acb-evolver/internal/db/seed.go b/cmd/acb-evolver/internal/db/seed.go index 241d1b9..0ddfc7d 100644 --- a/cmd/acb-evolver/internal/db/seed.go +++ b/cmd/acb-evolver/internal/db/seed.go @@ -33,6 +33,9 @@ var assassinCode string //go:embed seeds/opportunist_strategy.go.txt var opportunistCode string +//go:embed seeds/defender_strategy.cs.txt +var defenderCode string + // seedProgram describes a built-in strategy bot used to bootstrap the // programs database. type seedProgram struct { @@ -115,6 +118,14 @@ var seeds = []seedProgram{ economy: 0.3, code: hunterCode, }, + { + name: "defender", + language: "csharp", + island: IslandGamma, + aggression: 0.3, + economy: 0.4, + code: defenderCode, + }, // delta island – baseline / experimental { name: "random", diff --git a/cmd/acb-evolver/internal/db/seeds/defender_strategy.cs.txt b/cmd/acb-evolver/internal/db/seeds/defender_strategy.cs.txt new file mode 100644 index 0000000..d01f375 --- /dev/null +++ b/cmd/acb-evolver/internal/db/seeds/defender_strategy.cs.txt @@ -0,0 +1,501 @@ +// 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. +// +// 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.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; + +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; } = ""; +} + +// --- Defender Strategy --- + +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 claimedDests = new HashSet<(int, int)>(); + foreach (var b in myBots) + claimedDests.Add((b.Position.Row, b.Position.Col)); + 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) + { + var (dr, dc, _) = Array.Find(Cardinal, c => c.dir == move.Direction); + int nr = (move.Position.Row + dr + rows) % rows; + int nc = (move.Position.Col + dc + cols) % cols; + if (claimedDests.Contains((nr, nc))) + move = null; // destination occupied — hold + else + { + claimedDests.Remove((move.Position.Row, move.Position.Col)); + claimedDests.Add((nr, nc)); + 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/cmd/acb-evolver/internal/prompt/builder.go b/cmd/acb-evolver/internal/prompt/builder.go index d1c1869..e68e49f 100644 --- a/cmd/acb-evolver/internal/prompt/builder.go +++ b/cmd/acb-evolver/internal/prompt/builder.go @@ -95,7 +95,7 @@ type Request struct { // Island is the island this candidate will compete on. Island string // TargetLang is the programming language for the evolved bot - // (e.g. "go", "python", "rust", "typescript", "java", "php"). + // (e.g. "go", "python", "rust", "typescript", "java", "php", "csharp"). TargetLang string // Generation is the current evolution generation number. Generation int @@ -315,6 +315,8 @@ func langDisplayName(lang string) string { return "Java" case "php": return "PHP" + case "csharp": + return "C#" default: return lang }