feat(evolver): add C# language support

- Add csharp case to buildCandidate() with dotnet-script and mcs fallback
- Add defender_strategy.cs.txt seed file (combined Program.cs, Strategy.cs, Grid.cs)
- Add defender seed to population (gamma island, aggression=0.3, economy=0.4)
- Add csharp to langDisplayName() and update comments

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-06-17 05:08:19 -04:00
parent c28a463997
commit de15046e3e
4 changed files with 536 additions and 2 deletions

View file

@ -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)
}

View file

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

View file

@ -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<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; } = "";
}
// --- 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<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 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<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)
{
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<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

@ -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
}